From 587a3c09a62781afba4bceea759bbe5b488ac48a Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 15:02:13 +0000 Subject: [PATCH 01/15] BMC takeover for srv1/srv2: custody container, operator-supplied current password, typed custody-read refusal srv1 and srv2 were brought up by hand on an operator-chosen BMC root password, so neither the factory probe nor custody could authenticate to them. This adds the route that takes such a BMC into custody, and ran it on both on 2026-09-18. - gunbc.bmc_onboarding: srv1/srv2 onboarding plans, BMC endpoints read from the fleet intent. - gunbc.secret_provision_actuator: create_secret_container split out of create_secret_for_attempt (which now delegates), for flows whose payload is minted after custody is established. - gunbc.tools.bmc_onboard: - the custody container is observed, and created once on a pre-mutation 404, before the first version is written and before the BMC is touched; an unknown create outcome stops for a human; - bmc_takeover_credential proves an operator-supplied current password (file named by GUNBC_BMC_TAKEOVER_PASSWORD_FILE, never source) against the BMC, then mints, stores, reads back, rotates FROM that password and re-authenticates; rotation now takes the current password as a parameter instead of assuming the factory one; - a failed custody read in the credential probe is classified before its payload is decoded (it crashed the interpreter on a null when no bmc-srv1-admin existed); - srv1/srv2 entries select the run's token source (WIF or GUNBC_GCP_ACCESS_TOKEN_FILE). Receipt (2026-09-18, operator token, run from srv1): both takeovers exited 0 with version 1 written and read back; independently, the stored bmc-srvN-admin secret authenticates (200) and the old demo password is refused (401) on both BMCs. Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/bmc/bmc_onboarding.dag | 11 ++ dag/gunbc/secret_provision_actuator.dag | 45 ++++- dag/gunbc/tools/bmc_onboard.dag | 171 +++++++++++++++++- .../bmc_custody_container_witness_test.dag | 21 +++ .../bmc_onboarding_lifecycle_witness_test.dag | 11 ++ 5 files changed, 243 insertions(+), 16 deletions(-) create mode 100644 dag/test/claim/bmc/bmc_custody_container_witness_test.dag diff --git a/dag/gunbc/bmc/bmc_onboarding.dag b/dag/gunbc/bmc/bmc_onboarding.dag index 9ec3c3b2bc1..308e9b17ff0 100644 --- a/dag/gunbc/bmc/bmc_onboarding.dag +++ b/dag/gunbc/bmc/bmc_onboarding.dag @@ -9,6 +9,7 @@ import extdeps.bmc.types { RedfishBootSourceOverrideTarget, RedfishAccountRole, import gunbc.auth.access_token_source { AccessEnsurePlan } import extdeps.bmc.openbmc { openbmc_factory_login } import extdeps.boards.asrock_rack { asrock_altrad8ud_redfish_system_id } +import gunbc.fleet_intent { srv1_baseboard, srv2_baseboard } // QUARANTINED LEGACY LIFECYCLE (machine-intake ruling 2026-08-29, answer (b)). // FactoryDefault -> CredentialsRotated -> OsInstalled -> FabricJoined is a @@ -243,6 +244,16 @@ data srv3_onboarding_plan: BmcOnboardingPlan = altra_onboarding_plan(bmc_host: " data srv4_onboarding_plan: BmcOnboardingPlan = altra_onboarding_plan(bmc_host: "192.168.1.195", secret_name: "bmc-srv4-admin") +// srv1 AND srv2 JOIN THE LIFECYCLE srv3 AND srv4 WENT THROUGH, with their BMC endpoints read from the +// fleet intent rather than retyped. They do NOT start from the factory credential: both were brought +// up by hand on an operator-chosen root password (operator, 2026-09-18), and the one factory probe +// against srv1 was refused (401). Their route in is therefore gunbc.tools.bmc_onboard +// bmc_takeover_credential, which rotates from an operator-supplied current password; the +// OpenBMC factory login below still names the ACCOUNT the rotation acts on, which is root on both. +data srv1_onboarding_plan: BmcOnboardingPlan = altra_onboarding_plan(bmc_host: srv1_baseboard.bmc.host, secret_name: "bmc-srv1-admin") + +data srv2_onboarding_plan: BmcOnboardingPlan = altra_onboarding_plan(bmc_host: srv2_baseboard.bmc.host, secret_name: "bmc-srv2-admin") + type BmcCredentialProbeResult = FactoryCredentialActive | RotatedCredentialActive diff --git a/dag/gunbc/secret_provision_actuator.dag b/dag/gunbc/secret_provision_actuator.dag index 7c660dfe59f..c9fa7d5fd2d 100644 --- a/dag/gunbc/secret_provision_actuator.dag +++ b/dag/gunbc/secret_provision_actuator.dag @@ -274,6 +274,33 @@ fn create_secret_for_attempt( snapshot: OwnedCredentialSnapshot, token: Secret, ) -> SecretCreationOutcome uses net: Network { + match create_secret_container(project: project, secret_id: secret_id, token: token) { + SecretContainerCreated { container } => SecretCreatedForAttempt { + capability: version_add_capability_for_observed_container(container: container, attempt: attempt, snapshot: snapshot), + } + SecretContainerCreationRefused { cause } => SecretCreationRefused { cause: cause } + SecretContainerCreationOutcomeUnknown { cause } => SecretCreationOutcomeUnknown { cause: cause } + } +} + +// THE CONTAINER CREATE ON ITS OWN, for a flow whose payload does not exist yet when its custody is +// established. gunbc.tools.bmc_onboard mints a BMC credential inside the run, so it cannot hand a +// snapshot to create_secret_for_attempt; before this split its only choices were to invent one or +// to fork the create. A 200 POST whose resource names the requested project and secret confirms the +// container exactly as a 200 GET does, so the result is the same ObservedSecretContainer seal +// observe_secret_container mints, and version_add_capability_for_observed_container derives the +// version-add authorization from either. The three outcomes and their opposite retry rules are +// SecretCreationOutcome's, stated there. +type SecretContainerCreation + = SecretContainerCreated { container: ObservedSecretContainer } + | SecretContainerCreationRefused { cause: NonEmptyStr } + | SecretContainerCreationOutcomeUnknown { cause: NonEmptyStr } + +fn create_secret_container( + project: GcpProjectId, + secret_id: NonEmptyStr, + token: Secret, +) -> SecretContainerCreation uses net: Network { let created = gcp.SecretManager.CreateSecret( access_token: token, project_id: project, @@ -282,30 +309,28 @@ fn create_secret_for_attempt( match classify_rest_outcome(standing: RestMutationExchange { rest: created.outcome }) { RestExchangeSucceeded => if !created_resource_names_requested_secret(name: created.name, project: project, secret_id: secret_id) { - SecretCreationRefused { + SecretContainerCreationRefused { cause: concat( "CreateSecret returned a resource that does not name the requested secret, so the capability would authorize a write against a different container: ", created.name) as NonEmptyStr, } } else { - SecretCreatedForAttempt { - capability: CreatedSecretForAttempt { - attempt: attempt, + SecretContainerCreated { + container: ObservedSecretContainer { project: project, secret_id: secret_id, - created_resource: created.name as NonEmptyStr, - snapshot: snapshot, + observed_resource: created.name as NonEmptyStr, }, } } RestExchangeStatusRefused { status: _, body: body } => - SecretCreationRefused { cause: concat("the service refused to create the container: ", body) as NonEmptyStr } + SecretContainerCreationRefused { cause: concat("the service refused to create the container: ", body) as NonEmptyStr } RestExchangeCommitAmbiguous { detail } => - SecretCreationOutcomeUnknown { cause: detail } + SecretContainerCreationOutcomeUnknown { cause: detail } RestExchangeUnreached { cause } => - SecretCreationOutcomeUnknown { cause: cause } + SecretContainerCreationOutcomeUnknown { cause: cause } RestExchangeUndecodable { status: _, cause } => - SecretCreationOutcomeUnknown { cause: cause } + SecretContainerCreationOutcomeUnknown { cause: cause } } } diff --git a/dag/gunbc/tools/bmc_onboard.dag b/dag/gunbc/tools/bmc_onboard.dag index 4319de42d10..b2f2c901246 100644 --- a/dag/gunbc/tools/bmc_onboard.dag +++ b/dag/gunbc/tools/bmc_onboard.dag @@ -21,12 +21,29 @@ import extdeps.cloud.gcp.secret_manager { SmResolvedVersionIdentityDecodeRefused, } import gunbc.fleet_secrets_config { fleet_secrets_gcp_project } +import extdeps.transports.rest { + classify_rest_outcome, + RestExchangeSucceeded, RestExchangeStatusRefused, RestExchangeCommitAmbiguous, + RestExchangeUnreached, RestExchangeUndecodable, RestReadExchange, RestExchangePerformance, +} +import gunbc.secret_provision_actuator { + SecretContainerAbsent, + SecretContainerCreated, + SecretContainerCreationOutcomeUnknown, + SecretContainerCreationRefused, + SecretContainerObserved, + SecretObservationRefused, + create_secret_container, + observe_secret_container, +} import gunbc.auth.netrc_binding { NetrcPlacement, NetrcBindingPlaced, NetrcBindingRefused, NetrcBindingRefusedResidueRemains, place_netrc_binding, } import gunbc.bmc_onboarding { BmcOnboardingPlan, + srv1_onboarding_plan, + srv2_onboarding_plan, srv3_onboarding_plan, srv4_onboarding_plan, BmcCredentialProbeResult, @@ -42,10 +59,14 @@ import gunbc.auth.access_token_source { AccessTokenReady, AccessTokenUpsertRequired, GcloudPrintToken, + AccessTokenSourceSelected, + AccessTokenSourceUnavailable, + select_access_token_source, access_token_upsert_required_reason, ensure_access_token, } import std.types { FilePath, NonEmptyStr, Secret, String } +import std.algebra { trim } import std.logic { Bool } import std.resources { Network } import std.bytes { utf8_encode_bytes } @@ -183,13 +204,13 @@ fn netrc_failure_reason(outcome: NetrcWriteOutcome, stage: NonEmptyStr) -> Strin // would leave the PREVIOUS credential's file in place, and the reauth would then appear to succeed // while proving nothing about the rotation it exists to confirm -- a green that means the opposite // of what it reads as. -fn bmc_rotate_credential(plan: BmcOnboardingPlan, new_credential: Secret) -> ProcessExit +fn bmc_rotate_credential(plan: BmcOnboardingPlan, current_password: String, new_credential: Secret) -> ProcessExit uses net: Network { let host = plan.bmc.host let account = plan.factory_login.username - let rotate_w = write_netrc_outcome(host: host, account: account, password: plan.factory_login.published_password) + let rotate_w = write_netrc_outcome(host: host, account: account, password: current_password) if netrc_write_succeeded(outcome: rotate_w) == false { return exit_failure(reason: netrc_failure_reason(outcome: rotate_w, stage: "rotate")) } @@ -274,7 +295,46 @@ fn bmc_readback_target(stored_name: String) -> BmcReadbackTarget { // fetches the CURRENT credential in order to probe with it, and has no preceding write whose version // it could address; "latest" is the question it actually means to ask. Sweeping both because they // share a spelling would have changed a correct read into a pinned one. -fn bmc_store_and_rotate(plan: BmcOnboardingPlan, token_source: AccessTokenSource, credential: Secret) -> ProcessExit +// THE CUSTODY CONTAINER IS ESTABLISHED BEFORE THE FIRST VERSION IS WRITTEN, and before anything +// touches the BMC. srv3 and srv4 had their containers created by hand; srv1 and srv2 have none, and +// AddVersion against a missing container fails. That failure was already safe -- the store precedes +// the rotation -- but it left a manual step outside the flow. The GET is taken before any mutation, +// so a 404 is the store's answer and may authorize exactly one create; an unknown create outcome ends +// the run for a human, because a later 404 no longer establishes absence. Creating needs the +// project-level secrets.create permission, which an operator token carries and the least-privilege +// federated identity deliberately does not: under the latter an absent container refuses as a +// permission denial, which is the honest answer. +type BmcCustodyContainer + = BmcCustodyEstablished + | BmcCustodyRefused { reason: String } + +fn bmc_establish_custody_container(secret_id: NonEmptyStr, token: Secret) -> BmcCustodyContainer + uses net: Network +{ + match observe_secret_container(project: fleet_secrets_gcp_project, secret_id: secret_id, token: token) { + SecretContainerObserved { container: _ } => BmcCustodyEstablished + SecretObservationRefused { cause: c } => + BmcCustodyRefused { reason: concat("custody: the credential container could not be read: ", c as String) } + SecretContainerAbsent { project: _, secret_id: _ } => + match create_secret_container(project: fleet_secrets_gcp_project, secret_id: secret_id, token: token) { + SecretContainerCreated { container: _ } => BmcCustodyEstablished + SecretContainerCreationRefused { cause: c } => + BmcCustodyRefused { reason: concat("custody: the credential container is absent and could not be created: ", c as String) } + SecretContainerCreationOutcomeUnknown { cause: c } => + BmcCustodyRefused { reason: concat("custody: CreateSecret outcome UNKNOWN -- do not re-run; confirm by hand whether the container exists: ", c as String) } + } + } +} + +fn bmc_custody_refused(custody: BmcCustodyContainer) -> Bool { + match custody { BmcCustodyEstablished => false BmcCustodyRefused { reason: _ } => true } +} + +fn bmc_custody_refusal_reason(custody: BmcCustodyContainer) -> String { + match custody { BmcCustodyEstablished => "" BmcCustodyRefused { reason: r } => r } +} + +fn bmc_store_and_rotate(plan: BmcOnboardingPlan, token_source: AccessTokenSource, credential: Secret, current_password: String) -> ProcessExit uses net: Network { let payload_b64 = encode_sm_access_version_payload_wire(credential: credential) @@ -288,6 +348,10 @@ fn bmc_store_and_rotate(plan: BmcOnboardingPlan, token_source: AccessTokenSource AccessTokenEnsureRefused { reason: reason } => return exit_failure(reason: reason as String) AccessTokenReady { token: token } => { + let custody = bmc_establish_custody_container(secret_id: secret_id, token: token) + if bmc_custody_refused(custody: custody) { + return exit_failure(reason: bmc_custody_refusal_reason(custody: custody)) + } stored = gcp.SecretManager.AddVersion( access_token: token, secret_name: gcp_secret_full_name(project: fleet_secrets_gcp_project, secret_id: secret_id), @@ -319,7 +383,7 @@ fn bmc_store_and_rotate(plan: BmcOnboardingPlan, token_source: AccessTokenSource } } - return bmc_rotate_credential(plan: plan, new_credential: credential) + return bmc_rotate_credential(plan: plan, current_password: current_password, new_credential: credential) } } } @@ -348,7 +412,7 @@ fn bmc_assimilate_credential(plan: BmcOnboardingPlan, token_source: AccessTokenS return exit_failure(reason: concat("acquire: factory login failed: ", acq.body)) } - return bmc_store_and_rotate(plan: plan, token_source: token_source, credential: mint_bmc_credential()) + return bmc_store_and_rotate(plan: plan, token_source: token_source, credential: mint_bmc_credential(), current_password: plan.factory_login.published_password) } // A NETRC THAT COULD NOT BE WRITTEN IS NOT EVIDENCE ABOUT THE CREDENTIAL, and this probe keeps those @@ -356,6 +420,20 @@ fn bmc_assimilate_credential(plan: BmcOnboardingPlan, token_source: AccessTokenS // arm, because reporting `factory login failed` for a local filesystem fault would send an operator // to the BMC to investigate a problem that is on this host. The same rule governs the stored- // credential arm below it. +// A CUSTODY READ THAT DID NOT SUCCEED CARRIES NO PAYLOAD, and decoding one anyway crashed the +// interpreter on a null (observed on srv1 2026-09-18, where no bmc-srv1-admin existed yet). The +// outcome is classified before the payload is touched, so a missing or unreadable custody secret is +// the typed unknown-phase refusal it always meant to be. +fn bmc_stored_credential_read_succeeded(performance: RestExchangePerformance) -> Bool { + match performance { + RestExchangeSucceeded => true + RestExchangeStatusRefused { status: _, body: _ } => false + RestExchangeCommitAmbiguous { detail: _ } => false + RestExchangeUnreached { cause: _ } => false + RestExchangeUndecodable { status: _, cause: _ } => false + } +} + fn bmc_probe_credential_phase(plan: BmcOnboardingPlan, token_source: AccessTokenSource) -> BmcCredentialProbeResult uses net: Network { @@ -386,6 +464,9 @@ fn bmc_probe_credential_phase(plan: BmcOnboardingPlan, token_source: AccessToken secret: onboarding_secret_id(plan: plan), version: "latest" ) + if !bmc_stored_credential_read_succeeded(performance: classify_rest_outcome(standing: RestReadExchange { rest: secret_resp.outcome })) { + return CredentialStateUnknown { reason: "the factory credential was refused and no stored credential could be read from custody" } + } match decode_sm_access_version_secret_wire(data_b64: secret_resp.data_b64) { SmAccessVersionSecretDecoded { credential: password } => { let stored_w = write_netrc_outcome(host: host, account: account, password: password as String) @@ -419,7 +500,7 @@ fn bmc_converge_credential(plan: BmcOnboardingPlan, token_source: AccessTokenSou probe = bmc_probe_credential_phase(plan: plan, token_source: token_source) match probe { - FactoryCredentialActive => return bmc_store_and_rotate(plan: plan, token_source: token_source, credential: mint_bmc_credential()) + FactoryCredentialActive => return bmc_store_and_rotate(plan: plan, token_source: token_source, credential: mint_bmc_credential(), current_password: plan.factory_login.published_password) RotatedCredentialActive => return ExitSuccess CredentialAccessUpsertRequired { plan: upsert_plan } => return exit_failure( @@ -449,3 +530,81 @@ fn srv4_converge_credential() -> ProcessExit { bmc_converge_credential(plan: srv4_onboarding_plan, token_source: GcloudPrintToken) } + +// srv1 AND srv2 TAKE WHATEVER CREDENTIAL THE RUN HAS -- the Workload Identity token in a federated +// workflow, or an operator token file in a session -- through the one selection authority, rather +// than the gcloud CLI the older srv3/srv4 entries hard-code. +fn bmc_converge_credential_selected(plan: BmcOnboardingPlan) -> ProcessExit + uses net: Network +{ + match select_access_token_source() { + AccessTokenSourceUnavailable { cause: c } => return exit_failure(reason: c as String) + AccessTokenSourceSelected { source: s } => return bmc_converge_credential(plan: plan, token_source: s) + } +} + +// TAKEOVER: a BMC whose admin password was set by hand, outside custody. srv1 and srv2 were brought +// up as a demo on an operator-chosen root password (operator, 2026-09-18), so neither the factory +// probe nor custody can authenticate to them. The operator supplies that current password ONCE, in +// a file named by the environment -- never in source, since this repository is its own audit log -- +// and it is proven against the BMC before anything is written. From there the flow is the factory +// flow's: establish custody, mint, store, read back, rotate FROM the supplied password, re-authenticate. +// After a takeover the supplied password is dead, which is the point: it has lived outside custody. +data bmc_takeover_password_file_env: NonEmptyStr = "GUNBC_BMC_TAKEOVER_PASSWORD_FILE" as NonEmptyStr + +fn bmc_takeover_credential(plan: BmcOnboardingPlan) -> ProcessExit + uses net: Network +{ + let path = match shell.Env.Get(name: bmc_takeover_password_file_env).value { + Absent => "" + Present { value: raw } => trim(s: raw) + } + if path == "" { + return exit_failure(reason: concat(bmc_takeover_password_file_env as String, " is not set: a takeover needs the BMC's current password in a file")) + } + let read = Filesystem.Read(path: path) + if read.success == false { + return exit_failure(reason: concat("takeover: the current-password file could not be read: ", read.error)) + } + let current = trim(s: read.content) + if current == "" { + return exit_failure(reason: "takeover: the current-password file is empty") + } + let current_w = write_netrc_outcome(host: plan.bmc.host, account: plan.factory_login.username, password: current) + if netrc_write_succeeded(outcome: current_w) == false { + return exit_failure(reason: netrc_failure_reason(outcome: current_w, stage: "takeover current credential")) + } + proof = redfish.Http.GetSystem(bmc_host: plan.bmc.host, netrc_file: bmc_onboard_netrc_path as NonEmptyStr) + if proof.success == false { + return exit_failure(reason: concat("takeover: the supplied current password was refused; nothing was written: ", proof.body)) + } + match select_access_token_source() { + AccessTokenSourceUnavailable { cause: c } => return exit_failure(reason: c as String) + AccessTokenSourceSelected { source: s } => + return bmc_store_and_rotate(plan: plan, token_source: s, credential: mint_bmc_credential(), current_password: current) + } +} + +fn srv1_takeover_credential() -> ProcessExit + uses net: Network +{ + bmc_takeover_credential(plan: srv1_onboarding_plan) +} + +fn srv2_takeover_credential() -> ProcessExit + uses net: Network +{ + bmc_takeover_credential(plan: srv2_onboarding_plan) +} + +fn srv1_converge_credential() -> ProcessExit + uses net: Network +{ + bmc_converge_credential_selected(plan: srv1_onboarding_plan) +} + +fn srv2_converge_credential() -> ProcessExit + uses net: Network +{ + bmc_converge_credential_selected(plan: srv2_onboarding_plan) +} diff --git a/dag/test/claim/bmc/bmc_custody_container_witness_test.dag b/dag/test/claim/bmc/bmc_custody_container_witness_test.dag new file mode 100644 index 00000000000..8628fce340f --- /dev/null +++ b/dag/test/claim/bmc/bmc_custody_container_witness_test.dag @@ -0,0 +1,21 @@ +module test.claim.bmc_custody_container_witness + +import std.types { Bool } +import gunbc.tools.bmc_onboard { + BmcCustodyEstablished, + BmcCustodyRefused, + bmc_custody_refused, + bmc_custody_refusal_reason, +} +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +// The store step stops on a refused custody container and carries its reason; an established one +// lets it proceed. Resolving this module also typechecks the effectful custody step beside it, +// whose arms are the actuator's already-witnessed observation and creation outcomes. +test fn a_refused_custody_container_stops_the_store_with_its_reason() -> Bool { + bmc_custody_refused(custody: BmcCustodyRefused { reason: "custody: absent" }) + && bmc_custody_refusal_reason(custody: BmcCustodyRefused { reason: "custody: absent" }) == "custody: absent" + && !bmc_custody_refused(custody: BmcCustodyEstablished) +} diff --git a/dag/test/claim/bmc/bmc_onboarding_lifecycle_witness_test.dag b/dag/test/claim/bmc/bmc_onboarding_lifecycle_witness_test.dag index bc7622c2dc6..a6b87f24e5b 100644 --- a/dag/test/claim/bmc/bmc_onboarding_lifecycle_witness_test.dag +++ b/dag/test/claim/bmc/bmc_onboarding_lifecycle_witness_test.dag @@ -158,6 +158,17 @@ test fn srv4_plan_targets_195_with_srv4_secret() -> Bool { && redfish_account_role_wire(role: srv4_onboarding_plan.rotated_account_role) == "Administrator" } +// srv1 and srv2 read their BMC endpoints from the fleet intent, so the literals here are the +// expectation and the plan is the subject: a plan that retyped a wrong address would fail this. +test fn srv1_and_srv2_plans_take_their_bmc_from_the_fleet_intent() -> Bool { + srv1_onboarding_plan.bmc.host == "192.168.1.183" + && srv2_onboarding_plan.bmc.host == "192.168.1.184" + && srv1_onboarding_plan.secret_name == "bmc-srv1-admin" + && srv2_onboarding_plan.secret_name == "bmc-srv2-admin" + && phase_tag(p: srv1_onboarding_plan.start_phase) == 0 + && redfish_account_role_wire(role: srv2_onboarding_plan.rotated_account_role) == "Administrator" +} + test fn credential_probe_maps_to_correct_phase() -> Bool { match bmc_credential_probe_to_phase(probe: FactoryCredentialActive) { Present { value: FactoryDefault } => true From f523f25efd0248d209d1643b431e02015193c792 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 15:56:28 +0000 Subject: [PATCH 02/15] Converge a read-only gunbc-health Redfish account per BMC, credential in its own custody secret Co-Authored-By: Claude Opus 5 (1M context) --- dag/extdeps/bmc/http.dag | 38 +++ dag/gunbc/bmc/bmc_health_reader.dag | 141 +++++++++++ .../tools/bmc_health_reader_converge.dag | 222 ++++++++++++++++++ dag/gunbc/tools/bmc_onboard.dag | 90 +++---- .../bmc/bmc_health_reader_witness_test.dag | 72 ++++++ 5 files changed, 523 insertions(+), 40 deletions(-) create mode 100644 dag/gunbc/bmc/bmc_health_reader.dag create mode 100644 dag/gunbc/tools/bmc_health_reader_converge.dag create mode 100644 dag/test/claim/bmc/bmc_health_reader_witness_test.dag diff --git a/dag/extdeps/bmc/http.dag b/dag/extdeps/bmc/http.dag index 436e0271b2d..a63ee17053c 100644 --- a/dag/extdeps/bmc/http.dag +++ b/dag/extdeps/bmc/http.dag @@ -127,6 +127,44 @@ service redfish.Http { } } + operation GetAccounts { + input { bmc_host: NonEmptyStr, netrc_file: NonEmptyStr } + output { body: String from "stdout", success: Bool from "exit_success" } + readonly + transport shell { + argv: ["curl", "--fail-with-body", "-sS", "-k", "--netrc-file", "{netrc_file}", "https://{bmc_host}/redfish/v1/AccountService/Accounts"] + } + exit { + 0 => Unit + nonzero => String "redfish accounts collection GET failed" + } + } + + operation GetAccount { + input { bmc_host: NonEmptyStr, netrc_file: NonEmptyStr, account_id: NonEmptyStr } + output { body: String from "stdout", success: Bool from "exit_success" } + readonly + transport shell { + argv: ["curl", "--fail-with-body", "-sS", "-k", "--netrc-file", "{netrc_file}", "https://{bmc_host}/redfish/v1/AccountService/Accounts/{account_id}"] + } + exit { + 0 => Unit + nonzero => String "redfish account GET failed" + } + } + + operation CreateAccount { + input { bmc_host: NonEmptyStr, netrc_file: NonEmptyStr, request_body_file: NonEmptyStr } + output { body: String from "stdout", success: Bool from "exit_success" } + transport shell { + argv: ["curl", "--fail-with-body", "-sS", "-k", "--netrc-file", "{netrc_file}", "-X", "POST", "-H", "Content-Type: application/json", "--data", "@{request_body_file}", "https://{bmc_host}/redfish/v1/AccountService/Accounts"] + } + exit { + 0 => Unit + nonzero => String "redfish account POST failed" + } + } + operation SetAccountPassword { input { bmc_host: NonEmptyStr, netrc_file: NonEmptyStr, account_id: NonEmptyStr, request_body_file: NonEmptyStr } output { body: String from "stdout", success: Bool from "exit_success" } diff --git a/dag/gunbc/bmc/bmc_health_reader.dag b/dag/gunbc/bmc/bmc_health_reader.dag new file mode 100644 index 00000000000..4232e570783 --- /dev/null +++ b/dag/gunbc/bmc/bmc_health_reader.dag @@ -0,0 +1,141 @@ +module gunbc.bmc_health_reader + +import std.types { Bool, List, NonEmptyStr, String } +import extdeps.bmc.types { AccountRoleReadOnly, redfish_account_role_wire } +import extdeps.languages.json.emit { JsonArray, JsonBool, JsonString, JsonValue, json_bool, json_kv, json_object, json_string, serialize_json } +import extdeps.languages.json.parse { + JsonDocumentParsed, + JsonDocumentUnreadable, + JsonMemberFound, + json_object_unique_member, + parse_json_document, +} + +// THE LEAST-PRIVILEGED PRINCIPAL THE FLEET HEALTH CHECK READS A BMC AS. +// +// Fleet health acquisition is out of band and must never hold a BMC administrator credential +// (operator ruling, 2026-09-18): its reads run on a schedule, so its credential is exercised far +// more often than the administrator's should be. Each BMC therefore carries one dedicated Redfish +// account with the ReadOnly role, whose password lives in its own Secret Manager container so it is +// independently revocable per host. Creating that account IS a mutation and uses the administrator +// credential once; that happens here, in convergence, and never inside the health check. +// +// ReadOnly is the DMTF Redfish predefined role holding only Login and ConfigureSelf +// (extdeps.bmc.access redfish_rbac_policy): it can read every resource and change nothing but its +// own password. +data bmc_health_reader_username: NonEmptyStr = "gunbc-health" + +fn bmc_health_reader_secret_id(host: NonEmptyStr) -> NonEmptyStr { + concat("bmc-", host as String, "-health-reader") as NonEmptyStr +} + +// WHAT THE BMC SAYS ABOUT THE READER ACCOUNT. `role` is the wire RoleId as the BMC rendered it and is +// compared to the ReadOnly wire name; it is kept as text because a BMC may carry an OEM role this +// corpus does not model, and that account must be refused by name rather than coerced. +type ReaderAccountObservation + = ReaderAccountAbsent + | ReaderAccountPresent { role: String, enabled: Bool } + | ReaderAccountUnreadable { cause: NonEmptyStr } + +type ReaderAccountPlan + = CreateReaderAccount + | ReaderAccountConverged + | ReaderAccountRefused { cause: NonEmptyStr } + +// AN EXISTING ACCOUNT OF THAT NAME WITH ANY OTHER ROLE IS REFUSED, NOT CORRECTED. This flow did not +// create it, and demoting or disabling an account someone else made is a decision about their +// access, not a convergence of ours. A disabled reader is refused for the same reason: re-enabling +// it would resurrect whatever disabled it. +fn role_is_read_only(role: String) -> Bool { + role == redfish_account_role_wire(role: AccountRoleReadOnly) as String +} + +fn plan_reader_account(observation: ReaderAccountObservation) -> ReaderAccountPlan { + match observation { + ReaderAccountAbsent => CreateReaderAccount + ReaderAccountUnreadable { cause } => ReaderAccountRefused { cause: cause } + ReaderAccountPresent { role, enabled } => + if !role_is_read_only(role: role) { + ReaderAccountRefused { + cause: concat("an account named ", bmc_health_reader_username as String, " already exists with role ", role, "; this flow does not change an account it did not create") as NonEmptyStr, + } + } else if !enabled { + ReaderAccountRefused { + cause: concat("the ", bmc_health_reader_username as String, " account exists but is disabled; re-enabling it is a decision about why it was disabled") as NonEmptyStr, + } + } else { + ReaderAccountConverged + } + } +} + +fn json_string_member(v: JsonValue, key: String) -> String? { + match json_object_unique_member(v: v, key: key) { + JsonMemberFound { value: JsonString { value } } => Present { value: value } + _ => none + } +} + +fn json_bool_member(v: JsonValue, key: String) -> Bool? { + match json_object_unique_member(v: v, key: key) { + JsonMemberFound { value: JsonBool { value } } => Present { value: value } + _ => none + } +} + +fn member_ids(v: JsonValue) -> List? { + match json_object_unique_member(v: v, key: "Members") { + JsonMemberFound { value: JsonArray { elements } } => + Present { value: flat_map(elements, e => match json_string_member(v: e, key: "@odata.id") { + Present { value: id } => [id] + none => [] + }) } + _ => none + } +} + +// THE COLLECTION DECIDES PRESENCE, and only a document that parses and carries a Members array can +// decide it. An unreadable collection is not an empty one: reading it as absent would authorize a +// create against a BMC whose accounts nobody saw. +fn reader_listed_in_accounts(body: String) -> ReaderListing { + match parse_json_document(s: body) { + JsonDocumentUnreadable { gap: _ } => ReaderListingUnreadable { cause: "the Redfish accounts collection is not a JSON document" } + JsonDocumentParsed { value } => match member_ids(v: value) { + none => ReaderListingUnreadable { cause: "the Redfish accounts collection carries no Members array" } + Present { value: ids } => + if any(ids, id => ends_with(s: id, suffix: concat("/Accounts/", bmc_health_reader_username as String))) { + ReaderListed + } else { + ReaderNotListed + } + } + } +} + +type ReaderListing + = ReaderListed + | ReaderNotListed + | ReaderListingUnreadable { cause: NonEmptyStr } + +fn reader_account_from_json(body: String) -> ReaderAccountObservation { + match parse_json_document(s: body) { + JsonDocumentUnreadable { gap: _ } => ReaderAccountUnreadable { cause: "the Redfish account resource is not a JSON document" } + JsonDocumentParsed { value } => match json_string_member(v: value, key: "RoleId") { + none => ReaderAccountUnreadable { cause: "the Redfish account resource carries no RoleId" } + Present { value: role } => match json_bool_member(v: value, key: "Enabled") { + none => ReaderAccountUnreadable { cause: "the Redfish account resource carries no Enabled flag" } + Present { value: enabled } => ReaderAccountPresent { role: role, enabled: enabled } + } + } + } +} + +// THE CREATE BODY, whose role is the modeled wire name rather than a retyped string. +fn reader_account_create_body(password: String) -> String { + serialize_json(v: json_object(members: [ + json_kv(key: "UserName", value: json_string(s: bmc_health_reader_username as String)), + json_kv(key: "Password", value: json_string(s: password)), + json_kv(key: "RoleId", value: json_string(s: redfish_account_role_wire(role: AccountRoleReadOnly) as String)), + json_kv(key: "Enabled", value: json_bool(b: true)), + ])) +} diff --git a/dag/gunbc/tools/bmc_health_reader_converge.dag b/dag/gunbc/tools/bmc_health_reader_converge.dag new file mode 100644 index 00000000000..a0734c934a3 --- /dev/null +++ b/dag/gunbc/tools/bmc_health_reader_converge.dag @@ -0,0 +1,222 @@ +module gunbc.tools.bmc_health_reader_converge + +import extdeps.bmc.http +import extdeps.shell +import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.cloud.gcp.secret_manager { + decode_sm_access_version_secret_wire, + SmAccessVersionSecretDecoded, + SmAccessVersionSecretDecodeRefused, +} +import extdeps.transports.rest { classify_rest_outcome, RestReadExchange } +import gunbc.fleet_secrets_config { fleet_secrets_gcp_project } +import gunbc.bmc_onboarding { + BmcOnboardingPlan, + srv1_onboarding_plan, + srv2_onboarding_plan, + srv3_onboarding_plan, + srv4_onboarding_plan, +} +import gunbc.bmc_health_reader { + CreateReaderAccount, + ReaderAccountAbsent, + ReaderAccountConverged, + ReaderAccountObservation, + ReaderAccountRefused, + ReaderAccountUnreadable, + ReaderListed, + ReaderListingUnreadable, + ReaderNotListed, + bmc_health_reader_secret_id, + bmc_health_reader_username, + plan_reader_account, + reader_account_create_body, + reader_account_from_json, + reader_listed_in_accounts, +} +import gunbc.tools.bmc_onboard { + bmc_custody_refused, + bmc_custody_refusal_reason, + bmc_onboard_netrc_path, + bmc_stored_credential_read_succeeded, + bmc_store_credential_verified, + mint_bmc_credential, + netrc_failure_reason, + netrc_write_succeeded, + onboarding_secret_id, + write_netrc_outcome, +} +import gunbc.auth.access_token_source { + AccessTokenEnsureRefused, + AccessTokenReady, + AccessTokenSourceSelected, + AccessTokenSourceUnavailable, + AccessTokenUpsertRequired, + access_token_upsert_required_reason, + ensure_access_token, + select_access_token_source, +} +import std.types { NonEmptyStr, Secret, String } +import std.logic { Bool } +import std.resources { Network } +import std.process { ProcessExit, ExitSuccess, exit_failure } + +// CONVERGE ONE BMC'S READ-ONLY HEALTH ACCOUNT, using the administrator credential from custody once. +// +// observe (administrator reads the accounts collection, then the reader account if listed) +// -> plan (gunbc.bmc_health_reader plan_reader_account) +// -> converged: prove the custody reader credential authenticates, change nothing +// -> create: mint, store and read back bmc--health-reader, POST the account, then verify +// INDEPENDENTLY that the reader logs in and that the administrator reads its role as ReadOnly. +// +// The credential is stored before the account exists, so a failed POST leaves an orphan version and +// no account; the next run observes the account absent and stores a fresh one. The reverse order +// would leave an account whose password nobody holds. +// +// The health check itself never imports this module: it receives only the reader credential. +data bmc_health_reader_body_path: NonEmptyStr = "/tmp/bmc_health_reader_body.json" + +fn bmc_custody_credential(secret_id: NonEmptyStr, token: Secret) -> String? + uses net: Network +{ + resp = gcp.SecretManager.AccessVersion( + access_token: token, + project_id: fleet_secrets_gcp_project, + secret: secret_id, + version: "latest" + ) + if !bmc_stored_credential_read_succeeded(performance: classify_rest_outcome(standing: RestReadExchange { rest: resp.outcome })) { + return none + } + return match decode_sm_access_version_secret_wire(data_b64: resp.data_b64) { + SmAccessVersionSecretDecoded { credential: c } => Present { value: c as String } + SmAccessVersionSecretDecodeRefused { cause: _ } => none + } +} + +fn bmc_login_succeeds(host: NonEmptyStr, account: NonEmptyStr, password: String) -> Bool + uses net: Network +{ + let w = write_netrc_outcome(host: host, account: account, password: password) + if netrc_write_succeeded(outcome: w) == false { + return false + } + probe = redfish.Http.GetSystem(bmc_host: host, netrc_file: bmc_onboard_netrc_path as NonEmptyStr) + return probe.success +} + +// Reads run under the administrator netrc, which the caller has already written. +fn observe_reader_account(host: NonEmptyStr) -> ReaderAccountObservation + uses net: Network +{ + accounts = redfish.Http.GetAccounts(bmc_host: host, netrc_file: bmc_onboard_netrc_path as NonEmptyStr) + if accounts.success == false { + return ReaderAccountUnreadable { cause: concat("the accounts collection could not be read: ", accounts.body) as NonEmptyStr } + } + return match reader_listed_in_accounts(body: accounts.body) { + ReaderListingUnreadable { cause: c } => ReaderAccountUnreadable { cause: c } + ReaderNotListed => ReaderAccountAbsent + ReaderListed => { + account = redfish.Http.GetAccount(bmc_host: host, netrc_file: bmc_onboard_netrc_path as NonEmptyStr, account_id: bmc_health_reader_username) + if account.success { + reader_account_from_json(body: account.body) + } else { + ReaderAccountUnreadable { cause: concat("the listed reader account could not be read: ", account.body) as NonEmptyStr } + } + } + } +} + +fn bmc_health_reader_converge(plan: BmcOnboardingPlan, host_name: NonEmptyStr) -> ProcessExit + uses net: Network +{ + match select_access_token_source() { + AccessTokenSourceUnavailable { cause: c } => return exit_failure(reason: c as String) + AccessTokenSourceSelected { source: s } => match ensure_access_token(source: s) { + AccessTokenUpsertRequired { plan: p } => return exit_failure(reason: access_token_upsert_required_reason(plan: p)) + AccessTokenEnsureRefused { reason: r } => return exit_failure(reason: r as String) + AccessTokenReady { token: t } => + match bmc_custody_credential(secret_id: onboarding_secret_id(plan: plan), token: t) { + none => return exit_failure(reason: concat("reader: the administrator credential is not in custody for ", plan.bmc.host as String)) + Present { value: admin_password } => + return bmc_health_reader_converge_as_admin(plan: plan, host_name: host_name, token: t, admin_password: admin_password) + } + } + } +} + +fn bmc_health_reader_converge_as_admin(plan: BmcOnboardingPlan, host_name: NonEmptyStr, token: Secret, admin_password: String) -> ProcessExit + uses net: Network +{ + let host = plan.bmc.host + let reader_secret = bmc_health_reader_secret_id(host: host_name) + let admin_w = write_netrc_outcome(host: host, account: plan.factory_login.username, password: admin_password) + if netrc_write_succeeded(outcome: admin_w) == false { + return exit_failure(reason: netrc_failure_reason(outcome: admin_w, stage: "reader administrator")) + } + match plan_reader_account(observation: observe_reader_account(host: host)) { + ReaderAccountRefused { cause: c } => return exit_failure(reason: concat("reader: ", c as String)) + ReaderAccountConverged => + match bmc_custody_credential(secret_id: reader_secret, token: token) { + none => return exit_failure(reason: "reader: the account exists but its credential is not in custody; it was not created by this flow or custody was lost") + Present { value: held } => + return if bmc_login_succeeds(host: host, account: bmc_health_reader_username, password: held) { + ExitSuccess + } else { + exit_failure(reason: "reader: the account exists but the custody credential does not authenticate") + } + } + CreateReaderAccount => { + let credential = mint_bmc_credential() + let stored = bmc_store_credential_verified(secret_id: reader_secret, token: token, credential: credential, host_label: host as String) + if bmc_custody_refused(custody: stored) { + return exit_failure(reason: bmc_custody_refusal_reason(custody: stored)) + } + let body = Filesystem.WriteOwnerOnly(path: bmc_health_reader_body_path, content: reader_account_create_body(password: credential as String)) + if body.success == false { + return exit_failure(reason: concat("reader: create body write failed: ", body.error)) + } + created = redfish.Http.CreateAccount(bmc_host: host, netrc_file: bmc_onboard_netrc_path as NonEmptyStr, request_body_file: bmc_health_reader_body_path) + let body_removed = shell.Remove.FileForce(path: bmc_health_reader_body_path).success + if created.success == false { + return exit_failure(reason: concat("reader: account POST refused: ", created.body)) + } + if body_removed == false { + return exit_failure(reason: concat("reader: the account was created but the request body holding its password could not be removed: ", bmc_health_reader_body_path as String)) + } + let role_seen = observe_reader_account(host: host) + if !bmc_login_succeeds(host: host, account: bmc_health_reader_username, password: credential as String) { + return exit_failure(reason: "reader: the created account does not authenticate with the stored credential") + } + return match plan_reader_account(observation: role_seen) { + ReaderAccountConverged => ExitSuccess + ReaderAccountRefused { cause: c } => exit_failure(reason: concat("reader: created, but read back as: ", c as String)) + CreateReaderAccount => exit_failure(reason: "reader: created, but the accounts collection does not list it") + } + } + } +} + +fn srv1_health_reader_converge() -> ProcessExit + uses net: Network +{ + bmc_health_reader_converge(plan: srv1_onboarding_plan, host_name: "srv1") +} + +fn srv2_health_reader_converge() -> ProcessExit + uses net: Network +{ + bmc_health_reader_converge(plan: srv2_onboarding_plan, host_name: "srv2") +} + +fn srv3_health_reader_converge() -> ProcessExit + uses net: Network +{ + bmc_health_reader_converge(plan: srv3_onboarding_plan, host_name: "srv3") +} + +fn srv4_health_reader_converge() -> ProcessExit + uses net: Network +{ + bmc_health_reader_converge(plan: srv4_onboarding_plan, host_name: "srv4") +} diff --git a/dag/gunbc/tools/bmc_onboard.dag b/dag/gunbc/tools/bmc_onboard.dag index b2f2c901246..81835231c92 100644 --- a/dag/gunbc/tools/bmc_onboard.dag +++ b/dag/gunbc/tools/bmc_onboard.dag @@ -334,12 +334,55 @@ fn bmc_custody_refusal_reason(custody: BmcCustodyContainer) -> String { match custody { BmcCustodyEstablished => "" BmcCustodyRefused { reason: r } => r } } -fn bmc_store_and_rotate(plan: BmcOnboardingPlan, token_source: AccessTokenSource, credential: Secret, current_password: String) -> ProcessExit +// STORE, THEN READ BACK THE EXACT VERSION, BEFORE THE CREDENTIAL IS PUT TO USE ANYWHERE. Shared by +// every flow that mints a BMC credential -- the administrator rotation and the health-reader account +// -- because the rule is the same for both: a credential the BMC accepts but custody cannot return +// is a lockout, so nothing is sent to the BMC until custody has proven it holds these bytes. +fn bmc_store_credential_verified(secret_id: NonEmptyStr, token: Secret, credential: Secret, host_label: String) -> BmcCustodyContainer uses net: Network { - let payload_b64 = encode_sm_access_version_payload_wire(credential: credential) - let secret_id = onboarding_secret_id(plan: plan) + let custody = bmc_establish_custody_container(secret_id: secret_id, token: token) + if bmc_custody_refused(custody: custody) { + return custody + } + stored = gcp.SecretManager.AddVersion( + access_token: token, + secret_name: gcp_secret_full_name(project: fleet_secrets_gcp_project, secret_id: secret_id), + payload_b64: encode_sm_access_version_payload_wire(credential: credential) + ) + match bmc_readback_target(stored_name: stored.name as String) { + BmcReadbackUnaddressable { supplied: supplied } => + return BmcCustodyRefused { reason: concat("store: AddVersion returned a name this run cannot address a read-back against: ", supplied) } + BmcReadbackAddressed { identity: stored_identity, version: stored_version } => { + readback = gcp.SecretManager.AccessVersion( + access_token: token, + project_id: fleet_secrets_gcp_project, + secret: secret_id, + version: stored_version + ) + let expected_payload = utf8_encode_bytes(s: credential as String) + let readback_ok = match decode_sm_access_version_payload_wire(data_b64: readback.data_b64) { + SmAccessVersionPayloadDecoded { payload: stored_payload } => + stored_payload == expected_payload + SmAccessVersionPayloadDecodeRefused { cause: _ } => false + } + match decode_sm_resolved_version_identity_wire(name: readback.name as String) { + SmResolvedVersionIdentityDecodeRefused { cause: _ } => + return BmcCustodyRefused { reason: "store: GCP read-back resolved version identity refused by wire decoder" } + SmResolvedVersionIdentityDecoded { identity: readback_identity } => + return if !readback_ok || stored_identity != readback_identity { + BmcCustodyRefused { reason: concat("store: GCP durability read-back mismatch -- refusing to use the credential on ", host_label, "; stored version ", stored_identity as String) } + } else { + BmcCustodyEstablished + } + } + } + } +} +fn bmc_store_and_rotate(plan: BmcOnboardingPlan, token_source: AccessTokenSource, credential: Secret, current_password: String) -> ProcessExit + uses net: Network +{ match ensure_access_token(source: token_source) { AccessTokenUpsertRequired { plan: upsert_plan } => return exit_failure( @@ -348,44 +391,11 @@ fn bmc_store_and_rotate(plan: BmcOnboardingPlan, token_source: AccessTokenSource AccessTokenEnsureRefused { reason: reason } => return exit_failure(reason: reason as String) AccessTokenReady { token: token } => { - let custody = bmc_establish_custody_container(secret_id: secret_id, token: token) - if bmc_custody_refused(custody: custody) { - return exit_failure(reason: bmc_custody_refusal_reason(custody: custody)) - } - stored = gcp.SecretManager.AddVersion( - access_token: token, - secret_name: gcp_secret_full_name(project: fleet_secrets_gcp_project, secret_id: secret_id), - payload_b64: payload_b64 - ) - match bmc_readback_target(stored_name: stored.name as String) { - BmcReadbackUnaddressable { supplied: supplied } => - return exit_failure(reason: concat("store: AddVersion returned a name this run cannot address a read-back against: ", supplied)) - BmcReadbackAddressed { identity: stored_identity, version: stored_version } => { - readback = gcp.SecretManager.AccessVersion( - access_token: token, - project_id: fleet_secrets_gcp_project, - secret: secret_id, - version: stored_version - ) - let expected_payload = utf8_encode_bytes(s: credential as String) - let readback_ok = match decode_sm_access_version_payload_wire(data_b64: readback.data_b64) { - SmAccessVersionPayloadDecoded { payload: stored_payload } => - stored_payload == expected_payload - SmAccessVersionPayloadDecodeRefused { cause: _ } => false - } - match decode_sm_resolved_version_identity_wire(name: readback.name as String) { - SmResolvedVersionIdentityDecodeRefused { cause: _ } => - return exit_failure(reason: "store: GCP read-back resolved version identity refused by wire decoder") - SmResolvedVersionIdentityDecoded { identity: readback_identity } => { - if !readback_ok || stored_identity != readback_identity { - return exit_failure(reason: concat(concat(concat("store: GCP durability read-back mismatch — refusing to rotate (would lock out ", plan.bmc.host), concat("); stored version ", stored_identity as String)), "")) - } - } - } - - return bmc_rotate_credential(plan: plan, current_password: current_password, new_credential: credential) - } + let stored = bmc_store_credential_verified(secret_id: onboarding_secret_id(plan: plan), token: token, credential: credential, host_label: plan.bmc.host as String) + if bmc_custody_refused(custody: stored) { + return exit_failure(reason: bmc_custody_refusal_reason(custody: stored)) } + return bmc_rotate_credential(plan: plan, current_password: current_password, new_credential: credential) } } } diff --git a/dag/test/claim/bmc/bmc_health_reader_witness_test.dag b/dag/test/claim/bmc/bmc_health_reader_witness_test.dag new file mode 100644 index 00000000000..486e6fa4972 --- /dev/null +++ b/dag/test/claim/bmc/bmc_health_reader_witness_test.dag @@ -0,0 +1,72 @@ +module test.claim.bmc_health_reader_witness + +import std.types { Bool, String } +import gunbc.bmc_health_reader { + CreateReaderAccount, + ReaderAccountAbsent, + ReaderAccountConverged, + ReaderAccountPresent, + ReaderAccountRefused, + ReaderAccountUnreadable, + ReaderListed, + ReaderListingUnreadable, + ReaderNotListed, + bmc_health_reader_secret_id, + plan_reader_account, + reader_account_create_body, + reader_account_from_json, + reader_listed_in_accounts, +} +import gunbc.tools.bmc_health_reader_converge { bmc_health_reader_body_path } +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +// Supplied bodies in the shape the DMTF Redfish AccountService schema gives the collection and a +// ManagerAccount resource. They are this witness's inputs, not observations of any BMC; the live +// route that reads the real ones is gunbc.tools.bmc_health_reader_converge, which this module also +// imports so that resolving these claims typechecks it. +data collection_with_reader: String = "{\"Members\":[{\"@odata.id\":\"/redfish/v1/AccountService/Accounts/root\"},{\"@odata.id\":\"/redfish/v1/AccountService/Accounts/gunbc-health\"}],\"Members@odata.count\":2}" +data collection_without_reader: String = "{\"Members\":[{\"@odata.id\":\"/redfish/v1/AccountService/Accounts/root\"},{\"@odata.id\":\"/redfish/v1/AccountService/Accounts/gunbc-health-old\"}]}" + +test fn only_an_exact_member_counts_as_the_reader() -> Bool { + let listed = match reader_listed_in_accounts(body: collection_with_reader) { ReaderListed => true _ => false } + let near_miss = match reader_listed_in_accounts(body: collection_without_reader) { ReaderNotListed => true _ => false } + listed && near_miss +} + +// An unreadable collection is not an empty one: absence would authorize a create. +test fn an_unreadable_collection_never_reads_as_absent() -> Bool { + let garbage = match reader_listed_in_accounts(body: "login") { ReaderListingUnreadable { cause: _ } => true _ => false } + let no_members = match reader_listed_in_accounts(body: "{\"Name\":\"Accounts\"}") { ReaderListingUnreadable { cause: _ } => true _ => false } + let refused = match plan_reader_account(observation: ReaderAccountUnreadable { cause: "x" }) { ReaderAccountRefused { cause: _ } => true _ => false } + garbage && no_members && refused +} + +test fn absent_creates_and_an_enabled_read_only_reader_is_converged() -> Bool { + let create = match plan_reader_account(observation: ReaderAccountAbsent) { CreateReaderAccount => true _ => false } + let converged = match plan_reader_account(observation: reader_account_from_json(body: "{\"UserName\":\"gunbc-health\",\"RoleId\":\"ReadOnly\",\"Enabled\":true}")) { + ReaderAccountConverged => true + _ => false + } + create && converged +} + +// An account of that name this flow did not make is refused, never demoted or re-enabled. +test fn a_wrong_role_or_a_disabled_reader_is_refused_not_corrected() -> Bool { + let admin = match plan_reader_account(observation: ReaderAccountPresent { role: "Administrator", enabled: true }) { ReaderAccountRefused { cause: _ } => true _ => false } + let disabled = match plan_reader_account(observation: ReaderAccountPresent { role: "ReadOnly", enabled: false }) { ReaderAccountRefused { cause: _ } => true _ => false } + let no_role = match reader_account_from_json(body: "{\"UserName\":\"gunbc-health\",\"Enabled\":true}") { ReaderAccountUnreadable { cause: _ } => true _ => false } + admin && disabled && no_role +} + +// The body is read back through the same decoder the observation uses, rather than matched as +// text: the body the flow sends must be one the flow itself would judge converged. +test fn the_create_body_asks_for_read_only_and_the_secret_is_per_host() -> Bool { + let body = reader_account_create_body(password: "p") + let judged = match plan_reader_account(observation: reader_account_from_json(body: body)) { ReaderAccountConverged => true _ => false } + judged + && string_contains(s: body, pattern: "gunbc-health") + && bmc_health_reader_secret_id(host: "srv3") == "bmc-srv3-health-reader" + && bmc_health_reader_body_path == "/tmp/bmc_health_reader_body.json" +} From a05b21fa5c764074aa00ff6cac1eec566aecc3cc Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 16:28:23 +0000 Subject: [PATCH 03/15] gunbc_health: OpenBMC refuses a hyphen in UserName (PropertyValueFormatError, srv1) Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/bmc/bmc_health_reader.dag | 5 +++-- dag/test/claim/bmc/bmc_health_reader_witness_test.dag | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/dag/gunbc/bmc/bmc_health_reader.dag b/dag/gunbc/bmc/bmc_health_reader.dag index 4232e570783..e220f4cb71d 100644 --- a/dag/gunbc/bmc/bmc_health_reader.dag +++ b/dag/gunbc/bmc/bmc_health_reader.dag @@ -22,8 +22,9 @@ import extdeps.languages.json.parse { // // ReadOnly is the DMTF Redfish predefined role holding only Login and ConfigureSelf // (extdeps.bmc.access redfish_rbac_policy): it can read every resource and change nothing but its -// own password. -data bmc_health_reader_username: NonEmptyStr = "gunbc-health" +// own password. The name is an underscore, not a hyphen, because OpenBMC refuses a hyphen in a +// UserName (Base.1.16.0.PropertyValueFormatError, observed on srv1 2026-09-18). +data bmc_health_reader_username: NonEmptyStr = "gunbc_health" fn bmc_health_reader_secret_id(host: NonEmptyStr) -> NonEmptyStr { concat("bmc-", host as String, "-health-reader") as NonEmptyStr diff --git a/dag/test/claim/bmc/bmc_health_reader_witness_test.dag b/dag/test/claim/bmc/bmc_health_reader_witness_test.dag index 486e6fa4972..62c49de7e8a 100644 --- a/dag/test/claim/bmc/bmc_health_reader_witness_test.dag +++ b/dag/test/claim/bmc/bmc_health_reader_witness_test.dag @@ -26,8 +26,8 @@ data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly // ManagerAccount resource. They are this witness's inputs, not observations of any BMC; the live // route that reads the real ones is gunbc.tools.bmc_health_reader_converge, which this module also // imports so that resolving these claims typechecks it. -data collection_with_reader: String = "{\"Members\":[{\"@odata.id\":\"/redfish/v1/AccountService/Accounts/root\"},{\"@odata.id\":\"/redfish/v1/AccountService/Accounts/gunbc-health\"}],\"Members@odata.count\":2}" -data collection_without_reader: String = "{\"Members\":[{\"@odata.id\":\"/redfish/v1/AccountService/Accounts/root\"},{\"@odata.id\":\"/redfish/v1/AccountService/Accounts/gunbc-health-old\"}]}" +data collection_with_reader: String = "{\"Members\":[{\"@odata.id\":\"/redfish/v1/AccountService/Accounts/root\"},{\"@odata.id\":\"/redfish/v1/AccountService/Accounts/gunbc_health\"}],\"Members@odata.count\":2}" +data collection_without_reader: String = "{\"Members\":[{\"@odata.id\":\"/redfish/v1/AccountService/Accounts/root\"},{\"@odata.id\":\"/redfish/v1/AccountService/Accounts/gunbc_health_old\"}]}" test fn only_an_exact_member_counts_as_the_reader() -> Bool { let listed = match reader_listed_in_accounts(body: collection_with_reader) { ReaderListed => true _ => false } @@ -45,7 +45,7 @@ test fn an_unreadable_collection_never_reads_as_absent() -> Bool { test fn absent_creates_and_an_enabled_read_only_reader_is_converged() -> Bool { let create = match plan_reader_account(observation: ReaderAccountAbsent) { CreateReaderAccount => true _ => false } - let converged = match plan_reader_account(observation: reader_account_from_json(body: "{\"UserName\":\"gunbc-health\",\"RoleId\":\"ReadOnly\",\"Enabled\":true}")) { + let converged = match plan_reader_account(observation: reader_account_from_json(body: "{\"UserName\":\"gunbc_health\",\"RoleId\":\"ReadOnly\",\"Enabled\":true}")) { ReaderAccountConverged => true _ => false } @@ -56,7 +56,7 @@ test fn absent_creates_and_an_enabled_read_only_reader_is_converged() -> Bool { test fn a_wrong_role_or_a_disabled_reader_is_refused_not_corrected() -> Bool { let admin = match plan_reader_account(observation: ReaderAccountPresent { role: "Administrator", enabled: true }) { ReaderAccountRefused { cause: _ } => true _ => false } let disabled = match plan_reader_account(observation: ReaderAccountPresent { role: "ReadOnly", enabled: false }) { ReaderAccountRefused { cause: _ } => true _ => false } - let no_role = match reader_account_from_json(body: "{\"UserName\":\"gunbc-health\",\"Enabled\":true}") { ReaderAccountUnreadable { cause: _ } => true _ => false } + let no_role = match reader_account_from_json(body: "{\"UserName\":\"gunbc_health\",\"Enabled\":true}") { ReaderAccountUnreadable { cause: _ } => true _ => false } admin && disabled && no_role } @@ -66,7 +66,7 @@ test fn the_create_body_asks_for_read_only_and_the_secret_is_per_host() -> Bool let body = reader_account_create_body(password: "p") let judged = match plan_reader_account(observation: reader_account_from_json(body: body)) { ReaderAccountConverged => true _ => false } judged - && string_contains(s: body, pattern: "gunbc-health") + && string_contains(s: body, pattern: "gunbc_health") && bmc_health_reader_secret_id(host: "srv3") == "bmc-srv3-health-reader" && bmc_health_reader_body_path == "/tmp/bmc_health_reader_body.json" } From 4a2345db4eca61c02aa602220420c90b20af43ec Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 16:47:36 +0000 Subject: [PATCH 04/15] An accounts member without one string @odata.id makes the collection unreadable, never absent (review 67794) Members are read through gunbc.scm.json_member rather than a local optional-collapsing lookup; a skipped member used to shorten the membership and let an unread reader read as absent, authorizing a create. RoleId/Enabled go through the same authority. Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/bmc/bmc_health_reader.dag | 54 ++++++++++--------- .../bmc/bmc_health_reader_witness_test.dag | 7 ++- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/dag/gunbc/bmc/bmc_health_reader.dag b/dag/gunbc/bmc/bmc_health_reader.dag index e220f4cb71d..ef195b4b17c 100644 --- a/dag/gunbc/bmc/bmc_health_reader.dag +++ b/dag/gunbc/bmc/bmc_health_reader.dag @@ -2,12 +2,18 @@ module gunbc.bmc_health_reader import std.types { Bool, List, NonEmptyStr, String } import extdeps.bmc.types { AccountRoleReadOnly, redfish_account_role_wire } -import extdeps.languages.json.emit { JsonArray, JsonBool, JsonString, JsonValue, json_bool, json_kv, json_object, json_string, serialize_json } +import extdeps.languages.json.emit { JsonBool, JsonValue, json_bool, json_kv, json_object, json_string, serialize_json } +import gunbc.scm.json_member { + MemberArray, + MemberString, + MemberValue, + read_member, + read_member_value, + read_string_member, +} import extdeps.languages.json.parse { JsonDocumentParsed, JsonDocumentUnreadable, - JsonMemberFound, - json_object_unique_member, parse_json_document, } @@ -70,27 +76,27 @@ fn plan_reader_account(observation: ReaderAccountObservation) -> ReaderAccountPl } } -fn json_string_member(v: JsonValue, key: String) -> String? { - match json_object_unique_member(v: v, key: key) { - JsonMemberFound { value: JsonString { value } } => Present { value: value } - _ => none - } -} - -fn json_bool_member(v: JsonValue, key: String) -> Bool? { - match json_object_unique_member(v: v, key: key) { - JsonMemberFound { value: JsonBool { value } } => Present { value: value } +// EVERY MEMBER MUST NAME ITSELF, OR THE COLLECTION WAS NOT READ. An element without exactly one +// string @odata.id is not skipped: skipping it would shorten the membership, and a shortened +// membership is how an unread reader account reads as absent and authorizes a create (review 67794). +// Member lookups go through gunbc.scm.json_member, which keeps missing, duplicated, non-object and +// wrong-shape apart instead of collapsing them into an absent value. +fn member_ids(v: JsonValue) -> List? { + match read_member(v: v, key: "Members") { + MemberArray { elements } => fold(elements, init: Present { value: [] }, f: (acc, e) => match acc { + none => none + Present { value: ids } => match read_string_member(v: e, key: "@odata.id") { + MemberString { value: id } => Present { value: concat(ids, [id]) } + _ => none + } + }) _ => none } } -fn member_ids(v: JsonValue) -> List? { - match json_object_unique_member(v: v, key: "Members") { - JsonMemberFound { value: JsonArray { elements } } => - Present { value: flat_map(elements, e => match json_string_member(v: e, key: "@odata.id") { - Present { value: id } => [id] - none => [] - }) } +fn enabled_member(v: JsonValue) -> Bool? { + match read_member_value(v: v, key: "Enabled") { + MemberValue { value: JsonBool { value } } => Present { value: value } _ => none } } @@ -102,7 +108,7 @@ fn reader_listed_in_accounts(body: String) -> ReaderListing { match parse_json_document(s: body) { JsonDocumentUnreadable { gap: _ } => ReaderListingUnreadable { cause: "the Redfish accounts collection is not a JSON document" } JsonDocumentParsed { value } => match member_ids(v: value) { - none => ReaderListingUnreadable { cause: "the Redfish accounts collection carries no Members array" } + none => ReaderListingUnreadable { cause: "the Redfish accounts collection carries no Members array, or a member without exactly one string @odata.id" } Present { value: ids } => if any(ids, id => ends_with(s: id, suffix: concat("/Accounts/", bmc_health_reader_username as String))) { ReaderListed @@ -121,12 +127,12 @@ type ReaderListing fn reader_account_from_json(body: String) -> ReaderAccountObservation { match parse_json_document(s: body) { JsonDocumentUnreadable { gap: _ } => ReaderAccountUnreadable { cause: "the Redfish account resource is not a JSON document" } - JsonDocumentParsed { value } => match json_string_member(v: value, key: "RoleId") { - none => ReaderAccountUnreadable { cause: "the Redfish account resource carries no RoleId" } - Present { value: role } => match json_bool_member(v: value, key: "Enabled") { + JsonDocumentParsed { value } => match read_string_member(v: value, key: "RoleId") { + MemberString { value: role } => match enabled_member(v: value) { none => ReaderAccountUnreadable { cause: "the Redfish account resource carries no Enabled flag" } Present { value: enabled } => ReaderAccountPresent { role: role, enabled: enabled } } + _ => ReaderAccountUnreadable { cause: "the Redfish account resource carries no single string RoleId" } } } } diff --git a/dag/test/claim/bmc/bmc_health_reader_witness_test.dag b/dag/test/claim/bmc/bmc_health_reader_witness_test.dag index 62c49de7e8a..f2c6a047228 100644 --- a/dag/test/claim/bmc/bmc_health_reader_witness_test.dag +++ b/dag/test/claim/bmc/bmc_health_reader_witness_test.dag @@ -35,12 +35,15 @@ test fn only_an_exact_member_counts_as_the_reader() -> Bool { listed && near_miss } -// An unreadable collection is not an empty one: absence would authorize a create. +// An unreadable collection is not an empty one: absence would authorize a create. That includes a +// collection with one member that does not name itself (review 67794), which used to be skipped. test fn an_unreadable_collection_never_reads_as_absent() -> Bool { let garbage = match reader_listed_in_accounts(body: "login") { ReaderListingUnreadable { cause: _ } => true _ => false } let no_members = match reader_listed_in_accounts(body: "{\"Name\":\"Accounts\"}") { ReaderListingUnreadable { cause: _ } => true _ => false } + let unnamed_member = match reader_listed_in_accounts(body: "{\"Members\":[{\"@odata.id\":\"/redfish/v1/AccountService/Accounts/root\"},{\"Name\":\"gunbc_health\"}]}") { ReaderListingUnreadable { cause: _ } => true _ => false } + let duplicated_id = match reader_listed_in_accounts(body: "{\"Members\":[{\"@odata.id\":\"a\",\"@odata.id\":\"b\"}]}") { ReaderListingUnreadable { cause: _ } => true _ => false } let refused = match plan_reader_account(observation: ReaderAccountUnreadable { cause: "x" }) { ReaderAccountRefused { cause: _ } => true _ => false } - garbage && no_members && refused + garbage && no_members && unnamed_member && duplicated_id && refused } test fn absent_creates_and_an_enabled_read_only_reader_is_converged() -> Bool { From a16c9ad5af5b8f941fe0b5a99597726eb3ad5562 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 17:08:43 +0000 Subject: [PATCH 05/15] Custody outcome matched at each call site; role read-back taken before the reader login; per-host body path refused on failed removal (review 67817) - bmc_custody_refused / bmc_custody_refusal_reason deleted: the accessor fabricated "" for an unreachable arm. bmc_store_credential_verified, bmc_store_and_rotate and the reader create now match BmcCustodyContainer directly; the witness that locked the predicate pair in is deleted (the tool module stays typechecked through the reader witness's import). - The reader create is its own function: POST, then the administrator role read-back is matched, and only in its converged arm does the reader login repoint the shared netrc. - The create body path is per host, and a failed removal refuses before the POST outcome is read. Co-Authored-By: Claude Opus 5 (1M context) --- .../tools/bmc_health_reader_converge.dag | 68 +++++++++------- dag/gunbc/tools/bmc_onboard.dag | 78 +++++++++---------- .../bmc_custody_container_witness_test.dag | 21 ----- .../bmc/bmc_health_reader_witness_test.dag | 2 +- 4 files changed, 77 insertions(+), 92 deletions(-) delete mode 100644 dag/test/claim/bmc/bmc_custody_container_witness_test.dag diff --git a/dag/gunbc/tools/bmc_health_reader_converge.dag b/dag/gunbc/tools/bmc_health_reader_converge.dag index a0734c934a3..8f5685dd53c 100644 --- a/dag/gunbc/tools/bmc_health_reader_converge.dag +++ b/dag/gunbc/tools/bmc_health_reader_converge.dag @@ -35,8 +35,8 @@ import gunbc.bmc_health_reader { reader_listed_in_accounts, } import gunbc.tools.bmc_onboard { - bmc_custody_refused, - bmc_custody_refusal_reason, + BmcCustodyEstablished, + BmcCustodyRefused, bmc_onboard_netrc_path, bmc_stored_credential_read_succeeded, bmc_store_credential_verified, @@ -74,7 +74,12 @@ import std.process { ProcessExit, ExitSuccess, exit_failure } // would leave an account whose password nobody holds. // // The health check itself never imports this module: it receives only the reader credential. -data bmc_health_reader_body_path: NonEmptyStr = "/tmp/bmc_health_reader_body.json" +// +// The create body holds the reader's password in plaintext, so its path is per host (two hosts +// converging at once must not share it) and a failed removal refuses whatever the POST answered. +fn bmc_health_reader_body_path(host_name: NonEmptyStr) -> NonEmptyStr { + concat("/tmp/bmc_health_reader_body_", host_name as String, ".json") as NonEmptyStr +} fn bmc_custody_credential(secret_id: NonEmptyStr, token: Secret) -> String? uses net: Network @@ -168,35 +173,44 @@ fn bmc_health_reader_converge_as_admin(plan: BmcOnboardingPlan, host_name: NonEm } CreateReaderAccount => { let credential = mint_bmc_credential() - let stored = bmc_store_credential_verified(secret_id: reader_secret, token: token, credential: credential, host_label: host as String) - if bmc_custody_refused(custody: stored) { - return exit_failure(reason: bmc_custody_refusal_reason(custody: stored)) - } - let body = Filesystem.WriteOwnerOnly(path: bmc_health_reader_body_path, content: reader_account_create_body(password: credential as String)) - if body.success == false { - return exit_failure(reason: concat("reader: create body write failed: ", body.error)) - } - created = redfish.Http.CreateAccount(bmc_host: host, netrc_file: bmc_onboard_netrc_path as NonEmptyStr, request_body_file: bmc_health_reader_body_path) - let body_removed = shell.Remove.FileForce(path: bmc_health_reader_body_path).success - if created.success == false { - return exit_failure(reason: concat("reader: account POST refused: ", created.body)) - } - if body_removed == false { - return exit_failure(reason: concat("reader: the account was created but the request body holding its password could not be removed: ", bmc_health_reader_body_path as String)) - } - let role_seen = observe_reader_account(host: host) - if !bmc_login_succeeds(host: host, account: bmc_health_reader_username, password: credential as String) { - return exit_failure(reason: "reader: the created account does not authenticate with the stored credential") - } - return match plan_reader_account(observation: role_seen) { - ReaderAccountConverged => ExitSuccess - ReaderAccountRefused { cause: c } => exit_failure(reason: concat("reader: created, but read back as: ", c as String)) - CreateReaderAccount => exit_failure(reason: "reader: created, but the accounts collection does not list it") + match bmc_store_credential_verified(secret_id: reader_secret, token: token, credential: credential, host_label: host as String) { + BmcCustodyRefused { reason: r } => return exit_failure(reason: r) + BmcCustodyEstablished => return bmc_health_reader_create(host: host, host_name: host_name, credential: credential) } } } } +// Runs under the administrator netrc the caller wrote. The role read-back is taken under it too, +// BEFORE the reader login repoints the shared netrc: the reader is not entitled to read accounts. +fn bmc_health_reader_create(host: NonEmptyStr, host_name: NonEmptyStr, credential: Secret) -> ProcessExit + uses net: Network +{ + let body_path = bmc_health_reader_body_path(host_name: host_name) + let body = Filesystem.WriteOwnerOnly(path: body_path, content: reader_account_create_body(password: credential as String)) + if body.success == false { + return exit_failure(reason: concat("reader: create body write failed: ", body.error)) + } + created = redfish.Http.CreateAccount(bmc_host: host, netrc_file: bmc_onboard_netrc_path as NonEmptyStr, request_body_file: body_path) + let body_removed = shell.Remove.FileForce(path: body_path).success + if body_removed == false { + return exit_failure(reason: concat("reader: the request body holding the reader password could not be removed: ", body_path as String)) + } + if created.success == false { + return exit_failure(reason: concat("reader: account POST refused: ", created.body)) + } + match plan_reader_account(observation: observe_reader_account(host: host)) { + ReaderAccountRefused { cause: c } => return exit_failure(reason: concat("reader: created, but read back as: ", c as String)) + CreateReaderAccount => return exit_failure(reason: "reader: created, but the accounts collection does not list it") + ReaderAccountConverged => + return if bmc_login_succeeds(host: host, account: bmc_health_reader_username, password: credential as String) { + ExitSuccess + } else { + exit_failure(reason: "reader: the created account does not authenticate with the stored credential") + } + } +} + fn srv1_health_reader_converge() -> ProcessExit uses net: Network { diff --git a/dag/gunbc/tools/bmc_onboard.dag b/dag/gunbc/tools/bmc_onboard.dag index 81835231c92..b013b0d0cf4 100644 --- a/dag/gunbc/tools/bmc_onboard.dag +++ b/dag/gunbc/tools/bmc_onboard.dag @@ -326,14 +326,6 @@ fn bmc_establish_custody_container(secret_id: NonEmptyStr, token: Secret) -> Bmc } } -fn bmc_custody_refused(custody: BmcCustodyContainer) -> Bool { - match custody { BmcCustodyEstablished => false BmcCustodyRefused { reason: _ } => true } -} - -fn bmc_custody_refusal_reason(custody: BmcCustodyContainer) -> String { - match custody { BmcCustodyEstablished => "" BmcCustodyRefused { reason: r } => r } -} - // STORE, THEN READ BACK THE EXACT VERSION, BEFORE THE CREDENTIAL IS PUT TO USE ANYWHERE. Shared by // every flow that mints a BMC credential -- the administrator rotation and the health-reader account // -- because the rule is the same for both: a credential the BMC accepts but custody cannot return @@ -341,40 +333,41 @@ fn bmc_custody_refusal_reason(custody: BmcCustodyContainer) -> String { fn bmc_store_credential_verified(secret_id: NonEmptyStr, token: Secret, credential: Secret, host_label: String) -> BmcCustodyContainer uses net: Network { - let custody = bmc_establish_custody_container(secret_id: secret_id, token: token) - if bmc_custody_refused(custody: custody) { - return custody - } - stored = gcp.SecretManager.AddVersion( - access_token: token, - secret_name: gcp_secret_full_name(project: fleet_secrets_gcp_project, secret_id: secret_id), - payload_b64: encode_sm_access_version_payload_wire(credential: credential) - ) - match bmc_readback_target(stored_name: stored.name as String) { - BmcReadbackUnaddressable { supplied: supplied } => - return BmcCustodyRefused { reason: concat("store: AddVersion returned a name this run cannot address a read-back against: ", supplied) } - BmcReadbackAddressed { identity: stored_identity, version: stored_version } => { - readback = gcp.SecretManager.AccessVersion( + match bmc_establish_custody_container(secret_id: secret_id, token: token) { + BmcCustodyRefused { reason: r } => return BmcCustodyRefused { reason: r } + BmcCustodyEstablished => { + stored = gcp.SecretManager.AddVersion( access_token: token, - project_id: fleet_secrets_gcp_project, - secret: secret_id, - version: stored_version + secret_name: gcp_secret_full_name(project: fleet_secrets_gcp_project, secret_id: secret_id), + payload_b64: encode_sm_access_version_payload_wire(credential: credential) ) - let expected_payload = utf8_encode_bytes(s: credential as String) - let readback_ok = match decode_sm_access_version_payload_wire(data_b64: readback.data_b64) { - SmAccessVersionPayloadDecoded { payload: stored_payload } => - stored_payload == expected_payload - SmAccessVersionPayloadDecodeRefused { cause: _ } => false - } - match decode_sm_resolved_version_identity_wire(name: readback.name as String) { - SmResolvedVersionIdentityDecodeRefused { cause: _ } => - return BmcCustodyRefused { reason: "store: GCP read-back resolved version identity refused by wire decoder" } - SmResolvedVersionIdentityDecoded { identity: readback_identity } => - return if !readback_ok || stored_identity != readback_identity { - BmcCustodyRefused { reason: concat("store: GCP durability read-back mismatch -- refusing to use the credential on ", host_label, "; stored version ", stored_identity as String) } - } else { - BmcCustodyEstablished + match bmc_readback_target(stored_name: stored.name as String) { + BmcReadbackUnaddressable { supplied: supplied } => + return BmcCustodyRefused { reason: concat("store: AddVersion returned a name this run cannot address a read-back against: ", supplied) } + BmcReadbackAddressed { identity: stored_identity, version: stored_version } => { + readback = gcp.SecretManager.AccessVersion( + access_token: token, + project_id: fleet_secrets_gcp_project, + secret: secret_id, + version: stored_version + ) + let expected_payload = utf8_encode_bytes(s: credential as String) + let readback_ok = match decode_sm_access_version_payload_wire(data_b64: readback.data_b64) { + SmAccessVersionPayloadDecoded { payload: stored_payload } => + stored_payload == expected_payload + SmAccessVersionPayloadDecodeRefused { cause: _ } => false + } + match decode_sm_resolved_version_identity_wire(name: readback.name as String) { + SmResolvedVersionIdentityDecodeRefused { cause: _ } => + return BmcCustodyRefused { reason: "store: GCP read-back resolved version identity refused by wire decoder" } + SmResolvedVersionIdentityDecoded { identity: readback_identity } => + return if !readback_ok || stored_identity != readback_identity { + BmcCustodyRefused { reason: concat("store: GCP durability read-back mismatch -- refusing to use the credential on ", host_label, "; stored version ", stored_identity as String) } + } else { + BmcCustodyEstablished + } } + } } } } @@ -391,11 +384,10 @@ fn bmc_store_and_rotate(plan: BmcOnboardingPlan, token_source: AccessTokenSource AccessTokenEnsureRefused { reason: reason } => return exit_failure(reason: reason as String) AccessTokenReady { token: token } => { - let stored = bmc_store_credential_verified(secret_id: onboarding_secret_id(plan: plan), token: token, credential: credential, host_label: plan.bmc.host as String) - if bmc_custody_refused(custody: stored) { - return exit_failure(reason: bmc_custody_refusal_reason(custody: stored)) + match bmc_store_credential_verified(secret_id: onboarding_secret_id(plan: plan), token: token, credential: credential, host_label: plan.bmc.host as String) { + BmcCustodyRefused { reason: r } => return exit_failure(reason: r) + BmcCustodyEstablished => return bmc_rotate_credential(plan: plan, current_password: current_password, new_credential: credential) } - return bmc_rotate_credential(plan: plan, current_password: current_password, new_credential: credential) } } } diff --git a/dag/test/claim/bmc/bmc_custody_container_witness_test.dag b/dag/test/claim/bmc/bmc_custody_container_witness_test.dag deleted file mode 100644 index 8628fce340f..00000000000 --- a/dag/test/claim/bmc/bmc_custody_container_witness_test.dag +++ /dev/null @@ -1,21 +0,0 @@ -module test.claim.bmc_custody_container_witness - -import std.types { Bool } -import gunbc.tools.bmc_onboard { - BmcCustodyEstablished, - BmcCustodyRefused, - bmc_custody_refused, - bmc_custody_refusal_reason, -} -import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } - -data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly - -// The store step stops on a refused custody container and carries its reason; an established one -// lets it proceed. Resolving this module also typechecks the effectful custody step beside it, -// whose arms are the actuator's already-witnessed observation and creation outcomes. -test fn a_refused_custody_container_stops_the_store_with_its_reason() -> Bool { - bmc_custody_refused(custody: BmcCustodyRefused { reason: "custody: absent" }) - && bmc_custody_refusal_reason(custody: BmcCustodyRefused { reason: "custody: absent" }) == "custody: absent" - && !bmc_custody_refused(custody: BmcCustodyEstablished) -} diff --git a/dag/test/claim/bmc/bmc_health_reader_witness_test.dag b/dag/test/claim/bmc/bmc_health_reader_witness_test.dag index f2c6a047228..77b18fda788 100644 --- a/dag/test/claim/bmc/bmc_health_reader_witness_test.dag +++ b/dag/test/claim/bmc/bmc_health_reader_witness_test.dag @@ -71,5 +71,5 @@ test fn the_create_body_asks_for_read_only_and_the_secret_is_per_host() -> Bool judged && string_contains(s: body, pattern: "gunbc_health") && bmc_health_reader_secret_id(host: "srv3") == "bmc-srv3-health-reader" - && bmc_health_reader_body_path == "/tmp/bmc_health_reader_body.json" + && bmc_health_reader_body_path(host_name: "srv3") != bmc_health_reader_body_path(host_name: "srv4") } From 86586da5be0389f1186e7e48dc939b067e26e06a Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 17:31:39 +0000 Subject: [PATCH 06/15] Custody reads and reader logins keep the failure they computed (review 67836) - bmc_custody_credential answers Held | Absent (404 only) | Unreadable { cause }; a refused status or an unreached call no longer reads as a secret that is not in custody. - bmc_login_attempt answers Accepted | Rejected { body } | NotAttempted { reason }; a netrc that could not be written keeps netrc_failure_reason's located cause instead of reading as a refused password. Co-Authored-By: Claude Opus 5 (1M context) --- .../tools/bmc_health_reader_converge.dag | 92 ++++++++++++++----- .../bmc/bmc_health_reader_witness_test.dag | 34 ++++++- 2 files changed, 102 insertions(+), 24 deletions(-) diff --git a/dag/gunbc/tools/bmc_health_reader_converge.dag b/dag/gunbc/tools/bmc_health_reader_converge.dag index 8f5685dd53c..37769443beb 100644 --- a/dag/gunbc/tools/bmc_health_reader_converge.dag +++ b/dag/gunbc/tools/bmc_health_reader_converge.dag @@ -8,7 +8,16 @@ import extdeps.cloud.gcp.secret_manager { SmAccessVersionSecretDecoded, SmAccessVersionSecretDecodeRefused, } -import extdeps.transports.rest { classify_rest_outcome, RestReadExchange } +import extdeps.transports.rest { + classify_rest_outcome, + RestExchangeCommitAmbiguous, + RestExchangePerformance, + RestExchangeStatusRefused, + RestExchangeSucceeded, + RestExchangeUndecodable, + RestExchangeUnreached, + RestReadExchange, +} import gunbc.fleet_secrets_config { fleet_secrets_gcp_project } import gunbc.bmc_onboarding { BmcOnboardingPlan, @@ -81,7 +90,27 @@ fn bmc_health_reader_body_path(host_name: NonEmptyStr) -> NonEmptyStr { concat("/tmp/bmc_health_reader_body_", host_name as String, ".json") as NonEmptyStr } -fn bmc_custody_credential(secret_id: NonEmptyStr, token: Secret) -> String? +// THREE ANSWERS, NOT A MAYBE. A 404 is the store saying the secret does not exist; a refused status, +// an unreached call or an undecodable payload says nothing about existence and is carried as the +// read failure it is (review 67836). Collapsing them would tell an operator to create a secret that +// may be sitting there behind a permission they lack. +type CustodyCredentialRead + = CustodyCredentialHeld { password: String } + | CustodyCredentialAbsent + | CustodyCredentialUnreadable { cause: String } + +fn custody_read_refusal_cause(performance: RestExchangePerformance) -> CustodyCredentialRead { + match performance { + RestExchangeSucceeded => CustodyCredentialUnreadable { cause: "the read succeeded but its payload did not decode" } + RestExchangeStatusRefused { status: status, body: body } => + if status == 404 { CustodyCredentialAbsent } else { CustodyCredentialUnreadable { cause: concat("Secret Manager refused the read: ", body) } } + RestExchangeCommitAmbiguous { detail: d } => CustodyCredentialUnreadable { cause: d as String } + RestExchangeUnreached { cause: c } => CustodyCredentialUnreadable { cause: c as String } + RestExchangeUndecodable { status: _, cause: c } => CustodyCredentialUnreadable { cause: c as String } + } +} + +fn bmc_custody_credential(secret_id: NonEmptyStr, token: Secret) -> CustodyCredentialRead uses net: Network { resp = gcp.SecretManager.AccessVersion( @@ -90,24 +119,41 @@ fn bmc_custody_credential(secret_id: NonEmptyStr, token: Secret) -> String? secret: secret_id, version: "latest" ) - if !bmc_stored_credential_read_succeeded(performance: classify_rest_outcome(standing: RestReadExchange { rest: resp.outcome })) { - return none + let performance = classify_rest_outcome(standing: RestReadExchange { rest: resp.outcome }) + if !bmc_stored_credential_read_succeeded(performance: performance) { + return custody_read_refusal_cause(performance: performance) } return match decode_sm_access_version_secret_wire(data_b64: resp.data_b64) { - SmAccessVersionSecretDecoded { credential: c } => Present { value: c as String } - SmAccessVersionSecretDecodeRefused { cause: _ } => none + SmAccessVersionSecretDecoded { credential: c } => CustodyCredentialHeld { password: c as String } + SmAccessVersionSecretDecodeRefused { cause: _ } => CustodyCredentialUnreadable { cause: "the custody payload did not decode" } } } -fn bmc_login_succeeds(host: NonEmptyStr, account: NonEmptyStr, password: String) -> Bool +// A LOGIN THAT NEVER REACHED THE BMC IS NOT A REJECTED PASSWORD. A netrc that could not be written -- +// including one that left credential residue on disk -- is a local fault whose remedy is cleanup, so +// it keeps netrc_failure_reason's located cause instead of reading as "does not authenticate". +type BmcLoginAttempt + = BmcLoginAccepted + | BmcLoginRejected { body: String } + | BmcLoginNotAttempted { reason: String } + +fn bmc_login_attempt(host: NonEmptyStr, account: NonEmptyStr, password: String) -> BmcLoginAttempt uses net: Network { let w = write_netrc_outcome(host: host, account: account, password: password) if netrc_write_succeeded(outcome: w) == false { - return false + return BmcLoginNotAttempted { reason: netrc_failure_reason(outcome: w, stage: "reader login") } } probe = redfish.Http.GetSystem(bmc_host: host, netrc_file: bmc_onboard_netrc_path as NonEmptyStr) - return probe.success + return if probe.success { BmcLoginAccepted } else { BmcLoginRejected { body: probe.body } } +} + +fn reader_login_exit(attempt: BmcLoginAttempt, rejected_prefix: String) -> ProcessExit { + match attempt { + BmcLoginAccepted => ExitSuccess + BmcLoginRejected { body: b } => exit_failure(reason: concat(rejected_prefix, b)) + BmcLoginNotAttempted { reason: r } => exit_failure(reason: concat("reader: the login was not attempted: ", r)) + } } // Reads run under the administrator netrc, which the caller has already written. @@ -142,8 +188,9 @@ fn bmc_health_reader_converge(plan: BmcOnboardingPlan, host_name: NonEmptyStr) - AccessTokenEnsureRefused { reason: r } => return exit_failure(reason: r as String) AccessTokenReady { token: t } => match bmc_custody_credential(secret_id: onboarding_secret_id(plan: plan), token: t) { - none => return exit_failure(reason: concat("reader: the administrator credential is not in custody for ", plan.bmc.host as String)) - Present { value: admin_password } => + CustodyCredentialAbsent => return exit_failure(reason: concat("reader: the administrator credential is not in custody for ", plan.bmc.host as String)) + CustodyCredentialUnreadable { cause: c } => return exit_failure(reason: concat("reader: the administrator credential could not be read from custody: ", c)) + CustodyCredentialHeld { password: admin_password } => return bmc_health_reader_converge_as_admin(plan: plan, host_name: host_name, token: t, admin_password: admin_password) } } @@ -163,13 +210,13 @@ fn bmc_health_reader_converge_as_admin(plan: BmcOnboardingPlan, host_name: NonEm ReaderAccountRefused { cause: c } => return exit_failure(reason: concat("reader: ", c as String)) ReaderAccountConverged => match bmc_custody_credential(secret_id: reader_secret, token: token) { - none => return exit_failure(reason: "reader: the account exists but its credential is not in custody; it was not created by this flow or custody was lost") - Present { value: held } => - return if bmc_login_succeeds(host: host, account: bmc_health_reader_username, password: held) { - ExitSuccess - } else { - exit_failure(reason: "reader: the account exists but the custody credential does not authenticate") - } + CustodyCredentialAbsent => return exit_failure(reason: "reader: the account exists but its credential is not in custody; it was not created by this flow or custody was lost") + CustodyCredentialUnreadable { cause: c } => return exit_failure(reason: concat("reader: the reader credential could not be read from custody: ", c)) + CustodyCredentialHeld { password: held } => + return reader_login_exit( + attempt: bmc_login_attempt(host: host, account: bmc_health_reader_username, password: held), + rejected_prefix: "reader: the account exists but the custody credential is refused: ", + ) } CreateReaderAccount => { let credential = mint_bmc_credential() @@ -203,11 +250,10 @@ fn bmc_health_reader_create(host: NonEmptyStr, host_name: NonEmptyStr, credentia ReaderAccountRefused { cause: c } => return exit_failure(reason: concat("reader: created, but read back as: ", c as String)) CreateReaderAccount => return exit_failure(reason: "reader: created, but the accounts collection does not list it") ReaderAccountConverged => - return if bmc_login_succeeds(host: host, account: bmc_health_reader_username, password: credential as String) { - ExitSuccess - } else { - exit_failure(reason: "reader: the created account does not authenticate with the stored credential") - } + return reader_login_exit( + attempt: bmc_login_attempt(host: host, account: bmc_health_reader_username, password: credential as String), + rejected_prefix: "reader: the created account refuses the stored credential: ", + ) } } diff --git a/dag/test/claim/bmc/bmc_health_reader_witness_test.dag b/dag/test/claim/bmc/bmc_health_reader_witness_test.dag index 77b18fda788..e7282dad8f9 100644 --- a/dag/test/claim/bmc/bmc_health_reader_witness_test.dag +++ b/dag/test/claim/bmc/bmc_health_reader_witness_test.dag @@ -17,7 +17,17 @@ import gunbc.bmc_health_reader { reader_account_from_json, reader_listed_in_accounts, } -import gunbc.tools.bmc_health_reader_converge { bmc_health_reader_body_path } +import gunbc.tools.bmc_health_reader_converge { + BmcLoginNotAttempted, + BmcLoginRejected, + CustodyCredentialAbsent, + CustodyCredentialUnreadable, + bmc_health_reader_body_path, + custody_read_refusal_cause, + reader_login_exit, +} +import extdeps.transports.rest { RestExchangeStatusRefused, RestExchangeUnreached } +import std.process { ExitFailure } import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly @@ -73,3 +83,25 @@ test fn the_create_body_asks_for_read_only_and_the_secret_is_per_host() -> Bool && bmc_health_reader_secret_id(host: "srv3") == "bmc-srv3-health-reader" && bmc_health_reader_body_path(host_name: "srv3") != bmc_health_reader_body_path(host_name: "srv4") } + +// Only a 404 says the secret is absent; a refused status or an unreached call is a read failure +// that says nothing about existence (review 67836). +test fn only_a_404_reads_as_absent_custody() -> Bool { + let absent = match custody_read_refusal_cause(performance: RestExchangeStatusRefused { status: 404, body: "not found" }) { CustodyCredentialAbsent => true _ => false } + let denied = match custody_read_refusal_cause(performance: RestExchangeStatusRefused { status: 403, body: "denied" }) { CustodyCredentialUnreadable { cause: _ } => true _ => false } + let unreached = match custody_read_refusal_cause(performance: RestExchangeUnreached { cause: "timeout" }) { CustodyCredentialUnreadable { cause: _ } => true _ => false } + absent && denied && unreached +} + +// A login that never reached the BMC keeps its local cause instead of reading as a refused password. +test fn a_login_that_was_never_attempted_is_not_a_refused_password() -> Bool { + let not_attempted = match reader_login_exit(attempt: BmcLoginNotAttempted { reason: "netrc residue remains" }, rejected_prefix: "refused: ") { + ExitFailure { code: _, reason: r } => string_contains(s: r, pattern: "not attempted") && !string_contains(s: r, pattern: "refused: ") + _ => false + } + let rejected = match reader_login_exit(attempt: BmcLoginRejected { body: "401" }, rejected_prefix: "refused: ") { + ExitFailure { code: _, reason: r } => string_contains(s: r, pattern: "refused: 401") + _ => false + } + not_attempted && rejected +} From 8fa9212480f8767513aa8a2dfb16f0c5f4b417df Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 17:58:23 +0000 Subject: [PATCH 07/15] The administrator probe reads custody through the same three-way classifier (review 67848) CustodyCredentialRead / custody_read_refusal_cause / bmc_custody_credential move down into gunbc.tools.bmc_onboard, and bmc_probe_credential_phase uses them: a 404 says custody holds no credential, every other failed read carries its cause, instead of one fixed sentence. Co-Authored-By: Claude Opus 5 (1M context) --- .../tools/bmc_health_reader_converge.dag | 59 ++---------------- dag/gunbc/tools/bmc_onboard.dag | 61 ++++++++++++++----- .../bmc/bmc_health_reader_witness_test.dag | 7 +-- 3 files changed, 54 insertions(+), 73 deletions(-) diff --git a/dag/gunbc/tools/bmc_health_reader_converge.dag b/dag/gunbc/tools/bmc_health_reader_converge.dag index 37769443beb..7b526c85290 100644 --- a/dag/gunbc/tools/bmc_health_reader_converge.dag +++ b/dag/gunbc/tools/bmc_health_reader_converge.dag @@ -3,21 +3,6 @@ module gunbc.tools.bmc_health_reader_converge import extdeps.bmc.http import extdeps.shell import extdeps.filesystem.filesystem_io { Filesystem } -import extdeps.cloud.gcp.secret_manager { - decode_sm_access_version_secret_wire, - SmAccessVersionSecretDecoded, - SmAccessVersionSecretDecodeRefused, -} -import extdeps.transports.rest { - classify_rest_outcome, - RestExchangeCommitAmbiguous, - RestExchangePerformance, - RestExchangeStatusRefused, - RestExchangeSucceeded, - RestExchangeUndecodable, - RestExchangeUnreached, - RestReadExchange, -} import gunbc.fleet_secrets_config { fleet_secrets_gcp_project } import gunbc.bmc_onboarding { BmcOnboardingPlan, @@ -44,10 +29,13 @@ import gunbc.bmc_health_reader { reader_listed_in_accounts, } import gunbc.tools.bmc_onboard { + CustodyCredentialAbsent, + CustodyCredentialHeld, + CustodyCredentialUnreadable, + bmc_custody_credential, BmcCustodyEstablished, BmcCustodyRefused, bmc_onboard_netrc_path, - bmc_stored_credential_read_succeeded, bmc_store_credential_verified, mint_bmc_credential, netrc_failure_reason, @@ -90,45 +78,6 @@ fn bmc_health_reader_body_path(host_name: NonEmptyStr) -> NonEmptyStr { concat("/tmp/bmc_health_reader_body_", host_name as String, ".json") as NonEmptyStr } -// THREE ANSWERS, NOT A MAYBE. A 404 is the store saying the secret does not exist; a refused status, -// an unreached call or an undecodable payload says nothing about existence and is carried as the -// read failure it is (review 67836). Collapsing them would tell an operator to create a secret that -// may be sitting there behind a permission they lack. -type CustodyCredentialRead - = CustodyCredentialHeld { password: String } - | CustodyCredentialAbsent - | CustodyCredentialUnreadable { cause: String } - -fn custody_read_refusal_cause(performance: RestExchangePerformance) -> CustodyCredentialRead { - match performance { - RestExchangeSucceeded => CustodyCredentialUnreadable { cause: "the read succeeded but its payload did not decode" } - RestExchangeStatusRefused { status: status, body: body } => - if status == 404 { CustodyCredentialAbsent } else { CustodyCredentialUnreadable { cause: concat("Secret Manager refused the read: ", body) } } - RestExchangeCommitAmbiguous { detail: d } => CustodyCredentialUnreadable { cause: d as String } - RestExchangeUnreached { cause: c } => CustodyCredentialUnreadable { cause: c as String } - RestExchangeUndecodable { status: _, cause: c } => CustodyCredentialUnreadable { cause: c as String } - } -} - -fn bmc_custody_credential(secret_id: NonEmptyStr, token: Secret) -> CustodyCredentialRead - uses net: Network -{ - resp = gcp.SecretManager.AccessVersion( - access_token: token, - project_id: fleet_secrets_gcp_project, - secret: secret_id, - version: "latest" - ) - let performance = classify_rest_outcome(standing: RestReadExchange { rest: resp.outcome }) - if !bmc_stored_credential_read_succeeded(performance: performance) { - return custody_read_refusal_cause(performance: performance) - } - return match decode_sm_access_version_secret_wire(data_b64: resp.data_b64) { - SmAccessVersionSecretDecoded { credential: c } => CustodyCredentialHeld { password: c as String } - SmAccessVersionSecretDecodeRefused { cause: _ } => CustodyCredentialUnreadable { cause: "the custody payload did not decode" } - } -} - // A LOGIN THAT NEVER REACHED THE BMC IS NOT A REJECTED PASSWORD. A netrc that could not be written -- // including one that left credential residue on disk -- is a local fault whose remedy is cleanup, so // it keeps netrc_failure_reason's located cause instead of reading as "does not authenticate". diff --git a/dag/gunbc/tools/bmc_onboard.dag b/dag/gunbc/tools/bmc_onboard.dag index b013b0d0cf4..3cd81b71b67 100644 --- a/dag/gunbc/tools/bmc_onboard.dag +++ b/dag/gunbc/tools/bmc_onboard.dag @@ -436,6 +436,46 @@ fn bmc_stored_credential_read_succeeded(performance: RestExchangePerformance) -> } } +// THREE ANSWERS, NOT A MAYBE. A 404 is the store saying the secret does not exist; a refused status, +// an unreached call or an undecodable payload says nothing about existence and is carried as the +// read failure it is (reviews 67836, 67848). Collapsing them would tell an operator to create a secret that +// may be sitting there behind a permission they lack. +type CustodyCredentialRead + = CustodyCredentialHeld { password: String } + | CustodyCredentialAbsent + | CustodyCredentialUnreadable { cause: String } + +fn custody_read_refusal_cause(performance: RestExchangePerformance) -> CustodyCredentialRead { + match performance { + RestExchangeSucceeded => CustodyCredentialUnreadable { cause: "the read succeeded but its payload did not decode" } + RestExchangeStatusRefused { status: status, body: body } => + if status == 404 { CustodyCredentialAbsent } else { CustodyCredentialUnreadable { cause: concat("Secret Manager refused the read: ", body) } } + RestExchangeCommitAmbiguous { detail: d } => CustodyCredentialUnreadable { cause: d as String } + RestExchangeUnreached { cause: c } => CustodyCredentialUnreadable { cause: c as String } + RestExchangeUndecodable { status: _, cause: c } => CustodyCredentialUnreadable { cause: c as String } + } +} + +fn bmc_custody_credential(secret_id: NonEmptyStr, token: Secret) -> CustodyCredentialRead + uses net: Network +{ + resp = gcp.SecretManager.AccessVersion( + access_token: token, + project_id: fleet_secrets_gcp_project, + secret: secret_id, + version: "latest" + ) + let performance = classify_rest_outcome(standing: RestReadExchange { rest: resp.outcome }) + if !bmc_stored_credential_read_succeeded(performance: performance) { + return custody_read_refusal_cause(performance: performance) + } + return match decode_sm_access_version_secret_wire(data_b64: resp.data_b64) { + SmAccessVersionSecretDecoded { credential: c } => CustodyCredentialHeld { password: c as String } + SmAccessVersionSecretDecodeRefused { cause: _ } => CustodyCredentialUnreadable { cause: "the custody payload did not decode" } + } +} + + fn bmc_probe_credential_phase(plan: BmcOnboardingPlan, token_source: AccessTokenSource) -> BmcCredentialProbeResult uses net: Network { @@ -460,18 +500,13 @@ fn bmc_probe_credential_phase(plan: BmcOnboardingPlan, token_source: AccessToken AccessTokenEnsureRefused { reason: reason } => return CredentialEnsureRefused { reason: reason } AccessTokenReady { token: token } => { - secret_resp = gcp.SecretManager.AccessVersion( - access_token: token, - project_id: fleet_secrets_gcp_project, - secret: onboarding_secret_id(plan: plan), - version: "latest" - ) - if !bmc_stored_credential_read_succeeded(performance: classify_rest_outcome(standing: RestReadExchange { rest: secret_resp.outcome })) { - return CredentialStateUnknown { reason: "the factory credential was refused and no stored credential could be read from custody" } - } - match decode_sm_access_version_secret_wire(data_b64: secret_resp.data_b64) { - SmAccessVersionSecretDecoded { credential: password } => { - let stored_w = write_netrc_outcome(host: host, account: account, password: password as String) + match bmc_custody_credential(secret_id: onboarding_secret_id(plan: plan), token: token) { + CustodyCredentialAbsent => + return CredentialStateUnknown { reason: "the factory credential was refused and custody holds no stored credential (404)" } + CustodyCredentialUnreadable { cause: c } => + return CredentialStateUnknown { reason: concat("the factory credential was refused and the stored credential could not be read from custody: ", c) } + CustodyCredentialHeld { password: password } => { + let stored_w = write_netrc_outcome(host: host, account: account, password: password) if netrc_write_succeeded(outcome: stored_w) == false { return CredentialStateUnknown { reason: netrc_failure_reason(outcome: stored_w, stage: "stored credential probe") } } @@ -485,8 +520,6 @@ fn bmc_probe_credential_phase(plan: BmcOnboardingPlan, token_source: AccessToken CredentialStateUnknown { reason: "stored credential rejected by BMC" } } } - SmAccessVersionSecretDecodeRefused { cause: _ } => - CredentialStateUnknown { reason: "stored credential payload refused by wire decoder" } } } } diff --git a/dag/test/claim/bmc/bmc_health_reader_witness_test.dag b/dag/test/claim/bmc/bmc_health_reader_witness_test.dag index e7282dad8f9..430026a08ef 100644 --- a/dag/test/claim/bmc/bmc_health_reader_witness_test.dag +++ b/dag/test/claim/bmc/bmc_health_reader_witness_test.dag @@ -20,12 +20,10 @@ import gunbc.bmc_health_reader { import gunbc.tools.bmc_health_reader_converge { BmcLoginNotAttempted, BmcLoginRejected, - CustodyCredentialAbsent, - CustodyCredentialUnreadable, bmc_health_reader_body_path, - custody_read_refusal_cause, reader_login_exit, } +import gunbc.tools.bmc_onboard { CustodyCredentialAbsent, CustodyCredentialUnreadable, custody_read_refusal_cause } import extdeps.transports.rest { RestExchangeStatusRefused, RestExchangeUnreached } import std.process { ExitFailure } import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } @@ -85,7 +83,8 @@ test fn the_create_body_asks_for_read_only_and_the_secret_is_per_host() -> Bool } // Only a 404 says the secret is absent; a refused status or an unreached call is a read failure -// that says nothing about existence (review 67836). +// that says nothing about existence (reviews 67836, 67848). The administrator probe and the reader +// flow both read custody through this one classifier. test fn only_a_404_reads_as_absent_custody() -> Bool { let absent = match custody_read_refusal_cause(performance: RestExchangeStatusRefused { status: 404, body: "not found" }) { CustodyCredentialAbsent => true _ => false } let denied = match custody_read_refusal_cause(performance: RestExchangeStatusRefused { status: 403, body: "denied" }) { CustodyCredentialUnreadable { cause: _ } => true _ => false } From 6e12487c09359005940d4b8e8ef43ccc6390c4e7 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 18:26:06 +0000 Subject: [PATCH 08/15] One host carrier per BMC plan; custody read is one match (review 67867) - BmcOnboardingPlan carries the fleet HostIdentity; altra_onboarding_plan derives the admin secret name from it and takes the endpoint from that host's fleet_intent baseboard, for all four hosts. The reader converge takes only the plan: the reader secret and body path derive from plan.host, so one host's secret cannot be paired with another host's BMC. - bmc_stored_credential_read_succeeded is deleted; custody_read_refusal_cause answers none for a success and bmc_custody_credential matches once, with no unreachable arm. Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/bmc/bmc_health_reader.dag | 3 +- dag/gunbc/bmc/bmc_onboarding.dag | 23 +- dag/gunbc/product/host_health.dag | 734 ++++++++++++++++++ .../tools/bmc_health_reader_converge.dag | 28 +- dag/gunbc/tools/bmc_onboard.dag | 41 +- .../bmc/bmc_health_reader_witness_test.dag | 27 +- .../host_health_assessment_witness_test.dag | 378 +++++++++ 7 files changed, 1180 insertions(+), 54 deletions(-) create mode 100644 dag/gunbc/product/host_health.dag create mode 100644 dag/test/claim/host_health_assessment_witness_test.dag diff --git a/dag/gunbc/bmc/bmc_health_reader.dag b/dag/gunbc/bmc/bmc_health_reader.dag index ef195b4b17c..d32ee53a04d 100644 --- a/dag/gunbc/bmc/bmc_health_reader.dag +++ b/dag/gunbc/bmc/bmc_health_reader.dag @@ -1,6 +1,7 @@ module gunbc.bmc_health_reader import std.types { Bool, List, NonEmptyStr, String } +import product.placement_supply { HostIdentity } import extdeps.bmc.types { AccountRoleReadOnly, redfish_account_role_wire } import extdeps.languages.json.emit { JsonBool, JsonValue, json_bool, json_kv, json_object, json_string, serialize_json } import gunbc.scm.json_member { @@ -32,7 +33,7 @@ import extdeps.languages.json.parse { // UserName (Base.1.16.0.PropertyValueFormatError, observed on srv1 2026-09-18). data bmc_health_reader_username: NonEmptyStr = "gunbc_health" -fn bmc_health_reader_secret_id(host: NonEmptyStr) -> NonEmptyStr { +fn bmc_health_reader_secret_id(host: HostIdentity) -> NonEmptyStr { concat("bmc-", host as String, "-health-reader") as NonEmptyStr } diff --git a/dag/gunbc/bmc/bmc_onboarding.dag b/dag/gunbc/bmc/bmc_onboarding.dag index 308e9b17ff0..4261e9a26d2 100644 --- a/dag/gunbc/bmc/bmc_onboarding.dag +++ b/dag/gunbc/bmc/bmc_onboarding.dag @@ -9,7 +9,9 @@ import extdeps.bmc.types { RedfishBootSourceOverrideTarget, RedfishAccountRole, import gunbc.auth.access_token_source { AccessEnsurePlan } import extdeps.bmc.openbmc { openbmc_factory_login } import extdeps.boards.asrock_rack { asrock_altrad8ud_redfish_system_id } -import gunbc.fleet_intent { srv1_baseboard, srv2_baseboard } +import gunbc.fleet_intent { srv1_baseboard, srv2_baseboard, srv3_baseboard, srv4_baseboard } +import gunbc.fleet_intent_network { operator_host_srv1, operator_host_srv2, operator_host_srv3, operator_host_srv4 } +import product.placement_supply { HostIdentity } // QUARANTINED LEGACY LIFECYCLE (machine-intake ruling 2026-08-29, answer (b)). // FactoryDefault -> CredentialsRotated -> OsInstalled -> FabricJoined is a @@ -213,6 +215,7 @@ fn bmc_decom_to_factory_state(s: BmcLifecycleState) -> BmcLifecycleState { } type BmcOnboardingPlan { + host: HostIdentity bmc: BmcEndpoint redfish_system_id: NonEmptyStr factory_login: FactoryLogin @@ -229,20 +232,26 @@ fn bmc_plan_steady_state_posture(plan: BmcOnboardingPlan) -> BmcCredentialPostur bmc_phase_credential_posture(phase: bmc_plan_steady_state_phase(plan: plan)) } -fn altra_onboarding_plan(bmc_host: NonEmptyStr, secret_name: NonEmptyStr) -> BmcOnboardingPlan { +// ONE HOST CARRIER, EVERY NAME DERIVED FROM IT. The plan carries the fleet host identity beside its +// BMC endpoint, and the custody secret name is derived from that identity rather than typed next to +// it, so no caller can pair one host's secret with another host's BMC (review 67867). Both inputs +// come from the fleet authorities: the identity from gunbc.fleet_intent_network, the endpoint from +// that host's gunbc.fleet_intent baseboard. +fn altra_onboarding_plan(host: HostIdentity, bmc_host: NonEmptyStr) -> BmcOnboardingPlan { BmcOnboardingPlan { + host: host, bmc: BmcEndpoint { host: bmc_host, protocol: Redfish }, redfish_system_id: asrock_altrad8ud_redfish_system_id, factory_login: openbmc_factory_login, - secret_name: secret_name, + secret_name: concat("bmc-", host as String, "-admin") as NonEmptyStr, rotated_account_role: AccountRoleAdministrator, start_phase: FactoryDefault, } } -data srv3_onboarding_plan: BmcOnboardingPlan = altra_onboarding_plan(bmc_host: "192.168.1.192", secret_name: "bmc-srv3-admin") +data srv3_onboarding_plan: BmcOnboardingPlan = altra_onboarding_plan(host: operator_host_srv3, bmc_host: srv3_baseboard.bmc.host) -data srv4_onboarding_plan: BmcOnboardingPlan = altra_onboarding_plan(bmc_host: "192.168.1.195", secret_name: "bmc-srv4-admin") +data srv4_onboarding_plan: BmcOnboardingPlan = altra_onboarding_plan(host: operator_host_srv4, bmc_host: srv4_baseboard.bmc.host) // srv1 AND srv2 JOIN THE LIFECYCLE srv3 AND srv4 WENT THROUGH, with their BMC endpoints read from the // fleet intent rather than retyped. They do NOT start from the factory credential: both were brought @@ -250,9 +259,9 @@ data srv4_onboarding_plan: BmcOnboardingPlan = altra_onboarding_plan(bmc_host: " // against srv1 was refused (401). Their route in is therefore gunbc.tools.bmc_onboard // bmc_takeover_credential, which rotates from an operator-supplied current password; the // OpenBMC factory login below still names the ACCOUNT the rotation acts on, which is root on both. -data srv1_onboarding_plan: BmcOnboardingPlan = altra_onboarding_plan(bmc_host: srv1_baseboard.bmc.host, secret_name: "bmc-srv1-admin") +data srv1_onboarding_plan: BmcOnboardingPlan = altra_onboarding_plan(host: operator_host_srv1, bmc_host: srv1_baseboard.bmc.host) -data srv2_onboarding_plan: BmcOnboardingPlan = altra_onboarding_plan(bmc_host: srv2_baseboard.bmc.host, secret_name: "bmc-srv2-admin") +data srv2_onboarding_plan: BmcOnboardingPlan = altra_onboarding_plan(host: operator_host_srv2, bmc_host: srv2_baseboard.bmc.host) type BmcCredentialProbeResult = FactoryCredentialActive diff --git a/dag/gunbc/product/host_health.dag b/dag/gunbc/product/host_health.dag new file mode 100644 index 00000000000..c330f21f296 --- /dev/null +++ b/dag/gunbc/product/host_health.dag @@ -0,0 +1,734 @@ +module product.host_health + +import std.types { Bool, Int, List, NonEmptyStr } +import std.measure { Celsius, celsius, celsius_count } +import product.placement_supply { HostIdentity } +import product.fan_tach { FanTachWindow } +import product.fan_tach_health { + FanTachDeviation, + FanTachGoal, + FanTachRefusalCause, + FanTachUnknown, + assess_fan_tach, + fan_tach_observed, +} +import std.goal_assessment { + GoalAssessment, + GoalAssessmentRefused, + GoalDiverged, + GoalIndeterminate, + GoalInspected, + GoalInspection, + GoalObservationRefused, + GoalSatisfied, + ObservationAttempt, + ObservationEstablished, + ObservationRefused, + assess_by_deviations_and_unknowns, +} +import v2.std.algebra { non_empty_to_list } + +// ONE HOST'S FITNESS AGAINST A DECLARED HEALTH GOAL, ASSESSED FROM A BOUNDED READ-ONLY WINDOW. +// +// This module is pure. It owns the carriers a health observation fills and the assessment that +// judges them; it acquires nothing and it decides nothing operational. Whether a divergent host is +// drained, readmitted or repaired is admission policy downstream, and nothing here can express an +// effect -- the assessment is a function from values to a std.goal_assessment coproduct. +// +// THE CHECK IS STATELESS IN IMPLEMENTATION AND RECEIPT-RELATIVE IN SEMANTICS. Memory error +// counters are cumulative, so an opening/closing pair taken inside one run only sees an error that +// lands while the run is open. "Nothing new since the last time this host was judged fit" needs a +// left edge older than the run, and that edge is an explicit input -- HostHealthBaselineBasis -- +// never hidden state the checker keeps. With no admitted baseline, a nonzero opening total is +// history nobody has dispositioned: it is neither a fresh failure nor health, so it is carried as an +// unknown and the host cannot be green. +// +// SIGNALS ARE NOT SUMMED ACROSS SOURCES. The memory-controller counters, firmware error records and +// BMC event log can each report the same hardware event; adding them would fabricate a rate. Each +// signal keeps its own slot and its own continuity. + +// WHAT MUST STAY FIXED FOR TWO COUNTER READINGS TO BE SUBTRACTABLE. A counter restarted by its +// source (a new generation), a different producer, or a different endpoint roster each resets or +// re-denominates the count, so a difference across any of them is not a count of new errors. +// `producer` names the out-of-band resource family the counts came from; `generation` is the +// source's own reset identity as that producer reports it; `endpoint_set` is the ordered list of +// counter endpoints (per-DIMM or per-processor resources) read. The source is out of band by +// standing rule: a count the BMC does not expose is an unknown, never a host-OS read. +type CounterEpoch { + host: HostIdentity + producer: NonEmptyStr + generation: NonEmptyStr + endpoint_set: List +} + +type MemoryErrorCounters { + corrected: Int + uncorrected: Int +} + +type CounterSnapshot { + epoch: CounterEpoch + counters: MemoryErrorCounters +} + +type CumulativeCounterWindow { + opening: CounterSnapshot + closing: CounterSnapshot +} + +type CounterWindowUnestablishedCause + = CounterHostChanged + | CounterGenerationChanged + | CounterProducerChanged + | CounterEndpointSetChanged + | CounterRegressed + +// `opening_residue` is the total the left edge already carried. It is kept, not discarded: with an +// admitted baseline it is history someone judged; without one it is the undispositioned residue. +type CounterWindowStanding + = CounterDeltaEstablished { + corrected_delta: Int, + uncorrected_delta: Int, + opening_residue: MemoryErrorCounters, + } + | CounterWindowUnestablished { cause: CounterWindowUnestablishedCause } + +fn counter_epoch_change( + left: CounterEpoch, + right: CounterEpoch, +) -> CounterWindowUnestablishedCause? { + if left.host != right.host { + Present { value: CounterHostChanged } + } else if left.generation != right.generation { + Present { value: CounterGenerationChanged } + } else if left.producer != right.producer { + Present { value: CounterProducerChanged } + } else if left.endpoint_set != right.endpoint_set { + Present { value: CounterEndpointSetChanged } + } else { + none + } +} + +// A DECREASE IS A RESET, NEVER "ZERO NEW ERRORS". 5 -> 0 inside one apparent epoch means the counter +// was reset or its source replaced by something the epoch did not capture; subtracting would report +// a negative or a clamped zero, and both are fabrications. +fn counter_window_standing(left: CounterSnapshot, right: CounterSnapshot) -> CounterWindowStanding { + match counter_epoch_change(left: left.epoch, right: right.epoch) { + Present { value: cause } => CounterWindowUnestablished { cause: cause } + none => + if right.counters.corrected < left.counters.corrected + || right.counters.uncorrected < left.counters.uncorrected { + CounterWindowUnestablished { cause: CounterRegressed } + } else { + CounterDeltaEstablished { + corrected_delta: right.counters.corrected - left.counters.corrected, + uncorrected_delta: right.counters.uncorrected - left.counters.uncorrected, + opening_residue: left.counters, + } + } + } +} + +// THE LEFT EDGE IS AN INPUT. A prior baseline is the last snapshot someone ADMITTED or explicitly +// dispositioned -- never simply the previous run's reading. If the previous run saw a new error and +// became the next run's baseline automatically, the error would vanish after one cycle: that is the +// absorbing fallback DESIGN section 5 forbids. Selecting and admitting a baseline is therefore not +// this module's job, and nothing here can mint one from an observation. +type HostHealthBaselineBasis + = NoPriorBaseline + | PriorAdmittedBaseline { snapshot: CounterSnapshot } + +// AVAILABILITY IS CARRIED BESIDE THE VALUE, NEVER ENCODED IN IT. A present reading of 0 C stays a +// reading; a sensor the BMC reports as disabled and one that published no value this sample are +// different facts again. None of them is converted into "absent", which is derived only from the +// expectation join (an expected sensor with no window at all). +type TemperatureSample + = TemperatureReadingPresent { celsius: Celsius } + | TemperatureReadingUnavailable + | TemperatureSensorDisabled + +// THE BMC'S OWN THRESHOLD JUDGEMENT, KEPT AS EVIDENCE. It is not the only policy -- a BMC can carry +// no caution threshold at all for a part that needs one -- but it is not discarded either. The +// three standing levels follow the IPMI sensor threshold classes (non-critical, critical, +// non-recoverable), which Redfish's Status.Health also collapses into. +type BmcThresholdStanding + = BmcThresholdClear + | BmcThresholdNonCritical + | BmcThresholdCritical + | BmcThresholdNonRecoverable + | BmcThresholdUnreported + +type TemperatureWindow { + host: HostIdentity + sensor: NonEmptyStr + samples: List + worst_bmc_standing: BmcThresholdStanding +} + +// A LIMIT CARRIES WHO SAYS SO. There is deliberately no fleet-wide default: a limit is a fact about +// a component grade and a sensor's meaning, and until that fact is grounded the honest answer is +// that the limit is unestablished -- which can never produce a green. +type TemperatureLimit + = TemperatureLimitCited { critical: Celsius, authority: NonEmptyStr } + | TemperatureLimitUnestablished + +type TemperatureExpectation { + sensor: NonEmptyStr + limit: TemperatureLimit +} + +// WHICH SENSORS A HOST SHOULD EXPOSE IS ITSELF A FACT THAT CAN BE MISSING. A host whose sensor +// roster was never read has no denominator, so its temperature signal is unknown rather than judged +// against whatever happened to answer. +type HostTemperatureExpectation + = TemperatureRosterDeclared { sensors: List } + | TemperatureRosterUnestablished + +// Prefix equality of the rendered log does not prove the log was not cleared and refilled, so the +// continuity of a BMC event window is decided by the observer from independent generation evidence +// and arrives here already typed. New entries are carried with the BMC's own rendering; this layer +// does not decode event severity, so a new entry it cannot classify is an unknown, not a pass. +type BmcEventWindow + = BmcEventsAppended { entries: List } + | BmcEventLogContinuityUnestablished { cause: NonEmptyStr } + +type HostHealthSignal + = MemoryErrorCounterSignal + | ProcessorPlatformErrorSignal + | FanTachSignal + | TemperatureSignal + | BmcEventLogSignal + +type HostHealthGoal sole_constructor { + host: HostIdentity + fans: FanTachGoal + temperatures: HostTemperatureExpectation + baseline: HostHealthBaselineBasis +} + +// EVERY SIGNAL SLOT IS AN OBSERVATION ATTEMPT OF ITS OWN. One unreadable source does not erase the +// others: srv1 answering in-band while its BMC refuses still yields its memory and fan evidence, +// with the refused slot carried as a located unknown. +// +// Processor and platform errors (APEI/GHES records of non-memory sections, PCIe AER) have their own +// slot and no summation with the memory counters. Their producer must cover the whole counter +// epoch; a kernel ring buffer or a rotated journal does not, which is why an observer without a +// durable event producer on the host refuses this slot rather than reading "nothing found". +// WHY NOT std.goal_assessment ObservationAttempt HERE: a signal slot has THREE outcomes, not two. The +// out-of-band surface may answer, may answer and simply not carry the signal, or may fail to answer, +// and the last two have different remedies (a BMC telemetry requirement versus an access fault). +type HostSignalAttempt + = SignalObserved { observed: T } + | SignalNotExposedOutOfBand { observed_surface: NonEmptyStr } + | SignalUnreadable { cause: NonEmptyStr } + +type HostHealthObserved sole_constructor { + host: HostIdentity + memory: HostSignalAttempt + processor_platform: HostSignalAttempt> + fans: HostSignalAttempt> + temperatures: HostSignalAttempt> + bmc_events: HostSignalAttempt +} + +fn host_health_goal( + host: HostIdentity, + fans: FanTachGoal, + temperatures: HostTemperatureExpectation, + baseline: HostHealthBaselineBasis, +) -> HostHealthGoal { + HostHealthGoal { host: host, fans: fans, temperatures: temperatures, baseline: baseline } +} + +fn host_health_observed( + host: HostIdentity, + memory: HostSignalAttempt, + processor_platform: HostSignalAttempt>, + fans: HostSignalAttempt>, + temperatures: HostSignalAttempt>, + bmc_events: HostSignalAttempt, +) -> HostHealthObserved { + HostHealthObserved { + host: host, + memory: memory, + processor_platform: processor_platform, + fans: fans, + temperatures: temperatures, + bmc_events: bmc_events, + } +} + +type HostHealthDeviation + = CorrectedMemoryErrorIncrement { delta: Int, since_baseline: Bool } + | UncorrectedMemoryErrorIncrement { delta: Int, since_baseline: Bool } + | ProcessorPlatformErrorObserved { record: NonEmptyStr } + | FanDeviation { deviation: FanTachDeviation } + | TemperatureLimitExceeded { sensor: NonEmptyStr, maximum: Celsius, critical: Celsius } + | BmcSensorThresholdCrossed { sensor: NonEmptyStr, standing: BmcThresholdStanding } + | TemperatureObservationForUndeclaredSensor { sensor: NonEmptyStr } + +// A SIGNAL THE BMC NEVER EXPOSES AND ONE IT FAILED TO ANSWER ARE DIFFERENT GAPS with different owners: +// the first is a telemetry requirement on the BMC surface, the second an access or reachability fault. +type HostHealthUnknown + = RequiredSignalUnavailable { signal: HostHealthSignal, cause: NonEmptyStr } + | RequiredSignalNotExposedOutOfBand { signal: HostHealthSignal, observed_surface: NonEmptyStr } + | HistoricalErrorResidueUndispositioned { residue: MemoryErrorCounters } + | CounterContinuityUnestablished { cause: CounterWindowUnestablishedCause, against_baseline: Bool } + | FanUnknown { unknown: FanTachUnknown } + | TemperatureRosterUndeclared + | ExpectedTemperatureSensorMissing { sensor: NonEmptyStr } + | TemperatureReadingNeverPresent { sensor: NonEmptyStr, disabled: Bool } + | ThermalLimitUnestablished { sensor: NonEmptyStr } + | BmcEventUnclassified { entry: NonEmptyStr } + | EventLogContinuityUnestablished { cause: NonEmptyStr } + +type HostHealthRefusal + = ObservationForAnotherHost { goal_host: HostIdentity, observed_host: HostIdentity } + | BaselineForAnotherHost { goal_host: HostIdentity, baseline_host: HostIdentity } + | CounterWindowForAnotherHost { goal_host: HostIdentity, window_host: HostIdentity } + | TemperatureWindowForAnotherHost { sensor: NonEmptyStr } + | DuplicateExpectedTemperatureSensor { sensor: NonEmptyStr } + | DuplicateObservedTemperatureSensor { sensor: NonEmptyStr } + | EmptyTemperatureWindow { sensor: NonEmptyStr } + | FanAssessmentRefused { cause: FanTachRefusalCause } + +// THE EVIDENCE OF A GREEN NAMES WHAT WAS ESTABLISHED, not merely that nothing was wrong: which +// baseline the counters were read against and how many endpoints each signal judged. +type HostHealthEvidence sole_constructor { + host: HostIdentity + against_baseline: Bool + temperature_sensors_judged: Int + fan_headers_judged: Int +} + +type HostHealthAssessment = GoalAssessment + +// One signal's contribution. Refusals short-circuit the whole host; deviations and unknowns +// accumulate across signals so partial evidence is never lost to a sibling's gap. +type SignalVerdict { + deviations: List + unknowns: List + refusal: HostHealthRefusal? + judged: Int +} + +fn signal_clear(judged: Int) -> SignalVerdict { + SignalVerdict { deviations: [], unknowns: [], refusal: none, judged: judged } +} + +fn signal_unknown(unknown: HostHealthUnknown) -> SignalVerdict { + SignalVerdict { deviations: [], unknowns: [unknown], refusal: none, judged: 0 } +} + +fn signal_refused(refusal: HostHealthRefusal) -> SignalVerdict { + SignalVerdict { deviations: [], unknowns: [], refusal: Present { value: refusal }, judged: 0 } +} + +fn signal_unavailable(signal: HostHealthSignal, cause: NonEmptyStr) -> SignalVerdict { + signal_unknown(unknown: RequiredSignalUnavailable { signal: signal, cause: cause }) +} + +fn memory_increments( + corrected_delta: Int, + uncorrected_delta: Int, + since_baseline: Bool, +) -> List { + flat_map( + [ + if corrected_delta > 0 { + [CorrectedMemoryErrorIncrement { delta: corrected_delta, since_baseline: since_baseline }] + } else { [] }, + if uncorrected_delta > 0 { + [UncorrectedMemoryErrorIncrement { delta: uncorrected_delta, since_baseline: since_baseline }] + } else { [] }, + ], + group => group, + ) +} + +// WITH NO BASELINE, the in-run window still detects an error that lands while the check runs, and +// a nonzero opening total is undispositioned history -- so a positive total yields an unknown even +// when the in-run delta is zero, and a zero total with a stable window is clean-since-epoch. +// +// WITH A BASELINE, the left edge is the baseline snapshot; the in-run window must still be stable, +// because an epoch change during the run is a gap the baseline cannot bridge. +fn assess_memory( + goal_host: HostIdentity, + baseline: HostHealthBaselineBasis, + window: CumulativeCounterWindow, +) -> SignalVerdict { + if window.opening.epoch.host != goal_host { + signal_refused(refusal: CounterWindowForAnotherHost { goal_host: goal_host, window_host: window.opening.epoch.host }) + } else { + match counter_window_standing(left: window.opening, right: window.closing) { + CounterWindowUnestablished { cause } => + signal_unknown(unknown: CounterContinuityUnestablished { cause: cause, against_baseline: false }) + CounterDeltaEstablished { corrected_delta: run_ce, uncorrected_delta: run_ue, opening_residue: residue } => + match baseline { + NoPriorBaseline => SignalVerdict { + deviations: memory_increments(corrected_delta: run_ce, uncorrected_delta: run_ue, since_baseline: false), + unknowns: if residue.corrected > 0 || residue.uncorrected > 0 { + [HistoricalErrorResidueUndispositioned { residue: residue }] + } else { [] }, + refusal: none, + judged: 1, + } + PriorAdmittedBaseline { snapshot } => + if snapshot.epoch.host != goal_host { + signal_refused(refusal: BaselineForAnotherHost { goal_host: goal_host, baseline_host: snapshot.epoch.host }) + } else { + match counter_window_standing(left: snapshot, right: window.closing) { + CounterWindowUnestablished { cause } => + signal_unknown(unknown: CounterContinuityUnestablished { cause: cause, against_baseline: true }) + CounterDeltaEstablished { corrected_delta: ce, uncorrected_delta: ue, opening_residue: _ } => + SignalVerdict { + deviations: memory_increments(corrected_delta: ce, uncorrected_delta: ue, since_baseline: true), + unknowns: [], + refusal: none, + judged: 1, + } + } + } + } + } + } +} + +fn assess_processor_platform(records: List) -> SignalVerdict { + SignalVerdict { + deviations: map(records, r => ProcessorPlatformErrorObserved { record: r }), + unknowns: [], + refusal: none, + judged: 1, + } +} + +fn assess_fans(goal: FanTachGoal, windows: List) -> SignalVerdict { + match assess_fan_tach(goal: goal, observed: fan_tach_observed(windows: windows)) { + GoalSatisfied { evidence } => signal_clear(judged: evidence.headers_judged) + GoalDiverged { deviations } => SignalVerdict { + deviations: map(non_empty_to_list(deviations), d => FanDeviation { deviation: d }), + unknowns: [], + refusal: none, + judged: count(non_empty_to_list(deviations)), + } + GoalIndeterminate { known_deviations, unknowns } => SignalVerdict { + deviations: map(known_deviations, d => FanDeviation { deviation: d }), + unknowns: map(non_empty_to_list(unknowns), u => FanUnknown { unknown: u }), + refusal: none, + judged: count(known_deviations), + } + GoalAssessmentRefused { cause } => signal_refused(refusal: FanAssessmentRefused { cause: cause }) + } +} + +fn count_name(names: List, name: NonEmptyStr) -> Int { + fold(names, init: 0, f: (acc, n) => if n == name { acc + 1 } else { acc }) +} + +fn first_duplicate_name(names: List) -> NonEmptyStr? { + fold(names, init: none, f: (acc, n) => match acc { + Present { value } => Present { value: value } + none => if count_name(names: names, name: n) > 1 { Present { value: n } } else { none } + }) +} + +fn temperature_window_for(windows: List, sensor: NonEmptyStr) -> TemperatureWindow? { + filter(windows, w => w.sensor == sensor).first() +} + +fn temperature_expected(expectations: List, sensor: NonEmptyStr) -> Bool { + any(expectations, e => e.sensor == sensor) +} + +fn present_celsius(samples: List) -> List { + flat_map(samples, s => match s { + TemperatureReadingPresent { celsius } => [celsius_count(celsius)] + TemperatureReadingUnavailable => [] + TemperatureSensorDisabled => [] + }) +} + +fn any_disabled(samples: List) -> Bool { + any(samples, s => match s { TemperatureSensorDisabled => true _ => false }) +} + +fn bmc_standing_crossed(standing: BmcThresholdStanding) -> Bool { + match standing { + BmcThresholdNonCritical => true + BmcThresholdCritical => true + BmcThresholdNonRecoverable => true + BmcThresholdClear => false + BmcThresholdUnreported => false + } +} + +fn judge_temperature(expectation: TemperatureExpectation, windows: List) -> SignalVerdict { + match temperature_window_for(windows: windows, sensor: expectation.sensor) { + none => signal_unknown(unknown: ExpectedTemperatureSensorMissing { sensor: expectation.sensor }) + Present { value: w } => { + let readings = present_celsius(samples: w.samples) + let bmc = if bmc_standing_crossed(standing: w.worst_bmc_standing) { + [BmcSensorThresholdCrossed { sensor: w.sensor, standing: w.worst_bmc_standing }] + } else { [] } + match readings.first() { + none => SignalVerdict { + deviations: bmc, + unknowns: [TemperatureReadingNeverPresent { sensor: w.sensor, disabled: any_disabled(samples: w.samples) }], + refusal: none, + judged: 1, + } + Present { value: first } => { + let maximum = fold(readings, init: first, f: (acc, r) => if r > acc { r } else { acc }) + match expectation.limit { + TemperatureLimitUnestablished => SignalVerdict { + deviations: bmc, + unknowns: [ThermalLimitUnestablished { sensor: w.sensor }], + refusal: none, + judged: 1, + } + TemperatureLimitCited { critical, authority: _ } => SignalVerdict { + deviations: flat_map([ + bmc, + if maximum > celsius_count(critical) { + [TemperatureLimitExceeded { sensor: w.sensor, maximum: celsius(maximum), critical: critical }] + } else { [] }, + ], group => group), + unknowns: [], + refusal: none, + judged: 1, + } + } + } + } + } + } +} + +fn assess_temperatures( + goal_host: HostIdentity, + expectation: HostTemperatureExpectation, + windows: List, +) -> SignalVerdict { + match filter(windows, w => w.host != goal_host).first() { + Present { value: stray } => signal_refused(refusal: TemperatureWindowForAnotherHost { sensor: stray.sensor }) + none => match filter(windows, w => count(w.samples) == 0).first() { + Present { value: empty } => signal_refused(refusal: EmptyTemperatureWindow { sensor: empty.sensor }) + none => match first_duplicate_name(names: map(windows, w => w.sensor)) { + Present { value: sensor } => signal_refused(refusal: DuplicateObservedTemperatureSensor { sensor: sensor }) + none => match expectation { + TemperatureRosterUnestablished => signal_unknown(unknown: TemperatureRosterUndeclared) + TemperatureRosterDeclared { sensors } => + match first_duplicate_name(names: map(sensors, e => e.sensor)) { + Present { value: sensor } => signal_refused(refusal: DuplicateExpectedTemperatureSensor { sensor: sensor }) + none => merge_verdicts(verdicts: flat_map([ + map(sensors, e => judge_temperature(expectation: e, windows: windows)), + map( + filter(windows, w => !temperature_expected(expectations: sensors, sensor: w.sensor)), + w => SignalVerdict { + deviations: [TemperatureObservationForUndeclaredSensor { sensor: w.sensor }], + unknowns: [], + refusal: none, + judged: 0, + }, + ), + ], group => group)) + } + } + } + } + } +} + +fn assess_bmc_events(window: BmcEventWindow) -> SignalVerdict { + match window { + BmcEventLogContinuityUnestablished { cause } => + signal_unknown(unknown: EventLogContinuityUnestablished { cause: cause }) + BmcEventsAppended { entries } => SignalVerdict { + deviations: [], + unknowns: map(entries, e => BmcEventUnclassified { entry: e }), + refusal: none, + judged: 1, + } + } +} + +fn merge_verdicts(verdicts: List) -> SignalVerdict { + SignalVerdict { + deviations: flat_map(verdicts, v => v.deviations), + unknowns: flat_map(verdicts, v => v.unknowns), + refusal: fold(verdicts, init: none, f: (acc, v) => match acc { + Present { value } => Present { value: value } + none => v.refusal + }), + judged: fold(verdicts, init: 0, f: (acc, v) => acc + v.judged), + } +} + +fn signal_of( + attempt: HostSignalAttempt, + signal: HostHealthSignal, + judge: fn(T) -> SignalVerdict, +) -> SignalVerdict { + match attempt { + SignalUnreadable { cause } => signal_unavailable(signal: signal, cause: cause) + SignalNotExposedOutOfBand { observed_surface } => + signal_unknown(unknown: RequiredSignalNotExposedOutOfBand { signal: signal, observed_surface: observed_surface }) + SignalObserved { observed } => judge(observed) + } +} + +fn assess_host_health(goal: HostHealthGoal, observed: HostHealthObserved) -> HostHealthAssessment { + if observed.host != goal.host { + GoalAssessmentRefused { cause: ObservationForAnotherHost { goal_host: goal.host, observed_host: observed.host } } + } else { + let fans = signal_of(attempt: observed.fans, signal: FanTachSignal, + judge: windows => assess_fans(goal: goal.fans, windows: windows)) + let temperatures = signal_of(attempt: observed.temperatures, signal: TemperatureSignal, + judge: windows => assess_temperatures(goal_host: goal.host, expectation: goal.temperatures, windows: windows)) + let all = merge_verdicts(verdicts: [ + signal_of(attempt: observed.memory, signal: MemoryErrorCounterSignal, + judge: window => assess_memory(goal_host: goal.host, baseline: goal.baseline, window: window)), + signal_of(attempt: observed.processor_platform, signal: ProcessorPlatformErrorSignal, + judge: records => assess_processor_platform(records: records)), + fans, + temperatures, + signal_of(attempt: observed.bmc_events, signal: BmcEventLogSignal, + judge: window => assess_bmc_events(window: window)), + ]) + match all.refusal { + Present { value: refusal } => GoalAssessmentRefused { cause: refusal } + none => assess_by_deviations_and_unknowns( + evidence: HostHealthEvidence { + host: goal.host, + against_baseline: match goal.baseline { NoPriorBaseline => false PriorAdmittedBaseline { snapshot: _ } => true }, + temperature_sensors_judged: temperatures.judged, + fan_headers_judged: fans.judged, + }, + deviations: all.deviations, + unknowns: all.unknowns, + ) + } + } +} + +// ONE ENTRY PER ROSTERED HOST, AND THE OBSERVATION IS OPTIONAL WHILE THE GOAL IS NOT. The fold is +// driven by the goals, so a host whose collector produced nothing is still present: its whole +// observation is refused with a located cause, and it cannot shorten the denominator. +type HostHealthInspection = GoalInspection + +type FleetHealthDeviation { host: HostIdentity, deviation: HostHealthDeviation } + +type FleetHealthUnknown + = HostUnknownAt { host: HostIdentity, unknown: HostHealthUnknown } + | HostObservationRefusedAt { host: HostIdentity, cause: NonEmptyStr } + | HostAssessmentRefusedAt { host: HostIdentity, cause: HostHealthRefusal } + +type FleetHealthRefusal + = EmptyRoster + | DuplicateRosterHost { host: HostIdentity } + | ObservationForUnrosteredHost { host: HostIdentity } + | DuplicateHostObservation { host: HostIdentity } + +type FleetHealthEvidence sole_constructor { hosts_satisfied: List } + +type FleetHealthAssessment = GoalAssessment + +type FleetHealthReceipt sole_constructor { + hosts: List + assessment: FleetHealthAssessment +} + +fn host_names(hosts: List) -> List { + map(hosts, h => h as NonEmptyStr) +} + +// A HOST THE COLLECTOR COULD NOT REACH ARRIVES AS A KEYED REFUSAL. `observations` is keyed by host +// so an unreachable host's refusal is attributable; a refusal keyed to a host not on the roster is +// a refusal of the whole fleet assessment, because it means the collector and the roster disagree +// about what the fleet is. +type KeyedHostObservation { + host: HostIdentity + attempt: ObservationAttempt +} + +fn fleet_inspections(goals: List, observations: List) -> List { + map(goals, goal => match filter(observations, o => o.host == goal.host).first() { + none => GoalObservationRefused { + subject: goal.host, goal: goal, request: goal.host, + cause: concat("no observation was collected for rostered host ", goal.host as String) as NonEmptyStr, + } + Present { value: keyed } => match keyed.attempt { + ObservationRefused { cause } => GoalObservationRefused { subject: goal.host, goal: goal, request: goal.host, cause: cause } + ObservationEstablished { observed } => GoalInspected { + subject: goal.host, goal: goal, request: goal.host, observed: observed, + assessment: assess_host_health(goal: goal, observed: observed), + } + } + }) +} + +fn inspection_contribution(inspection: HostHealthInspection) -> SignalVerdictAt { + match inspection { + GoalObservationRefused { subject, goal: _, request: _, cause } => + SignalVerdictAt { satisfied: [], deviations: [], unknowns: [HostObservationRefusedAt { host: subject, cause: cause }] } + GoalInspected { subject, goal: _, request: _, observed: _, assessment } => match assessment { + GoalSatisfied { evidence: _ } => SignalVerdictAt { satisfied: [subject], deviations: [], unknowns: [] } + GoalDiverged { deviations } => SignalVerdictAt { + satisfied: [], + deviations: map(non_empty_to_list(deviations), d => FleetHealthDeviation { host: subject, deviation: d }), + unknowns: [], + } + GoalIndeterminate { known_deviations, unknowns } => SignalVerdictAt { + satisfied: [], + deviations: map(known_deviations, d => FleetHealthDeviation { host: subject, deviation: d }), + unknowns: map(non_empty_to_list(unknowns), u => HostUnknownAt { host: subject, unknown: u }), + } + GoalAssessmentRefused { cause } => SignalVerdictAt { + satisfied: [], deviations: [], unknowns: [HostAssessmentRefusedAt { host: subject, cause: cause }], + } + } + } +} + +type SignalVerdictAt { + satisfied: List + deviations: List + unknowns: List +} + +// THE FLEET IS GREEN ONLY IF EVERY ROSTERED HOST IS. A host-level refusal (the inputs for that host +// were malformed) is a fleet-level unknown rather than a fleet refusal, so one bad host does not +// hide the other hosts' findings; the fleet refuses only when the roster or the keying is itself +// inconsistent. +fn assess_fleet_health(goals: List, observations: List) -> FleetHealthReceipt { + let roster = map(goals, g => g.host) + let refusal: FleetHealthRefusal? = if count(roster) == 0 { Present { value: EmptyRoster } } else { + match first_duplicate_name(names: host_names(hosts: roster)) { + Present { value: h } => Present { value: DuplicateRosterHost { host: h as HostIdentity } } + none => match first_duplicate_name(names: host_names(hosts: map(observations, o => o.host))) { + Present { value: h } => Present { value: DuplicateHostObservation { host: h as HostIdentity } } + none => match filter(observations, o => !any(roster, r => r == o.host)).first() { + Present { value: stray } => Present { value: ObservationForUnrosteredHost { host: stray.host } } + none => none + } + } + } + } + let inspections = fleet_inspections(goals: goals, observations: observations) + match refusal { + Present { value: cause } => FleetHealthReceipt { hosts: inspections, assessment: GoalAssessmentRefused { cause: cause } } + none => { + let parts = map(inspections, i => inspection_contribution(inspection: i)) + FleetHealthReceipt { + hosts: inspections, + assessment: assess_by_deviations_and_unknowns( + evidence: FleetHealthEvidence { hosts_satisfied: flat_map(parts, p => p.satisfied) }, + deviations: flat_map(parts, p => p.deviations), + unknowns: flat_map(parts, p => p.unknowns), + ), + } + } + } +} diff --git a/dag/gunbc/tools/bmc_health_reader_converge.dag b/dag/gunbc/tools/bmc_health_reader_converge.dag index 7b526c85290..d096985f65f 100644 --- a/dag/gunbc/tools/bmc_health_reader_converge.dag +++ b/dag/gunbc/tools/bmc_health_reader_converge.dag @@ -54,6 +54,7 @@ import gunbc.auth.access_token_source { select_access_token_source, } import std.types { NonEmptyStr, Secret, String } +import product.placement_supply { HostIdentity } import std.logic { Bool } import std.resources { Network } import std.process { ProcessExit, ExitSuccess, exit_failure } @@ -74,8 +75,8 @@ import std.process { ProcessExit, ExitSuccess, exit_failure } // // The create body holds the reader's password in plaintext, so its path is per host (two hosts // converging at once must not share it) and a failed removal refuses whatever the POST answered. -fn bmc_health_reader_body_path(host_name: NonEmptyStr) -> NonEmptyStr { - concat("/tmp/bmc_health_reader_body_", host_name as String, ".json") as NonEmptyStr +fn bmc_health_reader_body_path(host: HostIdentity) -> NonEmptyStr { + concat("/tmp/bmc_health_reader_body_", host as String, ".json") as NonEmptyStr } // A LOGIN THAT NEVER REACHED THE BMC IS NOT A REJECTED PASSWORD. A netrc that could not be written -- @@ -127,7 +128,7 @@ fn observe_reader_account(host: NonEmptyStr) -> ReaderAccountObservation } } -fn bmc_health_reader_converge(plan: BmcOnboardingPlan, host_name: NonEmptyStr) -> ProcessExit +fn bmc_health_reader_converge(plan: BmcOnboardingPlan) -> ProcessExit uses net: Network { match select_access_token_source() { @@ -140,17 +141,17 @@ fn bmc_health_reader_converge(plan: BmcOnboardingPlan, host_name: NonEmptyStr) - CustodyCredentialAbsent => return exit_failure(reason: concat("reader: the administrator credential is not in custody for ", plan.bmc.host as String)) CustodyCredentialUnreadable { cause: c } => return exit_failure(reason: concat("reader: the administrator credential could not be read from custody: ", c)) CustodyCredentialHeld { password: admin_password } => - return bmc_health_reader_converge_as_admin(plan: plan, host_name: host_name, token: t, admin_password: admin_password) + return bmc_health_reader_converge_as_admin(plan: plan, token: t, admin_password: admin_password) } } } } -fn bmc_health_reader_converge_as_admin(plan: BmcOnboardingPlan, host_name: NonEmptyStr, token: Secret, admin_password: String) -> ProcessExit +fn bmc_health_reader_converge_as_admin(plan: BmcOnboardingPlan, token: Secret, admin_password: String) -> ProcessExit uses net: Network { let host = plan.bmc.host - let reader_secret = bmc_health_reader_secret_id(host: host_name) + let reader_secret = bmc_health_reader_secret_id(host: plan.host) let admin_w = write_netrc_outcome(host: host, account: plan.factory_login.username, password: admin_password) if netrc_write_succeeded(outcome: admin_w) == false { return exit_failure(reason: netrc_failure_reason(outcome: admin_w, stage: "reader administrator")) @@ -171,7 +172,7 @@ fn bmc_health_reader_converge_as_admin(plan: BmcOnboardingPlan, host_name: NonEm let credential = mint_bmc_credential() match bmc_store_credential_verified(secret_id: reader_secret, token: token, credential: credential, host_label: host as String) { BmcCustodyRefused { reason: r } => return exit_failure(reason: r) - BmcCustodyEstablished => return bmc_health_reader_create(host: host, host_name: host_name, credential: credential) + BmcCustodyEstablished => return bmc_health_reader_create(plan: plan, credential: credential) } } } @@ -179,10 +180,11 @@ fn bmc_health_reader_converge_as_admin(plan: BmcOnboardingPlan, host_name: NonEm // Runs under the administrator netrc the caller wrote. The role read-back is taken under it too, // BEFORE the reader login repoints the shared netrc: the reader is not entitled to read accounts. -fn bmc_health_reader_create(host: NonEmptyStr, host_name: NonEmptyStr, credential: Secret) -> ProcessExit +fn bmc_health_reader_create(plan: BmcOnboardingPlan, credential: Secret) -> ProcessExit uses net: Network { - let body_path = bmc_health_reader_body_path(host_name: host_name) + let host = plan.bmc.host + let body_path = bmc_health_reader_body_path(host: plan.host) let body = Filesystem.WriteOwnerOnly(path: body_path, content: reader_account_create_body(password: credential as String)) if body.success == false { return exit_failure(reason: concat("reader: create body write failed: ", body.error)) @@ -209,23 +211,23 @@ fn bmc_health_reader_create(host: NonEmptyStr, host_name: NonEmptyStr, credentia fn srv1_health_reader_converge() -> ProcessExit uses net: Network { - bmc_health_reader_converge(plan: srv1_onboarding_plan, host_name: "srv1") + bmc_health_reader_converge(plan: srv1_onboarding_plan) } fn srv2_health_reader_converge() -> ProcessExit uses net: Network { - bmc_health_reader_converge(plan: srv2_onboarding_plan, host_name: "srv2") + bmc_health_reader_converge(plan: srv2_onboarding_plan) } fn srv3_health_reader_converge() -> ProcessExit uses net: Network { - bmc_health_reader_converge(plan: srv3_onboarding_plan, host_name: "srv3") + bmc_health_reader_converge(plan: srv3_onboarding_plan) } fn srv4_health_reader_converge() -> ProcessExit uses net: Network { - bmc_health_reader_converge(plan: srv4_onboarding_plan, host_name: "srv4") + bmc_health_reader_converge(plan: srv4_onboarding_plan) } diff --git a/dag/gunbc/tools/bmc_onboard.dag b/dag/gunbc/tools/bmc_onboard.dag index 3cd81b71b67..700ca0f5bdd 100644 --- a/dag/gunbc/tools/bmc_onboard.dag +++ b/dag/gunbc/tools/bmc_onboard.dag @@ -422,19 +422,6 @@ fn bmc_assimilate_credential(plan: BmcOnboardingPlan, token_source: AccessTokenS // arm, because reporting `factory login failed` for a local filesystem fault would send an operator // to the BMC to investigate a problem that is on this host. The same rule governs the stored- // credential arm below it. -// A CUSTODY READ THAT DID NOT SUCCEED CARRIES NO PAYLOAD, and decoding one anyway crashed the -// interpreter on a null (observed on srv1 2026-09-18, where no bmc-srv1-admin existed yet). The -// outcome is classified before the payload is touched, so a missing or unreadable custody secret is -// the typed unknown-phase refusal it always meant to be. -fn bmc_stored_credential_read_succeeded(performance: RestExchangePerformance) -> Bool { - match performance { - RestExchangeSucceeded => true - RestExchangeStatusRefused { status: _, body: _ } => false - RestExchangeCommitAmbiguous { detail: _ } => false - RestExchangeUnreached { cause: _ } => false - RestExchangeUndecodable { status: _, cause: _ } => false - } -} // THREE ANSWERS, NOT A MAYBE. A 404 is the store saying the secret does not exist; a refused status, // an unreached call or an undecodable payload says nothing about existence and is carried as the @@ -445,17 +432,20 @@ type CustodyCredentialRead | CustodyCredentialAbsent | CustodyCredentialUnreadable { cause: String } -fn custody_read_refusal_cause(performance: RestExchangePerformance) -> CustodyCredentialRead { +fn custody_read_refusal_cause(performance: RestExchangePerformance) -> CustodyCredentialRead? { match performance { - RestExchangeSucceeded => CustodyCredentialUnreadable { cause: "the read succeeded but its payload did not decode" } + RestExchangeSucceeded => none RestExchangeStatusRefused { status: status, body: body } => - if status == 404 { CustodyCredentialAbsent } else { CustodyCredentialUnreadable { cause: concat("Secret Manager refused the read: ", body) } } - RestExchangeCommitAmbiguous { detail: d } => CustodyCredentialUnreadable { cause: d as String } - RestExchangeUnreached { cause: c } => CustodyCredentialUnreadable { cause: c as String } - RestExchangeUndecodable { status: _, cause: c } => CustodyCredentialUnreadable { cause: c as String } + Present { value: if status == 404 { CustodyCredentialAbsent } else { CustodyCredentialUnreadable { cause: concat("Secret Manager refused the read: ", body) } } } + RestExchangeCommitAmbiguous { detail: d } => Present { value: CustodyCredentialUnreadable { cause: d as String } } + RestExchangeUnreached { cause: c } => Present { value: CustodyCredentialUnreadable { cause: c as String } } + RestExchangeUndecodable { status: _, cause: c } => Present { value: CustodyCredentialUnreadable { cause: c as String } } } } +// ONE MATCH OVER THE CLASSIFIED OUTCOME (review 67867): a success decodes the payload, every other +// arm is the refusal custody_read_refusal_cause already named. There is no second pass that has to +// invent an answer for a success it can no longer see. fn bmc_custody_credential(secret_id: NonEmptyStr, token: Secret) -> CustodyCredentialRead uses net: Network { @@ -465,13 +455,12 @@ fn bmc_custody_credential(secret_id: NonEmptyStr, token: Secret) -> CustodyCrede secret: secret_id, version: "latest" ) - let performance = classify_rest_outcome(standing: RestReadExchange { rest: resp.outcome }) - if !bmc_stored_credential_read_succeeded(performance: performance) { - return custody_read_refusal_cause(performance: performance) - } - return match decode_sm_access_version_secret_wire(data_b64: resp.data_b64) { - SmAccessVersionSecretDecoded { credential: c } => CustodyCredentialHeld { password: c as String } - SmAccessVersionSecretDecodeRefused { cause: _ } => CustodyCredentialUnreadable { cause: "the custody payload did not decode" } + return match custody_read_refusal_cause(performance: classify_rest_outcome(standing: RestReadExchange { rest: resp.outcome })) { + Present { value: refusal } => refusal + none => match decode_sm_access_version_secret_wire(data_b64: resp.data_b64) { + SmAccessVersionSecretDecoded { credential: c } => CustodyCredentialHeld { password: c as String } + SmAccessVersionSecretDecodeRefused { cause: _ } => CustodyCredentialUnreadable { cause: "the custody payload did not decode" } + } } } diff --git a/dag/test/claim/bmc/bmc_health_reader_witness_test.dag b/dag/test/claim/bmc/bmc_health_reader_witness_test.dag index 430026a08ef..cf023561a6c 100644 --- a/dag/test/claim/bmc/bmc_health_reader_witness_test.dag +++ b/dag/test/claim/bmc/bmc_health_reader_witness_test.dag @@ -24,7 +24,9 @@ import gunbc.tools.bmc_health_reader_converge { reader_login_exit, } import gunbc.tools.bmc_onboard { CustodyCredentialAbsent, CustodyCredentialUnreadable, custody_read_refusal_cause } -import extdeps.transports.rest { RestExchangeStatusRefused, RestExchangeUnreached } +import extdeps.transports.rest { RestExchangeStatusRefused, RestExchangeSucceeded, RestExchangeUnreached } +import gunbc.bmc_onboarding { srv3_onboarding_plan, srv4_onboarding_plan } +import gunbc.fleet_intent_network { operator_host_srv3, operator_host_srv4 } import std.process { ExitFailure } import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } @@ -78,18 +80,19 @@ test fn the_create_body_asks_for_read_only_and_the_secret_is_per_host() -> Bool let judged = match plan_reader_account(observation: reader_account_from_json(body: body)) { ReaderAccountConverged => true _ => false } judged && string_contains(s: body, pattern: "gunbc_health") - && bmc_health_reader_secret_id(host: "srv3") == "bmc-srv3-health-reader" - && bmc_health_reader_body_path(host_name: "srv3") != bmc_health_reader_body_path(host_name: "srv4") + && bmc_health_reader_secret_id(host: operator_host_srv3) == "bmc-srv3-health-reader" + && bmc_health_reader_body_path(host: operator_host_srv3) != bmc_health_reader_body_path(host: operator_host_srv4) } // Only a 404 says the secret is absent; a refused status or an unreached call is a read failure // that says nothing about existence (reviews 67836, 67848). The administrator probe and the reader // flow both read custody through this one classifier. test fn only_a_404_reads_as_absent_custody() -> Bool { - let absent = match custody_read_refusal_cause(performance: RestExchangeStatusRefused { status: 404, body: "not found" }) { CustodyCredentialAbsent => true _ => false } - let denied = match custody_read_refusal_cause(performance: RestExchangeStatusRefused { status: 403, body: "denied" }) { CustodyCredentialUnreadable { cause: _ } => true _ => false } - let unreached = match custody_read_refusal_cause(performance: RestExchangeUnreached { cause: "timeout" }) { CustodyCredentialUnreadable { cause: _ } => true _ => false } - absent && denied && unreached + let absent = match custody_read_refusal_cause(performance: RestExchangeStatusRefused { status: 404, body: "not found" }) { Present { value: CustodyCredentialAbsent } => true _ => false } + let denied = match custody_read_refusal_cause(performance: RestExchangeStatusRefused { status: 403, body: "denied" }) { Present { value: CustodyCredentialUnreadable { cause: _ } } => true _ => false } + let unreached = match custody_read_refusal_cause(performance: RestExchangeUnreached { cause: "timeout" }) { Present { value: CustodyCredentialUnreadable { cause: _ } } => true _ => false } + let success_decodes = match custody_read_refusal_cause(performance: RestExchangeSucceeded) { none => true _ => false } + absent && denied && unreached && success_decodes } // A login that never reached the BMC keeps its local cause instead of reading as a refused password. @@ -104,3 +107,13 @@ test fn a_login_that_was_never_attempted_is_not_a_refused_password() -> Bool { } not_attempted && rejected } + +// One host carrier: the plan's endpoint, its admin secret and the reader secret all derive from the +// same fleet host, so no argument can pair one host's secret with another's BMC (review 67867). +test fn every_name_a_plan_uses_derives_from_its_one_host() -> Bool { + srv3_onboarding_plan.host == operator_host_srv3 + && srv3_onboarding_plan.secret_name == "bmc-srv3-admin" + && srv3_onboarding_plan.bmc.host == "192.168.1.192" + && bmc_health_reader_secret_id(host: srv4_onboarding_plan.host) == "bmc-srv4-health-reader" + && srv4_onboarding_plan.bmc.host == "192.168.1.195" +} diff --git a/dag/test/claim/host_health_assessment_witness_test.dag b/dag/test/claim/host_health_assessment_witness_test.dag new file mode 100644 index 00000000000..978fc71ba8d --- /dev/null +++ b/dag/test/claim/host_health_assessment_witness_test.dag @@ -0,0 +1,378 @@ +module test.claim.host_health_assessment_witness + +import std.types { Bool, Int, List, NonEmptyStr } +import std.measure { celsius } +import product.placement_supply { HostIdentity } +import product.fan_tach { FanTachWindow } +import gunbc.fleet_intent_network { operator_host_srv1, operator_host_srv2 } +import gunbc.fleet_fan_tach_expectation { srv1_fan_tach_expectation, srv2_fan_tach_expectation } +import gunbc.fleet_fan_tach_observation { srv1_windows, srv2_windows } +import product.fan_tach_health { fan_tach_goal } +import product.host_health { + BmcEventsAppended, + BmcEventLogContinuityUnestablished, + BmcThresholdClear, + CounterGenerationChanged, + CounterContinuityUnestablished, + CounterDeltaEstablished, + CounterEndpointSetChanged, + CounterEpoch, + CounterRegressed, + CounterSnapshot, + CounterWindowUnestablished, + CorrectedMemoryErrorIncrement, + CumulativeCounterWindow, + BaselineForAnotherHost, + DuplicateObservedTemperatureSensor, + ExpectedTemperatureSensorMissing, + FanDeviation, + HistoricalErrorResidueUndispositioned, + HostHealthBaselineBasis, + HostHealthAssessment, + HostHealthGoal, + HostHealthObserved, + HostHealthUnknown, + RequiredSignalNotExposedOutOfBand, + SignalObserved, + SignalNotExposedOutOfBand, + HostObservationRefusedAt, + KeyedHostObservation, + MemoryErrorCounters, + NoPriorBaseline, + PriorAdmittedBaseline, + TemperatureExpectation, + TemperatureLimitCited, + TemperatureLimitExceeded, + TemperatureLimitUnestablished, + TemperatureReadingNeverPresent, + TemperatureReadingPresent, + TemperatureReadingUnavailable, + TemperatureRosterDeclared, + TemperatureSample, + TemperatureSensorDisabled, + TemperatureWindow, + ThermalLimitUnestablished, + UncorrectedMemoryErrorIncrement, + EmptyRoster, + assess_fleet_health, + assess_host_health, + counter_window_standing, + host_health_goal, + host_health_observed, +} +import std.goal_assessment { + GoalAssessmentRefused, + GoalDiverged, + GoalIndeterminate, + GoalInspected, + GoalObservationRefused, + GoalSatisfied, + ObservationEstablished, +} +import v2.std.algebra { non_empty_to_list } +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +// EVERY INPUT HERE IS SUPPLIED, and that is deliberate (DESIGN section 3, a witness discriminates at +// one interface): these claims are about the ASSESSMENT, so they construct its inputs instead of +// running a collector. The fan windows are the fleet's own dated 20-sample specimens, which is why +// srv2 is the control host and srv1 the one that diverges. The real producer of these shapes is the +// observer route, and its inhabitance is owed by that route's own claim. + +// The fan floor is this witness's policy, chosen in the 4..16 gap the specimens show, and passed as +// a parameter so it cannot become the fleet's definition of healthy. +data rotating_floor: Int = 12 + +fn epoch(host: HostIdentity, boot: NonEmptyStr, endpoints: List) -> CounterEpoch { + CounterEpoch { + host: host, + producer: "redfish-memory-metrics", + generation: boot, + endpoint_set: endpoints, + } +} + +data mc0: List = ["mc0"] + +data no_records: List = [] + +data no_temperature_windows: List = [] + +fn snap(host: HostIdentity, boot: NonEmptyStr, ce: Int, ue: Int) -> CounterSnapshot { + CounterSnapshot { + epoch: epoch(host: host, boot: boot, endpoints: mc0), + counters: MemoryErrorCounters { corrected: ce, uncorrected: ue }, + } +} + +fn window(host: HostIdentity, open_ce: Int, close_ce: Int) -> CumulativeCounterWindow { + CumulativeCounterWindow { opening: snap(host: host, boot: "b1", ce: open_ce, ue: 0), closing: snap(host: host, boot: "b1", ce: close_ce, ue: 0) } +} + +fn cited(sensor: NonEmptyStr, critical: Int) -> TemperatureExpectation { + TemperatureExpectation { + sensor: sensor, + limit: TemperatureLimitCited { critical: celsius(critical), authority: "witness-supplied limit" }, + } +} + +fn temps(host: HostIdentity, sensor: NonEmptyStr, samples: List) -> TemperatureWindow { + TemperatureWindow { host: host, sensor: sensor, samples: samples, worst_bmc_standing: BmcThresholdClear } +} + +fn present(c: Int) -> TemperatureSample { + TemperatureReadingPresent { celsius: celsius(c) } +} + +// srv2's goal with every signal satisfiable: its healthy fan specimen, one cited sensor. +fn srv2_goal(baseline: HostHealthBaselineBasis) -> HostHealthGoal { + host_health_goal( + host: operator_host_srv2, + fans: fan_tach_goal(expectation: srv2_fan_tach_expectation, minimum_rotating: rotating_floor), + temperatures: TemperatureRosterDeclared { sensors: [cited(sensor: "TEMP_SOC", critical: 105)] }, + baseline: baseline, + ) +} + +fn no_baseline() -> HostHealthBaselineBasis { + NoPriorBaseline +} + +fn srv2_observed(memory: CumulativeCounterWindow, temperatures: List) -> HostHealthObserved { + host_health_observed( + host: operator_host_srv2, + memory: SignalObserved { observed: memory }, + processor_platform: SignalObserved { observed: no_records }, + fans: SignalObserved { observed: srv2_windows }, + temperatures: SignalObserved { observed: temperatures }, + bmc_events: SignalObserved { observed: BmcEventsAppended { entries: [] } }, + ) +} + +data srv2_soc_ok: List = [temps(host: operator_host_srv2, sensor: "TEMP_SOC", samples: [present(c: 55), present(c: 56)])] + +fn unknowns_of(goal: HostHealthGoal, observed: HostHealthObserved) -> List { + match assess_host_health(goal: goal, observed: observed) { + GoalIndeterminate { known_deviations: _, unknowns } => non_empty_to_list(unknowns) + GoalSatisfied { evidence: _ } => [] + GoalDiverged { deviations: _ } => [] + GoalAssessmentRefused { cause: _ } => [] + } +} + +test fn zero_to_zero_in_one_epoch_establishes_a_zero_delta() -> Bool { + match counter_window_standing(left: snap(host: operator_host_srv2, boot: "b1", ce: 0, ue: 0), right: snap(host: operator_host_srv2, boot: "b1", ce: 0, ue: 0)) { + CounterDeltaEstablished { corrected_delta, uncorrected_delta, opening_residue } => + corrected_delta == 0 && uncorrected_delta == 0 && opening_residue.corrected == 0 + CounterWindowUnestablished { cause: _ } => false + } +} + +test fn four_to_five_is_one_new_error_with_four_kept_as_residue() -> Bool { + match counter_window_standing(left: snap(host: operator_host_srv2, boot: "b1", ce: 4, ue: 0), right: snap(host: operator_host_srv2, boot: "b1", ce: 5, ue: 0)) { + CounterDeltaEstablished { corrected_delta, uncorrected_delta, opening_residue } => + corrected_delta == 1 && uncorrected_delta == 0 && opening_residue.corrected == 4 + CounterWindowUnestablished { cause: _ } => false + } +} + +test fn a_regression_a_reboot_or_a_new_endpoint_set_cannot_construct_a_delta() -> Bool { + let regressed = match counter_window_standing(left: snap(host: operator_host_srv2, boot: "b1", ce: 5, ue: 0), right: snap(host: operator_host_srv2, boot: "b1", ce: 0, ue: 0)) { + CounterWindowUnestablished { cause: CounterRegressed } => true + _ => false + } + let rebooted = match counter_window_standing(left: snap(host: operator_host_srv2, boot: "b1", ce: 0, ue: 0), right: snap(host: operator_host_srv2, boot: "b2", ce: 0, ue: 0)) { + CounterWindowUnestablished { cause: CounterGenerationChanged } => true + _ => false + } + let widened = match counter_window_standing( + left: snap(host: operator_host_srv2, boot: "b1", ce: 0, ue: 0), + right: CounterSnapshot { epoch: epoch(host: operator_host_srv2, boot: "b1", endpoints: ["mc0", "mc1"]), counters: MemoryErrorCounters { corrected: 0, uncorrected: 0 } }, + ) { + CounterWindowUnestablished { cause: CounterEndpointSetChanged } => true + _ => false + } + regressed && rebooted && widened +} + +// Without a green for SOME input, every red below could come from an assessment that never agrees. +test fn a_clean_host_with_every_signal_established_is_satisfied() -> Bool { + match assess_host_health(goal: srv2_goal(baseline: no_baseline()), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: srv2_soc_ok)) { + GoalSatisfied { evidence } => evidence.temperature_sensors_judged == 1 && evidence.fan_headers_judged == 5 + GoalDiverged { deviations: _ } => false + GoalIndeterminate { known_deviations: _, unknowns: _ } => false + GoalAssessmentRefused { cause: _ } => false + } +} + +// srv1 read 255 corrected errors with no admitted baseline on 2026-09-18; this is that shape. The +// window is stable and adds nothing, and the host still cannot be green. +test fn a_positive_total_without_a_baseline_is_undispositioned_residue() -> Bool { + let us = unknowns_of(goal: srv2_goal(baseline: no_baseline()), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 255, close_ce: 255), temperatures: srv2_soc_ok)) + count(us) == 1 && any(us, u => match u { HistoricalErrorResidueUndispositioned { residue } => residue.corrected == 255 _ => false }) +} + +// The same residue measured against an admitted baseline that already carried it is green, and one +// more error since that baseline is a divergence -- so the baseline is what separates history from +// news, not the in-run window. +test fn an_admitted_baseline_turns_residue_into_history_and_new_errors_into_divergence() -> Bool { + let baseline = PriorAdmittedBaseline { snapshot: snap(host: operator_host_srv2, boot: "b1", ce: 255, ue: 0) } + let quiet = match assess_host_health(goal: srv2_goal(baseline: baseline), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 255, close_ce: 255), temperatures: srv2_soc_ok)) { + GoalSatisfied { evidence } => evidence.against_baseline + _ => false + } + let one_more = match assess_host_health(goal: srv2_goal(baseline: baseline), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 256, close_ce: 256), temperatures: srv2_soc_ok)) { + GoalDiverged { deviations } => any(non_empty_to_list(deviations), d => match d { CorrectedMemoryErrorIncrement { delta, since_baseline } => delta == 1 && since_baseline _ => false }) + _ => false + } + quiet && one_more +} + +test fn a_baseline_for_another_host_refuses() -> Bool { + let foreign = PriorAdmittedBaseline { snapshot: snap(host: operator_host_srv1, boot: "b1", ce: 0, ue: 0) } + match assess_host_health(goal: srv2_goal(baseline: foreign), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: srv2_soc_ok)) { + GoalAssessmentRefused { cause: BaselineForAnotherHost { goal_host, baseline_host } } => baseline_host == operator_host_srv1 + _ => false + } +} + +test fn a_cleared_or_replaced_event_log_is_not_an_empty_delta() -> Bool { + let observed = host_health_observed( + host: operator_host_srv2, + memory: SignalObserved { observed: window(host: operator_host_srv2, open_ce: 0, close_ce: 0) }, + processor_platform: SignalObserved { observed: no_records }, + fans: SignalObserved { observed: srv2_windows }, + temperatures: SignalObserved { observed: srv2_soc_ok }, + bmc_events: SignalObserved { observed: BmcEventLogContinuityUnestablished { cause: "SEL erase timestamp changed during the window" } }, + ) + match assess_host_health(goal: srv2_goal(baseline: no_baseline()), observed: observed) { + GoalIndeterminate { known_deviations, unknowns } => count(known_deviations) == 0 && count(non_empty_to_list(unknowns)) == 1 + _ => false + } +} + +// srv2's specimen reads zero 1-4 times in 20 on every chassis fan and is satisfied (the positive +// control above); srv1's two degraded endpoints diverge. A lone zero is therefore not a failure, +// and the host layer does not re-judge fans -- it carries fan_tach_health's deviations through. +test fn the_host_layer_carries_the_fan_authoritys_two_srv1_degradations() -> Bool { + let goal = host_health_goal( + host: operator_host_srv1, + fans: fan_tach_goal(expectation: srv1_fan_tach_expectation, minimum_rotating: rotating_floor), + temperatures: TemperatureRosterDeclared { sensors: [] }, + baseline: NoPriorBaseline, + ) + let observed = host_health_observed( + host: operator_host_srv1, + memory: SignalObserved { observed: window(host: operator_host_srv1, open_ce: 0, close_ce: 0) }, + processor_platform: SignalObserved { observed: no_records }, + fans: SignalObserved { observed: srv1_windows }, + temperatures: SignalObserved { observed: no_temperature_windows }, + bmc_events: SignalObserved { observed: BmcEventsAppended { entries: [] } }, + ) + match assess_host_health(goal: goal, observed: observed) { + GoalDiverged { deviations } => count(filter(non_empty_to_list(deviations), d => match d { FanDeviation { deviation: _ } => true _ => false })) == 2 + _ => false + } +} + +test fn a_missing_expected_sensor_is_unknown_and_a_duplicate_refuses() -> Bool { + let missing = unknowns_of(goal: srv2_goal(baseline: no_baseline()), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: [])) + let dup = match assess_host_health(goal: srv2_goal(baseline: no_baseline()), observed: srv2_observed( + memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), + temperatures: [temps(host: operator_host_srv2, sensor: "TEMP_SOC", samples: [present(c: 55)]), temps(host: operator_host_srv2, sensor: "TEMP_SOC", samples: [present(c: 56)])], + )) { + GoalAssessmentRefused { cause: DuplicateObservedTemperatureSensor { sensor } } => sensor == "TEMP_SOC" + _ => false + } + any(missing, u => match u { ExpectedTemperatureSensorMissing { sensor } => sensor == "TEMP_SOC" _ => false }) && dup +} + +fn judge_soc(samples: List) -> HostHealthAssessment { + assess_host_health( + goal: srv2_goal(baseline: no_baseline()), + observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: [temps(host: operator_host_srv2, sensor: "TEMP_SOC", samples: samples)]), + ) +} + +test fn zero_celsius_unavailable_disabled_and_absent_remain_distinct() -> Bool { + let zero_is_a_reading = match judge_soc(samples: [present(c: 0)]) { GoalSatisfied { evidence: _ } => true _ => false } + let unavailable = match judge_soc(samples: [TemperatureReadingUnavailable]) { + GoalIndeterminate { known_deviations: _, unknowns } => any(non_empty_to_list(unknowns), u => match u { TemperatureReadingNeverPresent { sensor: _, disabled } => !disabled _ => false }) + _ => false + } + let disabled = match judge_soc(samples: [TemperatureSensorDisabled]) { + GoalIndeterminate { known_deviations: _, unknowns } => any(non_empty_to_list(unknowns), u => match u { TemperatureReadingNeverPresent { sensor: _, disabled } => disabled _ => false }) + _ => false + } + let absent = any(unknowns_of(goal: srv2_goal(baseline: no_baseline()), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: [])), + u => match u { ExpectedTemperatureSensorMissing { sensor: _ } => true _ => false }) + zero_is_a_reading && unavailable && disabled && absent +} + +test fn an_unestablished_limit_is_never_green_and_a_cited_one_diverges_above_it() -> Bool { + let unestablished = host_health_goal( + host: operator_host_srv2, + fans: fan_tach_goal(expectation: srv2_fan_tach_expectation, minimum_rotating: rotating_floor), + temperatures: TemperatureRosterDeclared { sensors: [TemperatureExpectation { sensor: "TEMP_SOC", limit: TemperatureLimitUnestablished }] }, + baseline: NoPriorBaseline, + ) + let cool = srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: srv2_soc_ok) + let never_green = any(unknowns_of(goal: unestablished, observed: cool), u => match u { ThermalLimitUnestablished { sensor } => sensor == "TEMP_SOC" _ => false }) + let hot = srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: [temps(host: operator_host_srv2, sensor: "TEMP_SOC", samples: [present(c: 55), present(c: 106)])]) + let exceeded = match assess_host_health(goal: srv2_goal(baseline: no_baseline()), observed: hot) { + GoalDiverged { deviations } => any(non_empty_to_list(deviations), d => match d { TemperatureLimitExceeded { sensor: _, maximum: _, critical: _ } => true _ => false }) + _ => false + } + never_green && exceeded +} + +test fn one_unreachable_host_prevents_fleet_green_without_hiding_the_others() -> Bool { + let srv1_goal = host_health_goal( + host: operator_host_srv1, + fans: fan_tach_goal(expectation: srv1_fan_tach_expectation, minimum_rotating: rotating_floor), + temperatures: TemperatureRosterDeclared { sensors: [] }, + baseline: NoPriorBaseline, + ) + let receipt = assess_fleet_health( + goals: [srv1_goal, srv2_goal(baseline: no_baseline())], + observations: [KeyedHostObservation { + host: operator_host_srv2, + attempt: ObservationEstablished { observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: srv2_soc_ok) }, + }], + ) + let both_inspected = count(receipt.hosts) == 2 + let verdict = match receipt.assessment { + GoalIndeterminate { known_deviations, unknowns } => + count(known_deviations) == 0 + && any(non_empty_to_list(unknowns), u => match u { HostObservationRefusedAt { host, cause: _ } => host == operator_host_srv1 _ => false }) + _ => false + } + let srv2_still_green = any(receipt.hosts, i => match i { + GoalInspected { subject, goal: _, request: _, observed: _, assessment } => subject == operator_host_srv2 && match assessment { GoalSatisfied { evidence: _ } => true _ => false } + GoalObservationRefused { subject: _, goal: _, request: _, cause: _ } => false + }) + both_inspected && verdict && srv2_still_green +} + +test fn an_empty_roster_refuses_rather_than_reading_as_a_green_fleet() -> Bool { + match assess_fleet_health(goals: [], observations: []).assessment { + GoalAssessmentRefused { cause: EmptyRoster } => true + _ => false + } +} + +// Corrected memory errors the BMC does not expose are a gap, never a pass and never an in-band read: +// the goal does not shrink to what the surface happened to carry. +test fn a_signal_the_bmc_does_not_expose_is_indeterminate_not_satisfied() -> Bool { + let observed = host_health_observed( + host: operator_host_srv2, + memory: SignalNotExposedOutOfBand { observed_surface: "Redfish Memory resources carry no MemoryMetrics" }, + processor_platform: SignalObserved { observed: no_records }, + fans: SignalObserved { observed: srv2_windows }, + temperatures: SignalObserved { observed: srv2_soc_ok }, + bmc_events: SignalObserved { observed: BmcEventsAppended { entries: [] } }, + ) + let us = unknowns_of(goal: srv2_goal(baseline: no_baseline()), observed: observed) + count(us) == 1 && any(us, u => match u { RequiredSignalNotExposedOutOfBand { signal: _, observed_surface: _ } => true _ => false }) +} From 1fed237a29119239e040d9b80e699bcd81f5690f Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Fri, 18 Sep 2026 18:38:54 +0000 Subject: [PATCH 09/15] Drop product.host_health and its witness from this PR: swept in by mistake (review 67874) They land with the HEALTH-0 collector that produces HostHealthObserved, not before it. Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/product/host_health.dag | 734 ------------------ .../host_health_assessment_witness_test.dag | 378 --------- 2 files changed, 1112 deletions(-) delete mode 100644 dag/gunbc/product/host_health.dag delete mode 100644 dag/test/claim/host_health_assessment_witness_test.dag diff --git a/dag/gunbc/product/host_health.dag b/dag/gunbc/product/host_health.dag deleted file mode 100644 index c330f21f296..00000000000 --- a/dag/gunbc/product/host_health.dag +++ /dev/null @@ -1,734 +0,0 @@ -module product.host_health - -import std.types { Bool, Int, List, NonEmptyStr } -import std.measure { Celsius, celsius, celsius_count } -import product.placement_supply { HostIdentity } -import product.fan_tach { FanTachWindow } -import product.fan_tach_health { - FanTachDeviation, - FanTachGoal, - FanTachRefusalCause, - FanTachUnknown, - assess_fan_tach, - fan_tach_observed, -} -import std.goal_assessment { - GoalAssessment, - GoalAssessmentRefused, - GoalDiverged, - GoalIndeterminate, - GoalInspected, - GoalInspection, - GoalObservationRefused, - GoalSatisfied, - ObservationAttempt, - ObservationEstablished, - ObservationRefused, - assess_by_deviations_and_unknowns, -} -import v2.std.algebra { non_empty_to_list } - -// ONE HOST'S FITNESS AGAINST A DECLARED HEALTH GOAL, ASSESSED FROM A BOUNDED READ-ONLY WINDOW. -// -// This module is pure. It owns the carriers a health observation fills and the assessment that -// judges them; it acquires nothing and it decides nothing operational. Whether a divergent host is -// drained, readmitted or repaired is admission policy downstream, and nothing here can express an -// effect -- the assessment is a function from values to a std.goal_assessment coproduct. -// -// THE CHECK IS STATELESS IN IMPLEMENTATION AND RECEIPT-RELATIVE IN SEMANTICS. Memory error -// counters are cumulative, so an opening/closing pair taken inside one run only sees an error that -// lands while the run is open. "Nothing new since the last time this host was judged fit" needs a -// left edge older than the run, and that edge is an explicit input -- HostHealthBaselineBasis -- -// never hidden state the checker keeps. With no admitted baseline, a nonzero opening total is -// history nobody has dispositioned: it is neither a fresh failure nor health, so it is carried as an -// unknown and the host cannot be green. -// -// SIGNALS ARE NOT SUMMED ACROSS SOURCES. The memory-controller counters, firmware error records and -// BMC event log can each report the same hardware event; adding them would fabricate a rate. Each -// signal keeps its own slot and its own continuity. - -// WHAT MUST STAY FIXED FOR TWO COUNTER READINGS TO BE SUBTRACTABLE. A counter restarted by its -// source (a new generation), a different producer, or a different endpoint roster each resets or -// re-denominates the count, so a difference across any of them is not a count of new errors. -// `producer` names the out-of-band resource family the counts came from; `generation` is the -// source's own reset identity as that producer reports it; `endpoint_set` is the ordered list of -// counter endpoints (per-DIMM or per-processor resources) read. The source is out of band by -// standing rule: a count the BMC does not expose is an unknown, never a host-OS read. -type CounterEpoch { - host: HostIdentity - producer: NonEmptyStr - generation: NonEmptyStr - endpoint_set: List -} - -type MemoryErrorCounters { - corrected: Int - uncorrected: Int -} - -type CounterSnapshot { - epoch: CounterEpoch - counters: MemoryErrorCounters -} - -type CumulativeCounterWindow { - opening: CounterSnapshot - closing: CounterSnapshot -} - -type CounterWindowUnestablishedCause - = CounterHostChanged - | CounterGenerationChanged - | CounterProducerChanged - | CounterEndpointSetChanged - | CounterRegressed - -// `opening_residue` is the total the left edge already carried. It is kept, not discarded: with an -// admitted baseline it is history someone judged; without one it is the undispositioned residue. -type CounterWindowStanding - = CounterDeltaEstablished { - corrected_delta: Int, - uncorrected_delta: Int, - opening_residue: MemoryErrorCounters, - } - | CounterWindowUnestablished { cause: CounterWindowUnestablishedCause } - -fn counter_epoch_change( - left: CounterEpoch, - right: CounterEpoch, -) -> CounterWindowUnestablishedCause? { - if left.host != right.host { - Present { value: CounterHostChanged } - } else if left.generation != right.generation { - Present { value: CounterGenerationChanged } - } else if left.producer != right.producer { - Present { value: CounterProducerChanged } - } else if left.endpoint_set != right.endpoint_set { - Present { value: CounterEndpointSetChanged } - } else { - none - } -} - -// A DECREASE IS A RESET, NEVER "ZERO NEW ERRORS". 5 -> 0 inside one apparent epoch means the counter -// was reset or its source replaced by something the epoch did not capture; subtracting would report -// a negative or a clamped zero, and both are fabrications. -fn counter_window_standing(left: CounterSnapshot, right: CounterSnapshot) -> CounterWindowStanding { - match counter_epoch_change(left: left.epoch, right: right.epoch) { - Present { value: cause } => CounterWindowUnestablished { cause: cause } - none => - if right.counters.corrected < left.counters.corrected - || right.counters.uncorrected < left.counters.uncorrected { - CounterWindowUnestablished { cause: CounterRegressed } - } else { - CounterDeltaEstablished { - corrected_delta: right.counters.corrected - left.counters.corrected, - uncorrected_delta: right.counters.uncorrected - left.counters.uncorrected, - opening_residue: left.counters, - } - } - } -} - -// THE LEFT EDGE IS AN INPUT. A prior baseline is the last snapshot someone ADMITTED or explicitly -// dispositioned -- never simply the previous run's reading. If the previous run saw a new error and -// became the next run's baseline automatically, the error would vanish after one cycle: that is the -// absorbing fallback DESIGN section 5 forbids. Selecting and admitting a baseline is therefore not -// this module's job, and nothing here can mint one from an observation. -type HostHealthBaselineBasis - = NoPriorBaseline - | PriorAdmittedBaseline { snapshot: CounterSnapshot } - -// AVAILABILITY IS CARRIED BESIDE THE VALUE, NEVER ENCODED IN IT. A present reading of 0 C stays a -// reading; a sensor the BMC reports as disabled and one that published no value this sample are -// different facts again. None of them is converted into "absent", which is derived only from the -// expectation join (an expected sensor with no window at all). -type TemperatureSample - = TemperatureReadingPresent { celsius: Celsius } - | TemperatureReadingUnavailable - | TemperatureSensorDisabled - -// THE BMC'S OWN THRESHOLD JUDGEMENT, KEPT AS EVIDENCE. It is not the only policy -- a BMC can carry -// no caution threshold at all for a part that needs one -- but it is not discarded either. The -// three standing levels follow the IPMI sensor threshold classes (non-critical, critical, -// non-recoverable), which Redfish's Status.Health also collapses into. -type BmcThresholdStanding - = BmcThresholdClear - | BmcThresholdNonCritical - | BmcThresholdCritical - | BmcThresholdNonRecoverable - | BmcThresholdUnreported - -type TemperatureWindow { - host: HostIdentity - sensor: NonEmptyStr - samples: List - worst_bmc_standing: BmcThresholdStanding -} - -// A LIMIT CARRIES WHO SAYS SO. There is deliberately no fleet-wide default: a limit is a fact about -// a component grade and a sensor's meaning, and until that fact is grounded the honest answer is -// that the limit is unestablished -- which can never produce a green. -type TemperatureLimit - = TemperatureLimitCited { critical: Celsius, authority: NonEmptyStr } - | TemperatureLimitUnestablished - -type TemperatureExpectation { - sensor: NonEmptyStr - limit: TemperatureLimit -} - -// WHICH SENSORS A HOST SHOULD EXPOSE IS ITSELF A FACT THAT CAN BE MISSING. A host whose sensor -// roster was never read has no denominator, so its temperature signal is unknown rather than judged -// against whatever happened to answer. -type HostTemperatureExpectation - = TemperatureRosterDeclared { sensors: List } - | TemperatureRosterUnestablished - -// Prefix equality of the rendered log does not prove the log was not cleared and refilled, so the -// continuity of a BMC event window is decided by the observer from independent generation evidence -// and arrives here already typed. New entries are carried with the BMC's own rendering; this layer -// does not decode event severity, so a new entry it cannot classify is an unknown, not a pass. -type BmcEventWindow - = BmcEventsAppended { entries: List } - | BmcEventLogContinuityUnestablished { cause: NonEmptyStr } - -type HostHealthSignal - = MemoryErrorCounterSignal - | ProcessorPlatformErrorSignal - | FanTachSignal - | TemperatureSignal - | BmcEventLogSignal - -type HostHealthGoal sole_constructor { - host: HostIdentity - fans: FanTachGoal - temperatures: HostTemperatureExpectation - baseline: HostHealthBaselineBasis -} - -// EVERY SIGNAL SLOT IS AN OBSERVATION ATTEMPT OF ITS OWN. One unreadable source does not erase the -// others: srv1 answering in-band while its BMC refuses still yields its memory and fan evidence, -// with the refused slot carried as a located unknown. -// -// Processor and platform errors (APEI/GHES records of non-memory sections, PCIe AER) have their own -// slot and no summation with the memory counters. Their producer must cover the whole counter -// epoch; a kernel ring buffer or a rotated journal does not, which is why an observer without a -// durable event producer on the host refuses this slot rather than reading "nothing found". -// WHY NOT std.goal_assessment ObservationAttempt HERE: a signal slot has THREE outcomes, not two. The -// out-of-band surface may answer, may answer and simply not carry the signal, or may fail to answer, -// and the last two have different remedies (a BMC telemetry requirement versus an access fault). -type HostSignalAttempt - = SignalObserved { observed: T } - | SignalNotExposedOutOfBand { observed_surface: NonEmptyStr } - | SignalUnreadable { cause: NonEmptyStr } - -type HostHealthObserved sole_constructor { - host: HostIdentity - memory: HostSignalAttempt - processor_platform: HostSignalAttempt> - fans: HostSignalAttempt> - temperatures: HostSignalAttempt> - bmc_events: HostSignalAttempt -} - -fn host_health_goal( - host: HostIdentity, - fans: FanTachGoal, - temperatures: HostTemperatureExpectation, - baseline: HostHealthBaselineBasis, -) -> HostHealthGoal { - HostHealthGoal { host: host, fans: fans, temperatures: temperatures, baseline: baseline } -} - -fn host_health_observed( - host: HostIdentity, - memory: HostSignalAttempt, - processor_platform: HostSignalAttempt>, - fans: HostSignalAttempt>, - temperatures: HostSignalAttempt>, - bmc_events: HostSignalAttempt, -) -> HostHealthObserved { - HostHealthObserved { - host: host, - memory: memory, - processor_platform: processor_platform, - fans: fans, - temperatures: temperatures, - bmc_events: bmc_events, - } -} - -type HostHealthDeviation - = CorrectedMemoryErrorIncrement { delta: Int, since_baseline: Bool } - | UncorrectedMemoryErrorIncrement { delta: Int, since_baseline: Bool } - | ProcessorPlatformErrorObserved { record: NonEmptyStr } - | FanDeviation { deviation: FanTachDeviation } - | TemperatureLimitExceeded { sensor: NonEmptyStr, maximum: Celsius, critical: Celsius } - | BmcSensorThresholdCrossed { sensor: NonEmptyStr, standing: BmcThresholdStanding } - | TemperatureObservationForUndeclaredSensor { sensor: NonEmptyStr } - -// A SIGNAL THE BMC NEVER EXPOSES AND ONE IT FAILED TO ANSWER ARE DIFFERENT GAPS with different owners: -// the first is a telemetry requirement on the BMC surface, the second an access or reachability fault. -type HostHealthUnknown - = RequiredSignalUnavailable { signal: HostHealthSignal, cause: NonEmptyStr } - | RequiredSignalNotExposedOutOfBand { signal: HostHealthSignal, observed_surface: NonEmptyStr } - | HistoricalErrorResidueUndispositioned { residue: MemoryErrorCounters } - | CounterContinuityUnestablished { cause: CounterWindowUnestablishedCause, against_baseline: Bool } - | FanUnknown { unknown: FanTachUnknown } - | TemperatureRosterUndeclared - | ExpectedTemperatureSensorMissing { sensor: NonEmptyStr } - | TemperatureReadingNeverPresent { sensor: NonEmptyStr, disabled: Bool } - | ThermalLimitUnestablished { sensor: NonEmptyStr } - | BmcEventUnclassified { entry: NonEmptyStr } - | EventLogContinuityUnestablished { cause: NonEmptyStr } - -type HostHealthRefusal - = ObservationForAnotherHost { goal_host: HostIdentity, observed_host: HostIdentity } - | BaselineForAnotherHost { goal_host: HostIdentity, baseline_host: HostIdentity } - | CounterWindowForAnotherHost { goal_host: HostIdentity, window_host: HostIdentity } - | TemperatureWindowForAnotherHost { sensor: NonEmptyStr } - | DuplicateExpectedTemperatureSensor { sensor: NonEmptyStr } - | DuplicateObservedTemperatureSensor { sensor: NonEmptyStr } - | EmptyTemperatureWindow { sensor: NonEmptyStr } - | FanAssessmentRefused { cause: FanTachRefusalCause } - -// THE EVIDENCE OF A GREEN NAMES WHAT WAS ESTABLISHED, not merely that nothing was wrong: which -// baseline the counters were read against and how many endpoints each signal judged. -type HostHealthEvidence sole_constructor { - host: HostIdentity - against_baseline: Bool - temperature_sensors_judged: Int - fan_headers_judged: Int -} - -type HostHealthAssessment = GoalAssessment - -// One signal's contribution. Refusals short-circuit the whole host; deviations and unknowns -// accumulate across signals so partial evidence is never lost to a sibling's gap. -type SignalVerdict { - deviations: List - unknowns: List - refusal: HostHealthRefusal? - judged: Int -} - -fn signal_clear(judged: Int) -> SignalVerdict { - SignalVerdict { deviations: [], unknowns: [], refusal: none, judged: judged } -} - -fn signal_unknown(unknown: HostHealthUnknown) -> SignalVerdict { - SignalVerdict { deviations: [], unknowns: [unknown], refusal: none, judged: 0 } -} - -fn signal_refused(refusal: HostHealthRefusal) -> SignalVerdict { - SignalVerdict { deviations: [], unknowns: [], refusal: Present { value: refusal }, judged: 0 } -} - -fn signal_unavailable(signal: HostHealthSignal, cause: NonEmptyStr) -> SignalVerdict { - signal_unknown(unknown: RequiredSignalUnavailable { signal: signal, cause: cause }) -} - -fn memory_increments( - corrected_delta: Int, - uncorrected_delta: Int, - since_baseline: Bool, -) -> List { - flat_map( - [ - if corrected_delta > 0 { - [CorrectedMemoryErrorIncrement { delta: corrected_delta, since_baseline: since_baseline }] - } else { [] }, - if uncorrected_delta > 0 { - [UncorrectedMemoryErrorIncrement { delta: uncorrected_delta, since_baseline: since_baseline }] - } else { [] }, - ], - group => group, - ) -} - -// WITH NO BASELINE, the in-run window still detects an error that lands while the check runs, and -// a nonzero opening total is undispositioned history -- so a positive total yields an unknown even -// when the in-run delta is zero, and a zero total with a stable window is clean-since-epoch. -// -// WITH A BASELINE, the left edge is the baseline snapshot; the in-run window must still be stable, -// because an epoch change during the run is a gap the baseline cannot bridge. -fn assess_memory( - goal_host: HostIdentity, - baseline: HostHealthBaselineBasis, - window: CumulativeCounterWindow, -) -> SignalVerdict { - if window.opening.epoch.host != goal_host { - signal_refused(refusal: CounterWindowForAnotherHost { goal_host: goal_host, window_host: window.opening.epoch.host }) - } else { - match counter_window_standing(left: window.opening, right: window.closing) { - CounterWindowUnestablished { cause } => - signal_unknown(unknown: CounterContinuityUnestablished { cause: cause, against_baseline: false }) - CounterDeltaEstablished { corrected_delta: run_ce, uncorrected_delta: run_ue, opening_residue: residue } => - match baseline { - NoPriorBaseline => SignalVerdict { - deviations: memory_increments(corrected_delta: run_ce, uncorrected_delta: run_ue, since_baseline: false), - unknowns: if residue.corrected > 0 || residue.uncorrected > 0 { - [HistoricalErrorResidueUndispositioned { residue: residue }] - } else { [] }, - refusal: none, - judged: 1, - } - PriorAdmittedBaseline { snapshot } => - if snapshot.epoch.host != goal_host { - signal_refused(refusal: BaselineForAnotherHost { goal_host: goal_host, baseline_host: snapshot.epoch.host }) - } else { - match counter_window_standing(left: snapshot, right: window.closing) { - CounterWindowUnestablished { cause } => - signal_unknown(unknown: CounterContinuityUnestablished { cause: cause, against_baseline: true }) - CounterDeltaEstablished { corrected_delta: ce, uncorrected_delta: ue, opening_residue: _ } => - SignalVerdict { - deviations: memory_increments(corrected_delta: ce, uncorrected_delta: ue, since_baseline: true), - unknowns: [], - refusal: none, - judged: 1, - } - } - } - } - } - } -} - -fn assess_processor_platform(records: List) -> SignalVerdict { - SignalVerdict { - deviations: map(records, r => ProcessorPlatformErrorObserved { record: r }), - unknowns: [], - refusal: none, - judged: 1, - } -} - -fn assess_fans(goal: FanTachGoal, windows: List) -> SignalVerdict { - match assess_fan_tach(goal: goal, observed: fan_tach_observed(windows: windows)) { - GoalSatisfied { evidence } => signal_clear(judged: evidence.headers_judged) - GoalDiverged { deviations } => SignalVerdict { - deviations: map(non_empty_to_list(deviations), d => FanDeviation { deviation: d }), - unknowns: [], - refusal: none, - judged: count(non_empty_to_list(deviations)), - } - GoalIndeterminate { known_deviations, unknowns } => SignalVerdict { - deviations: map(known_deviations, d => FanDeviation { deviation: d }), - unknowns: map(non_empty_to_list(unknowns), u => FanUnknown { unknown: u }), - refusal: none, - judged: count(known_deviations), - } - GoalAssessmentRefused { cause } => signal_refused(refusal: FanAssessmentRefused { cause: cause }) - } -} - -fn count_name(names: List, name: NonEmptyStr) -> Int { - fold(names, init: 0, f: (acc, n) => if n == name { acc + 1 } else { acc }) -} - -fn first_duplicate_name(names: List) -> NonEmptyStr? { - fold(names, init: none, f: (acc, n) => match acc { - Present { value } => Present { value: value } - none => if count_name(names: names, name: n) > 1 { Present { value: n } } else { none } - }) -} - -fn temperature_window_for(windows: List, sensor: NonEmptyStr) -> TemperatureWindow? { - filter(windows, w => w.sensor == sensor).first() -} - -fn temperature_expected(expectations: List, sensor: NonEmptyStr) -> Bool { - any(expectations, e => e.sensor == sensor) -} - -fn present_celsius(samples: List) -> List { - flat_map(samples, s => match s { - TemperatureReadingPresent { celsius } => [celsius_count(celsius)] - TemperatureReadingUnavailable => [] - TemperatureSensorDisabled => [] - }) -} - -fn any_disabled(samples: List) -> Bool { - any(samples, s => match s { TemperatureSensorDisabled => true _ => false }) -} - -fn bmc_standing_crossed(standing: BmcThresholdStanding) -> Bool { - match standing { - BmcThresholdNonCritical => true - BmcThresholdCritical => true - BmcThresholdNonRecoverable => true - BmcThresholdClear => false - BmcThresholdUnreported => false - } -} - -fn judge_temperature(expectation: TemperatureExpectation, windows: List) -> SignalVerdict { - match temperature_window_for(windows: windows, sensor: expectation.sensor) { - none => signal_unknown(unknown: ExpectedTemperatureSensorMissing { sensor: expectation.sensor }) - Present { value: w } => { - let readings = present_celsius(samples: w.samples) - let bmc = if bmc_standing_crossed(standing: w.worst_bmc_standing) { - [BmcSensorThresholdCrossed { sensor: w.sensor, standing: w.worst_bmc_standing }] - } else { [] } - match readings.first() { - none => SignalVerdict { - deviations: bmc, - unknowns: [TemperatureReadingNeverPresent { sensor: w.sensor, disabled: any_disabled(samples: w.samples) }], - refusal: none, - judged: 1, - } - Present { value: first } => { - let maximum = fold(readings, init: first, f: (acc, r) => if r > acc { r } else { acc }) - match expectation.limit { - TemperatureLimitUnestablished => SignalVerdict { - deviations: bmc, - unknowns: [ThermalLimitUnestablished { sensor: w.sensor }], - refusal: none, - judged: 1, - } - TemperatureLimitCited { critical, authority: _ } => SignalVerdict { - deviations: flat_map([ - bmc, - if maximum > celsius_count(critical) { - [TemperatureLimitExceeded { sensor: w.sensor, maximum: celsius(maximum), critical: critical }] - } else { [] }, - ], group => group), - unknowns: [], - refusal: none, - judged: 1, - } - } - } - } - } - } -} - -fn assess_temperatures( - goal_host: HostIdentity, - expectation: HostTemperatureExpectation, - windows: List, -) -> SignalVerdict { - match filter(windows, w => w.host != goal_host).first() { - Present { value: stray } => signal_refused(refusal: TemperatureWindowForAnotherHost { sensor: stray.sensor }) - none => match filter(windows, w => count(w.samples) == 0).first() { - Present { value: empty } => signal_refused(refusal: EmptyTemperatureWindow { sensor: empty.sensor }) - none => match first_duplicate_name(names: map(windows, w => w.sensor)) { - Present { value: sensor } => signal_refused(refusal: DuplicateObservedTemperatureSensor { sensor: sensor }) - none => match expectation { - TemperatureRosterUnestablished => signal_unknown(unknown: TemperatureRosterUndeclared) - TemperatureRosterDeclared { sensors } => - match first_duplicate_name(names: map(sensors, e => e.sensor)) { - Present { value: sensor } => signal_refused(refusal: DuplicateExpectedTemperatureSensor { sensor: sensor }) - none => merge_verdicts(verdicts: flat_map([ - map(sensors, e => judge_temperature(expectation: e, windows: windows)), - map( - filter(windows, w => !temperature_expected(expectations: sensors, sensor: w.sensor)), - w => SignalVerdict { - deviations: [TemperatureObservationForUndeclaredSensor { sensor: w.sensor }], - unknowns: [], - refusal: none, - judged: 0, - }, - ), - ], group => group)) - } - } - } - } - } -} - -fn assess_bmc_events(window: BmcEventWindow) -> SignalVerdict { - match window { - BmcEventLogContinuityUnestablished { cause } => - signal_unknown(unknown: EventLogContinuityUnestablished { cause: cause }) - BmcEventsAppended { entries } => SignalVerdict { - deviations: [], - unknowns: map(entries, e => BmcEventUnclassified { entry: e }), - refusal: none, - judged: 1, - } - } -} - -fn merge_verdicts(verdicts: List) -> SignalVerdict { - SignalVerdict { - deviations: flat_map(verdicts, v => v.deviations), - unknowns: flat_map(verdicts, v => v.unknowns), - refusal: fold(verdicts, init: none, f: (acc, v) => match acc { - Present { value } => Present { value: value } - none => v.refusal - }), - judged: fold(verdicts, init: 0, f: (acc, v) => acc + v.judged), - } -} - -fn signal_of( - attempt: HostSignalAttempt, - signal: HostHealthSignal, - judge: fn(T) -> SignalVerdict, -) -> SignalVerdict { - match attempt { - SignalUnreadable { cause } => signal_unavailable(signal: signal, cause: cause) - SignalNotExposedOutOfBand { observed_surface } => - signal_unknown(unknown: RequiredSignalNotExposedOutOfBand { signal: signal, observed_surface: observed_surface }) - SignalObserved { observed } => judge(observed) - } -} - -fn assess_host_health(goal: HostHealthGoal, observed: HostHealthObserved) -> HostHealthAssessment { - if observed.host != goal.host { - GoalAssessmentRefused { cause: ObservationForAnotherHost { goal_host: goal.host, observed_host: observed.host } } - } else { - let fans = signal_of(attempt: observed.fans, signal: FanTachSignal, - judge: windows => assess_fans(goal: goal.fans, windows: windows)) - let temperatures = signal_of(attempt: observed.temperatures, signal: TemperatureSignal, - judge: windows => assess_temperatures(goal_host: goal.host, expectation: goal.temperatures, windows: windows)) - let all = merge_verdicts(verdicts: [ - signal_of(attempt: observed.memory, signal: MemoryErrorCounterSignal, - judge: window => assess_memory(goal_host: goal.host, baseline: goal.baseline, window: window)), - signal_of(attempt: observed.processor_platform, signal: ProcessorPlatformErrorSignal, - judge: records => assess_processor_platform(records: records)), - fans, - temperatures, - signal_of(attempt: observed.bmc_events, signal: BmcEventLogSignal, - judge: window => assess_bmc_events(window: window)), - ]) - match all.refusal { - Present { value: refusal } => GoalAssessmentRefused { cause: refusal } - none => assess_by_deviations_and_unknowns( - evidence: HostHealthEvidence { - host: goal.host, - against_baseline: match goal.baseline { NoPriorBaseline => false PriorAdmittedBaseline { snapshot: _ } => true }, - temperature_sensors_judged: temperatures.judged, - fan_headers_judged: fans.judged, - }, - deviations: all.deviations, - unknowns: all.unknowns, - ) - } - } -} - -// ONE ENTRY PER ROSTERED HOST, AND THE OBSERVATION IS OPTIONAL WHILE THE GOAL IS NOT. The fold is -// driven by the goals, so a host whose collector produced nothing is still present: its whole -// observation is refused with a located cause, and it cannot shorten the denominator. -type HostHealthInspection = GoalInspection - -type FleetHealthDeviation { host: HostIdentity, deviation: HostHealthDeviation } - -type FleetHealthUnknown - = HostUnknownAt { host: HostIdentity, unknown: HostHealthUnknown } - | HostObservationRefusedAt { host: HostIdentity, cause: NonEmptyStr } - | HostAssessmentRefusedAt { host: HostIdentity, cause: HostHealthRefusal } - -type FleetHealthRefusal - = EmptyRoster - | DuplicateRosterHost { host: HostIdentity } - | ObservationForUnrosteredHost { host: HostIdentity } - | DuplicateHostObservation { host: HostIdentity } - -type FleetHealthEvidence sole_constructor { hosts_satisfied: List } - -type FleetHealthAssessment = GoalAssessment - -type FleetHealthReceipt sole_constructor { - hosts: List - assessment: FleetHealthAssessment -} - -fn host_names(hosts: List) -> List { - map(hosts, h => h as NonEmptyStr) -} - -// A HOST THE COLLECTOR COULD NOT REACH ARRIVES AS A KEYED REFUSAL. `observations` is keyed by host -// so an unreachable host's refusal is attributable; a refusal keyed to a host not on the roster is -// a refusal of the whole fleet assessment, because it means the collector and the roster disagree -// about what the fleet is. -type KeyedHostObservation { - host: HostIdentity - attempt: ObservationAttempt -} - -fn fleet_inspections(goals: List, observations: List) -> List { - map(goals, goal => match filter(observations, o => o.host == goal.host).first() { - none => GoalObservationRefused { - subject: goal.host, goal: goal, request: goal.host, - cause: concat("no observation was collected for rostered host ", goal.host as String) as NonEmptyStr, - } - Present { value: keyed } => match keyed.attempt { - ObservationRefused { cause } => GoalObservationRefused { subject: goal.host, goal: goal, request: goal.host, cause: cause } - ObservationEstablished { observed } => GoalInspected { - subject: goal.host, goal: goal, request: goal.host, observed: observed, - assessment: assess_host_health(goal: goal, observed: observed), - } - } - }) -} - -fn inspection_contribution(inspection: HostHealthInspection) -> SignalVerdictAt { - match inspection { - GoalObservationRefused { subject, goal: _, request: _, cause } => - SignalVerdictAt { satisfied: [], deviations: [], unknowns: [HostObservationRefusedAt { host: subject, cause: cause }] } - GoalInspected { subject, goal: _, request: _, observed: _, assessment } => match assessment { - GoalSatisfied { evidence: _ } => SignalVerdictAt { satisfied: [subject], deviations: [], unknowns: [] } - GoalDiverged { deviations } => SignalVerdictAt { - satisfied: [], - deviations: map(non_empty_to_list(deviations), d => FleetHealthDeviation { host: subject, deviation: d }), - unknowns: [], - } - GoalIndeterminate { known_deviations, unknowns } => SignalVerdictAt { - satisfied: [], - deviations: map(known_deviations, d => FleetHealthDeviation { host: subject, deviation: d }), - unknowns: map(non_empty_to_list(unknowns), u => HostUnknownAt { host: subject, unknown: u }), - } - GoalAssessmentRefused { cause } => SignalVerdictAt { - satisfied: [], deviations: [], unknowns: [HostAssessmentRefusedAt { host: subject, cause: cause }], - } - } - } -} - -type SignalVerdictAt { - satisfied: List - deviations: List - unknowns: List -} - -// THE FLEET IS GREEN ONLY IF EVERY ROSTERED HOST IS. A host-level refusal (the inputs for that host -// were malformed) is a fleet-level unknown rather than a fleet refusal, so one bad host does not -// hide the other hosts' findings; the fleet refuses only when the roster or the keying is itself -// inconsistent. -fn assess_fleet_health(goals: List, observations: List) -> FleetHealthReceipt { - let roster = map(goals, g => g.host) - let refusal: FleetHealthRefusal? = if count(roster) == 0 { Present { value: EmptyRoster } } else { - match first_duplicate_name(names: host_names(hosts: roster)) { - Present { value: h } => Present { value: DuplicateRosterHost { host: h as HostIdentity } } - none => match first_duplicate_name(names: host_names(hosts: map(observations, o => o.host))) { - Present { value: h } => Present { value: DuplicateHostObservation { host: h as HostIdentity } } - none => match filter(observations, o => !any(roster, r => r == o.host)).first() { - Present { value: stray } => Present { value: ObservationForUnrosteredHost { host: stray.host } } - none => none - } - } - } - } - let inspections = fleet_inspections(goals: goals, observations: observations) - match refusal { - Present { value: cause } => FleetHealthReceipt { hosts: inspections, assessment: GoalAssessmentRefused { cause: cause } } - none => { - let parts = map(inspections, i => inspection_contribution(inspection: i)) - FleetHealthReceipt { - hosts: inspections, - assessment: assess_by_deviations_and_unknowns( - evidence: FleetHealthEvidence { hosts_satisfied: flat_map(parts, p => p.satisfied) }, - deviations: flat_map(parts, p => p.deviations), - unknowns: flat_map(parts, p => p.unknowns), - ), - } - } - } -} diff --git a/dag/test/claim/host_health_assessment_witness_test.dag b/dag/test/claim/host_health_assessment_witness_test.dag deleted file mode 100644 index 978fc71ba8d..00000000000 --- a/dag/test/claim/host_health_assessment_witness_test.dag +++ /dev/null @@ -1,378 +0,0 @@ -module test.claim.host_health_assessment_witness - -import std.types { Bool, Int, List, NonEmptyStr } -import std.measure { celsius } -import product.placement_supply { HostIdentity } -import product.fan_tach { FanTachWindow } -import gunbc.fleet_intent_network { operator_host_srv1, operator_host_srv2 } -import gunbc.fleet_fan_tach_expectation { srv1_fan_tach_expectation, srv2_fan_tach_expectation } -import gunbc.fleet_fan_tach_observation { srv1_windows, srv2_windows } -import product.fan_tach_health { fan_tach_goal } -import product.host_health { - BmcEventsAppended, - BmcEventLogContinuityUnestablished, - BmcThresholdClear, - CounterGenerationChanged, - CounterContinuityUnestablished, - CounterDeltaEstablished, - CounterEndpointSetChanged, - CounterEpoch, - CounterRegressed, - CounterSnapshot, - CounterWindowUnestablished, - CorrectedMemoryErrorIncrement, - CumulativeCounterWindow, - BaselineForAnotherHost, - DuplicateObservedTemperatureSensor, - ExpectedTemperatureSensorMissing, - FanDeviation, - HistoricalErrorResidueUndispositioned, - HostHealthBaselineBasis, - HostHealthAssessment, - HostHealthGoal, - HostHealthObserved, - HostHealthUnknown, - RequiredSignalNotExposedOutOfBand, - SignalObserved, - SignalNotExposedOutOfBand, - HostObservationRefusedAt, - KeyedHostObservation, - MemoryErrorCounters, - NoPriorBaseline, - PriorAdmittedBaseline, - TemperatureExpectation, - TemperatureLimitCited, - TemperatureLimitExceeded, - TemperatureLimitUnestablished, - TemperatureReadingNeverPresent, - TemperatureReadingPresent, - TemperatureReadingUnavailable, - TemperatureRosterDeclared, - TemperatureSample, - TemperatureSensorDisabled, - TemperatureWindow, - ThermalLimitUnestablished, - UncorrectedMemoryErrorIncrement, - EmptyRoster, - assess_fleet_health, - assess_host_health, - counter_window_standing, - host_health_goal, - host_health_observed, -} -import std.goal_assessment { - GoalAssessmentRefused, - GoalDiverged, - GoalIndeterminate, - GoalInspected, - GoalObservationRefused, - GoalSatisfied, - ObservationEstablished, -} -import v2.std.algebra { non_empty_to_list } -import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } - -data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly - -// EVERY INPUT HERE IS SUPPLIED, and that is deliberate (DESIGN section 3, a witness discriminates at -// one interface): these claims are about the ASSESSMENT, so they construct its inputs instead of -// running a collector. The fan windows are the fleet's own dated 20-sample specimens, which is why -// srv2 is the control host and srv1 the one that diverges. The real producer of these shapes is the -// observer route, and its inhabitance is owed by that route's own claim. - -// The fan floor is this witness's policy, chosen in the 4..16 gap the specimens show, and passed as -// a parameter so it cannot become the fleet's definition of healthy. -data rotating_floor: Int = 12 - -fn epoch(host: HostIdentity, boot: NonEmptyStr, endpoints: List) -> CounterEpoch { - CounterEpoch { - host: host, - producer: "redfish-memory-metrics", - generation: boot, - endpoint_set: endpoints, - } -} - -data mc0: List = ["mc0"] - -data no_records: List = [] - -data no_temperature_windows: List = [] - -fn snap(host: HostIdentity, boot: NonEmptyStr, ce: Int, ue: Int) -> CounterSnapshot { - CounterSnapshot { - epoch: epoch(host: host, boot: boot, endpoints: mc0), - counters: MemoryErrorCounters { corrected: ce, uncorrected: ue }, - } -} - -fn window(host: HostIdentity, open_ce: Int, close_ce: Int) -> CumulativeCounterWindow { - CumulativeCounterWindow { opening: snap(host: host, boot: "b1", ce: open_ce, ue: 0), closing: snap(host: host, boot: "b1", ce: close_ce, ue: 0) } -} - -fn cited(sensor: NonEmptyStr, critical: Int) -> TemperatureExpectation { - TemperatureExpectation { - sensor: sensor, - limit: TemperatureLimitCited { critical: celsius(critical), authority: "witness-supplied limit" }, - } -} - -fn temps(host: HostIdentity, sensor: NonEmptyStr, samples: List) -> TemperatureWindow { - TemperatureWindow { host: host, sensor: sensor, samples: samples, worst_bmc_standing: BmcThresholdClear } -} - -fn present(c: Int) -> TemperatureSample { - TemperatureReadingPresent { celsius: celsius(c) } -} - -// srv2's goal with every signal satisfiable: its healthy fan specimen, one cited sensor. -fn srv2_goal(baseline: HostHealthBaselineBasis) -> HostHealthGoal { - host_health_goal( - host: operator_host_srv2, - fans: fan_tach_goal(expectation: srv2_fan_tach_expectation, minimum_rotating: rotating_floor), - temperatures: TemperatureRosterDeclared { sensors: [cited(sensor: "TEMP_SOC", critical: 105)] }, - baseline: baseline, - ) -} - -fn no_baseline() -> HostHealthBaselineBasis { - NoPriorBaseline -} - -fn srv2_observed(memory: CumulativeCounterWindow, temperatures: List) -> HostHealthObserved { - host_health_observed( - host: operator_host_srv2, - memory: SignalObserved { observed: memory }, - processor_platform: SignalObserved { observed: no_records }, - fans: SignalObserved { observed: srv2_windows }, - temperatures: SignalObserved { observed: temperatures }, - bmc_events: SignalObserved { observed: BmcEventsAppended { entries: [] } }, - ) -} - -data srv2_soc_ok: List = [temps(host: operator_host_srv2, sensor: "TEMP_SOC", samples: [present(c: 55), present(c: 56)])] - -fn unknowns_of(goal: HostHealthGoal, observed: HostHealthObserved) -> List { - match assess_host_health(goal: goal, observed: observed) { - GoalIndeterminate { known_deviations: _, unknowns } => non_empty_to_list(unknowns) - GoalSatisfied { evidence: _ } => [] - GoalDiverged { deviations: _ } => [] - GoalAssessmentRefused { cause: _ } => [] - } -} - -test fn zero_to_zero_in_one_epoch_establishes_a_zero_delta() -> Bool { - match counter_window_standing(left: snap(host: operator_host_srv2, boot: "b1", ce: 0, ue: 0), right: snap(host: operator_host_srv2, boot: "b1", ce: 0, ue: 0)) { - CounterDeltaEstablished { corrected_delta, uncorrected_delta, opening_residue } => - corrected_delta == 0 && uncorrected_delta == 0 && opening_residue.corrected == 0 - CounterWindowUnestablished { cause: _ } => false - } -} - -test fn four_to_five_is_one_new_error_with_four_kept_as_residue() -> Bool { - match counter_window_standing(left: snap(host: operator_host_srv2, boot: "b1", ce: 4, ue: 0), right: snap(host: operator_host_srv2, boot: "b1", ce: 5, ue: 0)) { - CounterDeltaEstablished { corrected_delta, uncorrected_delta, opening_residue } => - corrected_delta == 1 && uncorrected_delta == 0 && opening_residue.corrected == 4 - CounterWindowUnestablished { cause: _ } => false - } -} - -test fn a_regression_a_reboot_or_a_new_endpoint_set_cannot_construct_a_delta() -> Bool { - let regressed = match counter_window_standing(left: snap(host: operator_host_srv2, boot: "b1", ce: 5, ue: 0), right: snap(host: operator_host_srv2, boot: "b1", ce: 0, ue: 0)) { - CounterWindowUnestablished { cause: CounterRegressed } => true - _ => false - } - let rebooted = match counter_window_standing(left: snap(host: operator_host_srv2, boot: "b1", ce: 0, ue: 0), right: snap(host: operator_host_srv2, boot: "b2", ce: 0, ue: 0)) { - CounterWindowUnestablished { cause: CounterGenerationChanged } => true - _ => false - } - let widened = match counter_window_standing( - left: snap(host: operator_host_srv2, boot: "b1", ce: 0, ue: 0), - right: CounterSnapshot { epoch: epoch(host: operator_host_srv2, boot: "b1", endpoints: ["mc0", "mc1"]), counters: MemoryErrorCounters { corrected: 0, uncorrected: 0 } }, - ) { - CounterWindowUnestablished { cause: CounterEndpointSetChanged } => true - _ => false - } - regressed && rebooted && widened -} - -// Without a green for SOME input, every red below could come from an assessment that never agrees. -test fn a_clean_host_with_every_signal_established_is_satisfied() -> Bool { - match assess_host_health(goal: srv2_goal(baseline: no_baseline()), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: srv2_soc_ok)) { - GoalSatisfied { evidence } => evidence.temperature_sensors_judged == 1 && evidence.fan_headers_judged == 5 - GoalDiverged { deviations: _ } => false - GoalIndeterminate { known_deviations: _, unknowns: _ } => false - GoalAssessmentRefused { cause: _ } => false - } -} - -// srv1 read 255 corrected errors with no admitted baseline on 2026-09-18; this is that shape. The -// window is stable and adds nothing, and the host still cannot be green. -test fn a_positive_total_without_a_baseline_is_undispositioned_residue() -> Bool { - let us = unknowns_of(goal: srv2_goal(baseline: no_baseline()), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 255, close_ce: 255), temperatures: srv2_soc_ok)) - count(us) == 1 && any(us, u => match u { HistoricalErrorResidueUndispositioned { residue } => residue.corrected == 255 _ => false }) -} - -// The same residue measured against an admitted baseline that already carried it is green, and one -// more error since that baseline is a divergence -- so the baseline is what separates history from -// news, not the in-run window. -test fn an_admitted_baseline_turns_residue_into_history_and_new_errors_into_divergence() -> Bool { - let baseline = PriorAdmittedBaseline { snapshot: snap(host: operator_host_srv2, boot: "b1", ce: 255, ue: 0) } - let quiet = match assess_host_health(goal: srv2_goal(baseline: baseline), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 255, close_ce: 255), temperatures: srv2_soc_ok)) { - GoalSatisfied { evidence } => evidence.against_baseline - _ => false - } - let one_more = match assess_host_health(goal: srv2_goal(baseline: baseline), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 256, close_ce: 256), temperatures: srv2_soc_ok)) { - GoalDiverged { deviations } => any(non_empty_to_list(deviations), d => match d { CorrectedMemoryErrorIncrement { delta, since_baseline } => delta == 1 && since_baseline _ => false }) - _ => false - } - quiet && one_more -} - -test fn a_baseline_for_another_host_refuses() -> Bool { - let foreign = PriorAdmittedBaseline { snapshot: snap(host: operator_host_srv1, boot: "b1", ce: 0, ue: 0) } - match assess_host_health(goal: srv2_goal(baseline: foreign), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: srv2_soc_ok)) { - GoalAssessmentRefused { cause: BaselineForAnotherHost { goal_host, baseline_host } } => baseline_host == operator_host_srv1 - _ => false - } -} - -test fn a_cleared_or_replaced_event_log_is_not_an_empty_delta() -> Bool { - let observed = host_health_observed( - host: operator_host_srv2, - memory: SignalObserved { observed: window(host: operator_host_srv2, open_ce: 0, close_ce: 0) }, - processor_platform: SignalObserved { observed: no_records }, - fans: SignalObserved { observed: srv2_windows }, - temperatures: SignalObserved { observed: srv2_soc_ok }, - bmc_events: SignalObserved { observed: BmcEventLogContinuityUnestablished { cause: "SEL erase timestamp changed during the window" } }, - ) - match assess_host_health(goal: srv2_goal(baseline: no_baseline()), observed: observed) { - GoalIndeterminate { known_deviations, unknowns } => count(known_deviations) == 0 && count(non_empty_to_list(unknowns)) == 1 - _ => false - } -} - -// srv2's specimen reads zero 1-4 times in 20 on every chassis fan and is satisfied (the positive -// control above); srv1's two degraded endpoints diverge. A lone zero is therefore not a failure, -// and the host layer does not re-judge fans -- it carries fan_tach_health's deviations through. -test fn the_host_layer_carries_the_fan_authoritys_two_srv1_degradations() -> Bool { - let goal = host_health_goal( - host: operator_host_srv1, - fans: fan_tach_goal(expectation: srv1_fan_tach_expectation, minimum_rotating: rotating_floor), - temperatures: TemperatureRosterDeclared { sensors: [] }, - baseline: NoPriorBaseline, - ) - let observed = host_health_observed( - host: operator_host_srv1, - memory: SignalObserved { observed: window(host: operator_host_srv1, open_ce: 0, close_ce: 0) }, - processor_platform: SignalObserved { observed: no_records }, - fans: SignalObserved { observed: srv1_windows }, - temperatures: SignalObserved { observed: no_temperature_windows }, - bmc_events: SignalObserved { observed: BmcEventsAppended { entries: [] } }, - ) - match assess_host_health(goal: goal, observed: observed) { - GoalDiverged { deviations } => count(filter(non_empty_to_list(deviations), d => match d { FanDeviation { deviation: _ } => true _ => false })) == 2 - _ => false - } -} - -test fn a_missing_expected_sensor_is_unknown_and_a_duplicate_refuses() -> Bool { - let missing = unknowns_of(goal: srv2_goal(baseline: no_baseline()), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: [])) - let dup = match assess_host_health(goal: srv2_goal(baseline: no_baseline()), observed: srv2_observed( - memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), - temperatures: [temps(host: operator_host_srv2, sensor: "TEMP_SOC", samples: [present(c: 55)]), temps(host: operator_host_srv2, sensor: "TEMP_SOC", samples: [present(c: 56)])], - )) { - GoalAssessmentRefused { cause: DuplicateObservedTemperatureSensor { sensor } } => sensor == "TEMP_SOC" - _ => false - } - any(missing, u => match u { ExpectedTemperatureSensorMissing { sensor } => sensor == "TEMP_SOC" _ => false }) && dup -} - -fn judge_soc(samples: List) -> HostHealthAssessment { - assess_host_health( - goal: srv2_goal(baseline: no_baseline()), - observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: [temps(host: operator_host_srv2, sensor: "TEMP_SOC", samples: samples)]), - ) -} - -test fn zero_celsius_unavailable_disabled_and_absent_remain_distinct() -> Bool { - let zero_is_a_reading = match judge_soc(samples: [present(c: 0)]) { GoalSatisfied { evidence: _ } => true _ => false } - let unavailable = match judge_soc(samples: [TemperatureReadingUnavailable]) { - GoalIndeterminate { known_deviations: _, unknowns } => any(non_empty_to_list(unknowns), u => match u { TemperatureReadingNeverPresent { sensor: _, disabled } => !disabled _ => false }) - _ => false - } - let disabled = match judge_soc(samples: [TemperatureSensorDisabled]) { - GoalIndeterminate { known_deviations: _, unknowns } => any(non_empty_to_list(unknowns), u => match u { TemperatureReadingNeverPresent { sensor: _, disabled } => disabled _ => false }) - _ => false - } - let absent = any(unknowns_of(goal: srv2_goal(baseline: no_baseline()), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: [])), - u => match u { ExpectedTemperatureSensorMissing { sensor: _ } => true _ => false }) - zero_is_a_reading && unavailable && disabled && absent -} - -test fn an_unestablished_limit_is_never_green_and_a_cited_one_diverges_above_it() -> Bool { - let unestablished = host_health_goal( - host: operator_host_srv2, - fans: fan_tach_goal(expectation: srv2_fan_tach_expectation, minimum_rotating: rotating_floor), - temperatures: TemperatureRosterDeclared { sensors: [TemperatureExpectation { sensor: "TEMP_SOC", limit: TemperatureLimitUnestablished }] }, - baseline: NoPriorBaseline, - ) - let cool = srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: srv2_soc_ok) - let never_green = any(unknowns_of(goal: unestablished, observed: cool), u => match u { ThermalLimitUnestablished { sensor } => sensor == "TEMP_SOC" _ => false }) - let hot = srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: [temps(host: operator_host_srv2, sensor: "TEMP_SOC", samples: [present(c: 55), present(c: 106)])]) - let exceeded = match assess_host_health(goal: srv2_goal(baseline: no_baseline()), observed: hot) { - GoalDiverged { deviations } => any(non_empty_to_list(deviations), d => match d { TemperatureLimitExceeded { sensor: _, maximum: _, critical: _ } => true _ => false }) - _ => false - } - never_green && exceeded -} - -test fn one_unreachable_host_prevents_fleet_green_without_hiding_the_others() -> Bool { - let srv1_goal = host_health_goal( - host: operator_host_srv1, - fans: fan_tach_goal(expectation: srv1_fan_tach_expectation, minimum_rotating: rotating_floor), - temperatures: TemperatureRosterDeclared { sensors: [] }, - baseline: NoPriorBaseline, - ) - let receipt = assess_fleet_health( - goals: [srv1_goal, srv2_goal(baseline: no_baseline())], - observations: [KeyedHostObservation { - host: operator_host_srv2, - attempt: ObservationEstablished { observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: srv2_soc_ok) }, - }], - ) - let both_inspected = count(receipt.hosts) == 2 - let verdict = match receipt.assessment { - GoalIndeterminate { known_deviations, unknowns } => - count(known_deviations) == 0 - && any(non_empty_to_list(unknowns), u => match u { HostObservationRefusedAt { host, cause: _ } => host == operator_host_srv1 _ => false }) - _ => false - } - let srv2_still_green = any(receipt.hosts, i => match i { - GoalInspected { subject, goal: _, request: _, observed: _, assessment } => subject == operator_host_srv2 && match assessment { GoalSatisfied { evidence: _ } => true _ => false } - GoalObservationRefused { subject: _, goal: _, request: _, cause: _ } => false - }) - both_inspected && verdict && srv2_still_green -} - -test fn an_empty_roster_refuses_rather_than_reading_as_a_green_fleet() -> Bool { - match assess_fleet_health(goals: [], observations: []).assessment { - GoalAssessmentRefused { cause: EmptyRoster } => true - _ => false - } -} - -// Corrected memory errors the BMC does not expose are a gap, never a pass and never an in-band read: -// the goal does not shrink to what the surface happened to carry. -test fn a_signal_the_bmc_does_not_expose_is_indeterminate_not_satisfied() -> Bool { - let observed = host_health_observed( - host: operator_host_srv2, - memory: SignalNotExposedOutOfBand { observed_surface: "Redfish Memory resources carry no MemoryMetrics" }, - processor_platform: SignalObserved { observed: no_records }, - fans: SignalObserved { observed: srv2_windows }, - temperatures: SignalObserved { observed: srv2_soc_ok }, - bmc_events: SignalObserved { observed: BmcEventsAppended { entries: [] } }, - ) - let us = unknowns_of(goal: srv2_goal(baseline: no_baseline()), observed: observed) - count(us) == 1 && any(us, u => match u { RequiredSignalNotExposedOutOfBand { signal: _, observed_surface: _ } => true _ => false }) -} From 5b47649bcc9220caa09a2f32349f6fc71df7f740 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 19 Sep 2026 03:42:05 +0000 Subject: [PATCH 10/15] extdeps.bmc.redfish_telemetry: Sensor, Thermal and LogEntry decoders for both BMC images Co-Authored-By: Claude Opus 5 (1M context) --- dag/extdeps/bmc/redfish_telemetry.dag | 308 ++++++++++++++++++ .../claim/redfish_telemetry_witness_test.dag | 99 ++++++ 2 files changed, 407 insertions(+) create mode 100644 dag/extdeps/bmc/redfish_telemetry.dag create mode 100644 dag/test/claim/redfish_telemetry_witness_test.dag diff --git a/dag/extdeps/bmc/redfish_telemetry.dag b/dag/extdeps/bmc/redfish_telemetry.dag new file mode 100644 index 00000000000..f878d397498 --- /dev/null +++ b/dag/extdeps/bmc/redfish_telemetry.dag @@ -0,0 +1,308 @@ +module extdeps.bmc.redfish_telemetry + +import std.types { Bool, Int, List, NonEmptyStr, String } +import std.decl_ref { DeclarationRef, WholeDeclaration } +import extdeps.external_authority { ExternalAuthority, ExternalModelScope, ExternalSubjectRef } +import extdeps.uri { Uri, Https } +import extdeps.languages.json.emit { JsonNull, JsonNumber, JsonValue } +import extdeps.languages.json.parse { JsonDocumentParsed, JsonDocumentUnreadable, parse_json_document } +import gunbc.scm.json_member { + MemberArray, + MemberFailed, + MemberLookupFailed, + MemberObject, + MemberReadMissing, + MemberString, + MemberValue, + read_member, + read_member_value, + read_string_member, +} +import std.goal_assessment { ObservationAttempt, ObservationEstablished, ObservationRefused } + +// WHAT A REDFISH SERVICE SAYS ABOUT A SENSOR, A THERMAL SUBSYSTEM AND A LOG, as the DMTF schemas +// define those resources -- and nothing about what the readings mean for a host. +// +// Three resource shapes, because the fleet's two BMC images serve telemetry through different ones +// (extdeps.bmc.types RedfishThermalTelemetrySurface; observed 2026-09-18 with the read-only health +// account): the ASRock 2.07 image serves one Sensor resource per sensor under Chassis/{id}/Sensors +// and no Thermal resource; OpenBMC 3.22 serves the legacy Chassis/{id}/Thermal with Temperatures[] +// and Fans[] and only power readings under Sensors. Both are decoded into one point shape so the +// consumer asks one question of either image. +// +// A READING IS A VALUE, A NULL, OR MISSING -- THREE FACTS. Thermal carries `Reading: null` for a fan +// header with nothing connected; a member that is absent altogether is a different rendering. +// Neither is converted into zero, and zero is kept as a reading. +// +// HEALTH IS THE SERVICE'S OWN VERDICT (Resource.Health: OK, Warning, Critical), kept beside the +// reading rather than recomputed from thresholds: the service is the authority for its verdict, and +// the consumer decides what the verdict means. +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "redfish.dmtf.org/schemas/v1/Sensor.v1_2_0.json" + } +} + +data extdeps_model_scope: ExternalModelScope = ExternalModelScope { + subject: ExternalSubjectRef { + declaration: DeclarationRef { + module_path: "extdeps.bmc.redfish_telemetry", + decl_name: "RedfishTelemetryPoint", + field: WholeDeclaration + } + }, + first_citation: extdeps_external_authority_anchor, + further_citations: [ + ExternalAuthority { uri: Uri { scheme: Https, locator: "redfish.dmtf.org/schemas/v1/Thermal.v1_3_0.json" } }, + ExternalAuthority { uri: Uri { scheme: Https, locator: "redfish.dmtf.org/schemas/v1/LogEntry.v1_9_0.json" } }, + ExternalAuthority { uri: Uri { scheme: Https, locator: "redfish.dmtf.org/schemas/v1/Resource.json" } }, + ] +} + +type RedfishHealth + = RedfishHealthOk + | RedfishHealthWarning + | RedfishHealthCritical + | RedfishHealthOther { health: NonEmptyStr } + | RedfishHealthUnreported + +type RedfishResourceState + = RedfishStateEnabled + | RedfishStateDisabled + | RedfishStateAbsent + | RedfishStateOther { state: NonEmptyStr } + | RedfishStateUnreported + +// The whole-number part of the reading. Temperatures render as N.0 and tachometers as integers on +// both images, so the integer part is exact for every point this module decodes. +type RedfishReading + = RedfishReadingValue { whole: Int } + | RedfishReadingNull + | RedfishReadingMissing + +type RedfishPointKind + = RedfishTemperatureCelsius + | RedfishRotationalRpm + | RedfishOtherReading { reading_type: NonEmptyStr } + +type RedfishTelemetryPoint { + name: NonEmptyStr + kind: RedfishPointKind + reading: RedfishReading + health: RedfishHealth + state: RedfishResourceState +} + +fn redfish_status(v: JsonValue) -> ObservationAttempt { + match read_member(v: v, key: "Status") { + MemberObject { value: status } => ObservationEstablished { observed: RedfishStatusPair { + health: match read_string_member(v: status, key: "Health") { + MemberString { value: h } => + if h == "OK" { RedfishHealthOk } else if h == "Warning" { RedfishHealthWarning } else if h == "Critical" { RedfishHealthCritical } + else if h == "" { RedfishHealthUnreported } else { RedfishHealthOther { health: h as NonEmptyStr } } + _ => RedfishHealthUnreported + }, + state: match read_string_member(v: status, key: "State") { + MemberString { value: s } => + if s == "Enabled" { RedfishStateEnabled } else if s == "Disabled" { RedfishStateDisabled } else if s == "Absent" { RedfishStateAbsent } + else if s == "" { RedfishStateUnreported } else { RedfishStateOther { state: s as NonEmptyStr } } + _ => RedfishStateUnreported + }, + } } + MemberFailed { cause: MemberReadMissing { key: _ } } => + ObservationEstablished { observed: RedfishStatusPair { health: RedfishHealthUnreported, state: RedfishStateUnreported } } + _ => ObservationRefused { cause: "a Redfish Status member is present but is not a single object" } + } +} + +type RedfishStatusPair { + health: RedfishHealth + state: RedfishResourceState +} + +fn redfish_whole_number(lexeme: String) -> Int? { + match split(s: lexeme, delimiter: ".").first() { + Absent => none + Present { value: whole } => if whole == "" || whole == "-" { none } else { parse_int(s: whole) } + } +} + +// A reading that is present but neither a number nor null refuses the point: coercing it would +// fabricate a value, and skipping it would read as missing. +fn redfish_reading(v: JsonValue, key: String) -> ObservationAttempt { + match read_member_value(v: v, key: key) { + MemberLookupFailed { cause: MemberReadMissing { key: _ } } => ObservationEstablished { observed: RedfishReadingMissing } + MemberLookupFailed { cause: _ } => ObservationRefused { cause: concat("the Redfish reading member ", key, " is duplicated or its subject is not an object") as NonEmptyStr } + MemberValue { value: JsonNull } => ObservationEstablished { observed: RedfishReadingNull } + MemberValue { value: JsonNumber { lexeme } } => match redfish_whole_number(lexeme: lexeme as String) { + Present { value: n } => ObservationEstablished { observed: RedfishReadingValue { whole: n } } + none => ObservationRefused { cause: concat("the Redfish reading ", key, " is not a decimal number") as NonEmptyStr } + } + MemberValue { value: _ } => ObservationRefused { cause: concat("the Redfish reading ", key, " is neither a number nor null") as NonEmptyStr } + } +} + +fn redfish_point(v: JsonValue, kind: RedfishPointKind, reading_key: String) -> ObservationAttempt { + match read_string_member(v: v, key: "Name") { + MemberString { value: name } => + if name == "" { ObservationRefused { cause: "a Redfish telemetry point has an empty Name" } } else { + match redfish_reading(v: v, key: reading_key) { + ObservationRefused { cause } => ObservationRefused { cause: concat(name, ": ", cause as String) as NonEmptyStr } + ObservationEstablished { observed: reading } => match redfish_status(v: v) { + ObservationRefused { cause } => ObservationRefused { cause: concat(name, ": ", cause as String) as NonEmptyStr } + ObservationEstablished { observed: status } => ObservationEstablished { observed: RedfishTelemetryPoint { + name: name as NonEmptyStr, kind: kind, reading: reading, health: status.health, state: status.state, + } } + } + } + } + _ => ObservationRefused { cause: "a Redfish telemetry point carries no single string Name" } + } +} + +fn redfish_document(body: String, what: String) -> ObservationAttempt { + match parse_json_document(s: body) { + JsonDocumentUnreadable { gap: _ } => ObservationRefused { cause: concat("the Redfish ", what, " response is not a JSON document") as NonEmptyStr } + JsonDocumentParsed { value } => ObservationEstablished { observed: value } + } +} + +// ONE Sensor resource (Sensor.v1_2_0). ReadingType selects the kind; a type this module does not +// name is carried by name rather than dropped, so a caller can see what it did not ask for. +fn redfish_sensor_point(body: String) -> ObservationAttempt { + match redfish_document(body: body, what: "Sensor") { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: v } => match read_string_member(v: v, key: "ReadingType") { + MemberString { value: t } => redfish_point( + v: v, + kind: if t == "Temperature" { RedfishTemperatureCelsius } else if t == "Rotational" { RedfishRotationalRpm } + else if t == "" { RedfishOtherReading { reading_type: "unnamed" } } else { RedfishOtherReading { reading_type: t as NonEmptyStr } }, + reading_key: "Reading", + ) + _ => ObservationRefused { cause: "a Redfish Sensor carries no single string ReadingType" } + } + } +} + +fn redfish_points_of(elements: List, kind: RedfishPointKind, reading_key: String) -> ObservationAttempt, NonEmptyStr> { + let empty: List = [] + let initial: ObservationAttempt, NonEmptyStr> = ObservationEstablished { observed: empty } + let reversed = fold(elements, init: initial, f: (acc, e) => match acc { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: points } => match redfish_point(v: e, kind: kind, reading_key: reading_key) { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: p } => ObservationEstablished { observed: concat([p], points) } + } + }) + match reversed { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: points } => ObservationEstablished { observed: reverse(points) } + } +} + +// The legacy Thermal resource (Thermal.v1_3_0): Temperatures[] read ReadingCelsius, Fans[] read +// Reading in RPM. Both arrays must be present; an image that omits one has not reported it, which is +// a different fact from reporting none. +fn redfish_thermal_points(body: String) -> ObservationAttempt, NonEmptyStr> { + match redfish_document(body: body, what: "Thermal") { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: v } => match read_member(v: v, key: "Temperatures") { + MemberArray { elements: temps } => match read_member(v: v, key: "Fans") { + MemberArray { elements: fans } => match redfish_points_of(elements: temps, kind: RedfishTemperatureCelsius, reading_key: "ReadingCelsius") { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: t } => match redfish_points_of(elements: fans, kind: RedfishRotationalRpm, reading_key: "Reading") { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: f } => ObservationEstablished { observed: concat(t, f) } + } + } + _ => ObservationRefused { cause: "the Redfish Thermal resource carries no Fans array" } + } + _ => ObservationRefused { cause: "the Redfish Thermal resource carries no Temperatures array" } + } + } +} + +// A collection's member references, for walking a Sensors collection. Every member must name +// itself; a member that does not makes the collection unreadable rather than shorter. +fn redfish_member_ids(body: String) -> ObservationAttempt, NonEmptyStr> { + match redfish_document(body: body, what: "collection") { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: v } => match read_member_value(v: v, key: "Members@odata.nextLink") { + MemberValue { value: _ } => ObservationRefused { cause: "the Redfish collection is paginated (Members@odata.nextLink); a first page is not the collection" } + MemberLookupFailed { cause: _ } => match read_member(v: v, key: "Members") { + MemberArray { elements } => { + let empty: List = [] + let initial: ObservationAttempt, NonEmptyStr> = ObservationEstablished { observed: empty } + let reversed = fold(elements, init: initial, f: (acc, e) => match acc { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: ids } => match read_string_member(v: e, key: "@odata.id") { + MemberString { value: id } => if id == "" { ObservationRefused { cause: "a Redfish collection member has an empty @odata.id" } } else { ObservationEstablished { observed: concat([id as NonEmptyStr], ids) } } + _ => ObservationRefused { cause: "a Redfish collection member carries no single string @odata.id" } + } + }) + match reversed { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: ids } => ObservationEstablished { observed: reverse(ids) } + } + } + _ => ObservationRefused { cause: "the Redfish collection carries no Members array" } + } + } + } +} + +// ONE LOG ENTRY (LogEntry.v1_9_0), with the Id the service numbers entries by. The Id is what orders +// and watermarks a log; Created is the BMC's clock and orders nothing. Message is carried whole: its +// first line is the registry message id on OpenBMC, and interpreting it is the consumer's job. +type RedfishLogEntry { + id: Int + severity: NonEmptyStr + message: NonEmptyStr +} + +fn redfish_log_entry(v: JsonValue) -> ObservationAttempt { + match read_string_member(v: v, key: "Id") { + MemberString { value: id_text } => match parse_int(s: id_text) { + none => ObservationRefused { cause: concat("a Redfish log entry Id is not an integer: ", id_text) as NonEmptyStr } + Present { value: id } => match read_string_member(v: v, key: "Message") { + MemberString { value: m } => if m == "" { ObservationRefused { cause: "a Redfish log entry has an empty Message" } } else { + ObservationEstablished { observed: RedfishLogEntry { + id: id, + severity: match read_string_member(v: v, key: "Severity") { MemberString { value: s } => if s == "" { "Unreported" as NonEmptyStr } else { s as NonEmptyStr } _ => "Unreported" as NonEmptyStr }, + message: m as NonEmptyStr, + } } + } + _ => ObservationRefused { cause: "a Redfish log entry carries no single string Message" } + } + } + _ => ObservationRefused { cause: "a Redfish log entry carries no single string Id" } + } +} + +fn redfish_log_entries(body: String) -> ObservationAttempt, NonEmptyStr> { + match redfish_document(body: body, what: "log entry collection") { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: v } => match read_member_value(v: v, key: "Members@odata.nextLink") { + MemberValue { value: _ } => ObservationRefused { cause: "the Redfish log entry collection is paginated (Members@odata.nextLink); a first page is not the log" } + MemberLookupFailed { cause: _ } => match read_member(v: v, key: "Members") { + MemberArray { elements } => { + let empty: List = [] + let initial: ObservationAttempt, NonEmptyStr> = ObservationEstablished { observed: empty } + let reversed = fold(elements, init: initial, f: (acc, e) => match acc { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: entries } => match redfish_log_entry(v: e) { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: entry } => ObservationEstablished { observed: concat([entry], entries) } + } + }) + match reversed { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: entries } => ObservationEstablished { observed: reverse(entries) } + } + } + _ => ObservationRefused { cause: "the Redfish log entry collection carries no Members array" } + } + } + } +} diff --git a/dag/test/claim/redfish_telemetry_witness_test.dag b/dag/test/claim/redfish_telemetry_witness_test.dag new file mode 100644 index 00000000000..c56a8e3de10 --- /dev/null +++ b/dag/test/claim/redfish_telemetry_witness_test.dag @@ -0,0 +1,99 @@ +module test.claim.redfish_telemetry_witness + +import std.types { Bool, List, NonEmptyStr, String } +import extdeps.bmc.redfish_telemetry { + RedfishHealthCritical, + RedfishHealthOk, + RedfishLogEntry, + RedfishReadingNull, + RedfishReadingValue, + RedfishRotationalRpm, + RedfishTelemetryPoint, + RedfishTemperatureCelsius, + redfish_log_entries, + redfish_member_ids, + redfish_sensor_point, + redfish_thermal_points, +} +import std.goal_assessment { ObservationEstablished, ObservationRefused } +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +// Responses read with the gunbc_health read-only account on 2026-09-18 and trimmed to a few members: +// srv1 and srv2 run the ASRock 2.07 image (per-sensor Sensor resources), srv3 and srv4 run OpenBMC +// 3.22 (legacy Thermal, and an event log that carries memory ECC records). They are specimens of each +// image's rendering, not standing facts about those hosts. +data srv1_fan4_sensor: String = "{\"@odata.id\":\"/redfish/v1/Chassis/ALTRAD8UD_1L2T/Sensors/fantach_FAN4\",\"@odata.type\":\"#Sensor.v1_2_0.Sensor\",\"Id\":\"fantach_FAN4\",\"Name\":\"FAN4\",\"Reading\":0.0,\"ReadingRangeMax\":25000.0,\"ReadingRangeMin\":0.0,\"ReadingType\":\"Rotational\",\"ReadingUnits\":\"RPM\",\"Status\":{\"Health\":\"Critical\",\"State\":\"Enabled\"},\"Thresholds\":{\"LowerCritical\":{\"Reading\":100.0}}}" + +data srv1_dimm_c1_sensor: String = "{\"@odata.id\":\"/redfish/v1/Chassis/ALTRAD8UD_1L2T/Sensors/temperature_TEMP_DIMM_C1\",\"@odata.type\":\"#Sensor.v1_2_0.Sensor\",\"Id\":\"temperature_TEMP_DIMM_C1\",\"Name\":\"TEMP_DIMM_C1\",\"Reading\":99.0,\"ReadingRangeMax\":255.0,\"ReadingRangeMin\":0.0,\"ReadingType\":\"Temperature\",\"ReadingUnits\":\"Cel\",\"Status\":{\"Health\":\"OK\",\"State\":\"Enabled\"},\"Thresholds\":{\"UpperCritical\":{\"Reading\":105.0}}}" + +data srv3_thermal: String = "{\"Temperatures\":[{\"@odata.id\":\"/redfish/v1/Chassis/ALTRAD8UD_1L2T/Thermal#/Temperatures/0\",\"@odata.type\":\"#Thermal.v1_3_0.Temperature\",\"MaxReadingRangeTemp\":127.0,\"MemberId\":\"0\",\"MinReadingRangeTemp\":-128.0,\"Name\":\"TEMP_CARD_SIDE\",\"ReadingCelsius\":51.0,\"Status\":{\"Health\":\"OK\",\"State\":\"Enabled\"},\"UpperThresholdCritical\":90.0,\"UpperThresholdNonCritical\":80.0},{\"@odata.id\":\"/redfish/v1/Chassis/ALTRAD8UD_1L2T/Thermal#/Temperatures/1\",\"@odata.type\":\"#Thermal.v1_3_0.Temperature\",\"MaxReadingRangeTemp\":255.0,\"MemberId\":\"1\",\"MinReadingRangeTemp\":0.0,\"Name\":\"TEMP_Core_VRD\",\"ReadingCelsius\":44.0,\"Status\":{\"Health\":\"OK\",\"State\":\"Enabled\"},\"UpperThresholdCritical\":105.0}],\"Fans\":[{\"@odata.id\":\"/redfish/v1/Chassis/ALTRAD8UD_1L2T/Thermal#/Fans/0\",\"@odata.type\":\"#Thermal.v1_3_0.Fan\",\"LowerThresholdCritical\":100,\"MaxReadingRange\":25000,\"MemberId\":\"0\",\"MinReadingRange\":0,\"Name\":\"FAN1\",\"Reading\":4203,\"ReadingUnits\":\"RPM\",\"Status\":{\"Health\":\"OK\",\"State\":\"Enabled\"}},{\"@odata.id\":\"/redfish/v1/Chassis/ALTRAD8UD_1L2T/Thermal#/Fans/1\",\"@odata.type\":\"#Thermal.v1_3_0.Fan\",\"LowerThresholdCritical\":100,\"MaxReadingRange\":25000,\"MemberId\":\"1\",\"MinReadingRange\":0,\"Name\":\"FAN1_1\",\"Reading\":null,\"ReadingUnits\":\"RPM\",\"Status\":{\"Health\":\"OK\",\"State\":\"Enabled\"}}]}" + +data srv3_log_tail: String = "{\"Members@odata.count\":3,\"Members\":[{\"Id\":\"450345\",\"Created\":\"2026-09-18T16:28:14.500+00:00\",\"Severity\":\"Critical\",\"Message\":\"OpenBMC.0.1.AmpereWarning.Warning \\n Event DIMM_HOT at DIMM0 of channel 2 of Socket 0,Deasserted.\"},{\"Id\":\"450346\",\"Created\":\"2026-09-18T16:29:10.745+00:00\",\"Severity\":\"Critical\",\"Message\":\"OpenBMC.0.1.MemoryECCCorrectable.Critical \\n 0,2,0,9\"},{\"Id\":\"450347\",\"Created\":\"2026-09-18T16:29:10.800+00:00\",\"Severity\":\"Critical\",\"Message\":\"OpenBMC.0.1.MemoryExtendedECCCEData.Warning \\n 1,140,96\"}]}" + +data srv1_sensors_head: String = "{\"Members\":[{\"@odata.id\":\"/redfish/v1/Chassis/ALTRAD8UD_1L2T/Sensors/fantach_FAN1\"},{\"@odata.id\":\"/redfish/v1/Chassis/ALTRAD8UD_1L2T/Sensors/fantach_FAN1_1\"},{\"@odata.id\":\"/redfish/v1/Chassis/ALTRAD8UD_1L2T/Sensors/fantach_FAN2\"}],\"Members@odata.count\":3}" + +fn point_named(points: List, name: NonEmptyStr) -> RedfishTelemetryPoint? { + filter(points, p => p.name == name).first() +} + +// The 2.07 image: a stopped fan is a reading of zero with the service's own Critical verdict, and a +// hot DIMM is a temperature whose verdict is still OK -- the verdict and the reading stay apart. +test fn a_sensor_keeps_its_reading_and_the_services_verdict_apart() -> Bool { + let fan = match redfish_sensor_point(body: srv1_fan4_sensor) { + ObservationEstablished { observed: p } => + p.name == "FAN4" + && match p.kind { RedfishRotationalRpm => true _ => false } + && match p.reading { RedfishReadingValue { whole } => whole == 0 _ => false } + && match p.health { RedfishHealthCritical => true _ => false } + ObservationRefused { cause: _ } => false + } + let dimm = match redfish_sensor_point(body: srv1_dimm_c1_sensor) { + ObservationEstablished { observed: p } => + match p.kind { RedfishTemperatureCelsius => true _ => false } + && match p.reading { RedfishReadingValue { whole } => whole == 99 _ => false } + && match p.health { RedfishHealthOk => true _ => false } + ObservationRefused { cause: _ } => false + } + fan && dimm +} + +// The 3.22 image: Thermal carries temperatures and fans in one resource, and an empty fan header is a +// null reading rather than zero. +test fn thermal_decodes_both_arrays_and_keeps_a_null_reading_null() -> Bool { + match redfish_thermal_points(body: srv3_thermal) { + ObservationRefused { cause: _ } => false + ObservationEstablished { observed: points } => + count(points) == 4 + && match point_named(points: points, name: "TEMP_CARD_SIDE") { Present { value: p } => match p.reading { RedfishReadingValue { whole } => whole == 51 _ => false } none => false } + && match point_named(points: points, name: "FAN1") { Present { value: p } => match p.kind { RedfishRotationalRpm => true _ => false } none => false } + && match point_named(points: points, name: "FAN1_1") { Present { value: p } => match p.reading { RedfishReadingNull => true _ => false } none => false } + } +} + +test fn the_log_decodes_in_service_order_with_its_ids() -> Bool { + match redfish_log_entries(body: srv3_log_tail) { + ObservationRefused { cause: _ } => false + ObservationEstablished { observed: entries } => + map(entries, e => e.id) == [450345, 450346, 450347] + && any(entries, e => starts_with(s: e.message as String, prefix: "OpenBMC.0.1.MemoryECCCorrectable")) + } +} + +test fn a_collection_lists_its_members_in_order() -> Bool { + match redfish_member_ids(body: srv1_sensors_head) { + ObservationRefused { cause: _ } => false + ObservationEstablished { observed: ids } => count(ids) == 3 && match ids.first() { Present { value: first } => ends_with(s: first as String, suffix: "fantach_FAN1") none => false } + } +} + +// A page is not the collection, a string reading is not a number, and a member that does not name +// itself does not shorten the collection: each refuses. +test fn a_paginated_malformed_or_unnamed_response_refuses() -> Bool { + let paged = match redfish_log_entries(body: "{\"Members\":[],\"Members@odata.nextLink\":\"/next\"}") { ObservationRefused { cause: _ } => true _ => false } + let string_reading = match redfish_sensor_point(body: "{\"Name\":\"FAN1\",\"ReadingType\":\"Rotational\",\"Reading\":\"fast\",\"Status\":{\"Health\":\"OK\"}}") { ObservationRefused { cause: _ } => true _ => false } + let unnamed = match redfish_member_ids(body: "{\"Members\":[{\"@odata.id\":\"/a\"},{\"Name\":\"b\"}]}") { ObservationRefused { cause: _ } => true _ => false } + let not_json = match redfish_thermal_points(body: "401") { ObservationRefused { cause: _ } => true _ => false } + paged && string_reading && unnamed && not_json +} From 4a48a19332ae553d154117219abf9ba5c6eb8b56 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 19 Sep 2026 04:21:32 +0000 Subject: [PATCH 11/15] HEALTH-0: host health model, read-only Redfish collector, custody read split out - product.host_health: pure host/fleet assessment over std.goal_assessment (signals: memory counters, processor/platform, fans via product.fan_tach_health, temperatures, BMC event window). - product.fan_tach fan_tach_window_of: the window's one producer from samples. - extdeps.bmc.http: readonly GetManager, GetChassisThermal, GetChassisSensor, GetSystemEventLogEntries. - extdeps.bmc.openbmc_message_registry: MemoryECCCorrectable/Uncorrectable ids. - gunbc.fleet_health_observe: closed FleetHealthRedfishRead vocabulary, surface derived from the live firmware through the board catalog, sampled fan/temperature windows, event log window with wrap/clear continuity, private per-run netrc shredded on every path. - gunbc.bmc_custody_read: the custody read, split out of gunbc.tools.bmc_onboard so a read-only consumer need not import the minting tool. Co-Authored-By: Claude Opus 5 (1M context) --- dag/extdeps/bmc/http.dag | 52 ++ dag/extdeps/bmc/openbmc_message_registry.dag | 55 ++ dag/gunbc/bmc/bmc_custody_read.dag | 66 ++ dag/gunbc/fleet/fleet_health_observe.dag | 448 ++++++++++ dag/gunbc/product/fan_tach.dag | 30 + dag/gunbc/product/host_health.dag | 766 ++++++++++++++++++ .../tools/bmc_health_reader_converge.dag | 4 +- dag/gunbc/tools/bmc_onboard.dag | 52 +- .../bmc/bmc_health_reader_witness_test.dag | 2 +- .../fleet_health_observe_witness_test.dag | 141 ++++ .../host_health_assessment_witness_test.dag | 410 ++++++++++ 11 files changed, 1977 insertions(+), 49 deletions(-) create mode 100644 dag/extdeps/bmc/openbmc_message_registry.dag create mode 100644 dag/gunbc/bmc/bmc_custody_read.dag create mode 100644 dag/gunbc/fleet/fleet_health_observe.dag create mode 100644 dag/gunbc/product/host_health.dag create mode 100644 dag/test/claim/fleet/fleet_health_observe_witness_test.dag create mode 100644 dag/test/claim/host_health_assessment_witness_test.dag diff --git a/dag/extdeps/bmc/http.dag b/dag/extdeps/bmc/http.dag index a63ee17053c..e4ab0d2b5d3 100644 --- a/dag/extdeps/bmc/http.dag +++ b/dag/extdeps/bmc/http.dag @@ -127,6 +127,58 @@ service redfish.Http { } } + operation GetManager { + input { bmc_host: NonEmptyStr, netrc_file: NonEmptyStr } + output { body: String from "stdout", success: Bool from "exit_success" } + readonly + transport shell { + argv: ["curl", "--fail-with-body", "-sS", "-k", "--netrc-file", "{netrc_file}", "https://{bmc_host}/redfish/v1/Managers/bmc"] + } + exit { + 0 => Unit + nonzero => String "redfish manager GET failed" + } + } + + operation GetChassisThermal { + input { bmc_host: NonEmptyStr, chassis_id: NonEmptyStr, netrc_file: NonEmptyStr } + output { body: String from "stdout", success: Bool from "exit_success" } + readonly + transport shell { + argv: ["curl", "--fail-with-body", "-sS", "-k", "--netrc-file", "{netrc_file}", "https://{bmc_host}/redfish/v1/Chassis/{chassis_id}/Thermal"] + } + exit { + 0 => Unit + nonzero => String "redfish chassis thermal GET failed" + } + } + + operation GetChassisSensor { + input { bmc_host: NonEmptyStr, chassis_id: NonEmptyStr, sensor_id: NonEmptyStr, netrc_file: NonEmptyStr } + output { body: String from "stdout", success: Bool from "exit_success" } + readonly + transport shell { + argv: ["curl", "--fail-with-body", "-sS", "-k", "--netrc-file", "{netrc_file}", "https://{bmc_host}/redfish/v1/Chassis/{chassis_id}/Sensors/{sensor_id}"] + } + exit { + 0 => Unit + nonzero => String "redfish chassis sensor GET failed" + } + } + + operation GetSystemEventLogEntries { + input { bmc_host: NonEmptyStr, netrc_file: NonEmptyStr } + output { body: String from "stdout", success: Bool from "exit_success" } + readonly + transport shell { + argv: ["curl", "--fail-with-body", "-sS", "-k", "--netrc-file", "{netrc_file}", "https://{bmc_host}/redfish/v1/Systems/system/LogServices/EventLog/Entries"] + } + exit { + 0 => Unit + nonzero => String "redfish system event log GET failed" + } + } + operation GetAccounts { input { bmc_host: NonEmptyStr, netrc_file: NonEmptyStr } output { body: String from "stdout", success: Bool from "exit_success" } diff --git a/dag/extdeps/bmc/openbmc_message_registry.dag b/dag/extdeps/bmc/openbmc_message_registry.dag new file mode 100644 index 00000000000..36c1fae9abd --- /dev/null +++ b/dag/extdeps/bmc/openbmc_message_registry.dag @@ -0,0 +1,55 @@ +module extdeps.bmc.openbmc_message_registry + +import std.types { Bool, List, NonEmptyStr, String } +import std.algebra { trim } +import std.decl_ref { DeclarationRef, WholeDeclaration } +import extdeps.external_authority { ExternalAuthority, ExternalModelScope, ExternalSubjectRef } +import extdeps.uri { Uri, Https } + +// THE OPENBMC MESSAGE REGISTRY ENTRIES THIS CORPUS READS, by their registry MessageId. bmcweb renders +// a System Event Log entry's Message as the MessageId (with the entry's severity appended) on its +// first line and the message arguments after it -- observed on the ASRock ALTRAD8UD OpenBMC 3.22 +// image 2026-09-18 as "OpenBMC.0.1.MemoryECCCorrectable.Critical \n 0,2,0,9". Only the ids this +// corpus classifies are carried; every other entry is unclassified by the consumer, never guessed. +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "github.com/openbmc/bmcweb/blob/master/redfish-core/include/registries/openbmc_message_registry.hpp" + } +} + +data extdeps_model_scope: ExternalModelScope = ExternalModelScope { + subject: ExternalSubjectRef { + declaration: DeclarationRef { + module_path: "extdeps.bmc.openbmc_message_registry", + decl_name: "OpenBmcMemoryEccMessage", + field: WholeDeclaration + } + }, + first_citation: extdeps_external_authority_anchor, + further_citations: [] +} + +type OpenBmcMemoryEccMessage + = OpenBmcMemoryEccCorrectable + | OpenBmcMemoryEccUncorrectable + +data openbmc_memory_ecc_correctable_id: NonEmptyStr = "OpenBMC.0.1.MemoryECCCorrectable" +data openbmc_memory_ecc_uncorrectable_id: NonEmptyStr = "OpenBMC.0.1.MemoryECCUncorrectable" + +// The id is matched as the whole first segment before the appended severity, not as a prefix of the +// message, so MemoryECCCorrectableSomethingElse is not read as a correctable ECC record. +fn openbmc_message_id_is(message: String, id: NonEmptyStr) -> Bool { + let first_line = match split(s: message, delimiter: "\n").first() { Present { value: l } => trim(s: l) Absent => "" } + first_line == id as String || starts_with(s: first_line, prefix: concat(id as String, ".")) +} + +fn openbmc_memory_ecc_message(message: String) -> OpenBmcMemoryEccMessage? { + if openbmc_message_id_is(message: message, id: openbmc_memory_ecc_correctable_id) { + Present { value: OpenBmcMemoryEccCorrectable } + } else if openbmc_message_id_is(message: message, id: openbmc_memory_ecc_uncorrectable_id) { + Present { value: OpenBmcMemoryEccUncorrectable } + } else { + none + } +} diff --git a/dag/gunbc/bmc/bmc_custody_read.dag b/dag/gunbc/bmc/bmc_custody_read.dag new file mode 100644 index 00000000000..d6151e20679 --- /dev/null +++ b/dag/gunbc/bmc/bmc_custody_read.dag @@ -0,0 +1,66 @@ +module gunbc.bmc_custody_read + +import std.types { NonEmptyStr, Secret, String } +import std.resources { Network } +import extdeps.cloud.gcp.secret_manager { + decode_sm_access_version_secret_wire, + SmAccessVersionSecretDecoded, + SmAccessVersionSecretDecodeRefused, +} +import extdeps.transports.rest { + classify_rest_outcome, + RestExchangeCommitAmbiguous, + RestExchangePerformance, + RestExchangeStatusRefused, + RestExchangeSucceeded, + RestExchangeUndecodable, + RestExchangeUnreached, + RestReadExchange, +} +import gunbc.fleet_secrets_config { fleet_secrets_gcp_project } + +// READING ONE BMC CREDENTIAL OUT OF CUSTODY, AND NOTHING ELSE. It is its own module so that a +// read-only consumer -- the fleet health collector, which may hold only the health-reader +// credential -- can read custody without importing gunbc.tools.bmc_onboard, whose closure mints, +// stores and rotates credentials. The onboarding and reader-account flows import it from here too. + +// THREE ANSWERS, NOT A MAYBE. A 404 is the store saying the secret does not exist; a refused status, +// an unreached call or an undecodable payload says nothing about existence and is carried as the +// read failure it is (reviews 67836, 67848). Collapsing them would tell an operator to create a secret that +// may be sitting there behind a permission they lack. +type CustodyCredentialRead + = CustodyCredentialHeld { password: String } + | CustodyCredentialAbsent + | CustodyCredentialUnreadable { cause: String } + +fn custody_read_refusal_cause(performance: RestExchangePerformance) -> CustodyCredentialRead? { + match performance { + RestExchangeSucceeded => none + RestExchangeStatusRefused { status: status, body: body } => + Present { value: if status == 404 { CustodyCredentialAbsent } else { CustodyCredentialUnreadable { cause: concat("Secret Manager refused the read: ", body) } } } + RestExchangeCommitAmbiguous { detail: d } => Present { value: CustodyCredentialUnreadable { cause: d as String } } + RestExchangeUnreached { cause: c } => Present { value: CustodyCredentialUnreadable { cause: c as String } } + RestExchangeUndecodable { status: _, cause: c } => Present { value: CustodyCredentialUnreadable { cause: c as String } } + } +} + +// ONE MATCH OVER THE CLASSIFIED OUTCOME (review 67867): a success decodes the payload, every other +// arm is the refusal custody_read_refusal_cause already named. There is no second pass that has to +// invent an answer for a success it can no longer see. +fn bmc_custody_credential(secret_id: NonEmptyStr, token: Secret) -> CustodyCredentialRead + uses net: Network +{ + resp = gcp.SecretManager.AccessVersion( + access_token: token, + project_id: fleet_secrets_gcp_project, + secret: secret_id, + version: "latest" + ) + return match custody_read_refusal_cause(performance: classify_rest_outcome(standing: RestReadExchange { rest: resp.outcome })) { + Present { value: refusal } => refusal + none => match decode_sm_access_version_secret_wire(data_b64: resp.data_b64) { + SmAccessVersionSecretDecoded { credential: c } => CustodyCredentialHeld { password: c as String } + SmAccessVersionSecretDecodeRefused { cause: _ } => CustodyCredentialUnreadable { cause: "the custody payload did not decode" } + } + } +} diff --git a/dag/gunbc/fleet/fleet_health_observe.dag b/dag/gunbc/fleet/fleet_health_observe.dag new file mode 100644 index 00000000000..26a0a4c32c6 --- /dev/null +++ b/dag/gunbc/fleet/fleet_health_observe.dag @@ -0,0 +1,448 @@ +module gunbc.fleet_health_observe + +import extdeps.bmc.http +import std.types { Bool, Int, List, NonEmptyStr, Secret, String } +import std.algebra { trim } +import std.nat { Nat, nat_range_inclusive } +import std.checked_arithmetic { CheckedNatOverflow, CheckedNatReady, checked_int_magnitude } +import std.measure { celsius, rpm } +import std.resources { Network } +import std.goal_assessment { ObservationAttempt, ObservationEstablished, ObservationRefused } +import product.placement_supply { HostIdentity } +import product.fan_tach { + FanTachEndpointIdentity, + FanTachSample, + FanTachWindow, + SampleRotating, + SampleUnavailable, + SampleZero, + fan_tach_window_of, +} +import product.host_health { + BmcEventKind, + BmcEventLogContinuityUnestablished, + BmcEventRecord, + BmcEventWindow, + BmcEventsObserved, + BmcThresholdClear, + BmcThresholdCritical, + BmcThresholdNonCritical, + BmcThresholdNonRecoverable, + BmcThresholdStanding, + BmcThresholdUnreported, + CorrectedMemoryErrorEvent, + HostHealthObserved, + HostSignalAttempt, + SignalNotExposedOutOfBand, + SignalObserved, + SignalUnreadable, + TemperatureReadingPresent, + TemperatureReadingUnavailable, + TemperatureSample, + TemperatureSensorDisabled, + TemperatureWindow, + UnclassifiedEvent, + UncorrectedMemoryErrorEvent, + host_health_observed, +} +import extdeps.bmc.redfish_telemetry { + RedfishHealth, + RedfishHealthCritical, + RedfishHealthOk, + RedfishHealthOther, + RedfishHealthUnreported, + RedfishHealthWarning, + RedfishLogEntry, + RedfishOtherReading, + RedfishReadingMissing, + RedfishReadingNull, + RedfishReadingValue, + RedfishRotationalRpm, + RedfishStateDisabled, + RedfishTelemetryPoint, + RedfishTemperatureCelsius, + redfish_log_entries, + redfish_member_ids, + redfish_sensor_point, + redfish_thermal_points, +} +import extdeps.bmc.openbmc_message_registry { + OpenBmcMemoryEccCorrectable, + OpenBmcMemoryEccUncorrectable, + openbmc_memory_ecc_message, +} +import extdeps.bmc.types { + BmcRedfishSurfaceRow, + RedfishDualThermalSurface, + RedfishLegacyChassisThermal, + RedfishThermalSubsystemSensorCollection, + RedfishThermalTelemetrySurface, + SurfaceShapeAmbiguous, + SurfaceShapeKnown, + SurfaceShapeUncatalogued, + bmc_redfish_surface_for, +} +import extdeps.bmc.capability { BmcFirmwareVersion, BmcFirmwareVersionParsed, BmcFirmwareVersionRefused, bmc_firmware_version_from_wire } +import extdeps.bmc.endpoint { OpenBmc } +import extdeps.languages.json.parse { JsonDocumentParsed, JsonDocumentUnreadable, parse_json_document } +import gunbc.scm.json_member { MemberString, read_string_member } +import gunbc.auth.netrc_binding { + NetrcBinding, + NetrcBindingPlaced, + NetrcBindingRefused, + NetrcBindingRefusedResidueRemains, + place_netrc_binding_privately, + shred_netrc_binding, +} + +// THE FLEET HEALTH COLLECTOR: OUT OF BAND, READ ONLY, GOAL BLIND. +// +// Out of band by standing rule (operator, 2026-09-18): it talks only to a host's BMC over Redfish, +// never to the host OS, and a signal the BMC does not expose is carried as not exposed rather than +// fetched another way. Read only: every request it can make is an arm of FleetHealthRedfishRead, +// whose only realization is health_redfish_read below, and every arm is a `readonly` GET on +// redfish.Http -- no variant can name a POST, PATCH, reset, log clear or fan-control write. It +// authenticates as the gunbc_health ReadOnly account (gunbc.bmc_health_reader) through a private, +// per-run netrc it shreds on every path out. Goal blind: it takes no expectation; it reads the whole +// telemetry surface and the whole event log, and the assessment decides what was expected. +// +// ONE WINDOW PER RUN: the event log is read at the opening and at the closing, and the telemetry is +// sampled `samples` times between them. The surface is derived from the firmware the BMC reports +// NOW, through the board's catalog, so a flash changes the route the next run takes rather than +// leaving a stale per-host row behind it. +type FleetHealthRedfishRead + = ReadManager + | ReadChassisThermal { chassis_id: NonEmptyStr } + | ReadChassisSensors { chassis_id: NonEmptyStr } + | ReadChassisSensor { chassis_id: NonEmptyStr, sensor_id: NonEmptyStr } + | ReadSystemEventLog + +type HealthReadResponse { + body: String + success: Bool +} + +fn health_redfish_read(bmc_host: NonEmptyStr, netrc: NonEmptyStr, read: FleetHealthRedfishRead) -> HealthReadResponse + uses net: Network +{ + match read { + ReadManager => { + r = redfish.Http.GetManager(bmc_host: bmc_host, netrc_file: netrc) + return HealthReadResponse { body: r.body, success: r.success } + } + ReadChassisThermal { chassis_id: c } => { + r = redfish.Http.GetChassisThermal(bmc_host: bmc_host, chassis_id: c, netrc_file: netrc) + return HealthReadResponse { body: r.body, success: r.success } + } + ReadChassisSensors { chassis_id: c } => { + r = redfish.Http.GetChassisSensors(bmc_host: bmc_host, chassis_id: c, netrc_file: netrc) + return HealthReadResponse { body: r.body, success: r.success } + } + ReadChassisSensor { chassis_id: c, sensor_id: s } => { + r = redfish.Http.GetChassisSensor(bmc_host: bmc_host, chassis_id: c, sensor_id: s, netrc_file: netrc) + return HealthReadResponse { body: r.body, success: r.success } + } + ReadSystemEventLog => { + r = redfish.Http.GetSystemEventLogEntries(bmc_host: bmc_host, netrc_file: netrc) + return HealthReadResponse { body: r.body, success: r.success } + } + } +} + +// What the fleet declares about reading one host's BMC: where it is, which chassis its telemetry +// hangs under, and the board's surface catalog the live firmware is looked up in. +type BmcTelemetryProfile { + host: HostIdentity + bmc_host: NonEmptyStr + chassis_id: NonEmptyStr + surface_catalog: List +} + +fn manager_firmware(body: String) -> ObservationAttempt { + match parse_json_document(s: body) { + JsonDocumentUnreadable { gap: _ } => ObservationRefused { cause: "the Redfish Manager response is not a JSON document" } + JsonDocumentParsed { value } => match read_string_member(v: value, key: "FirmwareVersion") { + MemberString { value: text } => match bmc_firmware_version_from_wire(wire: trim(s: text)) { + BmcFirmwareVersionParsed { version } => ObservationEstablished { observed: BmcFirmwareVersion { family: OpenBmc, version: version } } + BmcFirmwareVersionRefused { wire } => ObservationRefused { cause: concat("the BMC FirmwareVersion is not major.minor.patch: ", wire) as NonEmptyStr } + } + _ => ObservationRefused { cause: "the Redfish Manager carries no single string FirmwareVersion" } + } + } +} + +fn telemetry_surface(telemetry: BmcTelemetryProfile, firmware: BmcFirmwareVersion) -> ObservationAttempt { + match bmc_redfish_surface_for(catalog: telemetry.surface_catalog, firmware: firmware) { + SurfaceShapeKnown { shape } => ObservationEstablished { observed: shape.thermal_surface } + SurfaceShapeUncatalogued { firmware: _ } => ObservationRefused { cause: "the BMC firmware is not in the board's Redfish surface catalog, so which resource carries its telemetry is not established" } + SurfaceShapeAmbiguous { firmware: _, matches: _ } => ObservationRefused { cause: "the BMC firmware matches more than one catalogued Redfish surface" } + } +} + +fn read_json(bmc_host: NonEmptyStr, netrc: NonEmptyStr, read: FleetHealthRedfishRead, what: String) -> ObservationAttempt + uses net: Network +{ + let r = health_redfish_read(bmc_host: bmc_host, netrc: netrc, read: read) + return if r.success { ObservationEstablished { observed: r.body } } else { ObservationRefused { cause: concat("the Redfish ", what, " read failed: ", r.body) as NonEmptyStr } } +} + +fn sample_sensor_collection(bmc_host: NonEmptyStr, netrc: NonEmptyStr, chassis_id: NonEmptyStr, ids: List) -> ObservationAttempt, NonEmptyStr> + uses net: Network +{ + let empty: List = [] + let initial: ObservationAttempt, NonEmptyStr> = ObservationEstablished { observed: empty } + let reversed = fold(ids, init: initial, f: (acc, id) => match acc { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: points } => { + let sensor_id = match reverse(split(s: id as String, delimiter: "/")).first() { Present { value: last } => last Absent => "" } + if sensor_id == "" { ObservationRefused { cause: concat("a Sensors member reference names no sensor: ", id as String) as NonEmptyStr } } else { + match read_json(bmc_host: bmc_host, netrc: netrc, read: ReadChassisSensor { chassis_id: chassis_id, sensor_id: sensor_id as NonEmptyStr }, what: "Sensor") { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: body } => match redfish_sensor_point(body: body) { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: p } => ObservationEstablished { observed: concat([p], points) } + } + } + } + } + }) + return match reversed { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: points } => ObservationEstablished { observed: reverse(points) } + } +} + +fn sample_telemetry(bmc_host: NonEmptyStr, netrc: NonEmptyStr, chassis_id: NonEmptyStr, surface: RedfishThermalTelemetrySurface) -> ObservationAttempt, NonEmptyStr> + uses net: Network +{ + match surface { + RedfishLegacyChassisThermal => match read_json(bmc_host: bmc_host, netrc: netrc, read: ReadChassisThermal { chassis_id: chassis_id }, what: "Thermal") { + ObservationRefused { cause } => return ObservationRefused { cause: cause } + ObservationEstablished { observed: body } => return redfish_thermal_points(body: body) + } + RedfishThermalSubsystemSensorCollection => match read_json(bmc_host: bmc_host, netrc: netrc, read: ReadChassisSensors { chassis_id: chassis_id }, what: "Sensors collection") { + ObservationRefused { cause } => return ObservationRefused { cause: cause } + ObservationEstablished { observed: body } => match redfish_member_ids(body: body) { + ObservationRefused { cause } => return ObservationRefused { cause: cause } + ObservationEstablished { observed: ids } => return sample_sensor_collection(bmc_host: bmc_host, netrc: netrc, chassis_id: chassis_id, ids: ids) + } + } + RedfishDualThermalSurface => return ObservationRefused { cause: "the catalogued surface serves both Thermal and Sensors; which one is authoritative for this image is not modeled" } + } +} + +fn fan_sample_of(p: RedfishTelemetryPoint) -> FanTachSample { + match p.reading { + RedfishReadingValue { whole } => + if whole == 0 { SampleZero } else if whole < 0 { SampleUnavailable } else { + match checked_int_magnitude(a: whole) { + CheckedNatReady { value: n } => SampleRotating { speed: rpm(n) } + CheckedNatOverflow { cause: _ } => SampleUnavailable + } + } + RedfishReadingNull => SampleUnavailable + RedfishReadingMissing => SampleUnavailable + } +} + +fn temperature_sample_of(p: RedfishTelemetryPoint) -> TemperatureSample { + match p.state { + RedfishStateDisabled => TemperatureSensorDisabled + _ => match p.reading { + RedfishReadingValue { whole } => TemperatureReadingPresent { celsius: celsius(whole) } + RedfishReadingNull => TemperatureReadingUnavailable + RedfishReadingMissing => TemperatureReadingUnavailable + } + } +} + +fn bmc_standing_of(health: RedfishHealth) -> BmcThresholdStanding { + match health { + RedfishHealthOk => BmcThresholdClear + RedfishHealthWarning => BmcThresholdNonCritical + RedfishHealthCritical => BmcThresholdCritical + RedfishHealthOther { health: _ } => BmcThresholdUnreported + RedfishHealthUnreported => BmcThresholdUnreported + } +} + +fn standing_rank(s: BmcThresholdStanding) -> Int { + match s { + BmcThresholdUnreported => 0 + BmcThresholdClear => 1 + BmcThresholdNonCritical => 2 + BmcThresholdCritical => 3 + BmcThresholdNonRecoverable => 4 + } +} + +fn is_fan(p: RedfishTelemetryPoint) -> Bool { match p.kind { RedfishRotationalRpm => true _ => false } } +fn is_temperature(p: RedfishTelemetryPoint) -> Bool { match p.kind { RedfishTemperatureCelsius => true _ => false } } + +// Names are taken from the FIRST sample, and every later sample must carry the same named set: a +// sensor that appears or vanishes mid-window is a population change, which refuses rather than +// producing a window over fewer samples than the others. +fn sample_names(samples: List>, pick: fn(RedfishTelemetryPoint) -> Bool) -> ObservationAttempt, NonEmptyStr> { + match samples.first() { + Absent => ObservationRefused { cause: "no telemetry sample was taken" } + Present { value: first } => { + let names = map(filter(first, pick), p => p.name) + if all(samples, s => map(filter(s, pick), p => p.name) == names) { ObservationEstablished { observed: names } } + else { ObservationRefused { cause: "the telemetry population changed during the window" } } + } + } +} + +fn points_named(samples: List>, name: NonEmptyStr) -> List { + flat_map(samples, s => filter(s, p => p.name == name)) +} + +fn fan_windows(host: HostIdentity, samples: List>) -> HostSignalAttempt> { + match sample_names(samples: samples, pick: is_fan) { + ObservationRefused { cause } => SignalUnreadable { cause: cause } + ObservationEstablished { observed: names } => SignalObserved { observed: flat_map(names, name => + match fan_tach_window_of(endpoint: FanTachEndpointIdentity { host: host, name: name }, samples: map(points_named(samples: samples, name: name), p => fan_sample_of(p: p))) { + Present { value: w } => [w] + none => [] + }) } + } +} + +fn temperature_windows(host: HostIdentity, samples: List>) -> HostSignalAttempt> { + match sample_names(samples: samples, pick: is_temperature) { + ObservationRefused { cause } => SignalUnreadable { cause: cause } + ObservationEstablished { observed: names } => SignalObserved { observed: map(names, name => { + let points = points_named(samples: samples, name: name) + TemperatureWindow { + host: host, + sensor: name, + samples: map(points, p => temperature_sample_of(p: p)), + worst_bmc_standing: fold(points, init: BmcThresholdUnreported, f: (acc, p) => { + let s = bmc_standing_of(health: p.health) + if standing_rank(s: s) > standing_rank(s: acc) { s } else { acc } + }), + } + }) } + } +} + +fn event_kind(message: NonEmptyStr) -> BmcEventKind { + match openbmc_memory_ecc_message(message: message as String) { + Present { value: OpenBmcMemoryEccCorrectable } => CorrectedMemoryErrorEvent + Present { value: OpenBmcMemoryEccUncorrectable } => UncorrectedMemoryErrorEvent + none => UnclassifiedEvent + } +} + +fn strictly_increasing(ids: List) -> Bool { + fold(ids, init: -1, f: (prev, id) => if prev == -2 || id <= prev { -2 } else { id }) != -2 +} + +// THE OPENING'S NEWEST ENTRY MUST SURVIVE TO THE CLOSE. If it is gone, the log wrapped past it or was +// cleared during the window, and "appended" cannot be computed: that is the continuity arm, never an +// empty append. Service ids must also be strictly increasing in both reads, because they are the +// only order this window trusts; the BMC clock orders nothing. +fn event_window(opening: List, closing: List) -> BmcEventWindow { + let open_ids = map(opening, e => e.id) + let close_ids = map(closing, e => e.id) + if !strictly_increasing(ids: open_ids) || !strictly_increasing(ids: close_ids) { + BmcEventLogContinuityUnestablished { cause: "the event log's entry ids are not strictly increasing, so no order is established" } + } else { + match reverse(open_ids).first() { + Absent => BmcEventsObserved { history_count: 0, appended: map(closing, e => BmcEventRecord { id: e.id, kind: event_kind(message: e.message), message: e.message }) } + Present { value: newest } => + if !any(close_ids, id => id == newest) { + BmcEventLogContinuityUnestablished { cause: "the opening's newest event log entry is gone at the close: the log wrapped past it or was cleared during the window" } + } else { + BmcEventsObserved { + history_count: count(opening), + appended: map(filter(closing, e => e.id > newest), e => BmcEventRecord { id: e.id, kind: event_kind(message: e.message), message: e.message }), + } + } + } + } +} + +fn read_event_log(bmc_host: NonEmptyStr, netrc: NonEmptyStr) -> ObservationAttempt, NonEmptyStr> + uses net: Network +{ + match read_json(bmc_host: bmc_host, netrc: netrc, read: ReadSystemEventLog, what: "system event log") { + ObservationRefused { cause } => return ObservationRefused { cause: cause } + ObservationEstablished { observed: body } => return redfish_log_entries(body: body) + } +} + +// Neither image serves MemoryMetrics or a processor error resource (both 404 on 2026-09-18 with the +// read-only account); memory errors reach this collector only as event log records on images whose +// registry emits them. These two slots are therefore not exposed out of band on this surface, and +// say so rather than being read another way. +data memory_counter_not_exposed: NonEmptyStr = "Redfish serves no MemoryMetrics or memory error counter on this board's images; memory errors arrive only as event log records" +data processor_counter_not_exposed: NonEmptyStr = "Redfish serves no processor or platform error counter on this board's images" + +fn observe_with_netrc(telemetry: BmcTelemetryProfile, netrc: NonEmptyStr, samples: Nat) -> HostHealthObserved + uses net: Network +{ + let bmc = telemetry.bmc_host + let opening = read_event_log(bmc_host: bmc, netrc: netrc) + let surface = match read_json(bmc_host: bmc, netrc: netrc, read: ReadManager, what: "Manager") { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: body } => match manager_firmware(body: body) { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: firmware } => telemetry_surface(telemetry: telemetry, firmware: firmware) + } + } + let sampled: ObservationAttempt>, NonEmptyStr> = match surface { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: s } => { + let empty: List> = [] + let initial: ObservationAttempt>, NonEmptyStr> = ObservationEstablished { observed: empty } + fold(nat_range_inclusive(lo: 1, hi: samples), init: initial, f: (acc, i) => match acc { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: taken } => match sample_telemetry(bmc_host: bmc, netrc: netrc, chassis_id: telemetry.chassis_id, surface: s) { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: points } => ObservationEstablished { observed: concat([points], taken) } + } + }) + } + } + let closing = read_event_log(bmc_host: bmc, netrc: netrc) + host_health_observed( + host: telemetry.host, + memory: SignalNotExposedOutOfBand { observed_surface: memory_counter_not_exposed }, + processor_platform: SignalNotExposedOutOfBand { observed_surface: processor_counter_not_exposed }, + fans: match sampled { + ObservationRefused { cause } => SignalUnreadable { cause: cause } + ObservationEstablished { observed: taken } => fan_windows(host: telemetry.host, samples: reverse(taken)) + }, + temperatures: match sampled { + ObservationRefused { cause } => SignalUnreadable { cause: cause } + ObservationEstablished { observed: taken } => temperature_windows(host: telemetry.host, samples: reverse(taken)) + }, + bmc_events: match opening { + ObservationRefused { cause } => SignalUnreadable { cause: cause } + ObservationEstablished { observed: o } => match closing { + ObservationRefused { cause } => SignalUnreadable { cause: cause } + ObservationEstablished { observed: c } => SignalObserved { observed: event_window(opening: o, closing: c) } + } + }, + ) +} + +// The reader credential goes into a private per-run netrc that is shredded on every path out; a +// shred that fails refuses the host's observation, because leaving the credential on disk is a +// standing exposure this run created. +fn observe_host_health(telemetry: BmcTelemetryProfile, reader_login: NonEmptyStr, reader_password: Secret, samples: Nat) -> ObservationAttempt + uses net: Network +{ + match place_netrc_binding_privately(machine: telemetry.bmc_host, login: reader_login, password: reader_password) { + NetrcBindingRefused { cause } => return ObservationRefused { cause: cause } + NetrcBindingRefusedResidueRemains { cause } => return ObservationRefused { cause: concat("credential material may remain on disk: ", cause as String) as NonEmptyStr } + NetrcBindingPlaced { binding } => { + let observed = observe_with_netrc(telemetry: telemetry, netrc: binding.path as NonEmptyStr, samples: samples) + if shred_netrc_binding(binding: binding) == false { + return ObservationRefused { cause: "the per-run reader netrc could not be removed after the observation" } + } + return ObservationEstablished { observed: observed } + } + } +} diff --git a/dag/gunbc/product/fan_tach.dag b/dag/gunbc/product/fan_tach.dag index ddb5179eb75..adc09d1425b 100644 --- a/dag/gunbc/product/fan_tach.dag +++ b/dag/gunbc/product/fan_tach.dag @@ -92,3 +92,33 @@ fn endpoint_names(endpoints: List) -> List fn window_endpoint_name(window: FanTachWindow) -> NonEmptyStr { window.endpoint.name } + +// THE WINDOW'S ONE PRODUCER. Until now every FanTachWindow in the tree was a hand-transcribed dated +// specimen (gunbc.fleet_fan_tach_observation); a live collector folds its own samples through this, +// so the counts and the spread are computed the one way. The rotation statistics are over rotating +// samples only: a zero or an unavailable sample has no speed to rank. A window with no samples has +// no constructor here -- an empty sample list is not a window of zero observations. When nothing +// rotated, FanTachWindow's statistics fields still require values and carry 0; every consumer +// (product.fan_tach_health) reads them only when rotating_count is positive. +fn fan_tach_window_of(endpoint: FanTachEndpointIdentity, samples: List) -> FanTachWindow? { + let speeds = sort_by(flat_map(samples, s => match s { + SampleRotating { speed } => [rpm_count(speed)] + SampleZero => [] + SampleUnavailable => [] + }), n => n) + let rotating = count(speeds) + if count(samples) == 0 { + none + } else { + Present { value: FanTachWindow { + endpoint: endpoint, + sample_count: count(samples), + zero_count: count(filter(samples, s => match s { SampleZero => true _ => false })), + unavailable_count: count(filter(samples, s => match s { SampleUnavailable => true _ => false })), + rotating_count: rotating, + minimum: rpm(match speeds.first() { Present { value: v } => v none => rpm_count(rpm(0)) }), + median: rpm(match speeds.skip(n: rotating / 2).first() { Present { value: v } => v none => rpm_count(rpm(0)) }), + maximum: rpm(match reverse(speeds).first() { Present { value: v } => v none => rpm_count(rpm(0)) }), + } } + } +} diff --git a/dag/gunbc/product/host_health.dag b/dag/gunbc/product/host_health.dag new file mode 100644 index 00000000000..65925fc26eb --- /dev/null +++ b/dag/gunbc/product/host_health.dag @@ -0,0 +1,766 @@ +module product.host_health + +import std.types { Bool, Int, List, NonEmptyStr } +import std.measure { Celsius, celsius, celsius_count } +import product.placement_supply { HostIdentity } +import product.fan_tach { FanTachWindow } +import product.fan_tach_health { + FanTachDeviation, + FanTachGoal, + FanTachRefusalCause, + FanTachUnknown, + assess_fan_tach, + fan_tach_observed, +} +import std.goal_assessment { + GoalAssessment, + GoalAssessmentRefused, + GoalDiverged, + GoalIndeterminate, + GoalInspected, + GoalInspection, + GoalObservationRefused, + GoalSatisfied, + ObservationAttempt, + ObservationEstablished, + ObservationRefused, + assess_by_deviations_and_unknowns, +} +import v2.std.algebra { non_empty_to_list } + +// ONE HOST'S FITNESS AGAINST A DECLARED HEALTH GOAL, ASSESSED FROM A BOUNDED READ-ONLY WINDOW. +// +// This module is pure. It owns the carriers a health observation fills and the assessment that +// judges them; it acquires nothing and it decides nothing operational. Whether a divergent host is +// drained, readmitted or repaired is admission policy downstream, and nothing here can express an +// effect -- the assessment is a function from values to a std.goal_assessment coproduct. +// +// THE CHECK IS STATELESS IN IMPLEMENTATION AND RECEIPT-RELATIVE IN SEMANTICS. Memory error +// counters are cumulative, so an opening/closing pair taken inside one run only sees an error that +// lands while the run is open. "Nothing new since the last time this host was judged fit" needs a +// left edge older than the run, and that edge is an explicit input -- HostHealthBaselineBasis -- +// never hidden state the checker keeps. With no admitted baseline, a nonzero opening total is +// history nobody has dispositioned: it is neither a fresh failure nor health, so it is carried as an +// unknown and the host cannot be green. +// +// SIGNALS ARE NOT SUMMED ACROSS SOURCES. The memory-controller counters, firmware error records and +// BMC event log can each report the same hardware event; adding them would fabricate a rate. Each +// signal keeps its own slot and its own continuity. + +// WHAT MUST STAY FIXED FOR TWO COUNTER READINGS TO BE SUBTRACTABLE. A counter restarted by its +// source (a new generation), a different producer, or a different endpoint roster each resets or +// re-denominates the count, so a difference across any of them is not a count of new errors. +// `producer` names the out-of-band resource family the counts came from; `generation` is the +// source's own reset identity as that producer reports it; `endpoint_set` is the ordered list of +// counter endpoints (per-DIMM or per-processor resources) read. The source is out of band by +// standing rule: a count the BMC does not expose is an unknown, never a host-OS read. +type CounterEpoch { + host: HostIdentity + producer: NonEmptyStr + generation: NonEmptyStr + endpoint_set: List +} + +type MemoryErrorCounters { + corrected: Int + uncorrected: Int +} + +type CounterSnapshot { + epoch: CounterEpoch + counters: MemoryErrorCounters +} + +type CumulativeCounterWindow { + opening: CounterSnapshot + closing: CounterSnapshot +} + +type CounterWindowUnestablishedCause + = CounterHostChanged + | CounterGenerationChanged + | CounterProducerChanged + | CounterEndpointSetChanged + | CounterRegressed + +// `opening_residue` is the total the left edge already carried. It is kept, not discarded: with an +// admitted baseline it is history someone judged; without one it is the undispositioned residue. +type CounterWindowStanding + = CounterDeltaEstablished { + corrected_delta: Int, + uncorrected_delta: Int, + opening_residue: MemoryErrorCounters, + } + | CounterWindowUnestablished { cause: CounterWindowUnestablishedCause } + +fn counter_epoch_change( + left: CounterEpoch, + right: CounterEpoch, +) -> CounterWindowUnestablishedCause? { + if left.host != right.host { + Present { value: CounterHostChanged } + } else if left.generation != right.generation { + Present { value: CounterGenerationChanged } + } else if left.producer != right.producer { + Present { value: CounterProducerChanged } + } else if left.endpoint_set != right.endpoint_set { + Present { value: CounterEndpointSetChanged } + } else { + none + } +} + +// A DECREASE IS A RESET, NEVER "ZERO NEW ERRORS". 5 -> 0 inside one apparent epoch means the counter +// was reset or its source replaced by something the epoch did not capture; subtracting would report +// a negative or a clamped zero, and both are fabrications. +fn counter_window_standing(left: CounterSnapshot, right: CounterSnapshot) -> CounterWindowStanding { + match counter_epoch_change(left: left.epoch, right: right.epoch) { + Present { value: cause } => CounterWindowUnestablished { cause: cause } + none => + if right.counters.corrected < left.counters.corrected + || right.counters.uncorrected < left.counters.uncorrected { + CounterWindowUnestablished { cause: CounterRegressed } + } else { + CounterDeltaEstablished { + corrected_delta: right.counters.corrected - left.counters.corrected, + uncorrected_delta: right.counters.uncorrected - left.counters.uncorrected, + opening_residue: left.counters, + } + } + } +} + +// THE LEFT EDGE IS AN INPUT. A prior baseline is the last snapshot someone ADMITTED or explicitly +// dispositioned -- never simply the previous run's reading. If the previous run saw a new error and +// became the next run's baseline automatically, the error would vanish after one cycle: that is the +// absorbing fallback DESIGN section 5 forbids. Selecting and admitting a baseline is therefore not +// this module's job, and nothing here can mint one from an observation. +type HostHealthBaselineBasis + = NoPriorBaseline + | PriorAdmittedBaseline { snapshot: CounterSnapshot } + +// AVAILABILITY IS CARRIED BESIDE THE VALUE, NEVER ENCODED IN IT. A present reading of 0 C stays a +// reading; a sensor the BMC reports as disabled and one that published no value this sample are +// different facts again. None of them is converted into "absent", which is derived only from the +// expectation join (an expected sensor with no window at all). +type TemperatureSample + = TemperatureReadingPresent { celsius: Celsius } + | TemperatureReadingUnavailable + | TemperatureSensorDisabled + +// THE BMC'S OWN THRESHOLD JUDGEMENT, KEPT AS EVIDENCE. It is not the only policy -- a BMC can carry +// no caution threshold at all for a part that needs one -- but it is not discarded either. The +// three standing levels follow the IPMI sensor threshold classes (non-critical, critical, +// non-recoverable), which Redfish's Status.Health also collapses into. +type BmcThresholdStanding + = BmcThresholdClear + | BmcThresholdNonCritical + | BmcThresholdCritical + | BmcThresholdNonRecoverable + | BmcThresholdUnreported + +type TemperatureWindow { + host: HostIdentity + sensor: NonEmptyStr + samples: List + worst_bmc_standing: BmcThresholdStanding +} + +// A LIMIT CARRIES WHO SAYS SO. There is deliberately no fleet-wide default: a limit is a fact about +// a component grade and a sensor's meaning, and until that fact is grounded the honest answer is +// that the limit is unestablished -- which can never produce a green. +type TemperatureLimit + = TemperatureLimitCited { critical: Celsius, authority: NonEmptyStr } + | TemperatureLimitUnestablished + +type TemperatureExpectation { + sensor: NonEmptyStr + limit: TemperatureLimit +} + +// WHICH SENSORS A HOST SHOULD EXPOSE IS ITSELF A FACT THAT CAN BE MISSING. A host whose sensor +// roster was never read has no denominator, so its temperature signal is unknown rather than judged +// against whatever happened to answer. +type HostTemperatureExpectation + = TemperatureRosterDeclared { sensors: List } + | TemperatureRosterUnestablished + +// A BMC EVENT LOG IS A WRAPPING HISTORY ORDERED BY THE SERVICE'S OWN ENTRY IDS. The observer reads it +// at the window's opening and closing and hands over what it established: how many entries were +// already there (history this run did not see arrive), and the entries appended during the window. +// Continuity is the observer's to establish -- the opening's newest entry must still be present at +// the close, or the log wrapped past it or was cleared -- and a window it cannot establish arrives +// as its own arm, never as an empty append. +// +// Entries arrive classified by the observer against the image's message registry. An entry nothing +// classifies is an unknown, not a pass. +type BmcEventKind + = CorrectedMemoryErrorEvent + | UncorrectedMemoryErrorEvent + | UnclassifiedEvent + +type BmcEventRecord { + id: Int + kind: BmcEventKind + message: NonEmptyStr +} + +type BmcEventWindow + = BmcEventsObserved { history_count: Int, appended: List } + | BmcEventLogContinuityUnestablished { cause: NonEmptyStr } + +type HostHealthSignal + = MemoryErrorCounterSignal + | ProcessorPlatformErrorSignal + | FanTachSignal + | TemperatureSignal + | BmcEventLogSignal + +type HostHealthGoal sole_constructor { + host: HostIdentity + fans: FanTachGoal + temperatures: HostTemperatureExpectation + baseline: HostHealthBaselineBasis +} + +// EVERY SIGNAL SLOT IS AN OBSERVATION ATTEMPT OF ITS OWN. One unreadable source does not erase the +// others: srv1 answering in-band while its BMC refuses still yields its memory and fan evidence, +// with the refused slot carried as a located unknown. +// +// Processor and platform errors (APEI/GHES records of non-memory sections, PCIe AER) have their own +// slot and no summation with the memory counters. Their producer must cover the whole counter +// epoch; a kernel ring buffer or a rotated journal does not, which is why an observer without a +// durable event producer on the host refuses this slot rather than reading "nothing found". +// WHY NOT std.goal_assessment ObservationAttempt HERE: a signal slot has THREE outcomes, not two. The +// out-of-band surface may answer, may answer and simply not carry the signal, or may fail to answer, +// and the last two have different remedies (a BMC telemetry requirement versus an access fault). +type HostSignalAttempt + = SignalObserved { observed: T } + | SignalNotExposedOutOfBand { observed_surface: NonEmptyStr } + | SignalUnreadable { cause: NonEmptyStr } + +type HostHealthObserved sole_constructor { + host: HostIdentity + memory: HostSignalAttempt + processor_platform: HostSignalAttempt> + fans: HostSignalAttempt> + temperatures: HostSignalAttempt> + bmc_events: HostSignalAttempt +} + +fn host_health_goal( + host: HostIdentity, + fans: FanTachGoal, + temperatures: HostTemperatureExpectation, + baseline: HostHealthBaselineBasis, +) -> HostHealthGoal { + HostHealthGoal { host: host, fans: fans, temperatures: temperatures, baseline: baseline } +} + +fn host_health_observed( + host: HostIdentity, + memory: HostSignalAttempt, + processor_platform: HostSignalAttempt>, + fans: HostSignalAttempt>, + temperatures: HostSignalAttempt>, + bmc_events: HostSignalAttempt, +) -> HostHealthObserved { + HostHealthObserved { + host: host, + memory: memory, + processor_platform: processor_platform, + fans: fans, + temperatures: temperatures, + bmc_events: bmc_events, + } +} + +type HostHealthDeviation + = CorrectedMemoryErrorIncrement { delta: Int, since_baseline: Bool } + | UncorrectedMemoryErrorIncrement { delta: Int, since_baseline: Bool } + | ProcessorPlatformErrorObserved { record: NonEmptyStr } + | FanDeviation { deviation: FanTachDeviation } + | TemperatureLimitExceeded { sensor: NonEmptyStr, maximum: Celsius, critical: Celsius } + | BmcSensorThresholdCrossed { sensor: NonEmptyStr, standing: BmcThresholdStanding } + | TemperatureObservationForUndeclaredSensor { sensor: NonEmptyStr } + | MemoryErrorEventLogged { corrected: Bool, message: NonEmptyStr } + +// A SIGNAL THE BMC NEVER EXPOSES AND ONE IT FAILED TO ANSWER ARE DIFFERENT GAPS with different owners: +// the first is a telemetry requirement on the BMC surface, the second an access or reachability fault. +type HostHealthUnknown + = RequiredSignalUnavailable { signal: HostHealthSignal, cause: NonEmptyStr } + | RequiredSignalNotExposedOutOfBand { signal: HostHealthSignal, observed_surface: NonEmptyStr } + | HistoricalErrorResidueUndispositioned { residue: MemoryErrorCounters } + | CounterContinuityUnestablished { cause: CounterWindowUnestablishedCause, against_baseline: Bool } + | FanUnknown { unknown: FanTachUnknown } + | TemperatureRosterUndeclared + | ExpectedTemperatureSensorMissing { sensor: NonEmptyStr } + | TemperatureReadingNeverPresent { sensor: NonEmptyStr, disabled: Bool } + | ThermalLimitUnestablished { sensor: NonEmptyStr } + | BmcEventUnclassified { entry: NonEmptyStr } + | HistoricalEventResidueUndispositioned { entries: Int } + | EventLogContinuityUnestablished { cause: NonEmptyStr } + +type HostHealthRefusal + = ObservationForAnotherHost { goal_host: HostIdentity, observed_host: HostIdentity } + | BaselineForAnotherHost { goal_host: HostIdentity, baseline_host: HostIdentity } + | CounterWindowForAnotherHost { goal_host: HostIdentity, window_host: HostIdentity } + | TemperatureWindowForAnotherHost { sensor: NonEmptyStr } + | DuplicateExpectedTemperatureSensor { sensor: NonEmptyStr } + | DuplicateObservedTemperatureSensor { sensor: NonEmptyStr } + | EmptyTemperatureWindow { sensor: NonEmptyStr } + | FanAssessmentRefused { cause: FanTachRefusalCause } + +// THE EVIDENCE OF A GREEN NAMES WHAT WAS ESTABLISHED, not merely that nothing was wrong: which +// baseline the counters were read against and how many endpoints each signal judged. +type HostHealthEvidence sole_constructor { + host: HostIdentity + against_baseline: Bool + temperature_sensors_judged: Int + fan_headers_judged: Int +} + +type HostHealthAssessment = GoalAssessment + +// One signal's contribution. Refusals short-circuit the whole host; deviations and unknowns +// accumulate across signals so partial evidence is never lost to a sibling's gap. +type SignalVerdict { + deviations: List + unknowns: List + refusal: HostHealthRefusal? + judged: Int +} + +fn signal_clear(judged: Int) -> SignalVerdict { + SignalVerdict { deviations: [], unknowns: [], refusal: none, judged: judged } +} + +fn signal_unknown(unknown: HostHealthUnknown) -> SignalVerdict { + SignalVerdict { deviations: [], unknowns: [unknown], refusal: none, judged: 0 } +} + +fn signal_refused(refusal: HostHealthRefusal) -> SignalVerdict { + SignalVerdict { deviations: [], unknowns: [], refusal: Present { value: refusal }, judged: 0 } +} + +fn signal_unavailable(signal: HostHealthSignal, cause: NonEmptyStr) -> SignalVerdict { + signal_unknown(unknown: RequiredSignalUnavailable { signal: signal, cause: cause }) +} + +fn memory_increments( + corrected_delta: Int, + uncorrected_delta: Int, + since_baseline: Bool, +) -> List { + flat_map( + [ + if corrected_delta > 0 { + [CorrectedMemoryErrorIncrement { delta: corrected_delta, since_baseline: since_baseline }] + } else { [] }, + if uncorrected_delta > 0 { + [UncorrectedMemoryErrorIncrement { delta: uncorrected_delta, since_baseline: since_baseline }] + } else { [] }, + ], + group => group, + ) +} + +// WITH NO BASELINE, the in-run window still detects an error that lands while the check runs, and +// a nonzero opening total is undispositioned history -- so a positive total yields an unknown even +// when the in-run delta is zero, and a zero total with a stable window is clean-since-epoch. +// +// WITH A BASELINE, the left edge is the baseline snapshot; the in-run window must still be stable, +// because an epoch change during the run is a gap the baseline cannot bridge. +fn assess_memory( + goal_host: HostIdentity, + baseline: HostHealthBaselineBasis, + window: CumulativeCounterWindow, +) -> SignalVerdict { + if window.opening.epoch.host != goal_host { + signal_refused(refusal: CounterWindowForAnotherHost { goal_host: goal_host, window_host: window.opening.epoch.host }) + } else { + match counter_window_standing(left: window.opening, right: window.closing) { + CounterWindowUnestablished { cause } => + signal_unknown(unknown: CounterContinuityUnestablished { cause: cause, against_baseline: false }) + CounterDeltaEstablished { corrected_delta: run_ce, uncorrected_delta: run_ue, opening_residue: residue } => + match baseline { + NoPriorBaseline => SignalVerdict { + deviations: memory_increments(corrected_delta: run_ce, uncorrected_delta: run_ue, since_baseline: false), + unknowns: if residue.corrected > 0 || residue.uncorrected > 0 { + [HistoricalErrorResidueUndispositioned { residue: residue }] + } else { [] }, + refusal: none, + judged: 1, + } + PriorAdmittedBaseline { snapshot } => + if snapshot.epoch.host != goal_host { + signal_refused(refusal: BaselineForAnotherHost { goal_host: goal_host, baseline_host: snapshot.epoch.host }) + } else { + match counter_window_standing(left: snapshot, right: window.closing) { + CounterWindowUnestablished { cause } => + signal_unknown(unknown: CounterContinuityUnestablished { cause: cause, against_baseline: true }) + CounterDeltaEstablished { corrected_delta: ce, uncorrected_delta: ue, opening_residue: _ } => + SignalVerdict { + deviations: memory_increments(corrected_delta: ce, uncorrected_delta: ue, since_baseline: true), + unknowns: [], + refusal: none, + judged: 1, + } + } + } + } + } + } +} + +fn assess_processor_platform(records: List) -> SignalVerdict { + SignalVerdict { + deviations: map(records, r => ProcessorPlatformErrorObserved { record: r }), + unknowns: [], + refusal: none, + judged: 1, + } +} + +fn assess_fans(goal: FanTachGoal, windows: List) -> SignalVerdict { + match assess_fan_tach(goal: goal, observed: fan_tach_observed(windows: windows)) { + GoalSatisfied { evidence } => signal_clear(judged: evidence.headers_judged) + GoalDiverged { deviations } => SignalVerdict { + deviations: map(non_empty_to_list(deviations), d => FanDeviation { deviation: d }), + unknowns: [], + refusal: none, + judged: count(non_empty_to_list(deviations)), + } + GoalIndeterminate { known_deviations, unknowns } => SignalVerdict { + deviations: map(known_deviations, d => FanDeviation { deviation: d }), + unknowns: map(non_empty_to_list(unknowns), u => FanUnknown { unknown: u }), + refusal: none, + judged: count(known_deviations), + } + GoalAssessmentRefused { cause } => signal_refused(refusal: FanAssessmentRefused { cause: cause }) + } +} + +fn count_name(names: List, name: NonEmptyStr) -> Int { + fold(names, init: 0, f: (acc, n) => if n == name { acc + 1 } else { acc }) +} + +fn first_duplicate_name(names: List) -> NonEmptyStr? { + fold(names, init: none, f: (acc, n) => match acc { + Present { value } => Present { value: value } + none => if count_name(names: names, name: n) > 1 { Present { value: n } } else { none } + }) +} + +fn temperature_window_for(windows: List, sensor: NonEmptyStr) -> TemperatureWindow? { + filter(windows, w => w.sensor == sensor).first() +} + +fn temperature_expected(expectations: List, sensor: NonEmptyStr) -> Bool { + any(expectations, e => e.sensor == sensor) +} + +fn present_celsius(samples: List) -> List { + flat_map(samples, s => match s { + TemperatureReadingPresent { celsius } => [celsius_count(celsius)] + TemperatureReadingUnavailable => [] + TemperatureSensorDisabled => [] + }) +} + +fn any_disabled(samples: List) -> Bool { + any(samples, s => match s { TemperatureSensorDisabled => true _ => false }) +} + +fn bmc_standing_crossed(standing: BmcThresholdStanding) -> Bool { + match standing { + BmcThresholdNonCritical => true + BmcThresholdCritical => true + BmcThresholdNonRecoverable => true + BmcThresholdClear => false + BmcThresholdUnreported => false + } +} + +fn judge_temperature(expectation: TemperatureExpectation, windows: List) -> SignalVerdict { + match temperature_window_for(windows: windows, sensor: expectation.sensor) { + none => signal_unknown(unknown: ExpectedTemperatureSensorMissing { sensor: expectation.sensor }) + Present { value: w } => { + let readings = present_celsius(samples: w.samples) + let bmc = if bmc_standing_crossed(standing: w.worst_bmc_standing) { + [BmcSensorThresholdCrossed { sensor: w.sensor, standing: w.worst_bmc_standing }] + } else { [] } + match readings.first() { + none => SignalVerdict { + deviations: bmc, + unknowns: [TemperatureReadingNeverPresent { sensor: w.sensor, disabled: any_disabled(samples: w.samples) }], + refusal: none, + judged: 1, + } + Present { value: first } => { + let maximum = fold(readings, init: first, f: (acc, r) => if r > acc { r } else { acc }) + match expectation.limit { + TemperatureLimitUnestablished => SignalVerdict { + deviations: bmc, + unknowns: [ThermalLimitUnestablished { sensor: w.sensor }], + refusal: none, + judged: 1, + } + TemperatureLimitCited { critical, authority: _ } => SignalVerdict { + deviations: flat_map([ + bmc, + if maximum > celsius_count(critical) { + [TemperatureLimitExceeded { sensor: w.sensor, maximum: celsius(maximum), critical: critical }] + } else { [] }, + ], group => group), + unknowns: [], + refusal: none, + judged: 1, + } + } + } + } + } + } +} + +fn assess_temperatures( + goal_host: HostIdentity, + expectation: HostTemperatureExpectation, + windows: List, +) -> SignalVerdict { + match filter(windows, w => w.host != goal_host).first() { + Present { value: stray } => signal_refused(refusal: TemperatureWindowForAnotherHost { sensor: stray.sensor }) + none => match filter(windows, w => count(w.samples) == 0).first() { + Present { value: empty } => signal_refused(refusal: EmptyTemperatureWindow { sensor: empty.sensor }) + none => match first_duplicate_name(names: map(windows, w => w.sensor)) { + Present { value: sensor } => signal_refused(refusal: DuplicateObservedTemperatureSensor { sensor: sensor }) + none => match expectation { + TemperatureRosterUnestablished => signal_unknown(unknown: TemperatureRosterUndeclared) + TemperatureRosterDeclared { sensors } => + match first_duplicate_name(names: map(sensors, e => e.sensor)) { + Present { value: sensor } => signal_refused(refusal: DuplicateExpectedTemperatureSensor { sensor: sensor }) + none => merge_verdicts(verdicts: flat_map([ + map(sensors, e => judge_temperature(expectation: e, windows: windows)), + map( + filter(windows, w => !temperature_expected(expectations: sensors, sensor: w.sensor)), + w => SignalVerdict { + deviations: [TemperatureObservationForUndeclaredSensor { sensor: w.sensor }], + unknowns: [], + refusal: none, + judged: 0, + }, + ), + ], group => group)) + } + } + } + } + } +} + +// History is residue: with no admitted event baseline (HEALTH-1 owns that), entries that were +// already in the log when the window opened are neither news nor cleared, so a nonzero history is +// an unknown. Appended memory errors are deviations; anything else appended is unclassified. +fn assess_bmc_events(window: BmcEventWindow) -> SignalVerdict { + match window { + BmcEventLogContinuityUnestablished { cause } => + signal_unknown(unknown: EventLogContinuityUnestablished { cause: cause }) + BmcEventsObserved { history_count, appended } => SignalVerdict { + deviations: flat_map(appended, e => match e.kind { + CorrectedMemoryErrorEvent => [MemoryErrorEventLogged { corrected: true, message: e.message }] + UncorrectedMemoryErrorEvent => [MemoryErrorEventLogged { corrected: false, message: e.message }] + UnclassifiedEvent => [] + }), + unknowns: flat_map([ + if history_count > 0 { [HistoricalEventResidueUndispositioned { entries: history_count }] } else { [] }, + flat_map(appended, e => match e.kind { + UnclassifiedEvent => [BmcEventUnclassified { entry: e.message }] + CorrectedMemoryErrorEvent => [] + UncorrectedMemoryErrorEvent => [] + }), + ], group => group), + refusal: none, + judged: 1, + } + } +} + +fn merge_verdicts(verdicts: List) -> SignalVerdict { + SignalVerdict { + deviations: flat_map(verdicts, v => v.deviations), + unknowns: flat_map(verdicts, v => v.unknowns), + refusal: fold(verdicts, init: none, f: (acc, v) => match acc { + Present { value } => Present { value: value } + none => v.refusal + }), + judged: fold(verdicts, init: 0, f: (acc, v) => acc + v.judged), + } +} + +fn signal_of( + attempt: HostSignalAttempt, + signal: HostHealthSignal, + judge: fn(T) -> SignalVerdict, +) -> SignalVerdict { + match attempt { + SignalUnreadable { cause } => signal_unavailable(signal: signal, cause: cause) + SignalNotExposedOutOfBand { observed_surface } => + signal_unknown(unknown: RequiredSignalNotExposedOutOfBand { signal: signal, observed_surface: observed_surface }) + SignalObserved { observed } => judge(observed) + } +} + +fn assess_host_health(goal: HostHealthGoal, observed: HostHealthObserved) -> HostHealthAssessment { + if observed.host != goal.host { + GoalAssessmentRefused { cause: ObservationForAnotherHost { goal_host: goal.host, observed_host: observed.host } } + } else { + let fans = signal_of(attempt: observed.fans, signal: FanTachSignal, + judge: windows => assess_fans(goal: goal.fans, windows: windows)) + let temperatures = signal_of(attempt: observed.temperatures, signal: TemperatureSignal, + judge: windows => assess_temperatures(goal_host: goal.host, expectation: goal.temperatures, windows: windows)) + let all = merge_verdicts(verdicts: [ + signal_of(attempt: observed.memory, signal: MemoryErrorCounterSignal, + judge: window => assess_memory(goal_host: goal.host, baseline: goal.baseline, window: window)), + signal_of(attempt: observed.processor_platform, signal: ProcessorPlatformErrorSignal, + judge: records => assess_processor_platform(records: records)), + fans, + temperatures, + signal_of(attempt: observed.bmc_events, signal: BmcEventLogSignal, + judge: window => assess_bmc_events(window: window)), + ]) + match all.refusal { + Present { value: refusal } => GoalAssessmentRefused { cause: refusal } + none => assess_by_deviations_and_unknowns( + evidence: HostHealthEvidence { + host: goal.host, + against_baseline: match goal.baseline { NoPriorBaseline => false PriorAdmittedBaseline { snapshot: _ } => true }, + temperature_sensors_judged: temperatures.judged, + fan_headers_judged: fans.judged, + }, + deviations: all.deviations, + unknowns: all.unknowns, + ) + } + } +} + +// ONE ENTRY PER ROSTERED HOST, AND THE OBSERVATION IS OPTIONAL WHILE THE GOAL IS NOT. The fold is +// driven by the goals, so a host whose collector produced nothing is still present: its whole +// observation is refused with a located cause, and it cannot shorten the denominator. +type HostHealthInspection = GoalInspection + +type FleetHealthDeviation { host: HostIdentity, deviation: HostHealthDeviation } + +type FleetHealthUnknown + = HostUnknownAt { host: HostIdentity, unknown: HostHealthUnknown } + | HostObservationRefusedAt { host: HostIdentity, cause: NonEmptyStr } + | HostAssessmentRefusedAt { host: HostIdentity, cause: HostHealthRefusal } + +type FleetHealthRefusal + = EmptyRoster + | DuplicateRosterHost { host: HostIdentity } + | ObservationForUnrosteredHost { host: HostIdentity } + | DuplicateHostObservation { host: HostIdentity } + +type FleetHealthEvidence sole_constructor { hosts_satisfied: List } + +type FleetHealthAssessment = GoalAssessment + +type FleetHealthReceipt sole_constructor { + hosts: List + assessment: FleetHealthAssessment +} + +fn host_names(hosts: List) -> List { + map(hosts, h => h as NonEmptyStr) +} + +// A HOST THE COLLECTOR COULD NOT REACH ARRIVES AS A KEYED REFUSAL. `observations` is keyed by host +// so an unreachable host's refusal is attributable; a refusal keyed to a host not on the roster is +// a refusal of the whole fleet assessment, because it means the collector and the roster disagree +// about what the fleet is. +type KeyedHostObservation { + host: HostIdentity + attempt: ObservationAttempt +} + +fn fleet_inspections(goals: List, observations: List) -> List { + map(goals, goal => match filter(observations, o => o.host == goal.host).first() { + none => GoalObservationRefused { + subject: goal.host, goal: goal, request: goal.host, + cause: concat("no observation was collected for rostered host ", goal.host as String) as NonEmptyStr, + } + Present { value: keyed } => match keyed.attempt { + ObservationRefused { cause } => GoalObservationRefused { subject: goal.host, goal: goal, request: goal.host, cause: cause } + ObservationEstablished { observed } => GoalInspected { + subject: goal.host, goal: goal, request: goal.host, observed: observed, + assessment: assess_host_health(goal: goal, observed: observed), + } + } + }) +} + +fn inspection_contribution(inspection: HostHealthInspection) -> SignalVerdictAt { + match inspection { + GoalObservationRefused { subject, goal: _, request: _, cause } => + SignalVerdictAt { satisfied: [], deviations: [], unknowns: [HostObservationRefusedAt { host: subject, cause: cause }] } + GoalInspected { subject, goal: _, request: _, observed: _, assessment } => match assessment { + GoalSatisfied { evidence: _ } => SignalVerdictAt { satisfied: [subject], deviations: [], unknowns: [] } + GoalDiverged { deviations } => SignalVerdictAt { + satisfied: [], + deviations: map(non_empty_to_list(deviations), d => FleetHealthDeviation { host: subject, deviation: d }), + unknowns: [], + } + GoalIndeterminate { known_deviations, unknowns } => SignalVerdictAt { + satisfied: [], + deviations: map(known_deviations, d => FleetHealthDeviation { host: subject, deviation: d }), + unknowns: map(non_empty_to_list(unknowns), u => HostUnknownAt { host: subject, unknown: u }), + } + GoalAssessmentRefused { cause } => SignalVerdictAt { + satisfied: [], deviations: [], unknowns: [HostAssessmentRefusedAt { host: subject, cause: cause }], + } + } + } +} + +type SignalVerdictAt { + satisfied: List + deviations: List + unknowns: List +} + +// THE FLEET IS GREEN ONLY IF EVERY ROSTERED HOST IS. A host-level refusal (the inputs for that host +// were malformed) is a fleet-level unknown rather than a fleet refusal, so one bad host does not +// hide the other hosts' findings; the fleet refuses only when the roster or the keying is itself +// inconsistent. +fn assess_fleet_health(goals: List, observations: List) -> FleetHealthReceipt { + let roster = map(goals, g => g.host) + let refusal: FleetHealthRefusal? = if count(roster) == 0 { Present { value: EmptyRoster } } else { + match first_duplicate_name(names: host_names(hosts: roster)) { + Present { value: h } => Present { value: DuplicateRosterHost { host: h as HostIdentity } } + none => match first_duplicate_name(names: host_names(hosts: map(observations, o => o.host))) { + Present { value: h } => Present { value: DuplicateHostObservation { host: h as HostIdentity } } + none => match filter(observations, o => !any(roster, r => r == o.host)).first() { + Present { value: stray } => Present { value: ObservationForUnrosteredHost { host: stray.host } } + none => none + } + } + } + } + let inspections = fleet_inspections(goals: goals, observations: observations) + match refusal { + Present { value: cause } => FleetHealthReceipt { hosts: inspections, assessment: GoalAssessmentRefused { cause: cause } } + none => { + let parts = map(inspections, i => inspection_contribution(inspection: i)) + FleetHealthReceipt { + hosts: inspections, + assessment: assess_by_deviations_and_unknowns( + evidence: FleetHealthEvidence { hosts_satisfied: flat_map(parts, p => p.satisfied) }, + deviations: flat_map(parts, p => p.deviations), + unknowns: flat_map(parts, p => p.unknowns), + ), + } + } + } +} diff --git a/dag/gunbc/tools/bmc_health_reader_converge.dag b/dag/gunbc/tools/bmc_health_reader_converge.dag index d096985f65f..2a04d47ecaf 100644 --- a/dag/gunbc/tools/bmc_health_reader_converge.dag +++ b/dag/gunbc/tools/bmc_health_reader_converge.dag @@ -28,11 +28,13 @@ import gunbc.bmc_health_reader { reader_account_from_json, reader_listed_in_accounts, } -import gunbc.tools.bmc_onboard { +import gunbc.bmc_custody_read { CustodyCredentialAbsent, CustodyCredentialHeld, CustodyCredentialUnreadable, bmc_custody_credential, +} +import gunbc.tools.bmc_onboard { BmcCustodyEstablished, BmcCustodyRefused, bmc_onboard_netrc_path, diff --git a/dag/gunbc/tools/bmc_onboard.dag b/dag/gunbc/tools/bmc_onboard.dag index 700ca0f5bdd..d0f17c9f86d 100644 --- a/dag/gunbc/tools/bmc_onboard.dag +++ b/dag/gunbc/tools/bmc_onboard.dag @@ -11,20 +11,18 @@ import extdeps.cloud.gcp.secret_manager { SmVersionIdNotAVersionResource, decode_sm_access_version_payload_wire, decode_sm_resolved_version_identity_wire, - decode_sm_access_version_secret_wire, encode_sm_access_version_payload_wire, SmAccessVersionPayloadDecoded, SmAccessVersionPayloadDecodeRefused, - SmAccessVersionSecretDecoded, - SmAccessVersionSecretDecodeRefused, SmResolvedVersionIdentityDecoded, SmResolvedVersionIdentityDecodeRefused, } import gunbc.fleet_secrets_config { fleet_secrets_gcp_project } -import extdeps.transports.rest { - classify_rest_outcome, - RestExchangeSucceeded, RestExchangeStatusRefused, RestExchangeCommitAmbiguous, - RestExchangeUnreached, RestExchangeUndecodable, RestReadExchange, RestExchangePerformance, +import gunbc.bmc_custody_read { + CustodyCredentialAbsent, + CustodyCredentialHeld, + CustodyCredentialUnreadable, + bmc_custody_credential, } import gunbc.secret_provision_actuator { SecretContainerAbsent, @@ -423,46 +421,6 @@ fn bmc_assimilate_credential(plan: BmcOnboardingPlan, token_source: AccessTokenS // to the BMC to investigate a problem that is on this host. The same rule governs the stored- // credential arm below it. -// THREE ANSWERS, NOT A MAYBE. A 404 is the store saying the secret does not exist; a refused status, -// an unreached call or an undecodable payload says nothing about existence and is carried as the -// read failure it is (reviews 67836, 67848). Collapsing them would tell an operator to create a secret that -// may be sitting there behind a permission they lack. -type CustodyCredentialRead - = CustodyCredentialHeld { password: String } - | CustodyCredentialAbsent - | CustodyCredentialUnreadable { cause: String } - -fn custody_read_refusal_cause(performance: RestExchangePerformance) -> CustodyCredentialRead? { - match performance { - RestExchangeSucceeded => none - RestExchangeStatusRefused { status: status, body: body } => - Present { value: if status == 404 { CustodyCredentialAbsent } else { CustodyCredentialUnreadable { cause: concat("Secret Manager refused the read: ", body) } } } - RestExchangeCommitAmbiguous { detail: d } => Present { value: CustodyCredentialUnreadable { cause: d as String } } - RestExchangeUnreached { cause: c } => Present { value: CustodyCredentialUnreadable { cause: c as String } } - RestExchangeUndecodable { status: _, cause: c } => Present { value: CustodyCredentialUnreadable { cause: c as String } } - } -} - -// ONE MATCH OVER THE CLASSIFIED OUTCOME (review 67867): a success decodes the payload, every other -// arm is the refusal custody_read_refusal_cause already named. There is no second pass that has to -// invent an answer for a success it can no longer see. -fn bmc_custody_credential(secret_id: NonEmptyStr, token: Secret) -> CustodyCredentialRead - uses net: Network -{ - resp = gcp.SecretManager.AccessVersion( - access_token: token, - project_id: fleet_secrets_gcp_project, - secret: secret_id, - version: "latest" - ) - return match custody_read_refusal_cause(performance: classify_rest_outcome(standing: RestReadExchange { rest: resp.outcome })) { - Present { value: refusal } => refusal - none => match decode_sm_access_version_secret_wire(data_b64: resp.data_b64) { - SmAccessVersionSecretDecoded { credential: c } => CustodyCredentialHeld { password: c as String } - SmAccessVersionSecretDecodeRefused { cause: _ } => CustodyCredentialUnreadable { cause: "the custody payload did not decode" } - } - } -} fn bmc_probe_credential_phase(plan: BmcOnboardingPlan, token_source: AccessTokenSource) -> BmcCredentialProbeResult diff --git a/dag/test/claim/bmc/bmc_health_reader_witness_test.dag b/dag/test/claim/bmc/bmc_health_reader_witness_test.dag index cf023561a6c..1914b66f44a 100644 --- a/dag/test/claim/bmc/bmc_health_reader_witness_test.dag +++ b/dag/test/claim/bmc/bmc_health_reader_witness_test.dag @@ -23,7 +23,7 @@ import gunbc.tools.bmc_health_reader_converge { bmc_health_reader_body_path, reader_login_exit, } -import gunbc.tools.bmc_onboard { CustodyCredentialAbsent, CustodyCredentialUnreadable, custody_read_refusal_cause } +import gunbc.bmc_custody_read { CustodyCredentialAbsent, CustodyCredentialUnreadable, custody_read_refusal_cause } import extdeps.transports.rest { RestExchangeStatusRefused, RestExchangeSucceeded, RestExchangeUnreached } import gunbc.bmc_onboarding { srv3_onboarding_plan, srv4_onboarding_plan } import gunbc.fleet_intent_network { operator_host_srv3, operator_host_srv4 } diff --git a/dag/test/claim/fleet/fleet_health_observe_witness_test.dag b/dag/test/claim/fleet/fleet_health_observe_witness_test.dag new file mode 100644 index 00000000000..1e035cf7a36 --- /dev/null +++ b/dag/test/claim/fleet/fleet_health_observe_witness_test.dag @@ -0,0 +1,141 @@ +module test.claim.fleet_health_observe_witness + +import std.types { Bool, Int, List, NonEmptyStr, String } +import std.goal_assessment { ObservationEstablished, ObservationRefused } +import gunbc.fleet_intent_network { operator_host_srv1 } +import product.host_health { + BmcEventLogContinuityUnestablished, + BmcEventsObserved, + BmcThresholdCritical, + CorrectedMemoryErrorEvent, + SignalObserved, + TemperatureReadingPresent, + UnclassifiedEvent, +} +import extdeps.bmc.redfish_telemetry { + RedfishHealthCritical, + RedfishHealthOk, + RedfishLogEntry, + RedfishReadingNull, + RedfishReadingValue, + RedfishRotationalRpm, + RedfishStateEnabled, + RedfishTelemetryPoint, + RedfishTemperatureCelsius, +} +import extdeps.bmc.types { RedfishLegacyChassisThermal, RedfishThermalSubsystemSensorCollection } +import extdeps.boards.asrock_rack { asrock_altrad8ud_1l2t_bmc_redfish_surface_catalog, asrock_altrad8ud_redfish_chassis_id } +import gunbc.fleet_health_observe { + BmcTelemetryProfile, + event_window, + fan_windows, + manager_firmware, + telemetry_surface, + temperature_windows, +} +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +// These claims supply the collector's intermediate values -- decoded log entries and telemetry +// points -- because they are about the window arithmetic, not about the wire. The wire decoders are +// witnessed on real responses in test.claim.redfish_telemetry_witness; the route that joins the two +// on live BMCs is the collector's own run. + +fn entry(id: Int, message: NonEmptyStr) -> RedfishLogEntry { + RedfishLogEntry { id: id, severity: "Critical", message: message } +} + +data ecc: NonEmptyStr = "OpenBMC.0.1.MemoryECCCorrectable.Critical \n 0,2,0,9" +data fan_note: NonEmptyStr = "FAN4 critical low threshold deassert." + +test fn appended_entries_are_those_after_the_openings_newest_and_are_classified() -> Bool { + match event_window(opening: [entry(id: 10, message: fan_note), entry(id: 11, message: fan_note)], + closing: [entry(id: 10, message: fan_note), entry(id: 11, message: fan_note), entry(id: 12, message: ecc), entry(id: 13, message: fan_note)]) { + BmcEventsObserved { history_count, appended } => + history_count == 2 + && map(appended, e => e.id) == [12, 13] + && match appended.first() { Present { value: e } => match e.kind { CorrectedMemoryErrorEvent => true _ => false } none => false } + && match reverse(appended).first() { Present { value: e } => match e.kind { UnclassifiedEvent => true _ => false } none => false } + BmcEventLogContinuityUnestablished { cause: _ } => false + } +} + +// srv3's log wraps at about 1200 entries; if the opening's newest entry has rolled off by the close, +// nothing about what was appended can be said. +test fn a_log_that_wrapped_or_was_cleared_in_the_window_is_not_an_empty_append() -> Bool { + let wrapped = match event_window(opening: [entry(id: 10, message: fan_note), entry(id: 11, message: fan_note)], closing: [entry(id: 12, message: fan_note)]) { + BmcEventLogContinuityUnestablished { cause: _ } => true + _ => false + } + let reordered = match event_window(opening: [entry(id: 11, message: fan_note), entry(id: 10, message: fan_note)], closing: [entry(id: 11, message: fan_note)]) { + BmcEventLogContinuityUnestablished { cause: _ } => true + _ => false + } + wrapped && reordered +} + +data srv1_profile: BmcTelemetryProfile = BmcTelemetryProfile { + host: operator_host_srv1, + bmc_host: "192.168.1.183", + chassis_id: asrock_altrad8ud_redfish_chassis_id, + surface_catalog: asrock_altrad8ud_1l2t_bmc_redfish_surface_catalog, +} + +// The surface follows the firmware the BMC reports, through the board catalog: 2.07 reads per-sensor +// resources, 3.22 reads legacy Thermal, and a version the catalog does not know refuses. +test fn the_surface_is_derived_from_the_reported_firmware() -> Bool { + let v207 = match manager_firmware(body: "{\"FirmwareVersion\":\"2.07.00\"}") { + ObservationEstablished { observed: f } => match telemetry_surface(telemetry: srv1_profile, firmware: f) { ObservationEstablished { observed: RedfishThermalSubsystemSensorCollection } => true _ => false } + _ => false + } + let v322 = match manager_firmware(body: "{\"FirmwareVersion\":\"3.22.00\"}") { + ObservationEstablished { observed: f } => match telemetry_surface(telemetry: srv1_profile, firmware: f) { ObservationEstablished { observed: RedfishLegacyChassisThermal } => true _ => false } + _ => false + } + let unknown = match manager_firmware(body: "{\"FirmwareVersion\":\"9.99.00\"}") { + ObservationEstablished { observed: f } => match telemetry_surface(telemetry: srv1_profile, firmware: f) { ObservationRefused { cause: _ } => true _ => false } + _ => false + } + let garbage = match manager_firmware(body: "{\"FirmwareVersion\":\"v2-dev\"}") { ObservationRefused { cause: _ } => true _ => false } + v207 && v322 && unknown && garbage +} + +fn point(name: NonEmptyStr, fan: Bool, reading: Int) -> RedfishTelemetryPoint { + RedfishTelemetryPoint { + name: name, + kind: if fan { RedfishRotationalRpm } else { RedfishTemperatureCelsius }, + reading: RedfishReadingValue { whole: reading }, + health: if fan && reading == 0 { RedfishHealthCritical } else { RedfishHealthOk }, + state: RedfishStateEnabled, + } +} + +// Three samples of srv1's shape: FAN4 at zero every time, a null header, a DIMM temperature. +test fn samples_fold_into_one_window_per_endpoint() -> Bool { + let s1 = [point(name: "FAN4", fan: true, reading: 0), point(name: "FAN1", fan: true, reading: 1200), point(name: "TEMP_DIMM_C1", fan: false, reading: 99)] + let s2 = [point(name: "FAN4", fan: true, reading: 0), point(name: "FAN1", fan: true, reading: 1100), point(name: "TEMP_DIMM_C1", fan: false, reading: 98)] + let s3 = [point(name: "FAN4", fan: true, reading: 0), point(name: "FAN1", fan: true, reading: 1300), point(name: "TEMP_DIMM_C1", fan: false, reading: 99)] + let fans = match fan_windows(host: operator_host_srv1, samples: [s1, s2, s3]) { + SignalObserved { observed: ws } => + any(ws, w => w.endpoint.name == "FAN4" && w.zero_count == 3 && w.rotating_count == 0 && w.sample_count == 3) + && any(ws, w => w.endpoint.name == "FAN1" && w.rotating_count == 3) + _ => false + } + let temps = match temperature_windows(host: operator_host_srv1, samples: [s1, s2, s3]) { + SignalObserved { observed: ws } => count(ws) == 1 && any(ws, w => count(w.samples) == 3) + _ => false + } + fans && temps +} + +// A sensor that vanishes mid-window changes the population; that refuses rather than producing a +// window over fewer samples than its neighbours. +test fn a_population_change_mid_window_refuses() -> Bool { + let s1 = [point(name: "FAN1", fan: true, reading: 1200), point(name: "FAN4", fan: true, reading: 0)] + let s2 = [point(name: "FAN1", fan: true, reading: 1200)] + match fan_windows(host: operator_host_srv1, samples: [s1, s2]) { + SignalObserved { observed: _ } => false + _ => true + } +} diff --git a/dag/test/claim/host_health_assessment_witness_test.dag b/dag/test/claim/host_health_assessment_witness_test.dag new file mode 100644 index 00000000000..52bae604920 --- /dev/null +++ b/dag/test/claim/host_health_assessment_witness_test.dag @@ -0,0 +1,410 @@ +module test.claim.host_health_assessment_witness + +import std.types { Bool, Int, List, NonEmptyStr } +import std.measure { celsius } +import product.placement_supply { HostIdentity } +import product.fan_tach { FanTachWindow } +import gunbc.fleet_intent_network { operator_host_srv1, operator_host_srv2 } +import gunbc.fleet_fan_tach_expectation { srv1_fan_tach_expectation, srv2_fan_tach_expectation } +import gunbc.fleet_fan_tach_observation { srv1_windows, srv2_windows } +import product.fan_tach_health { fan_tach_goal } +import product.host_health { + BmcEventRecord, + BmcEventsObserved, + CorrectedMemoryErrorEvent, + UnclassifiedEvent, + MemoryErrorEventLogged, + HistoricalEventResidueUndispositioned, + BmcEventUnclassified, + BmcEventLogContinuityUnestablished, + BmcThresholdClear, + CounterGenerationChanged, + CounterContinuityUnestablished, + CounterDeltaEstablished, + CounterEndpointSetChanged, + CounterEpoch, + CounterRegressed, + CounterSnapshot, + CounterWindowUnestablished, + CorrectedMemoryErrorIncrement, + CumulativeCounterWindow, + BaselineForAnotherHost, + DuplicateObservedTemperatureSensor, + ExpectedTemperatureSensorMissing, + FanDeviation, + HistoricalErrorResidueUndispositioned, + HostHealthBaselineBasis, + HostHealthAssessment, + HostHealthGoal, + HostHealthObserved, + HostHealthUnknown, + RequiredSignalNotExposedOutOfBand, + SignalObserved, + SignalNotExposedOutOfBand, + HostObservationRefusedAt, + KeyedHostObservation, + MemoryErrorCounters, + NoPriorBaseline, + PriorAdmittedBaseline, + TemperatureExpectation, + TemperatureLimitCited, + TemperatureLimitExceeded, + TemperatureLimitUnestablished, + TemperatureReadingNeverPresent, + TemperatureReadingPresent, + TemperatureReadingUnavailable, + TemperatureRosterDeclared, + TemperatureSample, + TemperatureSensorDisabled, + TemperatureWindow, + ThermalLimitUnestablished, + UncorrectedMemoryErrorIncrement, + EmptyRoster, + assess_fleet_health, + assess_host_health, + counter_window_standing, + host_health_goal, + host_health_observed, +} +import std.goal_assessment { + GoalAssessmentRefused, + GoalDiverged, + GoalIndeterminate, + GoalInspected, + GoalObservationRefused, + GoalSatisfied, + ObservationEstablished, +} +import v2.std.algebra { non_empty_to_list } +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +// EVERY INPUT HERE IS SUPPLIED, and that is deliberate (DESIGN section 3, a witness discriminates at +// one interface): these claims are about the ASSESSMENT, so they construct its inputs instead of +// running a collector. The fan windows are the fleet's own dated 20-sample specimens, which is why +// srv2 is the control host and srv1 the one that diverges. The real producer of these shapes is the +// observer route, and its inhabitance is owed by that route's own claim. + +// The fan floor is this witness's policy, chosen in the 4..16 gap the specimens show, and passed as +// a parameter so it cannot become the fleet's definition of healthy. +data rotating_floor: Int = 12 + +fn epoch(host: HostIdentity, boot: NonEmptyStr, endpoints: List) -> CounterEpoch { + CounterEpoch { + host: host, + producer: "redfish-memory-metrics", + generation: boot, + endpoint_set: endpoints, + } +} + +data mc0: List = ["mc0"] + +data no_records: List = [] + +data no_events: List = [] + +data no_temperature_windows: List = [] + +fn snap(host: HostIdentity, boot: NonEmptyStr, ce: Int, ue: Int) -> CounterSnapshot { + CounterSnapshot { + epoch: epoch(host: host, boot: boot, endpoints: mc0), + counters: MemoryErrorCounters { corrected: ce, uncorrected: ue }, + } +} + +fn window(host: HostIdentity, open_ce: Int, close_ce: Int) -> CumulativeCounterWindow { + CumulativeCounterWindow { opening: snap(host: host, boot: "b1", ce: open_ce, ue: 0), closing: snap(host: host, boot: "b1", ce: close_ce, ue: 0) } +} + +fn cited(sensor: NonEmptyStr, critical: Int) -> TemperatureExpectation { + TemperatureExpectation { + sensor: sensor, + limit: TemperatureLimitCited { critical: celsius(critical), authority: "witness-supplied limit" }, + } +} + +fn temps(host: HostIdentity, sensor: NonEmptyStr, samples: List) -> TemperatureWindow { + TemperatureWindow { host: host, sensor: sensor, samples: samples, worst_bmc_standing: BmcThresholdClear } +} + +fn present(c: Int) -> TemperatureSample { + TemperatureReadingPresent { celsius: celsius(c) } +} + +// srv2's goal with every signal satisfiable: its healthy fan specimen, one cited sensor. +fn srv2_goal(baseline: HostHealthBaselineBasis) -> HostHealthGoal { + host_health_goal( + host: operator_host_srv2, + fans: fan_tach_goal(expectation: srv2_fan_tach_expectation, minimum_rotating: rotating_floor), + temperatures: TemperatureRosterDeclared { sensors: [cited(sensor: "TEMP_SOC", critical: 105)] }, + baseline: baseline, + ) +} + +fn no_baseline() -> HostHealthBaselineBasis { + NoPriorBaseline +} + +fn srv2_observed(memory: CumulativeCounterWindow, temperatures: List) -> HostHealthObserved { + host_health_observed( + host: operator_host_srv2, + memory: SignalObserved { observed: memory }, + processor_platform: SignalObserved { observed: no_records }, + fans: SignalObserved { observed: srv2_windows }, + temperatures: SignalObserved { observed: temperatures }, + bmc_events: SignalObserved { observed: BmcEventsObserved { history_count: 0, appended: no_events } }, + ) +} + +data srv2_soc_ok: List = [temps(host: operator_host_srv2, sensor: "TEMP_SOC", samples: [present(c: 55), present(c: 56)])] + +fn unknowns_of(goal: HostHealthGoal, observed: HostHealthObserved) -> List { + match assess_host_health(goal: goal, observed: observed) { + GoalIndeterminate { known_deviations: _, unknowns } => non_empty_to_list(unknowns) + GoalSatisfied { evidence: _ } => [] + GoalDiverged { deviations: _ } => [] + GoalAssessmentRefused { cause: _ } => [] + } +} + +test fn zero_to_zero_in_one_epoch_establishes_a_zero_delta() -> Bool { + match counter_window_standing(left: snap(host: operator_host_srv2, boot: "b1", ce: 0, ue: 0), right: snap(host: operator_host_srv2, boot: "b1", ce: 0, ue: 0)) { + CounterDeltaEstablished { corrected_delta, uncorrected_delta, opening_residue } => + corrected_delta == 0 && uncorrected_delta == 0 && opening_residue.corrected == 0 + CounterWindowUnestablished { cause: _ } => false + } +} + +test fn four_to_five_is_one_new_error_with_four_kept_as_residue() -> Bool { + match counter_window_standing(left: snap(host: operator_host_srv2, boot: "b1", ce: 4, ue: 0), right: snap(host: operator_host_srv2, boot: "b1", ce: 5, ue: 0)) { + CounterDeltaEstablished { corrected_delta, uncorrected_delta, opening_residue } => + corrected_delta == 1 && uncorrected_delta == 0 && opening_residue.corrected == 4 + CounterWindowUnestablished { cause: _ } => false + } +} + +test fn a_regression_a_reboot_or_a_new_endpoint_set_cannot_construct_a_delta() -> Bool { + let regressed = match counter_window_standing(left: snap(host: operator_host_srv2, boot: "b1", ce: 5, ue: 0), right: snap(host: operator_host_srv2, boot: "b1", ce: 0, ue: 0)) { + CounterWindowUnestablished { cause: CounterRegressed } => true + _ => false + } + let rebooted = match counter_window_standing(left: snap(host: operator_host_srv2, boot: "b1", ce: 0, ue: 0), right: snap(host: operator_host_srv2, boot: "b2", ce: 0, ue: 0)) { + CounterWindowUnestablished { cause: CounterGenerationChanged } => true + _ => false + } + let widened = match counter_window_standing( + left: snap(host: operator_host_srv2, boot: "b1", ce: 0, ue: 0), + right: CounterSnapshot { epoch: epoch(host: operator_host_srv2, boot: "b1", endpoints: ["mc0", "mc1"]), counters: MemoryErrorCounters { corrected: 0, uncorrected: 0 } }, + ) { + CounterWindowUnestablished { cause: CounterEndpointSetChanged } => true + _ => false + } + regressed && rebooted && widened +} + +// Without a green for SOME input, every red below could come from an assessment that never agrees. +test fn a_clean_host_with_every_signal_established_is_satisfied() -> Bool { + match assess_host_health(goal: srv2_goal(baseline: no_baseline()), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: srv2_soc_ok)) { + GoalSatisfied { evidence } => evidence.temperature_sensors_judged == 1 && evidence.fan_headers_judged == 5 + GoalDiverged { deviations: _ } => false + GoalIndeterminate { known_deviations: _, unknowns: _ } => false + GoalAssessmentRefused { cause: _ } => false + } +} + +// srv1 read 255 corrected errors with no admitted baseline on 2026-09-18; this is that shape. The +// window is stable and adds nothing, and the host still cannot be green. +test fn a_positive_total_without_a_baseline_is_undispositioned_residue() -> Bool { + let us = unknowns_of(goal: srv2_goal(baseline: no_baseline()), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 255, close_ce: 255), temperatures: srv2_soc_ok)) + count(us) == 1 && any(us, u => match u { HistoricalErrorResidueUndispositioned { residue } => residue.corrected == 255 _ => false }) +} + +// The same residue measured against an admitted baseline that already carried it is green, and one +// more error since that baseline is a divergence -- so the baseline is what separates history from +// news, not the in-run window. +test fn an_admitted_baseline_turns_residue_into_history_and_new_errors_into_divergence() -> Bool { + let baseline = PriorAdmittedBaseline { snapshot: snap(host: operator_host_srv2, boot: "b1", ce: 255, ue: 0) } + let quiet = match assess_host_health(goal: srv2_goal(baseline: baseline), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 255, close_ce: 255), temperatures: srv2_soc_ok)) { + GoalSatisfied { evidence } => evidence.against_baseline + _ => false + } + let one_more = match assess_host_health(goal: srv2_goal(baseline: baseline), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 256, close_ce: 256), temperatures: srv2_soc_ok)) { + GoalDiverged { deviations } => any(non_empty_to_list(deviations), d => match d { CorrectedMemoryErrorIncrement { delta, since_baseline } => delta == 1 && since_baseline _ => false }) + _ => false + } + quiet && one_more +} + +test fn a_baseline_for_another_host_refuses() -> Bool { + let foreign = PriorAdmittedBaseline { snapshot: snap(host: operator_host_srv1, boot: "b1", ce: 0, ue: 0) } + match assess_host_health(goal: srv2_goal(baseline: foreign), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: srv2_soc_ok)) { + GoalAssessmentRefused { cause: BaselineForAnotherHost { goal_host, baseline_host } } => baseline_host == operator_host_srv1 + _ => false + } +} + +test fn a_cleared_or_replaced_event_log_is_not_an_empty_delta() -> Bool { + let observed = host_health_observed( + host: operator_host_srv2, + memory: SignalObserved { observed: window(host: operator_host_srv2, open_ce: 0, close_ce: 0) }, + processor_platform: SignalObserved { observed: no_records }, + fans: SignalObserved { observed: srv2_windows }, + temperatures: SignalObserved { observed: srv2_soc_ok }, + bmc_events: SignalObserved { observed: BmcEventLogContinuityUnestablished { cause: "SEL erase timestamp changed during the window" } }, + ) + match assess_host_health(goal: srv2_goal(baseline: no_baseline()), observed: observed) { + GoalIndeterminate { known_deviations, unknowns } => count(known_deviations) == 0 && count(non_empty_to_list(unknowns)) == 1 + _ => false + } +} + +// srv2's specimen reads zero 1-4 times in 20 on every chassis fan and is satisfied (the positive +// control above); srv1's two degraded endpoints diverge. A lone zero is therefore not a failure, +// and the host layer does not re-judge fans -- it carries fan_tach_health's deviations through. +test fn the_host_layer_carries_the_fan_authoritys_two_srv1_degradations() -> Bool { + let goal = host_health_goal( + host: operator_host_srv1, + fans: fan_tach_goal(expectation: srv1_fan_tach_expectation, minimum_rotating: rotating_floor), + temperatures: TemperatureRosterDeclared { sensors: [] }, + baseline: NoPriorBaseline, + ) + let observed = host_health_observed( + host: operator_host_srv1, + memory: SignalObserved { observed: window(host: operator_host_srv1, open_ce: 0, close_ce: 0) }, + processor_platform: SignalObserved { observed: no_records }, + fans: SignalObserved { observed: srv1_windows }, + temperatures: SignalObserved { observed: no_temperature_windows }, + bmc_events: SignalObserved { observed: BmcEventsObserved { history_count: 0, appended: no_events } }, + ) + match assess_host_health(goal: goal, observed: observed) { + GoalDiverged { deviations } => count(filter(non_empty_to_list(deviations), d => match d { FanDeviation { deviation: _ } => true _ => false })) == 2 + _ => false + } +} + +test fn a_missing_expected_sensor_is_unknown_and_a_duplicate_refuses() -> Bool { + let missing = unknowns_of(goal: srv2_goal(baseline: no_baseline()), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: [])) + let dup = match assess_host_health(goal: srv2_goal(baseline: no_baseline()), observed: srv2_observed( + memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), + temperatures: [temps(host: operator_host_srv2, sensor: "TEMP_SOC", samples: [present(c: 55)]), temps(host: operator_host_srv2, sensor: "TEMP_SOC", samples: [present(c: 56)])], + )) { + GoalAssessmentRefused { cause: DuplicateObservedTemperatureSensor { sensor } } => sensor == "TEMP_SOC" + _ => false + } + any(missing, u => match u { ExpectedTemperatureSensorMissing { sensor } => sensor == "TEMP_SOC" _ => false }) && dup +} + +fn judge_soc(samples: List) -> HostHealthAssessment { + assess_host_health( + goal: srv2_goal(baseline: no_baseline()), + observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: [temps(host: operator_host_srv2, sensor: "TEMP_SOC", samples: samples)]), + ) +} + +test fn zero_celsius_unavailable_disabled_and_absent_remain_distinct() -> Bool { + let zero_is_a_reading = match judge_soc(samples: [present(c: 0)]) { GoalSatisfied { evidence: _ } => true _ => false } + let unavailable = match judge_soc(samples: [TemperatureReadingUnavailable]) { + GoalIndeterminate { known_deviations: _, unknowns } => any(non_empty_to_list(unknowns), u => match u { TemperatureReadingNeverPresent { sensor: _, disabled } => !disabled _ => false }) + _ => false + } + let disabled = match judge_soc(samples: [TemperatureSensorDisabled]) { + GoalIndeterminate { known_deviations: _, unknowns } => any(non_empty_to_list(unknowns), u => match u { TemperatureReadingNeverPresent { sensor: _, disabled } => disabled _ => false }) + _ => false + } + let absent = any(unknowns_of(goal: srv2_goal(baseline: no_baseline()), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: [])), + u => match u { ExpectedTemperatureSensorMissing { sensor: _ } => true _ => false }) + zero_is_a_reading && unavailable && disabled && absent +} + +test fn an_unestablished_limit_is_never_green_and_a_cited_one_diverges_above_it() -> Bool { + let unestablished = host_health_goal( + host: operator_host_srv2, + fans: fan_tach_goal(expectation: srv2_fan_tach_expectation, minimum_rotating: rotating_floor), + temperatures: TemperatureRosterDeclared { sensors: [TemperatureExpectation { sensor: "TEMP_SOC", limit: TemperatureLimitUnestablished }] }, + baseline: NoPriorBaseline, + ) + let cool = srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: srv2_soc_ok) + let never_green = any(unknowns_of(goal: unestablished, observed: cool), u => match u { ThermalLimitUnestablished { sensor } => sensor == "TEMP_SOC" _ => false }) + let hot = srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: [temps(host: operator_host_srv2, sensor: "TEMP_SOC", samples: [present(c: 55), present(c: 106)])]) + let exceeded = match assess_host_health(goal: srv2_goal(baseline: no_baseline()), observed: hot) { + GoalDiverged { deviations } => any(non_empty_to_list(deviations), d => match d { TemperatureLimitExceeded { sensor: _, maximum: _, critical: _ } => true _ => false }) + _ => false + } + never_green && exceeded +} + +test fn one_unreachable_host_prevents_fleet_green_without_hiding_the_others() -> Bool { + let srv1_goal = host_health_goal( + host: operator_host_srv1, + fans: fan_tach_goal(expectation: srv1_fan_tach_expectation, minimum_rotating: rotating_floor), + temperatures: TemperatureRosterDeclared { sensors: [] }, + baseline: NoPriorBaseline, + ) + let receipt = assess_fleet_health( + goals: [srv1_goal, srv2_goal(baseline: no_baseline())], + observations: [KeyedHostObservation { + host: operator_host_srv2, + attempt: ObservationEstablished { observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: srv2_soc_ok) }, + }], + ) + let both_inspected = count(receipt.hosts) == 2 + let verdict = match receipt.assessment { + GoalIndeterminate { known_deviations, unknowns } => + count(known_deviations) == 0 + && any(non_empty_to_list(unknowns), u => match u { HostObservationRefusedAt { host, cause: _ } => host == operator_host_srv1 _ => false }) + _ => false + } + let srv2_still_green = any(receipt.hosts, i => match i { + GoalInspected { subject, goal: _, request: _, observed: _, assessment } => subject == operator_host_srv2 && match assessment { GoalSatisfied { evidence: _ } => true _ => false } + GoalObservationRefused { subject: _, goal: _, request: _, cause: _ } => false + }) + both_inspected && verdict && srv2_still_green +} + +test fn an_empty_roster_refuses_rather_than_reading_as_a_green_fleet() -> Bool { + match assess_fleet_health(goals: [], observations: []).assessment { + GoalAssessmentRefused { cause: EmptyRoster } => true + _ => false + } +} + +// Corrected memory errors the BMC does not expose are a gap, never a pass and never an in-band read: +// the goal does not shrink to what the surface happened to carry. +test fn a_signal_the_bmc_does_not_expose_is_indeterminate_not_satisfied() -> Bool { + let observed = host_health_observed( + host: operator_host_srv2, + memory: SignalNotExposedOutOfBand { observed_surface: "Redfish Memory resources carry no MemoryMetrics" }, + processor_platform: SignalObserved { observed: no_records }, + fans: SignalObserved { observed: srv2_windows }, + temperatures: SignalObserved { observed: srv2_soc_ok }, + bmc_events: SignalObserved { observed: BmcEventsObserved { history_count: 0, appended: no_events } }, + ) + let us = unknowns_of(goal: srv2_goal(baseline: no_baseline()), observed: observed) + count(us) == 1 && any(us, u => match u { RequiredSignalNotExposedOutOfBand { signal: _, observed_surface: _ } => true _ => false }) +} + +// srv3's log on 2026-09-18 had 1200 entries of history and was appending corrected ECC records and +// fan-threshold records. History is residue, an appended ECC record is a deviation, and an appended +// record nothing classifies is an unknown -- three different answers from one window. +test fn event_history_is_residue_appended_ecc_diverges_and_the_rest_is_unknown() -> Bool { + let observed = host_health_observed( + host: operator_host_srv2, + memory: SignalObserved { observed: window(host: operator_host_srv2, open_ce: 0, close_ce: 0) }, + processor_platform: SignalObserved { observed: no_records }, + fans: SignalObserved { observed: srv2_windows }, + temperatures: SignalObserved { observed: srv2_soc_ok }, + bmc_events: SignalObserved { observed: BmcEventsObserved { history_count: 1200, appended: [ + BmcEventRecord { id: 450346, kind: CorrectedMemoryErrorEvent, message: "OpenBMC.0.1.MemoryECCCorrectable.Critical 0,2,0,9" }, + BmcEventRecord { id: 450348, kind: UnclassifiedEvent, message: "FAN4 critical low threshold deassert." }, + ] } }, + ) + match assess_host_health(goal: srv2_goal(baseline: no_baseline()), observed: observed) { + GoalIndeterminate { known_deviations, unknowns } => + any(known_deviations, d => match d { MemoryErrorEventLogged { corrected, message: _ } => corrected _ => false }) + && any(non_empty_to_list(unknowns), u => match u { HistoricalEventResidueUndispositioned { entries } => entries == 1200 _ => false }) + && any(non_empty_to_list(unknowns), u => match u { BmcEventUnclassified { entry: _ } => true _ => false }) + _ => false + } +} From 3d1d9332a539225b0caa77ca4db7dbb65e9c63e0 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 19 Sep 2026 04:43:14 +0000 Subject: [PATCH 12/15] HEALTH-0 entry: gunbc.fleet_health fleet_health_check over the whole roster; board secondary tach headers declared - gunbc.fleet_health: goals derived from fleet_intent_known_hosts (fan expectation rows, the board's 20-sensor temperature roster with no cited limit, NoPriorBaseline); reader credential from custody; one keyed observation per rostered host; assess_fleet_health; receipt rendered as the exit reason and written to GUNBC_FLEET_HEALTH_RECEIPT when set. - gunbc.fleet_fan_tach_expectation: FAN1_1..FAN5_1, which both images publish and no host wires, declared tach-not-expected on all four hosts; the two fan witnesses that count judged headers updated with the reason beside them. Co-Authored-By: Claude Opus 5 (1M context) --- .../fleet/fleet_fan_tach_expectation.dag | 30 ++- dag/gunbc/fleet/fleet_health.dag | 231 ++++++++++++++++++ .../fan_tach_assessment_witness_test.dag | 12 +- .../fleet/fleet_health_goal_witness_test.dag | 24 ++ .../host_health_assessment_witness_test.dag | 2 +- 5 files changed, 286 insertions(+), 13 deletions(-) create mode 100644 dag/gunbc/fleet/fleet_health.dag create mode 100644 dag/test/claim/fleet/fleet_health_goal_witness_test.dag diff --git a/dag/gunbc/fleet/fleet_fan_tach_expectation.dag b/dag/gunbc/fleet/fleet_fan_tach_expectation.dag index 414f8ade99d..75623971c8a 100644 --- a/dag/gunbc/fleet/fleet_fan_tach_expectation.dag +++ b/dag/gunbc/fleet/fleet_fan_tach_expectation.dag @@ -198,6 +198,20 @@ fn expected_tach_count(expectation: HostFanTachExpectation) -> Int { // srv1/FAN4 and srv1/FAN5 are declared expected and read as absent, which is the finding rather // than a reason to drop them. +// THE ALTRAD8UD-1L2T'S SECONDARY TACH HEADERS. Both BMC images the fleet runs publish FAN1_1..FAN5_1 +// beside FAN1..FAN5 (read with the gunbc_health account 2026-09-18: ASRock 2.07 as Sensors +// fantach_FANn_1, OpenBMC 3.22 in Thermal.Fans), and on every host they read null. No fleet host +// wires a fan to them, so each is declared as not expected to rotate: a live collector reports every +// header the board publishes, and a header without an expectation row is an authority gap. Plugging +// a fan into one would surface as UnexpectedRotation rather than pass unseen. +data altrad8ud_secondary_tach_headers: List = [ + tach_not_expected(header: "FAN1_1"), + tach_not_expected(header: "FAN2_1"), + tach_not_expected(header: "FAN3_1"), + tach_not_expected(header: "FAN4_1"), + tach_not_expected(header: "FAN5_1"), +] + // srv1/srv2 - 4U, Noctua NH-D9 AMP-4926 4U on FAN1, four chassis bays on FAN2..FAN5. // FAN1 is expected to expose the processor-cooler tach. The number of physical fans driven by // that header, and which of them supplies the tach conductor, are UNOBSERVED in this layer - @@ -206,24 +220,24 @@ fn expected_tach_count(expectation: HostFanTachExpectation) -> Int { // of how these two machines were built. data srv1_fan_tach_expectation: HostFanTachExpectation = HostFanTachExpectation { host: operator_host_srv1, - headers: [ + headers: concat([ cooler_tach_expected(header: "FAN1"), chassis_tach_expected(header: "FAN2"), chassis_tach_expected(header: "FAN3"), chassis_tach_expected(header: "FAN4"), chassis_tach_expected(header: "FAN5"), - ], + ], altrad8ud_secondary_tach_headers), } data srv2_fan_tach_expectation: HostFanTachExpectation = HostFanTachExpectation { host: operator_host_srv2, - headers: [ + headers: concat([ cooler_tach_expected(header: "FAN1"), chassis_tach_expected(header: "FAN2"), chassis_tach_expected(header: "FAN3"), chassis_tach_expected(header: "FAN4"), chassis_tach_expected(header: "FAN5"), - ], + ], altrad8ud_secondary_tach_headers), } // srv3/srv4 - 2U, Dynatron W1 on FAN1, three chassis bays. THE TWO HOSTS USE DIFFERENT INDICES @@ -231,22 +245,22 @@ data srv2_fan_tach_expectation: HostFanTachExpectation = HostFanTachExpectation // check comparing counts passes while the configuration is wrong. data srv3_fan_tach_expectation: HostFanTachExpectation = HostFanTachExpectation { host: operator_host_srv3, - headers: [ + headers: concat([ cooler_tach_expected(header: "FAN1"), tach_not_expected(header: "FAN2"), chassis_tach_expected(header: "FAN3"), chassis_tach_expected(header: "FAN4"), chassis_tach_expected(header: "FAN5"), - ], + ], altrad8ud_secondary_tach_headers), } data srv4_fan_tach_expectation: HostFanTachExpectation = HostFanTachExpectation { host: operator_host_srv4, - headers: [ + headers: concat([ cooler_tach_expected(header: "FAN1"), chassis_tach_expected(header: "FAN2"), chassis_tach_expected(header: "FAN3"), chassis_tach_expected(header: "FAN4"), tach_not_expected(header: "FAN5"), - ], + ], altrad8ud_secondary_tach_headers), } diff --git a/dag/gunbc/fleet/fleet_health.dag b/dag/gunbc/fleet/fleet_health.dag new file mode 100644 index 00000000000..a1094cbcd42 --- /dev/null +++ b/dag/gunbc/fleet/fleet_health.dag @@ -0,0 +1,231 @@ +module gunbc.fleet_health + +import std.types { Bool, Int, List, NonEmptyStr, Secret, String } +import std.algebra { trim } +import extdeps.shell +import extdeps.filesystem.filesystem_io { Filesystem } +import std.nat { Nat } +import std.measure { celsius_count } +import std.resources { Network } +import std.process { ProcessExit, ExitSuccess, exit_failure } +import std.goal_assessment { + GoalAssessmentRefused, + GoalDiverged, + GoalIndeterminate, + GoalInspected, + GoalObservationRefused, + GoalSatisfied, + ObservationAttempt, + ObservationEstablished, + ObservationRefused, +} +import v2.std.algebra { non_empty_to_list } +import product.placement_supply { HostIdentity } +import gunbc.fleet_intent { ComputeHost, fleet_intent_known_hosts } +import gunbc.fleet_fan_tach_expectation { + HostFanTachExpectationAbsent, + HostFanTachExpectationDuplicate, + HostFanTachExpectationEstablished, + HostFanTachExpectationHeaderDuplicated, + host_fan_tach_expectation_admission, +} +import product.fan_tach_health { FanTachDeviation, fan_tach_goal } +import product.host_health { + FleetHealthDeviation, + FleetHealthReceipt, + FleetHealthUnknown, + HostAssessmentRefusedAt, + HostHealthGoal, + HostObservationRefusedAt, + HostUnknownAt, + KeyedHostObservation, + NoPriorBaseline, + TemperatureExpectation, + TemperatureLimitUnestablished, + TemperatureRosterDeclared, + assess_fleet_health, + host_health_goal, +} +import gunbc.fleet_health_observe { BmcTelemetryProfile, observe_host_health } +import gunbc.bmc_custody_read { + CustodyCredentialAbsent, + CustodyCredentialHeld, + CustodyCredentialUnreadable, + bmc_custody_credential, +} +import gunbc.bmc_health_reader { bmc_health_reader_secret_id, bmc_health_reader_username } +import gunbc.auth.access_token_source { + AccessTokenEnsureRefused, + AccessTokenReady, + AccessTokenSourceSelected, + AccessTokenSourceUnavailable, + AccessTokenUpsertRequired, + access_token_upsert_required_reason, + ensure_access_token, + select_access_token_source, +} +import extdeps.boards.asrock_rack { + asrock_altrad8ud_1l2t_bmc_redfish_surface_catalog, + asrock_altrad8ud_1l2t_catalog, + asrock_altrad8ud_redfish_chassis_id, +} + +// HEALTH-0: ONE READ-ONLY, OUT-OF-BAND HEALTH CHECK OVER THE WHOLE FLEET. +// +// A separate protocol beside convergence, not an arm of it (side-chat ruling 2026-09-18): it has no +// plan, no apply, no lease and no mutation, and nothing in its closure can express one. The roster is +// fleet_intent_known_hosts, so a newly enrolled host becomes a health obligation without an edit +// here, and a host the collector cannot read stays in the denominator as a located refusal. +// +// The goal is derived per host from what the fleet already declares: the fan expectation rows, and +// the board's temperature sensor roster. No temperature limit is cited yet, so every sensor's limit +// is unestablished -- which can never produce a green -- while the BMC's own threshold verdict is +// still carried as evidence. The baseline is NoPriorBaseline: selecting an admitted baseline is +// HEALTH-1, and the input seam for it is already HostHealthGoal.baseline. + +// THE SAMPLING POLICY, declared once. Five samples per window, and a fan is healthy when at least +// three of them read rotation: the dated 20-sample specimens (gunbc.fleet_fan_tach_observation) show +// healthy fans reading zero 1-4 times in 20 and srv1's degraded ones 11 and 16, so a 60% floor +// separates them, and this is that floor at five samples. +data fleet_health_samples: Nat = 5 +data fleet_health_minimum_rotating: Int = 3 + +// THE ALTRAD8UD-1L2T TEMPERATURE ROSTER. Both BMC images the fleet runs publish exactly these twenty +// temperature sensors (read with the gunbc_health account 2026-09-18: ASRock 2.07 as Sensors +// temperature_*, OpenBMC 3.22 in Thermal.Temperatures, identical name sets), so it is a board fact. +// No limit is cited for any of them yet. +data altrad8ud_temperature_sensors: List = [ + "TEMP_CARD_SIDE", "TEMP_Core_VRD", "TEMP_DIMM_A1", "TEMP_DIMM_B1", "TEMP_DIMM_C1", "TEMP_DIMM_D1", + "TEMP_DIMM_E1", "TEMP_DIMM_F1", "TEMP_DIMM_G1", "TEMP_DIMM_H1", "TEMP_DIMM_VRD", "TEMP_M2_1", + "TEMP_M2_2", "TEMP_MB", "TEMP_RCA_VR", "TEMP_SOC", "TEMP_SOC_VRD", "TEMP_TR1", "TEMP_VR", "TEMP_X550", +] + +fn host_telemetry_profile(host: ComputeHost) -> ObservationAttempt { + match host.baseboard { + Absent => ObservationRefused { cause: concat("host ", host.identity as String, " declares no baseboard, so no BMC to read") as NonEmptyStr } + Present { value: board } => + if board.catalog != asrock_altrad8ud_1l2t_catalog { + ObservationRefused { cause: concat("host ", host.identity as String, "'s board has no Redfish telemetry profile") as NonEmptyStr } + } else { + ObservationEstablished { observed: BmcTelemetryProfile { + host: host.identity, + bmc_host: board.bmc.host, + chassis_id: asrock_altrad8ud_redfish_chassis_id, + surface_catalog: asrock_altrad8ud_1l2t_bmc_redfish_surface_catalog, + } } + } + } +} + +fn host_goal(host: ComputeHost) -> ObservationAttempt { + match host_fan_tach_expectation_admission(host: host.identity) { + HostFanTachExpectationEstablished { expectation } => ObservationEstablished { observed: host_health_goal( + host: host.identity, + fans: fan_tach_goal(expectation: expectation, minimum_rotating: fleet_health_minimum_rotating), + temperatures: TemperatureRosterDeclared { sensors: map(altrad8ud_temperature_sensors, s => TemperatureExpectation { sensor: s, limit: TemperatureLimitUnestablished }) }, + baseline: NoPriorBaseline, + ) } + HostFanTachExpectationAbsent => ObservationRefused { cause: concat("host ", host.identity as String, " has no fan expectation row") as NonEmptyStr } + HostFanTachExpectationDuplicate { count: _ } => ObservationRefused { cause: concat("host ", host.identity as String, " has duplicate fan expectation rows") as NonEmptyStr } + HostFanTachExpectationHeaderDuplicated { header: _ } => ObservationRefused { cause: concat("host ", host.identity as String, " declares a fan header twice") as NonEmptyStr } + } +} + +// EVERY ROSTERED HOST NEEDS A GOAL. A host whose goal cannot be derived refuses the fleet check, +// because assessing the rest would shrink the denominator by exactly the host nobody could describe. +fn fleet_health_goals() -> ObservationAttempt, NonEmptyStr> { + let empty: List = [] + let initial: ObservationAttempt, NonEmptyStr> = ObservationEstablished { observed: empty } + let reversed = fold(fleet_intent_known_hosts, init: initial, f: (acc, h) => match acc { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: goals } => match host_goal(host: h) { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: g } => ObservationEstablished { observed: concat([g], goals) } + } + }) + match reversed { + ObservationRefused { cause } => ObservationRefused { cause: cause } + ObservationEstablished { observed: goals } => ObservationEstablished { observed: reverse(goals) } + } +} + +fn observe_rostered_host(host: ComputeHost, token: Secret) -> KeyedHostObservation + uses net: Network +{ + match host_telemetry_profile(host: host) { + ObservationRefused { cause } => return KeyedHostObservation { host: host.identity, attempt: ObservationRefused { cause: cause } } + ObservationEstablished { observed: telemetry } => + match bmc_custody_credential(secret_id: bmc_health_reader_secret_id(host: host.identity), token: token) { + CustodyCredentialAbsent => return KeyedHostObservation { host: host.identity, attempt: ObservationRefused { cause: "the health-reader credential is not in custody; converge it with gunbc.tools.bmc_health_reader_converge" } } + CustodyCredentialUnreadable { cause: c } => return KeyedHostObservation { host: host.identity, attempt: ObservationRefused { cause: concat("the health-reader credential could not be read from custody: ", c) as NonEmptyStr } } + CustodyCredentialHeld { password: p } => return KeyedHostObservation { + host: host.identity, + attempt: observe_host_health(telemetry: telemetry, reader_login: bmc_health_reader_username, reader_password: p as Secret, samples: fleet_health_samples), + } + } + } +} + +fn render_receipt(receipt: FleetHealthReceipt) -> String { + let lines = flat_map(receipt.hosts, i => match i { + GoalObservationRefused { subject, goal: _, request: _, cause } => [concat(subject as String, ": NOT OBSERVED -- ", cause as String)] + GoalInspected { subject, goal: _, request: _, observed: _, assessment } => match assessment { + GoalSatisfied { evidence: _ } => [concat(subject as String, ": HEALTHY")] + GoalDiverged { deviations } => concat([concat(subject as String, ": DIVERGED")], map(non_empty_to_list(deviations), d => concat(" deviation: ", to_string(d)))) + GoalIndeterminate { known_deviations, unknowns } => concat( + [concat(subject as String, ": INDETERMINATE (", to_string(count(known_deviations)), " deviation(s), ", to_string(count(non_empty_to_list(unknowns))), " unknown(s))")], + concat(map(known_deviations, d => concat(" deviation: ", to_string(d))), map(non_empty_to_list(unknowns), u => concat(" unknown: ", to_string(u)))), + ) + GoalAssessmentRefused { cause } => [concat(subject as String, ": ASSESSMENT REFUSED -- ", to_string(cause))] + } + }) + let verdict = match receipt.assessment { + GoalSatisfied { evidence: _ } => "fleet: HEALTHY" + GoalDiverged { deviations: _ } => "fleet: DIVERGED" + GoalIndeterminate { known_deviations: _, unknowns: _ } => "fleet: INDETERMINATE" + GoalAssessmentRefused { cause } => concat("fleet: REFUSED -- ", to_string(cause)) + } + join(concat(lines, [verdict]), "\n") +} + +// THE RECEIPT IS AN ARTIFACT, NOT ONLY A MESSAGE. A nonzero exit carries the rendered receipt as its +// reason; the same text is written to the path named by this variable when it is set, so a green run +// leaves a durable record too. Unset means no file, which is the operator's choice, not a failure. +data fleet_health_receipt_env: NonEmptyStr = "GUNBC_FLEET_HEALTH_RECEIPT" + +fn write_receipt(rendered: String) -> Bool { + match shell.Env.Get(name: fleet_health_receipt_env).value { + Absent => true + Present { value: path } => if trim(s: path) == "" { true } else { Filesystem.Write(path: trim(s: path), content: concat(rendered, "\n")).success } + } +} + +// THE ENTRY. Exit 0 only when the whole fleet is satisfied; every other answer is nonzero and the +// receipt says which hosts and why. Read only end to end. +fn fleet_health_check() -> ProcessExit + uses net: Network +{ + match fleet_health_goals() { + ObservationRefused { cause } => return exit_failure(reason: concat("fleet health: no goal: ", cause as String)) + ObservationEstablished { observed: goals } => match select_access_token_source() { + AccessTokenSourceUnavailable { cause: c } => return exit_failure(reason: c as String) + AccessTokenSourceSelected { source: s } => match ensure_access_token(source: s) { + AccessTokenUpsertRequired { plan: p } => return exit_failure(reason: access_token_upsert_required_reason(plan: p)) + AccessTokenEnsureRefused { reason: r } => return exit_failure(reason: r as String) + AccessTokenReady { token: t } => { + let observations = map(fleet_intent_known_hosts, h => observe_rostered_host(host: h, token: t)) + let receipt = assess_fleet_health(goals: goals, observations: observations) + let rendered = render_receipt(receipt: receipt) + let written = write_receipt(rendered: rendered) + if written == false { + return exit_failure(reason: concat("fleet health: the receipt could not be written to ", fleet_health_receipt_env as String, "\n", rendered)) + } + return match receipt.assessment { + GoalSatisfied { evidence: _ } => ExitSuccess + _ => exit_failure(reason: rendered) + } + } + } + } + } +} diff --git a/dag/test/claim/fan_tach_assessment_witness_test.dag b/dag/test/claim/fan_tach_assessment_witness_test.dag index 7108de42f33..9ab06025d44 100644 --- a/dag/test/claim/fan_tach_assessment_witness_test.dag +++ b/dag/test/claim/fan_tach_assessment_witness_test.dag @@ -108,13 +108,15 @@ test fn srv1_diverges_on_its_two_degraded_endpoints() -> Bool { // THE POSITIVE CONTROL. Without it the divergence above could be produced by an assessment that // never agrees for any input, which would make the wall decoration rather than a check. srv2 is -// the same fleet, the same floor, and healthy. +// the same fleet, the same floor, and healthy. Ten headers are judged: FAN1..FAN5 rotating, and the +// board's five secondary tach headers FANn_1 correctly absent (gunbc.fleet_fan_tach_expectation +// altrad8ud_secondary_tach_headers). test fn srv2_the_control_host_is_satisfied() -> Bool { match inspect_fan_tach(subject: subject_of(expectation: srv2_fan_tach_expectation, windows: srv2_windows)) { GoalObservationRefused { subject: s, goal: g, request: rq, cause: c } => false GoalInspected { subject: s, goal: g, request: rq, observed: o, assessment: a } => match a { - GoalSatisfied { evidence: e } => e.headers_judged == 5 && e.healthy.length() == 5 + GoalSatisfied { evidence: e } => e.headers_judged == 10 && e.healthy.length() == 10 GoalDiverged { deviations: ds } => false GoalIndeterminate { known_deviations: kd, unknowns: u } => false GoalAssessmentRefused { cause: c } => false @@ -369,9 +371,11 @@ test fn an_expected_header_publishing_nothing_is_also_an_anomaly() -> Bool { // UNAVAILABLE AND NEVER-ROTATED ARE DIFFERENT ABSENCES. srv3/FAN2 publishes nothing because // nothing is wired to it; that is not the same as a rotor that was read and never turned. +// Six correctly absent: srv3's unwired FAN2 plus the board's five secondary tach headers FANn_1 +// (gunbc.fleet_fan_tach_expectation altrad8ud_secondary_tach_headers), none of which has a window. test fn an_unwired_endpoint_is_nominal_not_a_missing_rotation() -> Bool { let js = judgements_of(expectation: srv3_fan_tach_expectation, windows: srv3_windows) - rotation_correctly_absent_count(healthy: healthy_of(judgements: js)) == 1 + rotation_correctly_absent_count(healthy: healthy_of(judgements: js)) == 6 && rotation_anomaly_count(deviations: deviations_of(judgements: js)) == 0 && rotation_observed_count(healthy: healthy_of(judgements: js)) == 4 } @@ -384,7 +388,7 @@ test fn a_not_expected_header_that_spins_is_drift() -> Bool { windows: with_window(windows: srv3_windows, header: "FAN2", replacement: srv3_fan2_spinning), ) expectation_drift_count(deviations: deviations_of(judgements: js)) == 1 - && rotation_correctly_absent_count(healthy: healthy_of(judgements: js)) == 0 + && rotation_correctly_absent_count(healthy: healthy_of(judgements: js)) == 5 } // AND DRIFT MUST BE ZERO ON THE UNMODIFIED FLEET, or the control above proves only that the counter diff --git a/dag/test/claim/fleet/fleet_health_goal_witness_test.dag b/dag/test/claim/fleet/fleet_health_goal_witness_test.dag new file mode 100644 index 00000000000..c264bb5ae8d --- /dev/null +++ b/dag/test/claim/fleet/fleet_health_goal_witness_test.dag @@ -0,0 +1,24 @@ +module test.claim.fleet_health_goal_witness + +import std.types { Bool, List } +import std.goal_assessment { ObservationEstablished, ObservationRefused } +import gunbc.fleet_intent { fleet_intent_known_hosts } +import gunbc.fleet_health { fleet_health_goals, host_telemetry_profile } +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +// The goal is derived from the roster, not restated: one goal per known host, in roster order, and +// every rostered host resolves a telemetry profile on its declared BMC. Resolving this module also +// typechecks the read-only entry fleet_health_check beside it. +test fn every_rostered_host_gets_a_goal_and_a_telemetry_profile() -> Bool { + let goals = match fleet_health_goals() { + ObservationEstablished { observed: gs } => map(gs, g => g.host) == map(fleet_intent_known_hosts, h => h.identity) + ObservationRefused { cause: _ } => false + } + let profiles = all(fleet_intent_known_hosts, h => match host_telemetry_profile(host: h) { + ObservationEstablished { observed: p } => p.host == h.identity + ObservationRefused { cause: _ } => false + }) + goals && profiles +} diff --git a/dag/test/claim/host_health_assessment_witness_test.dag b/dag/test/claim/host_health_assessment_witness_test.dag index 52bae604920..9b6ada3873f 100644 --- a/dag/test/claim/host_health_assessment_witness_test.dag +++ b/dag/test/claim/host_health_assessment_witness_test.dag @@ -207,7 +207,7 @@ test fn a_regression_a_reboot_or_a_new_endpoint_set_cannot_construct_a_delta() - // Without a green for SOME input, every red below could come from an assessment that never agrees. test fn a_clean_host_with_every_signal_established_is_satisfied() -> Bool { match assess_host_health(goal: srv2_goal(baseline: no_baseline()), observed: srv2_observed(memory: window(host: operator_host_srv2, open_ce: 0, close_ce: 0), temperatures: srv2_soc_ok)) { - GoalSatisfied { evidence } => evidence.temperature_sensors_judged == 1 && evidence.fan_headers_judged == 5 + GoalSatisfied { evidence } => evidence.temperature_sensors_judged == 1 && evidence.fan_headers_judged == 10 GoalDiverged { deviations: _ } => false GoalIndeterminate { known_deviations: _, unknowns: _ } => false GoalAssessmentRefused { cause: _ } => false From 5ab160f6dce35622b82fee6b79be5bd0455b48fe Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 19 Sep 2026 06:39:11 +0000 Subject: [PATCH 13/15] Admit the 13 bindings that move to gunbc.bmc_custody_read (namespace wave admission) The custody read moved out of gunbc.tools.bmc_onboard into its own read-only module; each consumer's binding of CustodyCredential{Held,Absent,Unreadable}, bmc_custody_credential and custody_read_refusal_cause now resolves to gunbc.bmc_custody_read. One TargetChanged row per binding, owner gunbc#11681; the rows are consumed on landing and owe deletion then. Co-Authored-By: Claude Opus 5 (1M context) --- ...nverge_as_admin_bmc_custody_credential.dag | 19 +++++++++++++++++++ ...verge_as_admin_custodycredentialabsent.dag | 19 +++++++++++++++++++ ...onverge_as_admin_custodycredentialheld.dag | 19 +++++++++++++++++++ ...e_as_admin_custodycredentialunreadable.dag | 19 +++++++++++++++++++ ...reader_converge_bmc_custody_credential.dag | 19 +++++++++++++++++++ ...eader_converge_custodycredentialabsent.dag | 19 +++++++++++++++++++ ..._reader_converge_custodycredentialheld.dag | 19 +++++++++++++++++++ ...r_converge_custodycredentialunreadable.dag | 19 +++++++++++++++++++ ...redential_phase_bmc_custody_credential.dag | 19 +++++++++++++++++++ ...edential_phase_custodycredentialabsent.dag | 19 +++++++++++++++++++ ...credential_phase_custodycredentialheld.dag | 19 +++++++++++++++++++ ...tial_phase_custodycredentialunreadable.dag | 19 +++++++++++++++++++ ...ent_custody_custody_read_refusal_cause.dag | 19 +++++++++++++++++++ 13 files changed, 247 insertions(+) create mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_bmc_custody_credential.dag create mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialabsent.dag create mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialheld.dag create mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialunreadable.dag create mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_bmc_custody_credential.dag create mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialabsent.dag create mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialheld.dag create mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialunreadable.dag create mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_bmc_custody_credential.dag create mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialabsent.dag create mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialheld.dag create mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialunreadable.dag create mode 100644 dag/gunbc/namespace/transition_admission/test_claim_bmc_health_reader_witness_only_a_404_reads_as_absent_custody_custody_read_refusal_cause.dag diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_bmc_custody_credential.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_bmc_custody_credential.dag new file mode 100644 index 00000000000..76be5233f99 --- /dev/null +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_bmc_custody_credential.dag @@ -0,0 +1,19 @@ +module gunbc.namespace.transition_admission.gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_bmc_custody_credential + +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 gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_bmc_custody_credential: TransitionAdmission = TransitionAdmission { + label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, + subject: Binding { + enclosing: decl_ref("gunbc.tools.bmc_health_reader_converge", "bmc_health_reader_converge_as_admin"), + spelling: "bmc_custody_credential" as NonEmptyStr, + expected_candidates: [decl_ref("gunbc.bmc_custody_read", "bmc_custody_credential")], + }, + disposition: TargetChanged, + deletion_follow_up: NotAuthored, + owner_pull_request: 11681 as UInt32, +} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialabsent.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialabsent.dag new file mode 100644 index 00000000000..154ed9414d2 --- /dev/null +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialabsent.dag @@ -0,0 +1,19 @@ +module gunbc.namespace.transition_admission.gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialabsent + +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 gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialabsent: TransitionAdmission = TransitionAdmission { + label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, + subject: Binding { + enclosing: decl_ref("gunbc.tools.bmc_health_reader_converge", "bmc_health_reader_converge_as_admin"), + spelling: "CustodyCredentialAbsent" as NonEmptyStr, + expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialAbsent")], + }, + disposition: TargetChanged, + deletion_follow_up: NotAuthored, + owner_pull_request: 11681 as UInt32, +} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialheld.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialheld.dag new file mode 100644 index 00000000000..eaab74b026b --- /dev/null +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialheld.dag @@ -0,0 +1,19 @@ +module gunbc.namespace.transition_admission.gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialheld + +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 gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialheld: TransitionAdmission = TransitionAdmission { + label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, + subject: Binding { + enclosing: decl_ref("gunbc.tools.bmc_health_reader_converge", "bmc_health_reader_converge_as_admin"), + spelling: "CustodyCredentialHeld" as NonEmptyStr, + expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialHeld")], + }, + disposition: TargetChanged, + deletion_follow_up: NotAuthored, + owner_pull_request: 11681 as UInt32, +} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialunreadable.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialunreadable.dag new file mode 100644 index 00000000000..22efb35df34 --- /dev/null +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialunreadable.dag @@ -0,0 +1,19 @@ +module gunbc.namespace.transition_admission.gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialunreadable + +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 gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialunreadable: TransitionAdmission = TransitionAdmission { + label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, + subject: Binding { + enclosing: decl_ref("gunbc.tools.bmc_health_reader_converge", "bmc_health_reader_converge_as_admin"), + spelling: "CustodyCredentialUnreadable" as NonEmptyStr, + expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialUnreadable")], + }, + disposition: TargetChanged, + deletion_follow_up: NotAuthored, + owner_pull_request: 11681 as UInt32, +} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_bmc_custody_credential.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_bmc_custody_credential.dag new file mode 100644 index 00000000000..63775b39b44 --- /dev/null +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_bmc_custody_credential.dag @@ -0,0 +1,19 @@ +module gunbc.namespace.transition_admission.gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_bmc_custody_credential + +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 gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_bmc_custody_credential: TransitionAdmission = TransitionAdmission { + label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, + subject: Binding { + enclosing: decl_ref("gunbc.tools.bmc_health_reader_converge", "bmc_health_reader_converge"), + spelling: "bmc_custody_credential" as NonEmptyStr, + expected_candidates: [decl_ref("gunbc.bmc_custody_read", "bmc_custody_credential")], + }, + disposition: TargetChanged, + deletion_follow_up: NotAuthored, + owner_pull_request: 11681 as UInt32, +} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialabsent.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialabsent.dag new file mode 100644 index 00000000000..e8a089078e5 --- /dev/null +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialabsent.dag @@ -0,0 +1,19 @@ +module gunbc.namespace.transition_admission.gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialabsent + +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 gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialabsent: TransitionAdmission = TransitionAdmission { + label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, + subject: Binding { + enclosing: decl_ref("gunbc.tools.bmc_health_reader_converge", "bmc_health_reader_converge"), + spelling: "CustodyCredentialAbsent" as NonEmptyStr, + expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialAbsent")], + }, + disposition: TargetChanged, + deletion_follow_up: NotAuthored, + owner_pull_request: 11681 as UInt32, +} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialheld.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialheld.dag new file mode 100644 index 00000000000..c3e3eb488f8 --- /dev/null +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialheld.dag @@ -0,0 +1,19 @@ +module gunbc.namespace.transition_admission.gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialheld + +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 gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialheld: TransitionAdmission = TransitionAdmission { + label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, + subject: Binding { + enclosing: decl_ref("gunbc.tools.bmc_health_reader_converge", "bmc_health_reader_converge"), + spelling: "CustodyCredentialHeld" as NonEmptyStr, + expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialHeld")], + }, + disposition: TargetChanged, + deletion_follow_up: NotAuthored, + owner_pull_request: 11681 as UInt32, +} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialunreadable.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialunreadable.dag new file mode 100644 index 00000000000..2c2dcd26070 --- /dev/null +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialunreadable.dag @@ -0,0 +1,19 @@ +module gunbc.namespace.transition_admission.gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialunreadable + +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 gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialunreadable: TransitionAdmission = TransitionAdmission { + label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, + subject: Binding { + enclosing: decl_ref("gunbc.tools.bmc_health_reader_converge", "bmc_health_reader_converge"), + spelling: "CustodyCredentialUnreadable" as NonEmptyStr, + expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialUnreadable")], + }, + disposition: TargetChanged, + deletion_follow_up: NotAuthored, + owner_pull_request: 11681 as UInt32, +} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_bmc_custody_credential.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_bmc_custody_credential.dag new file mode 100644 index 00000000000..166d2cbf39e --- /dev/null +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_bmc_custody_credential.dag @@ -0,0 +1,19 @@ +module gunbc.namespace.transition_admission.gunbc_tools_bmc_onboard_bmc_probe_credential_phase_bmc_custody_credential + +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 gunbc_tools_bmc_onboard_bmc_probe_credential_phase_bmc_custody_credential: TransitionAdmission = TransitionAdmission { + label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, + subject: Binding { + enclosing: decl_ref("gunbc.tools.bmc_onboard", "bmc_probe_credential_phase"), + spelling: "bmc_custody_credential" as NonEmptyStr, + expected_candidates: [decl_ref("gunbc.bmc_custody_read", "bmc_custody_credential")], + }, + disposition: TargetChanged, + deletion_follow_up: NotAuthored, + owner_pull_request: 11681 as UInt32, +} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialabsent.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialabsent.dag new file mode 100644 index 00000000000..70078c6c73f --- /dev/null +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialabsent.dag @@ -0,0 +1,19 @@ +module gunbc.namespace.transition_admission.gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialabsent + +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 gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialabsent: TransitionAdmission = TransitionAdmission { + label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, + subject: Binding { + enclosing: decl_ref("gunbc.tools.bmc_onboard", "bmc_probe_credential_phase"), + spelling: "CustodyCredentialAbsent" as NonEmptyStr, + expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialAbsent")], + }, + disposition: TargetChanged, + deletion_follow_up: NotAuthored, + owner_pull_request: 11681 as UInt32, +} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialheld.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialheld.dag new file mode 100644 index 00000000000..6e6ed50d3a1 --- /dev/null +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialheld.dag @@ -0,0 +1,19 @@ +module gunbc.namespace.transition_admission.gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialheld + +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 gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialheld: TransitionAdmission = TransitionAdmission { + label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, + subject: Binding { + enclosing: decl_ref("gunbc.tools.bmc_onboard", "bmc_probe_credential_phase"), + spelling: "CustodyCredentialHeld" as NonEmptyStr, + expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialHeld")], + }, + disposition: TargetChanged, + deletion_follow_up: NotAuthored, + owner_pull_request: 11681 as UInt32, +} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialunreadable.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialunreadable.dag new file mode 100644 index 00000000000..20f1fa3d0b9 --- /dev/null +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialunreadable.dag @@ -0,0 +1,19 @@ +module gunbc.namespace.transition_admission.gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialunreadable + +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 gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialunreadable: TransitionAdmission = TransitionAdmission { + label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, + subject: Binding { + enclosing: decl_ref("gunbc.tools.bmc_onboard", "bmc_probe_credential_phase"), + spelling: "CustodyCredentialUnreadable" as NonEmptyStr, + expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialUnreadable")], + }, + disposition: TargetChanged, + deletion_follow_up: NotAuthored, + owner_pull_request: 11681 as UInt32, +} diff --git a/dag/gunbc/namespace/transition_admission/test_claim_bmc_health_reader_witness_only_a_404_reads_as_absent_custody_custody_read_refusal_cause.dag b/dag/gunbc/namespace/transition_admission/test_claim_bmc_health_reader_witness_only_a_404_reads_as_absent_custody_custody_read_refusal_cause.dag new file mode 100644 index 00000000000..3e45a2016b5 --- /dev/null +++ b/dag/gunbc/namespace/transition_admission/test_claim_bmc_health_reader_witness_only_a_404_reads_as_absent_custody_custody_read_refusal_cause.dag @@ -0,0 +1,19 @@ +module gunbc.namespace.transition_admission.test_claim_bmc_health_reader_witness_only_a_404_reads_as_absent_custody_custody_read_refusal_cause + +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 test_claim_bmc_health_reader_witness_only_a_404_reads_as_absent_custody_custody_read_refusal_cause: TransitionAdmission = TransitionAdmission { + label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, + subject: Binding { + enclosing: decl_ref("test.claim.bmc_health_reader_witness", "only_a_404_reads_as_absent_custody"), + spelling: "custody_read_refusal_cause" as NonEmptyStr, + expected_candidates: [decl_ref("gunbc.bmc_custody_read", "custody_read_refusal_cause")], + }, + disposition: TargetChanged, + deletion_follow_up: NotAuthored, + owner_pull_request: 11681 as UInt32, +} From a3d4640c2ab5bd137cefc60d33e81115b9627ebb Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 19 Sep 2026 07:50:21 +0000 Subject: [PATCH 14/15] Delete the 13 consumed gunbc#11681 namespace admission rows They admitted the bindings that moved to gunbc.bmc_custody_read; once #11681 lands they are consumed and owe deletion. Co-Authored-By: Claude Opus 5 (1M context) --- ...nverge_as_admin_bmc_custody_credential.dag | 19 ------------------- ...verge_as_admin_custodycredentialabsent.dag | 19 ------------------- ...onverge_as_admin_custodycredentialheld.dag | 19 ------------------- ...e_as_admin_custodycredentialunreadable.dag | 19 ------------------- ...reader_converge_bmc_custody_credential.dag | 19 ------------------- ...eader_converge_custodycredentialabsent.dag | 19 ------------------- ..._reader_converge_custodycredentialheld.dag | 19 ------------------- ...r_converge_custodycredentialunreadable.dag | 19 ------------------- ...redential_phase_bmc_custody_credential.dag | 19 ------------------- ...edential_phase_custodycredentialabsent.dag | 19 ------------------- ...credential_phase_custodycredentialheld.dag | 19 ------------------- ...tial_phase_custodycredentialunreadable.dag | 19 ------------------- ...ent_custody_custody_read_refusal_cause.dag | 19 ------------------- 13 files changed, 247 deletions(-) delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_bmc_custody_credential.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialabsent.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialheld.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialunreadable.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_bmc_custody_credential.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialabsent.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialheld.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialunreadable.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_bmc_custody_credential.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialabsent.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialheld.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialunreadable.dag delete mode 100644 dag/gunbc/namespace/transition_admission/test_claim_bmc_health_reader_witness_only_a_404_reads_as_absent_custody_custody_read_refusal_cause.dag diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_bmc_custody_credential.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_bmc_custody_credential.dag deleted file mode 100644 index 76be5233f99..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_bmc_custody_credential.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_bmc_custody_credential - -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 gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_bmc_custody_credential: TransitionAdmission = TransitionAdmission { - label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.tools.bmc_health_reader_converge", "bmc_health_reader_converge_as_admin"), - spelling: "bmc_custody_credential" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.bmc_custody_read", "bmc_custody_credential")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11681 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialabsent.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialabsent.dag deleted file mode 100644 index 154ed9414d2..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialabsent.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialabsent - -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 gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialabsent: TransitionAdmission = TransitionAdmission { - label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.tools.bmc_health_reader_converge", "bmc_health_reader_converge_as_admin"), - spelling: "CustodyCredentialAbsent" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialAbsent")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11681 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialheld.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialheld.dag deleted file mode 100644 index eaab74b026b..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialheld.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialheld - -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 gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialheld: TransitionAdmission = TransitionAdmission { - label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.tools.bmc_health_reader_converge", "bmc_health_reader_converge_as_admin"), - spelling: "CustodyCredentialHeld" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialHeld")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11681 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialunreadable.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialunreadable.dag deleted file mode 100644 index 22efb35df34..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialunreadable.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialunreadable - -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 gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialunreadable: TransitionAdmission = TransitionAdmission { - label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.tools.bmc_health_reader_converge", "bmc_health_reader_converge_as_admin"), - spelling: "CustodyCredentialUnreadable" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialUnreadable")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11681 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_bmc_custody_credential.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_bmc_custody_credential.dag deleted file mode 100644 index 63775b39b44..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_bmc_custody_credential.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_bmc_custody_credential - -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 gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_bmc_custody_credential: TransitionAdmission = TransitionAdmission { - label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.tools.bmc_health_reader_converge", "bmc_health_reader_converge"), - spelling: "bmc_custody_credential" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.bmc_custody_read", "bmc_custody_credential")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11681 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialabsent.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialabsent.dag deleted file mode 100644 index e8a089078e5..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialabsent.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialabsent - -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 gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialabsent: TransitionAdmission = TransitionAdmission { - label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.tools.bmc_health_reader_converge", "bmc_health_reader_converge"), - spelling: "CustodyCredentialAbsent" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialAbsent")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11681 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialheld.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialheld.dag deleted file mode 100644 index c3e3eb488f8..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialheld.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialheld - -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 gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialheld: TransitionAdmission = TransitionAdmission { - label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.tools.bmc_health_reader_converge", "bmc_health_reader_converge"), - spelling: "CustodyCredentialHeld" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialHeld")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11681 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialunreadable.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialunreadable.dag deleted file mode 100644 index 2c2dcd26070..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialunreadable.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialunreadable - -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 gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialunreadable: TransitionAdmission = TransitionAdmission { - label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.tools.bmc_health_reader_converge", "bmc_health_reader_converge"), - spelling: "CustodyCredentialUnreadable" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialUnreadable")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11681 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_bmc_custody_credential.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_bmc_custody_credential.dag deleted file mode 100644 index 166d2cbf39e..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_bmc_custody_credential.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_tools_bmc_onboard_bmc_probe_credential_phase_bmc_custody_credential - -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 gunbc_tools_bmc_onboard_bmc_probe_credential_phase_bmc_custody_credential: TransitionAdmission = TransitionAdmission { - label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.tools.bmc_onboard", "bmc_probe_credential_phase"), - spelling: "bmc_custody_credential" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.bmc_custody_read", "bmc_custody_credential")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11681 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialabsent.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialabsent.dag deleted file mode 100644 index 70078c6c73f..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialabsent.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialabsent - -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 gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialabsent: TransitionAdmission = TransitionAdmission { - label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.tools.bmc_onboard", "bmc_probe_credential_phase"), - spelling: "CustodyCredentialAbsent" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialAbsent")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11681 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialheld.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialheld.dag deleted file mode 100644 index 6e6ed50d3a1..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialheld.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialheld - -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 gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialheld: TransitionAdmission = TransitionAdmission { - label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.tools.bmc_onboard", "bmc_probe_credential_phase"), - spelling: "CustodyCredentialHeld" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialHeld")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11681 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialunreadable.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialunreadable.dag deleted file mode 100644 index 20f1fa3d0b9..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialunreadable.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialunreadable - -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 gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialunreadable: TransitionAdmission = TransitionAdmission { - label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.tools.bmc_onboard", "bmc_probe_credential_phase"), - spelling: "CustodyCredentialUnreadable" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialUnreadable")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11681 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/test_claim_bmc_health_reader_witness_only_a_404_reads_as_absent_custody_custody_read_refusal_cause.dag b/dag/gunbc/namespace/transition_admission/test_claim_bmc_health_reader_witness_only_a_404_reads_as_absent_custody_custody_read_refusal_cause.dag deleted file mode 100644 index 3e45a2016b5..00000000000 --- a/dag/gunbc/namespace/transition_admission/test_claim_bmc_health_reader_witness_only_a_404_reads_as_absent_custody_custody_read_refusal_cause.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.test_claim_bmc_health_reader_witness_only_a_404_reads_as_absent_custody_custody_read_refusal_cause - -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 test_claim_bmc_health_reader_witness_only_a_404_reads_as_absent_custody_custody_read_refusal_cause: TransitionAdmission = TransitionAdmission { - label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("test.claim.bmc_health_reader_witness", "only_a_404_reads_as_absent_custody"), - spelling: "custody_read_refusal_cause" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.bmc_custody_read", "custody_read_refusal_cause")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11681 as UInt32, -} From a389b4b6c1309fafdabc49088c3b5c0bd6f2c15f Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Sat, 19 Sep 2026 07:50:43 +0000 Subject: [PATCH 15/15] Admission roster: name #11696 as the deletion follow-up for the 13 rows; delete the 40 consumed #11587 rows Touching the roster makes its consumed rows due: the 40 ACTION-USE admissions (owner #11587) were already satisfied at the base. The 13 custody-read rows now name gunbc#11696, which deletes them once #11681 lands. Co-Authored-By: Claude Opus 5 (1M context) --- ...tion_bmc_checkout_step_checkout_action.dag | 19 ------------------- ...n_gcp_wif_auth_step_google_auth_action.dag | 19 ------------------- ...nch_head_checkout_step_checkout_action.dag | 19 ------------------- ...tep_is_checkout_action_checkout_action.dag | 19 ------------------- ...d_witnesses_build_work_checkout_action.dag | 19 ------------------- ...witnesses_build_work_setup_rust_action.dag | 19 ------------------- ...ipt_upload_step_upload_artifact_action.dag | 19 ------------------- ...converge_checkout_step_checkout_action.dag | 19 ------------------- ...ipt_upload_step_upload_artifact_action.dag | 19 ------------------- ...ipt_upload_step_upload_artifact_action.dag | 19 ------------------- ...ipt_upload_step_upload_artifact_action.dag | 19 ------------------- ...ing_upload_step_upload_artifact_action.dag | 19 ------------------- ...ipt_upload_step_upload_artifact_action.dag | 19 ------------------- ...download_step_download_artifact_action.dag | 19 ------------------- ...lan_upload_step_upload_artifact_action.dag | 19 ------------------- ...ipt_upload_step_upload_artifact_action.dag | 19 ------------------- ...ipt_upload_step_upload_artifact_action.dag | 19 ------------------- ...download_step_download_artifact_action.dag | 19 ------------------- ...ipt_upload_step_upload_artifact_action.dag | 19 ------------------- ...ipt_upload_step_upload_artifact_action.dag | 19 ------------------- ...ipt_upload_step_upload_artifact_action.dag | 19 ------------------- ...dmission_checkout_step_checkout_action.dag | 19 ------------------- ...steps_ci_cache_cargo_step_cache_action.dag | 19 ------------------- ...steps_ci_checkout_step_checkout_action.dag | 19 ------------------- ...fleet_wif_auth_step_google_auth_action.dag | 19 ------------------- ...download_step_download_artifact_action.dag | 19 ------------------- ...ins_upload_step_upload_artifact_action.dag | 19 ------------------- ...s_ci_setup_rust_step_setup_rust_action.dag | 19 ------------------- ...p_installs_toolchain_setup_rust_action.dag | 19 ------------------- ...nverge_as_admin_bmc_custody_credential.dag | 4 ++-- ...verge_as_admin_custodycredentialabsent.dag | 4 ++-- ...onverge_as_admin_custodycredentialheld.dag | 4 ++-- ...e_as_admin_custodycredentialunreadable.dag | 4 ++-- ...reader_converge_bmc_custody_credential.dag | 4 ++-- ...eader_converge_custodycredentialabsent.dag | 4 ++-- ..._reader_converge_custodycredentialheld.dag | 4 ++-- ...r_converge_custodycredentialunreadable.dag | 4 ++-- ...redential_phase_bmc_custody_credential.dag | 4 ++-- ...edential_phase_custodycredentialabsent.dag | 4 ++-- ...credential_phase_custodycredentialheld.dag | 4 ++-- ...tial_phase_custodycredentialunreadable.dag | 4 ++-- ...ipt_upload_step_upload_artifact_action.dag | 19 ------------------- ...mit_bundle_step_upload_artifact_action.dag | 19 ------------------- ...ss_floor_checkout_step_checkout_action.dag | 19 ------------------- ...floor_toolchain_step_setup_rust_action.dag | 19 ------------------- ...tsv_upload_step_upload_artifact_action.dag | 19 ------------------- ...tness_checkout_variant_checkout_action.dag | 19 ------------------- ...push_credential_upload_artifact_action.dag | 19 ------------------- ...ent_custody_custody_read_refusal_cause.dag | 4 ++-- ...n_witness_checkout_pin_checkout_action.dag | 19 ------------------- ...eckout_pin_disagreeing_checkout_action.dag | 19 ------------------- ..._fixture_checkout_step_checkout_action.dag | 19 ------------------- ...fixture_install_step_setup_rust_action.dag | 19 ------------------- 53 files changed, 26 insertions(+), 786 deletions(-) delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_assimilate_bmc_token_federation_bmc_checkout_step_checkout_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_assimilate_bmc_token_federation_gcp_wif_auth_step_google_auth_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_ci_heal_credential_ci_heal_branch_head_checkout_step_checkout_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_ci_heal_credential_step_is_checkout_action_checkout_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fabric_witness_run_required_witnesses_build_work_checkout_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fabric_witness_run_required_witnesses_build_work_setup_rust_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_app_control_plane_receipt_upload_step_upload_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_checkout_step_checkout_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_guest_image_receipt_upload_step_upload_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_host_reset_return_receipt_upload_step_upload_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_microvm_boot_probe_receipt_upload_step_upload_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_microvm_host_standing_upload_step_upload_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_org_actions_receipt_upload_step_upload_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_plan_download_step_download_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_plan_upload_step_upload_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_r2_mint_preflight_receipt_upload_step_upload_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_r2_object_write_mint_receipt_upload_step_upload_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_receipt_download_step_download_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_receipt_upload_step_upload_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_runner_host_file_receipt_upload_step_upload_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_runner_password_session_tool_receipt_upload_step_upload_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_desired_admission_workflow_fleet_desired_admission_checkout_step_checkout_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_cache_cargo_step_cache_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_checkout_step_checkout_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_fleet_wif_auth_step_google_auth_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_release_bins_download_step_download_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_release_bins_upload_step_upload_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_setup_rust_step_setup_rust_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_toolchain_home_standing_step_installs_toolchain_setup_rust_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_floor_attempt_receipt_upload_step_upload_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_heal_author_commit_bundle_step_upload_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_witness_floor_checkout_step_checkout_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_witness_floor_toolchain_step_setup_rust_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_witness_floor_tsv_upload_step_upload_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/test_claim_actions_job_grant_witness_checkout_variant_checkout_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/test_claim_actions_job_grant_witness_witness_only_a_real_checkout_action_binds_the_push_credential_upload_artifact_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/test_claim_tool_pin_witness_checkout_pin_checkout_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/test_claim_tool_pin_witness_checkout_pin_disagreeing_checkout_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/test_claim_toolchain_home_standing_witness_fixture_checkout_step_checkout_action.dag delete mode 100644 dag/gunbc/namespace/transition_admission/test_claim_toolchain_home_standing_witness_fixture_install_step_setup_rust_action.dag diff --git a/dag/gunbc/namespace/transition_admission/gunbc_assimilate_bmc_token_federation_bmc_checkout_step_checkout_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_assimilate_bmc_token_federation_bmc_checkout_step_checkout_action.dag deleted file mode 100644 index bacc158271a..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_assimilate_bmc_token_federation_bmc_checkout_step_checkout_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_assimilate_bmc_token_federation_bmc_checkout_step_checkout_action - -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 gunbc_assimilate_bmc_token_federation_bmc_checkout_step_checkout_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION checkout_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.assimilate.bmc_token_federation", "bmc_checkout_step"), - spelling: "checkout_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "checkout_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_assimilate_bmc_token_federation_gcp_wif_auth_step_google_auth_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_assimilate_bmc_token_federation_gcp_wif_auth_step_google_auth_action.dag deleted file mode 100644 index 24a2a3a00b1..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_assimilate_bmc_token_federation_gcp_wif_auth_step_google_auth_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_assimilate_bmc_token_federation_gcp_wif_auth_step_google_auth_action - -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 gunbc_assimilate_bmc_token_federation_gcp_wif_auth_step_google_auth_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION google_auth_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.assimilate.bmc_token_federation", "gcp_wif_auth_step"), - spelling: "google_auth_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "google_auth_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_ci_heal_credential_ci_heal_branch_head_checkout_step_checkout_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_ci_heal_credential_ci_heal_branch_head_checkout_step_checkout_action.dag deleted file mode 100644 index e05c5352ba5..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_ci_heal_credential_ci_heal_branch_head_checkout_step_checkout_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_ci_heal_credential_ci_heal_branch_head_checkout_step_checkout_action - -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 gunbc_ci_heal_credential_ci_heal_branch_head_checkout_step_checkout_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION checkout_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.ci_heal_credential", "ci_heal_branch_head_checkout_step"), - spelling: "checkout_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "checkout_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_ci_heal_credential_step_is_checkout_action_checkout_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_ci_heal_credential_step_is_checkout_action_checkout_action.dag deleted file mode 100644 index c566eb9256a..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_ci_heal_credential_step_is_checkout_action_checkout_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_ci_heal_credential_step_is_checkout_action_checkout_action - -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 gunbc_ci_heal_credential_step_is_checkout_action_checkout_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION checkout_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.ci_heal_credential", "step_is_checkout_action"), - spelling: "checkout_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "checkout_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fabric_witness_run_required_witnesses_build_work_checkout_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fabric_witness_run_required_witnesses_build_work_checkout_action.dag deleted file mode 100644 index 491962ff3bd..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fabric_witness_run_required_witnesses_build_work_checkout_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fabric_witness_run_required_witnesses_build_work_checkout_action - -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 gunbc_fabric_witness_run_required_witnesses_build_work_checkout_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION checkout_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fabric_witness_run", "required_witnesses_build_work"), - spelling: "checkout_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "checkout_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fabric_witness_run_required_witnesses_build_work_setup_rust_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fabric_witness_run_required_witnesses_build_work_setup_rust_action.dag deleted file mode 100644 index cf6881f5567..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fabric_witness_run_required_witnesses_build_work_setup_rust_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fabric_witness_run_required_witnesses_build_work_setup_rust_action - -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 gunbc_fabric_witness_run_required_witnesses_build_work_setup_rust_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION setup_rust_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fabric_witness_run", "required_witnesses_build_work"), - spelling: "setup_rust_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "setup_rust_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_app_control_plane_receipt_upload_step_upload_artifact_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_app_control_plane_receipt_upload_step_upload_artifact_action.dag deleted file mode 100644 index f39219e26d2..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_app_control_plane_receipt_upload_step_upload_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_converge_workflow_fleet_converge_app_control_plane_receipt_upload_step_upload_artifact_action - -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 gunbc_fleet_converge_workflow_fleet_converge_app_control_plane_receipt_upload_step_upload_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION upload_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_converge_workflow", "fleet_converge_app_control_plane_receipt_upload_step"), - spelling: "upload_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "upload_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_checkout_step_checkout_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_checkout_step_checkout_action.dag deleted file mode 100644 index a4740c93199..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_checkout_step_checkout_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_converge_workflow_fleet_converge_checkout_step_checkout_action - -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 gunbc_fleet_converge_workflow_fleet_converge_checkout_step_checkout_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION checkout_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_converge_workflow", "fleet_converge_checkout_step"), - spelling: "checkout_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "checkout_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_guest_image_receipt_upload_step_upload_artifact_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_guest_image_receipt_upload_step_upload_artifact_action.dag deleted file mode 100644 index e3157977108..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_guest_image_receipt_upload_step_upload_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_converge_workflow_fleet_converge_guest_image_receipt_upload_step_upload_artifact_action - -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 gunbc_fleet_converge_workflow_fleet_converge_guest_image_receipt_upload_step_upload_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION upload_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_converge_workflow", "fleet_converge_guest_image_receipt_upload_step"), - spelling: "upload_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "upload_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_host_reset_return_receipt_upload_step_upload_artifact_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_host_reset_return_receipt_upload_step_upload_artifact_action.dag deleted file mode 100644 index 43580109050..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_host_reset_return_receipt_upload_step_upload_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_converge_workflow_fleet_converge_host_reset_return_receipt_upload_step_upload_artifact_action - -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 gunbc_fleet_converge_workflow_fleet_converge_host_reset_return_receipt_upload_step_upload_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION upload_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_converge_workflow", "fleet_converge_host_reset_return_receipt_upload_step"), - spelling: "upload_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "upload_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_microvm_boot_probe_receipt_upload_step_upload_artifact_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_microvm_boot_probe_receipt_upload_step_upload_artifact_action.dag deleted file mode 100644 index e2d5f8a7d27..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_microvm_boot_probe_receipt_upload_step_upload_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_converge_workflow_fleet_converge_microvm_boot_probe_receipt_upload_step_upload_artifact_action - -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 gunbc_fleet_converge_workflow_fleet_converge_microvm_boot_probe_receipt_upload_step_upload_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION upload_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_converge_workflow", "fleet_converge_microvm_boot_probe_receipt_upload_step"), - spelling: "upload_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "upload_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_microvm_host_standing_upload_step_upload_artifact_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_microvm_host_standing_upload_step_upload_artifact_action.dag deleted file mode 100644 index c0297c02c18..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_microvm_host_standing_upload_step_upload_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_converge_workflow_fleet_converge_microvm_host_standing_upload_step_upload_artifact_action - -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 gunbc_fleet_converge_workflow_fleet_converge_microvm_host_standing_upload_step_upload_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION upload_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_converge_workflow", "fleet_converge_microvm_host_standing_upload_step"), - spelling: "upload_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "upload_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_org_actions_receipt_upload_step_upload_artifact_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_org_actions_receipt_upload_step_upload_artifact_action.dag deleted file mode 100644 index 8da1494f56e..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_org_actions_receipt_upload_step_upload_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_converge_workflow_fleet_converge_org_actions_receipt_upload_step_upload_artifact_action - -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 gunbc_fleet_converge_workflow_fleet_converge_org_actions_receipt_upload_step_upload_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION upload_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_converge_workflow", "fleet_converge_org_actions_receipt_upload_step"), - spelling: "upload_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "upload_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_plan_download_step_download_artifact_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_plan_download_step_download_artifact_action.dag deleted file mode 100644 index b3ea3db0917..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_plan_download_step_download_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_converge_workflow_fleet_converge_plan_download_step_download_artifact_action - -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 gunbc_fleet_converge_workflow_fleet_converge_plan_download_step_download_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION download_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_converge_workflow", "fleet_converge_plan_download_step"), - spelling: "download_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "download_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_plan_upload_step_upload_artifact_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_plan_upload_step_upload_artifact_action.dag deleted file mode 100644 index 15a4c9b366c..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_plan_upload_step_upload_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_converge_workflow_fleet_converge_plan_upload_step_upload_artifact_action - -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 gunbc_fleet_converge_workflow_fleet_converge_plan_upload_step_upload_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION upload_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_converge_workflow", "fleet_converge_plan_upload_step"), - spelling: "upload_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "upload_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_r2_mint_preflight_receipt_upload_step_upload_artifact_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_r2_mint_preflight_receipt_upload_step_upload_artifact_action.dag deleted file mode 100644 index 32dccfe0cab..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_r2_mint_preflight_receipt_upload_step_upload_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_converge_workflow_fleet_converge_r2_mint_preflight_receipt_upload_step_upload_artifact_action - -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 gunbc_fleet_converge_workflow_fleet_converge_r2_mint_preflight_receipt_upload_step_upload_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION upload_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_converge_workflow", "fleet_converge_r2_mint_preflight_receipt_upload_step"), - spelling: "upload_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "upload_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_r2_object_write_mint_receipt_upload_step_upload_artifact_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_r2_object_write_mint_receipt_upload_step_upload_artifact_action.dag deleted file mode 100644 index 43624b7d8b5..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_r2_object_write_mint_receipt_upload_step_upload_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_converge_workflow_fleet_converge_r2_object_write_mint_receipt_upload_step_upload_artifact_action - -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 gunbc_fleet_converge_workflow_fleet_converge_r2_object_write_mint_receipt_upload_step_upload_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION upload_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_converge_workflow", "fleet_converge_r2_object_write_mint_receipt_upload_step"), - spelling: "upload_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "upload_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_receipt_download_step_download_artifact_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_receipt_download_step_download_artifact_action.dag deleted file mode 100644 index b6d832bd7b6..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_receipt_download_step_download_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_converge_workflow_fleet_converge_receipt_download_step_download_artifact_action - -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 gunbc_fleet_converge_workflow_fleet_converge_receipt_download_step_download_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION download_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_converge_workflow", "fleet_converge_receipt_download_step"), - spelling: "download_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "download_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_receipt_upload_step_upload_artifact_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_receipt_upload_step_upload_artifact_action.dag deleted file mode 100644 index c43769ade43..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_receipt_upload_step_upload_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_converge_workflow_fleet_converge_receipt_upload_step_upload_artifact_action - -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 gunbc_fleet_converge_workflow_fleet_converge_receipt_upload_step_upload_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION upload_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_converge_workflow", "fleet_converge_receipt_upload_step"), - spelling: "upload_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "upload_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_runner_host_file_receipt_upload_step_upload_artifact_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_runner_host_file_receipt_upload_step_upload_artifact_action.dag deleted file mode 100644 index 41fb8a41053..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_runner_host_file_receipt_upload_step_upload_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_converge_workflow_fleet_converge_runner_host_file_receipt_upload_step_upload_artifact_action - -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 gunbc_fleet_converge_workflow_fleet_converge_runner_host_file_receipt_upload_step_upload_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION upload_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_converge_workflow", "fleet_converge_runner_host_file_receipt_upload_step"), - spelling: "upload_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "upload_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_runner_password_session_tool_receipt_upload_step_upload_artifact_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_runner_password_session_tool_receipt_upload_step_upload_artifact_action.dag deleted file mode 100644 index 502f7072a41..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_converge_workflow_fleet_converge_runner_password_session_tool_receipt_upload_step_upload_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_converge_workflow_fleet_converge_runner_password_session_tool_receipt_upload_step_upload_artifact_action - -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 gunbc_fleet_converge_workflow_fleet_converge_runner_password_session_tool_receipt_upload_step_upload_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION upload_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_converge_workflow", "fleet_converge_runner_password_session_tool_receipt_upload_step"), - spelling: "upload_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "upload_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_desired_admission_workflow_fleet_desired_admission_checkout_step_checkout_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_desired_admission_workflow_fleet_desired_admission_checkout_step_checkout_action.dag deleted file mode 100644 index 7ae1cb93e6d..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_desired_admission_workflow_fleet_desired_admission_checkout_step_checkout_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_desired_admission_workflow_fleet_desired_admission_checkout_step_checkout_action - -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 gunbc_fleet_desired_admission_workflow_fleet_desired_admission_checkout_step_checkout_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION checkout_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_desired_admission_workflow", "fleet_desired_admission_checkout_step"), - spelling: "checkout_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "checkout_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_cache_cargo_step_cache_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_cache_cargo_step_cache_action.dag deleted file mode 100644 index 7f1b67ea289..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_cache_cargo_step_cache_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_workflow_steps_ci_cache_cargo_step_cache_action - -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 gunbc_fleet_workflow_steps_ci_cache_cargo_step_cache_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION cache_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_workflow_steps", "ci_cache_cargo_step"), - spelling: "cache_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "cache_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_checkout_step_checkout_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_checkout_step_checkout_action.dag deleted file mode 100644 index 0c55539a155..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_checkout_step_checkout_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_workflow_steps_ci_checkout_step_checkout_action - -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 gunbc_fleet_workflow_steps_ci_checkout_step_checkout_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION checkout_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_workflow_steps", "ci_checkout_step"), - spelling: "checkout_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "checkout_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_fleet_wif_auth_step_google_auth_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_fleet_wif_auth_step_google_auth_action.dag deleted file mode 100644 index 5a83e1c784b..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_fleet_wif_auth_step_google_auth_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_workflow_steps_ci_fleet_wif_auth_step_google_auth_action - -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 gunbc_fleet_workflow_steps_ci_fleet_wif_auth_step_google_auth_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION google_auth_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_workflow_steps", "ci_fleet_wif_auth_step"), - spelling: "google_auth_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "google_auth_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_release_bins_download_step_download_artifact_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_release_bins_download_step_download_artifact_action.dag deleted file mode 100644 index 473e76336e8..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_release_bins_download_step_download_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_workflow_steps_ci_release_bins_download_step_download_artifact_action - -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 gunbc_fleet_workflow_steps_ci_release_bins_download_step_download_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION download_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_workflow_steps", "ci_release_bins_download_step"), - spelling: "download_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "download_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_release_bins_upload_step_upload_artifact_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_release_bins_upload_step_upload_artifact_action.dag deleted file mode 100644 index 687a50ec180..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_release_bins_upload_step_upload_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_workflow_steps_ci_release_bins_upload_step_upload_artifact_action - -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 gunbc_fleet_workflow_steps_ci_release_bins_upload_step_upload_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION upload_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_workflow_steps", "ci_release_bins_upload_step"), - spelling: "upload_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "upload_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_setup_rust_step_setup_rust_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_setup_rust_step_setup_rust_action.dag deleted file mode 100644 index a678c835dc0..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_fleet_workflow_steps_ci_setup_rust_step_setup_rust_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_fleet_workflow_steps_ci_setup_rust_step_setup_rust_action - -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 gunbc_fleet_workflow_steps_ci_setup_rust_step_setup_rust_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION setup_rust_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.fleet_workflow_steps", "ci_setup_rust_step"), - spelling: "setup_rust_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "setup_rust_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_toolchain_home_standing_step_installs_toolchain_setup_rust_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_toolchain_home_standing_step_installs_toolchain_setup_rust_action.dag deleted file mode 100644 index b4edb03309f..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_toolchain_home_standing_step_installs_toolchain_setup_rust_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_toolchain_home_standing_step_installs_toolchain_setup_rust_action - -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 gunbc_toolchain_home_standing_step_installs_toolchain_setup_rust_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION setup_rust_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.toolchain_home_standing", "step_installs_toolchain"), - spelling: "setup_rust_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "setup_rust_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_bmc_custody_credential.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_bmc_custody_credential.dag index 76be5233f99..5bbff3146f7 100644 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_bmc_custody_credential.dag +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_bmc_custody_credential.dag @@ -4,7 +4,7 @@ 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 } +import gunbc.namespace.transition_admission { TransitionAdmission, Binding, PullRequest } data gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_bmc_custody_credential: TransitionAdmission = TransitionAdmission { label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, @@ -14,6 +14,6 @@ data gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_ expected_candidates: [decl_ref("gunbc.bmc_custody_read", "bmc_custody_credential")], }, disposition: TargetChanged, - deletion_follow_up: NotAuthored, + deletion_follow_up: PullRequest { number: 11696 as UInt32 }, owner_pull_request: 11681 as UInt32, } diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialabsent.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialabsent.dag index 154ed9414d2..c204ee26587 100644 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialabsent.dag +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialabsent.dag @@ -4,7 +4,7 @@ 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 } +import gunbc.namespace.transition_admission { TransitionAdmission, Binding, PullRequest } data gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialabsent: TransitionAdmission = TransitionAdmission { label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, @@ -14,6 +14,6 @@ data gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_ expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialAbsent")], }, disposition: TargetChanged, - deletion_follow_up: NotAuthored, + deletion_follow_up: PullRequest { number: 11696 as UInt32 }, owner_pull_request: 11681 as UInt32, } diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialheld.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialheld.dag index eaab74b026b..cb94cb950d2 100644 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialheld.dag +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialheld.dag @@ -4,7 +4,7 @@ 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 } +import gunbc.namespace.transition_admission { TransitionAdmission, Binding, PullRequest } data gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialheld: TransitionAdmission = TransitionAdmission { label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, @@ -14,6 +14,6 @@ data gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_ expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialHeld")], }, disposition: TargetChanged, - deletion_follow_up: NotAuthored, + deletion_follow_up: PullRequest { number: 11696 as UInt32 }, owner_pull_request: 11681 as UInt32, } diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialunreadable.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialunreadable.dag index 22efb35df34..b56a933da2d 100644 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialunreadable.dag +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialunreadable.dag @@ -4,7 +4,7 @@ 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 } +import gunbc.namespace.transition_admission { TransitionAdmission, Binding, PullRequest } data gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_custodycredentialunreadable: TransitionAdmission = TransitionAdmission { label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, @@ -14,6 +14,6 @@ data gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_as_admin_ expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialUnreadable")], }, disposition: TargetChanged, - deletion_follow_up: NotAuthored, + deletion_follow_up: PullRequest { number: 11696 as UInt32 }, owner_pull_request: 11681 as UInt32, } diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_bmc_custody_credential.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_bmc_custody_credential.dag index 63775b39b44..bf0fc4990a4 100644 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_bmc_custody_credential.dag +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_bmc_custody_credential.dag @@ -4,7 +4,7 @@ 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 } +import gunbc.namespace.transition_admission { TransitionAdmission, Binding, PullRequest } data gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_bmc_custody_credential: TransitionAdmission = TransitionAdmission { label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, @@ -14,6 +14,6 @@ data gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_bmc_custo expected_candidates: [decl_ref("gunbc.bmc_custody_read", "bmc_custody_credential")], }, disposition: TargetChanged, - deletion_follow_up: NotAuthored, + deletion_follow_up: PullRequest { number: 11696 as UInt32 }, owner_pull_request: 11681 as UInt32, } diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialabsent.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialabsent.dag index e8a089078e5..86493756c1e 100644 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialabsent.dag +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialabsent.dag @@ -4,7 +4,7 @@ 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 } +import gunbc.namespace.transition_admission { TransitionAdmission, Binding, PullRequest } data gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialabsent: TransitionAdmission = TransitionAdmission { label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, @@ -14,6 +14,6 @@ data gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycr expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialAbsent")], }, disposition: TargetChanged, - deletion_follow_up: NotAuthored, + deletion_follow_up: PullRequest { number: 11696 as UInt32 }, owner_pull_request: 11681 as UInt32, } diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialheld.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialheld.dag index c3e3eb488f8..5ccc6f077c5 100644 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialheld.dag +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialheld.dag @@ -4,7 +4,7 @@ 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 } +import gunbc.namespace.transition_admission { TransitionAdmission, Binding, PullRequest } data gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialheld: TransitionAdmission = TransitionAdmission { label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, @@ -14,6 +14,6 @@ data gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycr expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialHeld")], }, disposition: TargetChanged, - deletion_follow_up: NotAuthored, + deletion_follow_up: PullRequest { number: 11696 as UInt32 }, owner_pull_request: 11681 as UInt32, } diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialunreadable.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialunreadable.dag index 2c2dcd26070..2e63429e2ba 100644 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialunreadable.dag +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialunreadable.dag @@ -4,7 +4,7 @@ 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 } +import gunbc.namespace.transition_admission { TransitionAdmission, Binding, PullRequest } data gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycredentialunreadable: TransitionAdmission = TransitionAdmission { label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, @@ -14,6 +14,6 @@ data gunbc_tools_bmc_health_reader_converge_bmc_health_reader_converge_custodycr expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialUnreadable")], }, disposition: TargetChanged, - deletion_follow_up: NotAuthored, + deletion_follow_up: PullRequest { number: 11696 as UInt32 }, owner_pull_request: 11681 as UInt32, } diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_bmc_custody_credential.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_bmc_custody_credential.dag index 166d2cbf39e..8f476f88038 100644 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_bmc_custody_credential.dag +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_bmc_custody_credential.dag @@ -4,7 +4,7 @@ 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 } +import gunbc.namespace.transition_admission { TransitionAdmission, Binding, PullRequest } data gunbc_tools_bmc_onboard_bmc_probe_credential_phase_bmc_custody_credential: TransitionAdmission = TransitionAdmission { label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, @@ -14,6 +14,6 @@ data gunbc_tools_bmc_onboard_bmc_probe_credential_phase_bmc_custody_credential: expected_candidates: [decl_ref("gunbc.bmc_custody_read", "bmc_custody_credential")], }, disposition: TargetChanged, - deletion_follow_up: NotAuthored, + deletion_follow_up: PullRequest { number: 11696 as UInt32 }, owner_pull_request: 11681 as UInt32, } diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialabsent.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialabsent.dag index 70078c6c73f..40d6e1ae55e 100644 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialabsent.dag +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialabsent.dag @@ -4,7 +4,7 @@ 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 } +import gunbc.namespace.transition_admission { TransitionAdmission, Binding, PullRequest } data gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialabsent: TransitionAdmission = TransitionAdmission { label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, @@ -14,6 +14,6 @@ data gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialabsent: expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialAbsent")], }, disposition: TargetChanged, - deletion_follow_up: NotAuthored, + deletion_follow_up: PullRequest { number: 11696 as UInt32 }, owner_pull_request: 11681 as UInt32, } diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialheld.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialheld.dag index 6e6ed50d3a1..43c1dbed7df 100644 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialheld.dag +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialheld.dag @@ -4,7 +4,7 @@ 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 } +import gunbc.namespace.transition_admission { TransitionAdmission, Binding, PullRequest } data gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialheld: TransitionAdmission = TransitionAdmission { label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, @@ -14,6 +14,6 @@ data gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialheld: T expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialHeld")], }, disposition: TargetChanged, - deletion_follow_up: NotAuthored, + deletion_follow_up: PullRequest { number: 11696 as UInt32 }, owner_pull_request: 11681 as UInt32, } diff --git a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialunreadable.dag b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialunreadable.dag index 20f1fa3d0b9..3d36c5501c7 100644 --- a/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialunreadable.dag +++ b/dag/gunbc/namespace/transition_admission/gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialunreadable.dag @@ -4,7 +4,7 @@ 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 } +import gunbc.namespace.transition_admission { TransitionAdmission, Binding, PullRequest } data gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialunreadable: TransitionAdmission = TransitionAdmission { label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, @@ -14,6 +14,6 @@ data gunbc_tools_bmc_onboard_bmc_probe_credential_phase_custodycredentialunreada expected_candidates: [decl_ref("gunbc.bmc_custody_read", "CustodyCredentialUnreadable")], }, disposition: TargetChanged, - deletion_follow_up: NotAuthored, + deletion_follow_up: PullRequest { number: 11696 as UInt32 }, owner_pull_request: 11681 as UInt32, } diff --git a/dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_floor_attempt_receipt_upload_step_upload_artifact_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_floor_attempt_receipt_upload_step_upload_artifact_action.dag deleted file mode 100644 index 742d7282843..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_floor_attempt_receipt_upload_step_upload_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_witness_floor_workflow_floor_attempt_receipt_upload_step_upload_artifact_action - -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 gunbc_witness_floor_workflow_floor_attempt_receipt_upload_step_upload_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION upload_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.witness_floor_workflow", "floor_attempt_receipt_upload_step"), - spelling: "upload_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "upload_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_heal_author_commit_bundle_step_upload_artifact_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_heal_author_commit_bundle_step_upload_artifact_action.dag deleted file mode 100644 index cc891f5c0bf..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_heal_author_commit_bundle_step_upload_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_witness_floor_workflow_heal_author_commit_bundle_step_upload_artifact_action - -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 gunbc_witness_floor_workflow_heal_author_commit_bundle_step_upload_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION upload_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.witness_floor_workflow", "heal_author_commit_bundle_step"), - spelling: "upload_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "upload_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_witness_floor_checkout_step_checkout_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_witness_floor_checkout_step_checkout_action.dag deleted file mode 100644 index 57705429be6..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_witness_floor_checkout_step_checkout_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_witness_floor_workflow_witness_floor_checkout_step_checkout_action - -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 gunbc_witness_floor_workflow_witness_floor_checkout_step_checkout_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION checkout_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.witness_floor_workflow", "witness_floor_checkout_step"), - spelling: "checkout_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "checkout_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_witness_floor_toolchain_step_setup_rust_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_witness_floor_toolchain_step_setup_rust_action.dag deleted file mode 100644 index ad195b24045..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_witness_floor_toolchain_step_setup_rust_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_witness_floor_workflow_witness_floor_toolchain_step_setup_rust_action - -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 gunbc_witness_floor_workflow_witness_floor_toolchain_step_setup_rust_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION setup_rust_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.witness_floor_workflow", "witness_floor_toolchain_step"), - spelling: "setup_rust_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "setup_rust_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_witness_floor_tsv_upload_step_upload_artifact_action.dag b/dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_witness_floor_tsv_upload_step_upload_artifact_action.dag deleted file mode 100644 index 7e847da8187..00000000000 --- a/dag/gunbc/namespace/transition_admission/gunbc_witness_floor_workflow_witness_floor_tsv_upload_step_upload_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.gunbc_witness_floor_workflow_witness_floor_tsv_upload_step_upload_artifact_action - -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 gunbc_witness_floor_workflow_witness_floor_tsv_upload_step_upload_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION upload_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("gunbc.witness_floor_workflow", "witness_floor_tsv_upload_step"), - spelling: "upload_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "upload_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/test_claim_actions_job_grant_witness_checkout_variant_checkout_action.dag b/dag/gunbc/namespace/transition_admission/test_claim_actions_job_grant_witness_checkout_variant_checkout_action.dag deleted file mode 100644 index e5e94c8d70c..00000000000 --- a/dag/gunbc/namespace/transition_admission/test_claim_actions_job_grant_witness_checkout_variant_checkout_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.test_claim_actions_job_grant_witness_checkout_variant_checkout_action - -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 test_claim_actions_job_grant_witness_checkout_variant_checkout_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION checkout_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("test.claim.actions_job_grant_witness", "checkout_variant"), - spelling: "checkout_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "checkout_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/test_claim_actions_job_grant_witness_witness_only_a_real_checkout_action_binds_the_push_credential_upload_artifact_action.dag b/dag/gunbc/namespace/transition_admission/test_claim_actions_job_grant_witness_witness_only_a_real_checkout_action_binds_the_push_credential_upload_artifact_action.dag deleted file mode 100644 index ed9f2e5382c..00000000000 --- a/dag/gunbc/namespace/transition_admission/test_claim_actions_job_grant_witness_witness_only_a_real_checkout_action_binds_the_push_credential_upload_artifact_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.test_claim_actions_job_grant_witness_witness_only_a_real_checkout_action_binds_the_push_credential_upload_artifact_action - -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 test_claim_actions_job_grant_witness_witness_only_a_real_checkout_action_binds_the_push_credential_upload_artifact_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION upload_artifact_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("test.claim.actions_job_grant_witness", "witness_only_a_real_checkout_action_binds_the_push_credential"), - spelling: "upload_artifact_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "upload_artifact_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/test_claim_bmc_health_reader_witness_only_a_404_reads_as_absent_custody_custody_read_refusal_cause.dag b/dag/gunbc/namespace/transition_admission/test_claim_bmc_health_reader_witness_only_a_404_reads_as_absent_custody_custody_read_refusal_cause.dag index 3e45a2016b5..41545bd26ae 100644 --- a/dag/gunbc/namespace/transition_admission/test_claim_bmc_health_reader_witness_only_a_404_reads_as_absent_custody_custody_read_refusal_cause.dag +++ b/dag/gunbc/namespace/transition_admission/test_claim_bmc_health_reader_witness_only_a_404_reads_as_absent_custody_custody_read_refusal_cause.dag @@ -4,7 +4,7 @@ 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 } +import gunbc.namespace.transition_admission { TransitionAdmission, Binding, PullRequest } data test_claim_bmc_health_reader_witness_only_a_404_reads_as_absent_custody_custody_read_refusal_cause: TransitionAdmission = TransitionAdmission { label: "BMC custody read moves to its read-only module gunbc.bmc_custody_read (gunbc#11681)" as NonEmptyStr, @@ -14,6 +14,6 @@ data test_claim_bmc_health_reader_witness_only_a_404_reads_as_absent_custody_cus expected_candidates: [decl_ref("gunbc.bmc_custody_read", "custody_read_refusal_cause")], }, disposition: TargetChanged, - deletion_follow_up: NotAuthored, + deletion_follow_up: PullRequest { number: 11696 as UInt32 }, owner_pull_request: 11681 as UInt32, } diff --git a/dag/gunbc/namespace/transition_admission/test_claim_tool_pin_witness_checkout_pin_checkout_action.dag b/dag/gunbc/namespace/transition_admission/test_claim_tool_pin_witness_checkout_pin_checkout_action.dag deleted file mode 100644 index a15d2689cae..00000000000 --- a/dag/gunbc/namespace/transition_admission/test_claim_tool_pin_witness_checkout_pin_checkout_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.test_claim_tool_pin_witness_checkout_pin_checkout_action - -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 test_claim_tool_pin_witness_checkout_pin_checkout_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION checkout_action: witness-local tag coordinate replaces the deleted ActionRef" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("test.claim.tool_pin_witness", "checkout_pin"), - spelling: "checkout_action" as NonEmptyStr, - expected_candidates: [decl_ref("test.claim.tool_pin_witness", "checkout_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/test_claim_tool_pin_witness_checkout_pin_disagreeing_checkout_action.dag b/dag/gunbc/namespace/transition_admission/test_claim_tool_pin_witness_checkout_pin_disagreeing_checkout_action.dag deleted file mode 100644 index fd50bb56db5..00000000000 --- a/dag/gunbc/namespace/transition_admission/test_claim_tool_pin_witness_checkout_pin_disagreeing_checkout_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.test_claim_tool_pin_witness_checkout_pin_disagreeing_checkout_action - -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 test_claim_tool_pin_witness_checkout_pin_disagreeing_checkout_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION checkout_action: witness-local tag coordinate replaces the deleted ActionRef" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("test.claim.tool_pin_witness", "checkout_pin_disagreeing"), - spelling: "checkout_action" as NonEmptyStr, - expected_candidates: [decl_ref("test.claim.tool_pin_witness", "checkout_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/test_claim_toolchain_home_standing_witness_fixture_checkout_step_checkout_action.dag b/dag/gunbc/namespace/transition_admission/test_claim_toolchain_home_standing_witness_fixture_checkout_step_checkout_action.dag deleted file mode 100644 index 4adc190af67..00000000000 --- a/dag/gunbc/namespace/transition_admission/test_claim_toolchain_home_standing_witness_fixture_checkout_step_checkout_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.test_claim_toolchain_home_standing_witness_fixture_checkout_step_checkout_action - -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 test_claim_toolchain_home_standing_witness_fixture_checkout_step_checkout_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION checkout_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("test.claim.toolchain_home_standing_witness", "fixture_checkout_step"), - spelling: "checkout_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "checkout_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -} diff --git a/dag/gunbc/namespace/transition_admission/test_claim_toolchain_home_standing_witness_fixture_install_step_setup_rust_action.dag b/dag/gunbc/namespace/transition_admission/test_claim_toolchain_home_standing_witness_fixture_install_step_setup_rust_action.dag deleted file mode 100644 index 227ba999036..00000000000 --- a/dag/gunbc/namespace/transition_admission/test_claim_toolchain_home_standing_witness_fixture_install_step_setup_rust_action.dag +++ /dev/null @@ -1,19 +0,0 @@ -module gunbc.namespace.transition_admission.test_claim_toolchain_home_standing_witness_fixture_install_step_setup_rust_action - -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 test_claim_toolchain_home_standing_witness_fixture_install_step_setup_rust_action: TransitionAdmission = TransitionAdmission { - label: "ACTION-USE ADMISSION setup_rust_action home gunbc.action_use_admission" as NonEmptyStr, - subject: Binding { - enclosing: decl_ref("test.claim.toolchain_home_standing_witness", "fixture_install_step"), - spelling: "setup_rust_action" as NonEmptyStr, - expected_candidates: [decl_ref("gunbc.action_use_admission", "setup_rust_action")], - }, - disposition: TargetChanged, - deletion_follow_up: NotAuthored, - owner_pull_request: 11587 as UInt32, -}