From 557055853d01fd143cbd2a68ed66cdd1dc2950f1 Mon Sep 17 00:00:00 2001 From: Brian Date: Mon, 27 Jul 2026 23:48:46 -0400 Subject: [PATCH] Model BMC desired state and reconciliation --- dag/gunbc/bmc_converge.dag | 359 +++++++++ dag/gunbc/bmc_fan_projection.dag | 141 ++++ dag/gunbc/bmc_intent.dag | 134 ++++ dag/gunbc/fleet_bmc_state.dag | 186 +++++ dag/gunbc/fleet_intent.dag | 7 +- .../bmc_firmware_thermal_witness_test.dag | 719 ++++++++++++++++++ 6 files changed, 1545 insertions(+), 1 deletion(-) create mode 100644 dag/gunbc/bmc_converge.dag create mode 100644 dag/gunbc/bmc_fan_projection.dag create mode 100644 dag/gunbc/bmc_intent.dag create mode 100644 dag/gunbc/fleet_bmc_state.dag create mode 100644 dag/test/claim/bmc_firmware_thermal_witness_test.dag diff --git a/dag/gunbc/bmc_converge.dag b/dag/gunbc/bmc_converge.dag new file mode 100644 index 00000000000..ac21ec0f378 --- /dev/null +++ b/dag/gunbc/bmc_converge.dag @@ -0,0 +1,359 @@ +module gunbc.bmc_converge + +import extdeps.bmc.capability { + BmcFirmwareIntent, + BmcFirmwareObservation, + BmcFirmwareTrackRequired, + BmcFirmwareUnpinned, + BmcFirmwareVersion, + bmc_firmware_observation_matches_intent, + bmc_firmware_version_matches_track, +} +import extdeps.bmc.types { + BmcConfigPersistenceObservation, + BmcConfigPersistenceUnsupported, + BmcFanCurve, + BmcFanPolicyObservation, + BmcOverlayConfigObserved, + BmcUpdateServiceObservation, +} +import extdeps.boards.types { BaseboardModel } +import extdeps.crypto.hash { Digest } +import extdeps.filesystem.linux { + linux_overlay_observation_is_flash_backed, +} +import extdeps.uri { Uri } +import gunbc.bmc_intent { + BmcControlPlaneIntent, +} +import gunbc.bmc_fan_projection { + BmcFanDurabilityEvidence, + BmcFanPolicyProjectionCapability, + bmc_fan_policy_projection_durability_admission, + bmc_fan_policy_projection_durability_capability, + bmc_fan_policy_projection_supports_apply, + bmc_fan_policy_projection_topology_matches, +} +import product.placement_supply { HostIdentity } +import std.state_durability { + StatePerturbationGeneration, + state_durability_satisfied, + state_durability_verdict, + state_durability_verdict_reason, +} +import std.types { Bool, List, NonEmptyStr } +import std.upsert_decision { + Apply, + Conflict, + Converged, + Drifted, + Noop, + Refuse, + UnknownRefused, + UpsertClassification, +} + +type ApprovedBmcFirmwareArtifact { + firmware: BmcFirmwareVersion + board: BaseboardModel + image_uri: Uri + digest: Digest +} + +type BmcFirmwareUpdatePlan { + host: HostIdentity + observed: BmcFirmwareObservation + artifact: ApprovedBmcFirmwareArtifact + update_service: BmcUpdateServiceObservation +} + +type BmcFanPolicyApplyPlan { + host: HostIdentity + observed: BmcFanPolicyObservation + desired_curve: BmcFanCurve + config_path: NonEmptyStr + controller_service: NonEmptyStr + projection: BmcFanPolicyProjectionCapability +} + +data bmc_converge_shared_workflow_note: String = "BMC and Ubuntu convergence share std.upsert_decision for observe/classify/plan and std.realization_reconcile for apply/read-back. This module supplies only BMC policy comparison and a BMC-specific apply plan; it does not fork the reconciliation workflow or reuse HostOs shell effects against the BmcController control plane." + +fn bmc_firmware_classification( + host: HostIdentity, + board: BaseboardModel, + intent: BmcFirmwareIntent, + observation: BmcFirmwareObservation?, + artifact: ApprovedBmcFirmwareArtifact?, + update_service: BmcUpdateServiceObservation?, +) -> UpsertClassification { + match observation { + Absent => + UpsertClassification { + verdict: UnknownRefused, + decision: Refuse { + reason: "bmc firmware observation absent; refuse both noop and update", + }, + } + Present { value: observed } => + if bmc_firmware_observation_matches_intent( + intent: intent, + observation: observed, + ) { + UpsertClassification { + verdict: Converged, + decision: Noop, + } + } else { + match artifact { + Absent => + UpsertClassification { + verdict: Drifted, + decision: Refuse { + reason: "bmc firmware drift observed, but no exact approved board-compatible image artifact is available", + }, + } + Present { value: approved } => + if approved.board == board + && bmc_firmware_version_matches_intent( + firmware: approved.firmware, + intent: intent, + ) { + match update_service { + Absent => + UpsertClassification { + verdict: Drifted, + decision: Refuse { + reason: "bmc firmware drift and approved artifact are known, but the current UpdateService surface has not been observed", + }, + } + Present { value: update_surface } => + if update_surface.firmware.endpoint == observed.endpoint + && update_surface.firmware.firmware == observed.firmware + && update_surface.active_bmc_image_updateable { + UpsertClassification { + verdict: Drifted, + decision: Apply { + plan: BmcFirmwareUpdatePlan { + host: host, + observed: observed, + artifact: approved, + update_service: update_surface, + }, + }, + } + } else { + UpsertClassification { + verdict: Conflict, + decision: Refuse { + reason: "observed UpdateService does not identify an updateable active image for the exact target controller and firmware", + }, + } + } + } + } else { + UpsertClassification { + verdict: Conflict, + decision: Refuse { + reason: "approved bmc firmware artifact does not match the target board and declared firmware track", + }, + } + } + } + } + } +} + +fn bmc_firmware_version_matches_intent( + firmware: BmcFirmwareVersion, + intent: BmcFirmwareIntent, +) -> Bool { + match intent { + BmcFirmwareUnpinned => false + BmcFirmwareTrackRequired { track: required } => + bmc_firmware_version_matches_track( + firmware: firmware, + track: required, + ) + } +} + +fn bmc_fan_policy_firmware_supported( + desired_firmware: BmcFirmwareIntent, + observed_firmware: BmcFirmwareVersion, +) -> Bool { + match desired_firmware { + BmcFirmwareUnpinned => false + BmcFirmwareTrackRequired { track: required } => + bmc_firmware_version_matches_track( + firmware: observed_firmware, + track: required, + ) + } +} + +fn bmc_config_persistence_supports_apply( + observation: BmcConfigPersistenceObservation, + expected_firmware: BmcFirmwareVersion, + config_path: NonEmptyStr, +) -> Bool { + match observation { + BmcOverlayConfigObserved { + firmware: firmware, + config_path: observed_path, + overlay: overlay, + writable: writable, + upper_override_present: _, + available_bytes: _, + observed_at: _, + } => + firmware == expected_firmware + && writable + && observed_path == config_path + && linux_overlay_observation_is_flash_backed( + observation: overlay, + ) + BmcConfigPersistenceUnsupported { + firmware: _, + config_path: _, + reason: _, + observed_at: _, + } => false + } +} + +fn bmc_fan_policy_classification( + host: HostIdentity, + board: BaseboardModel, + intent: BmcControlPlaneIntent, + observation: BmcFanPolicyObservation?, + persistence: BmcConfigPersistenceObservation?, + projection: BmcFanPolicyProjectionCapability?, + durability_generation: StatePerturbationGeneration, + durability_evidence: List, +) -> UpsertClassification { + let desired = intent.fan_policy.curve + match observation { + Absent => + UpsertClassification { + verdict: UnknownRefused, + decision: Refuse { + reason: "bmc fan-policy observation absent; Redfish InitialDuty is not curve evidence", + }, + } + Present { value: observed } => + if !bmc_fan_policy_firmware_supported( + desired_firmware: intent.firmware, + observed_firmware: observed.firmware, + ) { + UpsertClassification { + verdict: Conflict, + decision: Refuse { + reason: "bmc firmware must converge and be re-observed before selecting a version-specific fan-policy realizer", + }, + } + } else { + match projection { + Absent => + UpsertClassification { + verdict: UnknownRefused, + decision: Refuse { + reason: "bmc fan state cannot converge without an exact board-and-firmware projection for both curve and installed-tach topology", + }, + } + Present { value: projector } => + if projector.required_tach_inputs != intent.fan_policy.required_tach_inputs { + UpsertClassification { + verdict: Conflict, + decision: Refuse { + reason: "bmc fan projection installed-tach topology differs from fleet intent", + }, + } + } else if !bmc_fan_policy_projection_supports_apply( + capability: projector, + board: board, + desired_curve: desired, + observation: observed, + ) { + UpsertClassification { + verdict: Conflict, + decision: Refuse { + reason: "bmc fan projection capability does not match the observed board, exact firmware, config path, and controller service", + }, + } + } else { + match persistence { + Absent => + UpsertClassification { + verdict: UnknownRefused, + decision: Refuse { + reason: "bmc fan realizer lifetime cannot be evaluated because its current filesystem substrate has not been observed", + }, + } + Present { value: storage } => + if !bmc_config_persistence_supports_apply( + observation: storage, + expected_firmware: observed.firmware, + config_path: observed.board_config_path, + ) { + UpsertClassification { + verdict: Conflict, + decision: Refuse { + reason: "bmc fan realizer is not grounded by the observed overlay, SquashFS lower, writable JFFS2 upper, exact firmware, and config path", + }, + } + } else { + let durability = state_durability_verdict( + intent: intent.fan_policy.durability, + admission: bmc_fan_policy_projection_durability_admission( + host: host, + capability: projector, + perturbation_generation: durability_generation, + ), + capability: Present { + value: bmc_fan_policy_projection_durability_capability( + capability: projector, + ), + }, + evidence: durability_evidence, + ) + if !state_durability_satisfied(verdict: durability) { + UpsertClassification { + verdict: UnknownRefused, + decision: Refuse { + reason: concat( + "bmc fan realizer does not satisfy the declared state lifetime: ", + state_durability_verdict_reason(verdict: durability), + ), + }, + } + } else if observed.curve == desired + && bmc_fan_policy_projection_topology_matches( + capability: projector, + observation: observed, + ) { + UpsertClassification { + verdict: Converged, + decision: Noop, + } + } else { + UpsertClassification { + verdict: Drifted, + decision: Apply { + plan: BmcFanPolicyApplyPlan { + host: host, + observed: observed, + desired_curve: desired, + config_path: observed.board_config_path, + controller_service: observed.controller_service, + projection: projector, + }, + }, + } + } + } + } + } + } + } + } +} diff --git a/dag/gunbc/bmc_fan_projection.dag b/dag/gunbc/bmc_fan_projection.dag new file mode 100644 index 00000000000..9a75d4a1354 --- /dev/null +++ b/dag/gunbc/bmc_fan_projection.dag @@ -0,0 +1,141 @@ +module gunbc.bmc_fan_projection + +import extdeps.bmc.capability { BmcFirmwareVersion } +import extdeps.bmc.types { + BmcFanCurve, + BmcFanPolicyObservation, + FanPidAggregatedEntry, + FanPidIndependentEntries, +} +import extdeps.boards.types { BaseboardModel } +import extdeps.crypto.hash { Digest } +import extdeps.filesystem.linux { + LinuxFilePublicationProtocol, + linux_file_publication_has_synchronized_commit, +} +import product.placement_supply { HostIdentity } +import std.state_durability { + NoDurabilityCommitBarrier, + StateDurabilityAdmission, + StateDurabilityBoundary, + StateDurabilityCapability, + StateDurabilityCapabilityVersion, + StateDurabilityEvidence, + StatePerturbationGeneration, + StateRealizationIdentity, + SynchronizedCommit, + durability_commit_barrier_is_synchronized, +} +import std.types { Bool, List, NonEmptyStr } + +type BmcFanConfigReadbackSubject { + config_path: NonEmptyStr +} + +type BmcFanDurabilityAdmission = + StateDurabilityAdmission + +type BmcFanDurabilityEvidence = + StateDurabilityEvidence + +type BmcFanPolicyProjectionCapability { + board: BaseboardModel + firmware: BmcFirmwareVersion + projected_curve: BmcFanCurve + projected_config_digest: Digest + config_path: NonEmptyStr + controller_service: NonEmptyStr + controller_zone_path: NonEmptyStr + aggregate_fan_pid_entry: NonEmptyStr + realization: StateRealizationIdentity + durability_capability_version: StateDurabilityCapabilityVersion + publication_protocol: LinuxFilePublicationProtocol + durability_boundaries: List + required_tach_inputs: List +} + +fn bmc_fan_policy_projection_supports_apply( + capability: BmcFanPolicyProjectionCapability, + board: BaseboardModel, + desired_curve: BmcFanCurve, + observation: BmcFanPolicyObservation, +) -> Bool { + capability.board == board + && capability.firmware == observation.firmware + && capability.projected_curve == desired_curve + && capability.config_path == observation.board_config_path + && capability.controller_service == observation.controller_service + && linux_file_publication_has_synchronized_commit( + protocol: capability.publication_protocol, + ) + && durability_commit_barrier_is_synchronized( + barrier: bmc_fan_policy_projection_durability_capability( + capability: capability, + ).commit_barrier, + ) +} + +fn bmc_fan_state_durability_capability( + realization: StateRealizationIdentity, + capability_version: StateDurabilityCapabilityVersion, + boundaries: List, + publication_protocol: LinuxFilePublicationProtocol, +) -> StateDurabilityCapability { + StateDurabilityCapability { + realization: realization, + capability_version: capability_version, + supported_boundaries: boundaries, + commit_barrier: if linux_file_publication_has_synchronized_commit( + protocol: publication_protocol, + ) { + SynchronizedCommit + } else { + NoDurabilityCommitBarrier + }, + } +} + +fn bmc_fan_policy_projection_durability_capability( + capability: BmcFanPolicyProjectionCapability, +) -> StateDurabilityCapability { + bmc_fan_state_durability_capability( + realization: capability.realization, + capability_version: capability.durability_capability_version, + boundaries: capability.durability_boundaries, + publication_protocol: capability.publication_protocol, + ) +} + +fn bmc_fan_policy_projection_durability_admission( + host: HostIdentity, + capability: BmcFanPolicyProjectionCapability, + perturbation_generation: StatePerturbationGeneration, +) -> BmcFanDurabilityAdmission { + StateDurabilityAdmission { + target: host, + desired_state_digest: capability.projected_config_digest, + realization: capability.realization, + capability_version: capability.durability_capability_version, + perturbation_generation: perturbation_generation, + readback_subject: BmcFanConfigReadbackSubject { + config_path: capability.config_path, + }, + } +} + +fn bmc_fan_policy_projection_topology_matches( + capability: BmcFanPolicyProjectionCapability, + observation: BmcFanPolicyObservation, +) -> Bool { + match observation.controller_shape { + FanPidAggregatedEntry { + entry_name: entry_name, + tach_inputs: tach_inputs, + } => + entry_name == capability.aggregate_fan_pid_entry + && tach_inputs == capability.required_tach_inputs + FanPidIndependentEntries { entry_names: _ } => false + } +} + +data bmc_fan_projection_exact_version_note: String = "A fleet firmware track says which states are valid; it does not select a JSON realizer. A projection capability is admitted only for an exact board, firmware version, desired semantic curve, desired projected-config digest, config path, controller service, zone control path, aggregate fan PID entry, and installed tach-input topology witnessed together. Its durability capability and admission are derived from the same realization content identity, capability version/hash, lifecycle boundaries, executable publication protocol, target host, readback subject, and declared perturbation generation; there is no separately writable string ID that can let an old receipt qualify changed policy bytes. Thus OpenBMC 3.06 and 3.22 can both satisfy a 3.x intent while only 3.22 has the currently qualified projection." diff --git a/dag/gunbc/bmc_intent.dag b/dag/gunbc/bmc_intent.dag new file mode 100644 index 00000000000..74849b6efd2 --- /dev/null +++ b/dag/gunbc/bmc_intent.dag @@ -0,0 +1,134 @@ +module gunbc.bmc_intent + +import extdeps.bmc.capability { + BmcFirmwareIntent, + BmcFirmwareTrack, + BmcFirmwareTrackRequired, +} +import extdeps.bmc.types { + BmcFanAcousticPolicy, + BmcFanCurve, + BmcFanCurveStep, + BmcFanThermalGuard, + bmc_fan_curve_effectively_reaches_full_duty_before, + bmc_fan_curve_reaches_emergency_full_duty, + bmc_fan_curve_stays_within_quiet_duty, +} +import extdeps.cpu.ampere { ampere_altra_max_m12830_thermal_limits } +import extdeps.cpu.types { ProcessorThermalLimits } +import std.bmc { OpenBmc } +import std.measure { celsius, celsius_count, percent } +import std.state_durability { + ControlPlaneRestartBoundary, + DurableAcross, + LifecycleGracefulRestart, + StateDurabilityBoundary, + StateDurabilityIntent, +} + +type BmcFanPolicyIntent { + durability: StateDurabilityIntent + curve: BmcFanCurve + required_tach_inputs: List + acoustic_policy: BmcFanAcousticPolicy + thermal_guard: BmcFanThermalGuard + processor_thermal_limits: ProcessorThermalLimits +} + +type BmcControlPlaneIntent { + firmware: BmcFirmwareIntent + fan_policy: BmcFanPolicyIntent +} + +data fleet_bmc_fan_graceful_restart_boundary: StateDurabilityBoundary = ControlPlaneRestartBoundary { + mode: LifecycleGracefulRestart, +} + +data fleet_bmc_fan_durability_intent: StateDurabilityIntent = DurableAcross { + boundaries: [fleet_bmc_fan_graceful_restart_boundary], +} + +data fleet_quiet_temp_soc_fan_curve_steps: List = [ + BmcFanCurveStep { at_or_above: celsius(40), duty: percent(30) }, + BmcFanCurveStep { at_or_above: celsius(75), duty: percent(30) }, + BmcFanCurveStep { at_or_above: celsius(90), duty: percent(30) }, + BmcFanCurveStep { at_or_above: celsius(93), duty: percent(55) }, + BmcFanCurveStep { at_or_above: celsius(95), duty: percent(70) }, + BmcFanCurveStep { at_or_above: celsius(97), duty: percent(85) }, + BmcFanCurveStep { at_or_above: celsius(98), duty: percent(100) }, +] + +data fleet_quiet_temp_soc_fan_curve: BmcFanCurve = BmcFanCurve { + control_sensor: "TEMP_SOC", + steps: fleet_quiet_temp_soc_fan_curve_steps, + minimum_duty: percent(30), + sensor_unavailable_duty: percent(75), + zone_failsafe_duty: percent(75), + positive_hysteresis_degrees_celsius: 1, + negative_hysteresis_degrees_celsius: 2, +} + +data fleet_fan_acoustic_policy: BmcFanAcousticPolicy = BmcFanAcousticPolicy { + quiet_through_temperature: celsius(90), + quiet_duty_ceiling: percent(30), + emergency_full_duty_at_or_below: celsius(98), +} + +data fleet_fan_thermal_guard: BmcFanThermalGuard = BmcFanThermalGuard { + processor_rollback_temperature: celsius(100), + component_rollback_temperature: celsius(90), + required_component_sensors: [ + "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_MB", + "TEMP_RCA_VR", + "TEMP_SOC_VRD", + "TEMP_VR", + "TEMP_X550", + "TEMP_CARD_SIDE", + ], +} + +fn fleet_fan_policy_is_thermally_bounded() -> Bool { + bmc_fan_curve_stays_within_quiet_duty( + curve: fleet_quiet_temp_soc_fan_curve, + policy: fleet_fan_acoustic_policy, + ) + && bmc_fan_curve_reaches_emergency_full_duty( + curve: fleet_quiet_temp_soc_fan_curve, + policy: fleet_fan_acoustic_policy, + ) + && bmc_fan_curve_effectively_reaches_full_duty_before( + curve: fleet_quiet_temp_soc_fan_curve, + temperature: ampere_altra_max_m12830_thermal_limits.maximum_continuous_junction_temperature, + ) + && (celsius_count(fleet_fan_acoustic_policy.quiet_through_temperature) < celsius_count(ampere_altra_max_m12830_thermal_limits.maximum_continuous_junction_temperature)) + && (celsius_count(fleet_fan_acoustic_policy.emergency_full_duty_at_or_below) < celsius_count(ampere_altra_max_m12830_thermal_limits.thermal_throttle_temperature)) +} + +data fleet_bmc_valid_state: BmcControlPlaneIntent = BmcControlPlaneIntent { + firmware: BmcFirmwareTrackRequired { + track: BmcFirmwareTrack { + family: OpenBmc, + major_version: 3, + }, + }, + fan_policy: BmcFanPolicyIntent { + durability: fleet_bmc_fan_durability_intent, + curve: fleet_quiet_temp_soc_fan_curve, + required_tach_inputs: ["FAN1", "FAN2", "FAN3", "FAN4"], + acoustic_policy: fleet_fan_acoustic_policy, + thermal_guard: fleet_fan_thermal_guard, + processor_thermal_limits: ampere_altra_max_m12830_thermal_limits, + }, +} + +data fleet_bmc_valid_state_note: String = "The single fleet-wide BMC desired state for the current homogeneous ALTRAD8UD/M128-30 fleet: OpenBMC major track 3 and the fleet-owned quiet TEMP_SOC curve. Its declared lifetime currently requires preservation and reactivation across a graceful BMC control-plane restart; power loss, forced restart, firmware replacement, and factory reset are different boundaries and remain unclaimed until separately qualified. The curve stays at the factory-established 30 percent minimum through 90C, then rises to 55/70/85/100 percent at 93/95/97/98C. With the cited controller's 1C rising hysteresis, full cooling is effectively selected below the processor's cited 100C maximum-continuous junction temperature. Sensor or zone-controller loss deliberately requests 75 percent. This is desired policy, not a factory observation and not an acoustic measurement: inaudibility is a live acceptance criterion because different fan models produce different sound at one PWM duty. Firmware application still requires an exact approved release artifact." diff --git a/dag/gunbc/fleet_bmc_state.dag b/dag/gunbc/fleet_bmc_state.dag new file mode 100644 index 00000000000..1c58bde2ca7 --- /dev/null +++ b/dag/gunbc/fleet_bmc_state.dag @@ -0,0 +1,186 @@ +module gunbc.fleet_bmc_state + +import extdeps.bmc.types { + On, + ResetGracefulRestart, +} +import extdeps.filesystem.linux { + LinuxFilePublicationProtocol, + RenameReplaceWithinTargetDirectory, + SyncAllFilesystemsAfterReplacement, +} +import gunbc.bmc_intent { + fleet_bmc_fan_graceful_restart_boundary, + fleet_bmc_valid_state, +} +import gunbc.bmc_fan_projection { + BmcFanConfigReadbackSubject, + BmcFanDurabilityAdmission, + BmcFanDurabilityEvidence, + BmcFanPolicyProjectionCapability, + bmc_fan_policy_projection_durability_admission, +} +import gunbc.fleet_bmc_observation { + BmcFanRebootPersistenceReceipt, + srv4_bmc_fan_active_config_digest, + srv4_bmc_fan_observed_aggregate_entry, + srv4_bmc_fan_observed_config_path, + srv4_bmc_fan_observed_controller_service, + srv4_bmc_fan_observed_controller_zone_path, + srv4_bmc_fan_observed_tach_inputs, + srv4_bmc_fan_perturbation_generation, + srv4_bmc_fan_realization, + srv4_bmc_fan_reboot_persistence_receipt, + srv4_bmc_firmware_observation, +} +import gunbc.fleet_intent { + srv4_baseboard, + srv4_host, +} +import product.placement_supply { HostIdentity } +import std.content_hash { content_hash_atom } +import std.state_durability { + ActivationUnverified, + ActivationVerified, + StateActivationEvidence, + StateDurabilityBoundary, + StateDurabilityCapabilityVersion, + StateDurabilityEvidence, + StatePerturbationReceipt, + StatePreservationObservationRefused, + StatePreservationOutcome, + StatePreserved, + StateReadbackReceipt, +} +import std.types { Bool, List, String } + +data srv4_bmc_fan_durability_capability_version: StateDurabilityCapabilityVersion = StateDurabilityCapabilityVersion { + version: "openbmc-overlay-config-graceful-restart-v2", + definition_hash: content_hash_atom( + value: "rename-in-target-directory-sync-all-filesystems-graceful-bmc-restart-readback-v2", + ), +} + +data srv4_bmc_fan_publication_protocol: LinuxFilePublicationProtocol = LinuxFilePublicationProtocol { + stage_in_target_directory: true, + staged_content_validated: true, + replacement: RenameReplaceWithinTargetDirectory, + synchronization: SyncAllFilesystemsAfterReplacement, +} + +data srv4_bmc_fan_projection_capability: BmcFanPolicyProjectionCapability = BmcFanPolicyProjectionCapability { + board: srv4_baseboard.catalog.model, + firmware: srv4_bmc_firmware_observation.firmware, + projected_curve: fleet_bmc_valid_state.fan_policy.curve, + projected_config_digest: srv4_bmc_fan_active_config_digest, + config_path: srv4_bmc_fan_observed_config_path, + controller_service: srv4_bmc_fan_observed_controller_service, + controller_zone_path: srv4_bmc_fan_observed_controller_zone_path, + aggregate_fan_pid_entry: srv4_bmc_fan_observed_aggregate_entry, + realization: srv4_bmc_fan_realization, + durability_capability_version: srv4_bmc_fan_durability_capability_version, + publication_protocol: srv4_bmc_fan_publication_protocol, + durability_boundaries: [fleet_bmc_fan_graceful_restart_boundary], + required_tach_inputs: srv4_bmc_fan_observed_tach_inputs, +} + +data srv4_bmc_fan_durability_admission: BmcFanDurabilityAdmission = bmc_fan_policy_projection_durability_admission( + host: srv4_host.identity, + capability: srv4_bmc_fan_projection_capability, + perturbation_generation: srv4_bmc_fan_perturbation_generation, +) + +fn bmc_fan_reboot_host_power_stayed_on( + receipt: BmcFanRebootPersistenceReceipt, +) -> Bool { + match receipt.host_power_before { + On => + match receipt.host_power_after { + On => true + _ => false + } + _ => false + } +} + +fn bmc_fan_reboot_activation_evidence( + receipt: BmcFanRebootPersistenceReceipt, +) -> StateActivationEvidence { + if receipt.reset.reset_type == ResetGracefulRestart + && receipt.reset.http_status == 200 + && receipt.reset.accepted + && receipt.bmc_down_observed + && receipt.bmc_returned + && receipt.observed_outage_seconds > 0 + && bmc_fan_reboot_host_power_stayed_on(receipt: receipt) + && receipt.host_os_ssh_reachable_throughout + && receipt.post_reboot_controller_active + && !receipt.post_reboot_zone_failsafe_active + && receipt.post_reboot_observer_exit_success + && receipt.post_reboot_converge_noop_exit_success { + ActivationVerified + } else { + ActivationUnverified { + reason: "graceful BMC restart did not carry complete preservation and post-restart activation readback", + } + } +} + +fn bmc_fan_reboot_preservation_outcome( + receipt: BmcFanRebootPersistenceReceipt, +) -> StatePreservationOutcome { + if receipt.bmc_returned { + StatePreserved { + before: StateReadbackReceipt { + target: receipt.before.host, + subject: BmcFanConfigReadbackSubject { + config_path: receipt.before.policy.board_config_path, + }, + state_digest: receipt.before.active_config_digest, + observed_at: receipt.before.observed_at, + }, + after: StateReadbackReceipt { + target: receipt.host, + subject: BmcFanConfigReadbackSubject { + config_path: receipt.post_reboot_policy.board_config_path, + }, + state_digest: receipt.post_reboot_active_config_digest, + observed_at: receipt.observed_at, + }, + activation: bmc_fan_reboot_activation_evidence(receipt: receipt), + } + } else { + StatePreservationObservationRefused { + reason: "BMC did not return for post-boundary content and activation readback", + } + } +} + +fn bmc_fan_reboot_durability_evidence( + receipt: BmcFanRebootPersistenceReceipt, + admission: BmcFanDurabilityAdmission, + boundary: StateDurabilityBoundary, +) -> BmcFanDurabilityEvidence { + StateDurabilityEvidence { + admission: admission, + perturbation: StatePerturbationReceipt { + receipt_id: receipt.perturbation_receipt_id, + target: receipt.host, + boundary: boundary, + generation: receipt.perturbation_generation, + started_at: receipt.reset.observed_at, + completed_at: receipt.observed_at, + }, + outcome: bmc_fan_reboot_preservation_outcome(receipt: receipt), + } +} + +data srv4_bmc_fan_durability_evidence: List = [ + bmc_fan_reboot_durability_evidence( + receipt: srv4_bmc_fan_reboot_persistence_receipt, + admission: srv4_bmc_fan_durability_admission, + boundary: fleet_bmc_fan_graceful_restart_boundary, + ), +] + +data srv4_bmc_fan_policy_qualification_note: String = "The desired curve and projected file digest are admitted independently of the raw live observations. Qualification succeeds only because the typed srv4 receipt binds the same host, exact desired digest, realization content identity, capability definition version, perturbation generation, readback subject, and graceful-restart boundary, with before and after readback both equal to the desired digest." diff --git a/dag/gunbc/fleet_intent.dag b/dag/gunbc/fleet_intent.dag index 1233180798f..096aaf50387 100644 --- a/dag/gunbc/fleet_intent.dag +++ b/dag/gunbc/fleet_intent.dag @@ -25,6 +25,7 @@ import product.placement_supply { HostIdentity } import std.disposition { Disposition, Scaffold, SingleAuthority } import std.decl_ref { DeclarationRef, WholeDeclaration } import extdeps.boards.types { ServerBaseboard } +import gunbc.bmc_intent { BmcControlPlaneIntent, fleet_bmc_valid_state } import std.bmc { BmcEndpoint, Redfish } import std.credentials { CredentialFlow, Stored } import std.measure { hertz, ByteSize, byte_size, byte_size_count, Bandwidth, HardwareThreadCount, hardware_thread_count_value, Nano, measure_count } @@ -72,6 +73,7 @@ type ExecutionSurface { type ComputeHost { identity: HostIdentity baseboard: ServerBaseboard? + bmc_intent: BmcControlPlaneIntent? processors: List memory: List storage: List @@ -256,6 +258,7 @@ data srv3_memory_population: HostMemoryPopulation = HostMemoryPopulation { data srv1_host: ComputeHost = ComputeHost { identity: operator_host_srv1, baseboard: Present { value: srv1_baseboard }, + bmc_intent: Present { value: fleet_bmc_valid_state }, processors: [CpuProcessor { cpu: fleet_intent_cpu }], memory: memory_devices_from_population(pop: srv1_memory_population), storage: [srv1_nvme0], @@ -264,6 +267,7 @@ data srv1_host: ComputeHost = ComputeHost { data srv2_host: ComputeHost = ComputeHost { identity: operator_host_srv2, baseboard: Present { value: srv2_baseboard }, + bmc_intent: Present { value: fleet_bmc_valid_state }, processors: [CpuProcessor { cpu: fleet_intent_cpu }], memory: memory_devices_from_population(pop: srv2_memory_population), storage: [srv2_nvme0], @@ -272,6 +276,7 @@ data srv2_host: ComputeHost = ComputeHost { data srv3_host: ComputeHost = ComputeHost { identity: operator_host_srv3, baseboard: Present { value: srv3_baseboard }, + bmc_intent: Present { value: fleet_bmc_valid_state }, processors: [CpuProcessor { cpu: fleet_intent_cpu }], memory: memory_devices_from_population(pop: srv3_memory_population), storage: [srv3_nvme0], @@ -303,6 +308,7 @@ data srv4_memory_population_deferral_disposition: Disposition = Scaffold { data srv4_host: ComputeHost = ComputeHost { identity: operator_host_srv4, baseboard: Present { value: srv4_baseboard }, + bmc_intent: Present { value: fleet_bmc_valid_state }, processors: [CpuProcessor { cpu: fleet_intent_cpu }], memory: [MemoryDevice { capacity: gibibyte_to_byte_size(g: gibibyte(128)), memory_kind: Dram, bandwidth: none }], storage: [srv4_nvme0], @@ -321,4 +327,3 @@ fn fleet_intent_execution_surface() -> ExecutionSurface { network: [], } } - diff --git a/dag/test/claim/bmc_firmware_thermal_witness_test.dag b/dag/test/claim/bmc_firmware_thermal_witness_test.dag new file mode 100644 index 00000000000..ccd1dc3562c --- /dev/null +++ b/dag/test/claim/bmc_firmware_thermal_witness_test.dag @@ -0,0 +1,719 @@ +module test.claim.bmc_firmware_thermal_witness + +import extdeps.bmc.capability { + BmcFirmwareTrackRequired, + BmcFirmwareUnpinned, + bmc_firmware_observation_matches_intent, +} +import extdeps.bmc.types { + BmcConfigPersistenceUnsupported, + BmcFanCurve, + BmcFanDutyDecision, + BmcFanDutyFromCurve, + BmcFanDutyFromSensorFailsafe, + BmcFanPolicyObservation, + BmcOverlayConfigObserved, + FanPidAggregatedEntry, + FanPidIndependentEntries, + On, + RedfishFanControlResourceAbsent, + RedfishInitialDutyControlResource, + RedfishLegacyChassisThermal, + RedfishThermalQueryComplete, + RedfishThermalQueryRefused, + RedfishThermalSubsystemSensorCollection, + ResetGracefulRestart, + bmc_fan_duty_decision, + redfish_thermal_query_verdict, +} +import extdeps.boards.asrock_rack { + asrock_altrad8ud_openbmc_3_06_00_capabilities, + asrock_altrad8ud_openbmc_2_07_00_fan_policy_observation, + asrock_altrad8ud_openbmc_3_22_00_fan_policy_observation, + asrock_altrad8ud_factory_temp_soc_fan_curve, +} +import gunbc.fleet_bmc_observation { + srv3_bmc_firmware_observation, + srv3_bmc_redfish_surface_observation, + srv3_bmc_update_service_observation, + srv4_bmc_config_persistence_observation, + srv4_bmc_fan_converged_policy_observation, + srv4_bmc_fan_convergence_receipt, + srv4_bmc_fan_perturbation_generation, + srv4_bmc_fan_reboot_persistence_receipt, + srv4_bmc_firmware_observation, + srv4_bmc_redfish_surface_observation, + srv4_bmc_update_service_observation, +} +import gunbc.fleet_bmc_state { + srv4_bmc_fan_durability_admission, + srv4_bmc_fan_durability_evidence, + srv4_bmc_fan_projection_capability, +} +import extdeps.filesystem.linux { linux_overlay_observation_is_flash_backed } +import gunbc.bmc_converge { + ApprovedBmcFirmwareArtifact, + bmc_fan_policy_classification, + bmc_firmware_classification, +} +import gunbc.bmc_intent { + fleet_bmc_valid_state, + fleet_fan_policy_is_thermally_bounded, + fleet_quiet_temp_soc_fan_curve, +} +import gunbc.fleet_intent { + ComputeHost, + srv3_baseboard, + srv1_host, + srv2_host, + srv3_host, + srv4_baseboard, + srv4_host, +} +import std.measure { celsius, celsius_count, percent, percent_count, rpm_count } +import std.nat { Nat } +import extdeps.crypto.hash { sha256_digest } +import extdeps.uri { Uri, Https } +import std.upsert_decision { + Apply, + Conflict, + Converged, + Drifted, + Noop, + Refuse, + UnknownRefused, +} +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +data fixture_approved_openbmc_3_22_artifact: ApprovedBmcFirmwareArtifact = ApprovedBmcFirmwareArtifact { + firmware: asrock_altrad8ud_openbmc_3_22_00_fan_policy_observation.firmware, + board: srv3_baseboard.catalog.model, + image_uri: Uri { + scheme: Https, + locator: "example.invalid/fixture/altrad8ud-1l2t_3.22.00.static.mtd.tar", + }, + digest: sha256_digest( + hex: "0000000000000000000000000000000000000000000000000000000000000000", + ), +} + +fn curve_decision_has_duty(decision: BmcFanDutyDecision, expected: Nat) -> Bool { + match decision { + BmcFanDutyFromCurve { reading: _, duty: d } => percent_count(d) == expected + BmcFanDutyFromSensorFailsafe { duty: _ } => false + } +} + +fn host_uses_global_bmc_valid_state(host: ComputeHost) -> Bool { + match host.bmc_intent { + Present { value: intent } => intent == fleet_bmc_valid_state + Absent => false + } +} + +test fn every_metal_host_uses_the_single_global_openbmc_3_valid_state() -> Bool { + host_uses_global_bmc_valid_state(host: srv1_host) + && host_uses_global_bmc_valid_state(host: srv2_host) + && host_uses_global_bmc_valid_state(host: srv3_host) + && host_uses_global_bmc_valid_state(host: srv4_host) + && match fleet_bmc_valid_state.firmware { + BmcFirmwareTrackRequired { track: track } => + track.major_version == 3 + BmcFirmwareUnpinned => false + } +} + +test fn current_firmware_observations_make_srv3_drift_and_srv4_converged() -> Bool { + srv3_bmc_firmware_observation.firmware.version == "2.07.00" + && srv4_bmc_firmware_observation.firmware.version == "3.22.00" + && !bmc_firmware_observation_matches_intent( + intent: fleet_bmc_valid_state.firmware, + observation: srv3_bmc_firmware_observation + ) + && bmc_firmware_observation_matches_intent( + intent: fleet_bmc_valid_state.firmware, + observation: srv4_bmc_firmware_observation + ) +} + +test fn both_update_services_are_observed_but_neither_exposes_a_backup_bmc_image() -> Bool { + srv3_bmc_update_service_observation.active_bmc_image_updateable + && srv4_bmc_update_service_observation.active_bmc_image_updateable + && srv3_bmc_update_service_observation.http_push_uri == "/redfish/v1/UpdateService/update" + && srv4_bmc_update_service_observation.http_push_uri == "/redfish/v1/UpdateService/update" + && srv3_bmc_update_service_observation.bmc_image_member_count == 1 + && srv4_bmc_update_service_observation.bmc_image_member_count == 1 + && !srv3_bmc_update_service_observation.backup_bmc_image_observed + && !srv4_bmc_update_service_observation.backup_bmc_image_observed +} + +test fn firmware_classification_reuses_upsert_states_and_refuses_unapproved_update() -> Bool { + let srv3 = bmc_firmware_classification( + host: srv3_host.identity, + board: srv3_baseboard.catalog.model, + intent: fleet_bmc_valid_state.firmware, + observation: Present { value: srv3_bmc_firmware_observation }, + artifact: none, + update_service: none, + ) + let srv4 = bmc_firmware_classification( + host: srv4_host.identity, + board: srv4_baseboard.catalog.model, + intent: fleet_bmc_valid_state.firmware, + observation: Present { value: srv4_bmc_firmware_observation }, + artifact: none, + update_service: none, + ) + let unknown = bmc_firmware_classification( + host: srv1_host.identity, + board: srv3_baseboard.catalog.model, + intent: fleet_bmc_valid_state.firmware, + observation: none, + artifact: none, + update_service: none, + ) + srv3.verdict == Drifted + && match srv3.decision { + Refuse { reason: _ } => true + Noop => false + Apply { plan: _ } => false + } + && srv4.verdict == Converged + && match srv4.decision { + Noop => true + Refuse { reason: _ } => false + Apply { plan: _ } => false + } + && unknown.verdict == UnknownRefused + && match unknown.decision { + Refuse { reason: _ } => true + Noop => false + Apply { plan: _ } => false + } +} + +test fn approved_exact_firmware_artifact_builds_a_typed_srv3_update_plan() -> Bool { + let classified = bmc_firmware_classification( + host: srv3_host.identity, + board: srv3_baseboard.catalog.model, + intent: fleet_bmc_valid_state.firmware, + observation: Present { value: srv3_bmc_firmware_observation }, + artifact: Present { value: fixture_approved_openbmc_3_22_artifact }, + update_service: Present { value: srv3_bmc_update_service_observation }, + ) + let without_update_surface = bmc_firmware_classification( + host: srv3_host.identity, + board: srv3_baseboard.catalog.model, + intent: fleet_bmc_valid_state.firmware, + observation: Present { value: srv3_bmc_firmware_observation }, + artifact: Present { value: fixture_approved_openbmc_3_22_artifact }, + update_service: none, + ) + classified.verdict == Drifted + && match classified.decision { + Apply { plan: plan } => + plan.host == srv3_host.identity + && plan.observed == srv3_bmc_firmware_observation + && plan.artifact == fixture_approved_openbmc_3_22_artifact + && plan.update_service == srv3_bmc_update_service_observation + Noop => false + Refuse { reason: _ } => false + } + && without_update_surface.verdict == Drifted + && match without_update_surface.decision { + Refuse { reason: _ } => true + Noop => false + Apply { plan: _ } => false + } +} + +test fn redfish_surface_difference_is_observed_not_inferred() -> Bool { + srv3_bmc_redfish_surface_observation.thermal_surface == RedfishThermalSubsystemSensorCollection + && srv3_bmc_redfish_surface_observation.chassis_sensor_collection_member_count == 51 + && srv3_bmc_redfish_surface_observation.fan_control_surface == RedfishFanControlResourceAbsent + && srv4_bmc_redfish_surface_observation.thermal_surface == RedfishLegacyChassisThermal + && srv4_bmc_redfish_surface_observation.chassis_sensor_collection_member_count == 9 + && srv4_bmc_redfish_surface_observation.fan_control_surface == RedfishInitialDutyControlResource +} + +test fn both_observed_thermal_query_shapes_are_complete() -> Bool { + let srv3_verdict = redfish_thermal_query_verdict( + chassis_sensors_success: true, + thermal_subsystem_success: true, + legacy_thermal_success: false + ) + let srv4_verdict = redfish_thermal_query_verdict( + chassis_sensors_success: true, + thermal_subsystem_success: false, + legacy_thermal_success: true + ) + match srv3_verdict { + RedfishThermalQueryComplete { surface: srv3_surface } => + match srv4_verdict { + RedfishThermalQueryComplete { surface: srv4_surface } => + srv3_surface == RedfishThermalSubsystemSensorCollection + && srv4_surface == RedfishLegacyChassisThermal + RedfishThermalQueryRefused { reason: _ } => false + } + RedfishThermalQueryRefused { reason: _ } => false + } +} + +test fn sensors_http_success_alone_is_incomplete_red_control() -> Bool { + match redfish_thermal_query_verdict( + chassis_sensors_success: true, + thermal_subsystem_success: false, + legacy_thermal_success: false + ) { + RedfishThermalQueryComplete { surface: _ } => false + RedfishThermalQueryRefused { reason: _ } => true + } +} + +test fn firmware_versions_share_curve_but_not_controller_shape() -> Bool { + asrock_altrad8ud_openbmc_2_07_00_fan_policy_observation.curve == asrock_altrad8ud_factory_temp_soc_fan_curve + && asrock_altrad8ud_openbmc_3_22_00_fan_policy_observation.curve == asrock_altrad8ud_factory_temp_soc_fan_curve + && match asrock_altrad8ud_openbmc_2_07_00_fan_policy_observation.controller_shape { + FanPidIndependentEntries { entry_names: names } => + list_length(items: names) == 10 + FanPidAggregatedEntry { entry_name: _, tach_inputs: _ } => false + } + && match asrock_altrad8ud_openbmc_3_22_00_fan_policy_observation.controller_shape { + FanPidAggregatedEntry { entry_name: name, tach_inputs: inputs } => + name == "FAN1" && list_length(items: inputs) == 5 + FanPidIndependentEntries { entry_names: _ } => false + } +} + +test fn temp_soc_curve_explains_the_audible_step() -> Bool { + curve_decision_has_duty( + decision: bmc_fan_duty_decision( + curve: asrock_altrad8ud_factory_temp_soc_fan_curve, + reading: Present { value: celsius(39) } + ), + expected: 30 + ) + && curve_decision_has_duty( + decision: bmc_fan_duty_decision( + curve: asrock_altrad8ud_factory_temp_soc_fan_curve, + reading: Present { value: celsius(62) } + ), + expected: 40 + ) + && curve_decision_has_duty( + decision: bmc_fan_duty_decision( + curve: asrock_altrad8ud_factory_temp_soc_fan_curve, + reading: Present { value: celsius(69) } + ), + expected: 50 + ) + && curve_decision_has_duty( + decision: bmc_fan_duty_decision( + curve: asrock_altrad8ud_factory_temp_soc_fan_curve, + reading: Present { value: celsius(75) } + ), + expected: 75 + ) + && curve_decision_has_duty( + decision: bmc_fan_duty_decision( + curve: asrock_altrad8ud_factory_temp_soc_fan_curve, + reading: Present { value: celsius(85) } + ), + expected: 100 + ) +} + +test fn fleet_curve_is_quiet_through_90c_and_emergency_full_before_continuous_limit() -> Bool { + fleet_fan_policy_is_thermally_bounded() + && curve_decision_has_duty( + decision: bmc_fan_duty_decision( + curve: fleet_quiet_temp_soc_fan_curve, + reading: Present { value: celsius(74) } + ), + expected: 30 + ) + && curve_decision_has_duty( + decision: bmc_fan_duty_decision( + curve: fleet_quiet_temp_soc_fan_curve, + reading: Present { value: celsius(75) } + ), + expected: 30 + ) + && curve_decision_has_duty( + decision: bmc_fan_duty_decision( + curve: fleet_quiet_temp_soc_fan_curve, + reading: Present { value: celsius(90) } + ), + expected: 30 + ) + && curve_decision_has_duty( + decision: bmc_fan_duty_decision( + curve: fleet_quiet_temp_soc_fan_curve, + reading: Present { value: celsius(93) } + ), + expected: 55 + ) + && curve_decision_has_duty( + decision: bmc_fan_duty_decision( + curve: fleet_quiet_temp_soc_fan_curve, + reading: Present { value: celsius(97) } + ), + expected: 85 + ) + && curve_decision_has_duty( + decision: bmc_fan_duty_decision( + curve: fleet_quiet_temp_soc_fan_curve, + reading: Present { value: celsius(98) } + ), + expected: 100 + ) +} + +test fn sensor_unavailable_uses_same_duty_but_retains_cause() -> Bool { + match bmc_fan_duty_decision( + curve: asrock_altrad8ud_factory_temp_soc_fan_curve, + reading: Absent + ) { + BmcFanDutyFromSensorFailsafe { duty: d } => percent_count(d) == 75 + BmcFanDutyFromCurve { reading: _, duty: _ } => false + } +} + +fn srv4_fan_policy_with_curve(curve: BmcFanCurve) -> BmcFanPolicyObservation { + BmcFanPolicyObservation { + firmware: asrock_altrad8ud_openbmc_3_22_00_fan_policy_observation.firmware, + curve: curve, + selected_config_path: asrock_altrad8ud_openbmc_3_22_00_fan_policy_observation.selected_config_path, + board_config_path: asrock_altrad8ud_openbmc_3_22_00_fan_policy_observation.board_config_path, + controller_service: asrock_altrad8ud_openbmc_3_22_00_fan_policy_observation.controller_service, + controller_binary: asrock_altrad8ud_openbmc_3_22_00_fan_policy_observation.controller_binary, + controller_shape: FanPidAggregatedEntry { + entry_name: srv4_bmc_fan_projection_capability.aggregate_fan_pid_entry, + tach_inputs: srv4_bmc_fan_projection_capability.required_tach_inputs, + }, + observed_at: "fixture", + } +} + +fn minimum_duty_perturbation() -> BmcFanCurve { + BmcFanCurve { + control_sensor: fleet_quiet_temp_soc_fan_curve.control_sensor, + steps: fleet_quiet_temp_soc_fan_curve.steps, + minimum_duty: percent(31), + sensor_unavailable_duty: fleet_quiet_temp_soc_fan_curve.sensor_unavailable_duty, + zone_failsafe_duty: fleet_quiet_temp_soc_fan_curve.zone_failsafe_duty, + positive_hysteresis_degrees_celsius: fleet_quiet_temp_soc_fan_curve.positive_hysteresis_degrees_celsius, + negative_hysteresis_degrees_celsius: fleet_quiet_temp_soc_fan_curve.negative_hysteresis_degrees_celsius, + } +} + +test fn srv4_flash_overlay_is_observed_writable_and_live_overridden() -> Bool { + match srv4_bmc_config_persistence_observation { + BmcOverlayConfigObserved { + firmware: firmware, + config_path: path, + overlay: overlay, + writable: writable, + upper_override_present: upper_present, + available_bytes: _, + observed_at: _, + } => + firmware.version == "3.22.00" + && path == "/usr/share/swampd/config-asrr.json" + && linux_overlay_observation_is_flash_backed(observation: overlay) + && writable + && upper_present + BmcConfigPersistenceUnsupported { + firmware: _, + config_path: _, + reason: _, + observed_at: _, + } => false + } +} + +test fn srv4_live_receipt_is_converged_quiet_and_failsafe_clear() -> Bool { + srv4_bmc_fan_convergence_receipt.policy == srv4_bmc_fan_converged_policy_observation + && srv4_bmc_fan_convergence_receipt.policy.curve == fleet_quiet_temp_soc_fan_curve + && !srv4_bmc_fan_convergence_receipt.zone_failsafe_active + && srv4_bmc_fan_convergence_receipt.monitoring_sample_count == 12 + && srv4_bmc_fan_convergence_receipt.final_tachs.length() == 4 +} + +test fn srv4_durability_admission_binds_the_literal_live_subject() -> Bool { + let admission = srv4_bmc_fan_durability_admission + admission.target == srv4_host.identity + && admission.desired_state_digest == srv4_bmc_fan_projection_capability.projected_config_digest + && admission.desired_state_digest == srv4_bmc_fan_convergence_receipt.active_config_digest + && admission.realization == srv4_bmc_fan_projection_capability.realization + && admission.capability_version == srv4_bmc_fan_projection_capability.durability_capability_version + && admission.perturbation_generation == srv4_bmc_fan_perturbation_generation + && admission.readback_subject.config_path == srv4_bmc_fan_projection_capability.config_path + && srv4_bmc_fan_projection_capability.projected_curve == fleet_quiet_temp_soc_fan_curve +} + +test fn srv4_bmc_reboot_persistence_receipt_proves_same_config_and_noop() -> Bool { + let receipt = srv4_bmc_fan_reboot_persistence_receipt + let classified = bmc_fan_policy_classification( + host: srv4_host.identity, + board: srv4_baseboard.catalog.model, + intent: fleet_bmc_valid_state, + observation: Present { value: receipt.post_reboot_policy }, + persistence: Present { value: srv4_bmc_config_persistence_observation }, + projection: Present { value: srv4_bmc_fan_projection_capability }, + durability_generation: srv4_bmc_fan_perturbation_generation, + durability_evidence: srv4_bmc_fan_durability_evidence, + ) + receipt.reset.action_target == "/redfish/v1/Managers/bmc/Actions/Manager.Reset" + && receipt.reset.reset_type == ResetGracefulRestart + && receipt.reset.http_status == 200 + && receipt.reset.accepted + && receipt.bmc_down_observed + && receipt.bmc_returned + && receipt.observed_outage_seconds == 157 + && receipt.host_power_before == On + && receipt.host_power_after == On + && receipt.host_os_ssh_reachable_throughout + && receipt.before.active_config_digest == receipt.post_reboot_active_config_digest + && receipt.post_reboot_policy.curve == fleet_quiet_temp_soc_fan_curve + && receipt.post_reboot_tachs.length() == 4 + && fold( + receipt.post_reboot_tachs, + init: true, + f: (healthy, tach) => healthy && rpm_count(tach.reading) > 0, + ) + && (celsius_count(receipt.post_reboot_temp_soc) < celsius_count(fleet_bmc_valid_state.fan_policy.thermal_guard.processor_rollback_temperature)) + && receipt.post_reboot_controller_active + && !receipt.post_reboot_zone_failsafe_active + && receipt.post_reboot_observer_exit_success + && receipt.post_reboot_converge_noop_exit_success + && classified.verdict == Converged + && match classified.decision { + Noop => true + Refuse { reason: _ } => false + Apply { plan: _ } => false + } +} + +test fn srv4_receipt_cannot_qualify_a_later_perturbation_generation() -> Bool { + let classified = bmc_fan_policy_classification( + host: srv4_host.identity, + board: srv4_baseboard.catalog.model, + intent: fleet_bmc_valid_state, + observation: Present { value: srv4_bmc_fan_converged_policy_observation }, + persistence: Present { value: srv4_bmc_config_persistence_observation }, + projection: Present { value: srv4_bmc_fan_projection_capability }, + durability_generation: 2, + durability_evidence: srv4_bmc_fan_durability_evidence, + ) + classified.verdict == UnknownRefused + && match classified.decision { + Refuse { reason: reason } => + string_contains(s: reason, pattern: "no exact evidence") + Noop => false + Apply { plan: _ } => false + } +} + +test fn duplicate_srv4_durability_receipts_refuse_instead_of_first_wins() -> Bool { + let classified = bmc_fan_policy_classification( + host: srv4_host.identity, + board: srv4_baseboard.catalog.model, + intent: fleet_bmc_valid_state, + observation: Present { value: srv4_bmc_fan_converged_policy_observation }, + persistence: Present { value: srv4_bmc_config_persistence_observation }, + projection: Present { value: srv4_bmc_fan_projection_capability }, + durability_generation: srv4_bmc_fan_perturbation_generation, + durability_evidence: append( + srv4_bmc_fan_durability_evidence, + items: srv4_bmc_fan_durability_evidence, + ), + ) + classified.verdict == UnknownRefused + && match classified.decision { + Refuse { reason: reason } => + string_contains(s: reason, pattern: "duplicate or conflicting") + Noop => false + Apply { plan: _ } => false + } +} + +test fn converged_srv4_curve_without_topology_projection_refuses_noop() -> Bool { + let classified = bmc_fan_policy_classification( + host: srv4_host.identity, + board: srv4_baseboard.catalog.model, + intent: fleet_bmc_valid_state, + observation: Present { + value: srv4_fan_policy_with_curve(curve: fleet_quiet_temp_soc_fan_curve), + }, + persistence: none, + projection: none, + durability_generation: srv4_bmc_fan_perturbation_generation, + durability_evidence: srv4_bmc_fan_durability_evidence, + ) + classified.verdict == UnknownRefused + && match classified.decision { + Noop => false + Refuse { reason: _ } => true + Apply { plan: _ } => false + } +} + +test fn converged_curve_and_installed_tach_topology_are_noop() -> Bool { + let classified = bmc_fan_policy_classification( + host: srv4_host.identity, + board: srv4_baseboard.catalog.model, + intent: fleet_bmc_valid_state, + observation: Present { + value: srv4_fan_policy_with_curve(curve: fleet_quiet_temp_soc_fan_curve), + }, + persistence: Present { value: srv4_bmc_config_persistence_observation }, + projection: Present { value: srv4_bmc_fan_projection_capability }, + durability_generation: srv4_bmc_fan_perturbation_generation, + durability_evidence: srv4_bmc_fan_durability_evidence, + ) + classified.verdict == Converged + && match classified.decision { + Noop => true + Refuse { reason: _ } => false + Apply { plan: _ } => false + } +} + +test fn desired_curve_with_stale_uninstalled_fan_input_still_builds_apply_plan() -> Bool { + let stale_topology = BmcFanPolicyObservation { + firmware: asrock_altrad8ud_openbmc_3_22_00_fan_policy_observation.firmware, + curve: fleet_quiet_temp_soc_fan_curve, + selected_config_path: asrock_altrad8ud_openbmc_3_22_00_fan_policy_observation.selected_config_path, + board_config_path: asrock_altrad8ud_openbmc_3_22_00_fan_policy_observation.board_config_path, + controller_service: asrock_altrad8ud_openbmc_3_22_00_fan_policy_observation.controller_service, + controller_binary: asrock_altrad8ud_openbmc_3_22_00_fan_policy_observation.controller_binary, + controller_shape: asrock_altrad8ud_openbmc_3_22_00_fan_policy_observation.controller_shape, + observed_at: "fixture", + } + let classified = bmc_fan_policy_classification( + host: srv4_host.identity, + board: srv4_baseboard.catalog.model, + intent: fleet_bmc_valid_state, + observation: Present { value: stale_topology }, + persistence: Present { value: srv4_bmc_config_persistence_observation }, + projection: Present { value: srv4_bmc_fan_projection_capability }, + durability_generation: srv4_bmc_fan_perturbation_generation, + durability_evidence: srv4_bmc_fan_durability_evidence, + ) + classified.verdict == Drifted + && match classified.decision { + Apply { plan: _ } => true + Noop => false + Refuse { reason: _ } => false + } +} + +test fn drifted_srv4_curve_builds_typed_overlay_plan_only_with_exact_projection_and_persistence() -> Bool { + let drifted = asrock_altrad8ud_openbmc_3_22_00_fan_policy_observation + let with_storage = bmc_fan_policy_classification( + host: srv4_host.identity, + board: srv4_baseboard.catalog.model, + intent: fleet_bmc_valid_state, + observation: Present { value: drifted }, + persistence: Present { value: srv4_bmc_config_persistence_observation }, + projection: Present { value: srv4_bmc_fan_projection_capability }, + durability_generation: srv4_bmc_fan_perturbation_generation, + durability_evidence: srv4_bmc_fan_durability_evidence, + ) + let without_storage = bmc_fan_policy_classification( + host: srv4_host.identity, + board: srv4_baseboard.catalog.model, + intent: fleet_bmc_valid_state, + observation: Present { value: drifted }, + persistence: none, + projection: Present { value: srv4_bmc_fan_projection_capability }, + durability_generation: srv4_bmc_fan_perturbation_generation, + durability_evidence: srv4_bmc_fan_durability_evidence, + ) + let without_projection = bmc_fan_policy_classification( + host: srv4_host.identity, + board: srv4_baseboard.catalog.model, + intent: fleet_bmc_valid_state, + observation: Present { value: drifted }, + persistence: Present { value: srv4_bmc_config_persistence_observation }, + projection: none, + durability_generation: srv4_bmc_fan_perturbation_generation, + durability_evidence: srv4_bmc_fan_durability_evidence, + ) + with_storage.verdict == Drifted + && match with_storage.decision { + Apply { plan: plan } => + plan.host == srv4_host.identity + && plan.config_path == "/usr/share/swampd/config-asrr.json" + && plan.controller_service == "phosphor-pid-control.service" + && plan.desired_curve == fleet_quiet_temp_soc_fan_curve + && plan.projection == srv4_bmc_fan_projection_capability + Noop => false + Refuse { reason: _ } => false + } + && without_storage.verdict == UnknownRefused + && match without_storage.decision { + Refuse { reason: _ } => true + Noop => false + Apply { plan: _ } => false + } + && without_projection.verdict == UnknownRefused + && match without_projection.decision { + Refuse { reason: _ } => true + Noop => false + Apply { plan: _ } => false + } +} + +test fn major_three_track_does_not_authorize_a_322_projection_on_306() -> Bool { + let observed = BmcFanPolicyObservation { + firmware: asrock_altrad8ud_openbmc_3_06_00_capabilities.firmware, + curve: minimum_duty_perturbation(), + selected_config_path: "/usr/share/swampd/config.json", + board_config_path: "/usr/share/swampd/config-asrr.json", + controller_service: "phosphor-pid-control.service", + controller_binary: "/usr/bin/swampd", + controller_shape: asrock_altrad8ud_openbmc_3_22_00_fan_policy_observation.controller_shape, + observed_at: "fixture", + } + let classified = bmc_fan_policy_classification( + host: srv4_host.identity, + board: srv4_baseboard.catalog.model, + intent: fleet_bmc_valid_state, + observation: Present { value: observed }, + persistence: none, + projection: Present { value: srv4_bmc_fan_projection_capability }, + durability_generation: srv4_bmc_fan_perturbation_generation, + durability_evidence: [], + ) + classified.verdict == Conflict + && match classified.decision { + Refuse { reason: _ } => true + Noop => false + Apply { plan: _ } => false + } +} + +test fn srv3_fan_realization_refuses_until_global_firmware_track_converges() -> Bool { + let classified = bmc_fan_policy_classification( + host: srv3_host.identity, + board: srv3_baseboard.catalog.model, + intent: fleet_bmc_valid_state, + observation: Present { + value: asrock_altrad8ud_openbmc_2_07_00_fan_policy_observation, + }, + persistence: none, + projection: none, + durability_generation: 1, + durability_evidence: [], + ) + classified.verdict == Conflict + && match classified.decision { + Refuse { reason: _ } => true + Noop => false + Apply { plan: _ } => false + } +}