diff --git a/dag/gunbc/fleet/fleet_converge_apply.dag b/dag/gunbc/fleet/fleet_converge_apply.dag index 8aebcdc5609..c94aa9519a7 100644 --- a/dag/gunbc/fleet/fleet_converge_apply.dag +++ b/dag/gunbc/fleet/fleet_converge_apply.dag @@ -31,6 +31,88 @@ import std.realization_reconcile { NotConverged, reconciliation_converged, } +import gunbc.world_converge { + WorldObservation, WorldObserved, WorldUnobservable, + WorldDifference, WorldDiverged, + WorldActuation, WorldApplied, WorldActuationRefused, + WorldConvergenceHandler, +} + +type HostConvergenceSubject { identity: HostIdentity } + +type HostConvergenceContext { + policy: ConvergePolicy + transport: HostEffectTransport +} + +type HostConvergenceDifference = HostConvergenceRequired + +type HostConvergenceRefusal + = HostConvergencePolicyUnsound { reason: String } + | HostConvergenceUnknownIdentity { identity: HostIdentity } + | HostConvergenceApplyRefused { reason: String } + +fn observe_host_convergence( + subject: HostConvergenceSubject, + context: HostConvergenceContext, +) -> WorldObservation { + match context.policy { + ConvergePolicyUnsound { reason: reason } => + WorldUnobservable { cause: HostConvergencePolicyUnsound { reason: reason } } + ConvergePolicyDerived { hosts: hosts } => + match find_by_identity(hosts, identity: subject.identity, key: fn(host) { host.identity }) { + Present { value: host } => WorldObserved { value: host } + Absent => WorldUnobservable { + cause: HostConvergenceUnknownIdentity { identity: subject.identity }, + } + } + } +} + +fn host_convergence_differences( + _: HostConvergenceSubject, + _: HostConvergenceContext, + _: HostConverge, +) -> WorldDifference { + WorldDiverged { differences: [HostConvergenceRequired] } +} + +fn apply_host_convergence( + _: HostConvergenceSubject, + context: HostConvergenceContext, + observed: HostConverge, + _: List, +) -> WorldActuation { + match converge_apply_for_host(host_converge: observed, transport: context.transport) { + Absent => WorldActuationRefused { + cause: HostConvergenceUnknownIdentity { identity: observed.identity }, + } + Present { value: result } => + match result { + Converged { evidence: _, applied: _ } => WorldApplied { value: true } + NotConverged { reason: reason, applied: _ } => WorldActuationRefused { + cause: HostConvergenceApplyRefused { reason: reason }, + } + } + } +} + +fn unchanged_host_convergence_receipt( + _: HostConvergenceSubject, + _: HostConvergenceContext, + _: HostConverge, +) -> Bool { + false +} + +type HostWorldConvergenceHandler = WorldConvergenceHandler + +data host_world_convergence_handler: HostWorldConvergenceHandler = WorldConvergenceHandler { + observe: observe_host_convergence, + differences: host_convergence_differences, + apply: apply_host_convergence, + unchanged_receipt: unchanged_host_convergence_receipt, +} fn fleet_compute_host_for_identity(identity: HostIdentity) -> ComputeHost? { find_by_identity(fleet_intent_known_hosts, identity: identity, key: fn(host) { host.identity }) @@ -58,55 +140,6 @@ fn converge_apply_for_host( } } -type FleetConvergeApplyResult = - FleetConvergeApplied { host_count: Int, all_converged: Bool } - | FleetConvergeApplyRefused { reason: String } - -fn converge_apply(policy: ConvergePolicy, transport: HostEffectTransport) -> FleetConvergeApplyResult { - match policy { - ConvergePolicyUnsound { reason: reason } => - FleetConvergeApplyRefused { reason: concat("fleet_converge_apply: policy unsound — ", reason) } - ConvergePolicyDerived { hosts: hosts } => { - let folded = fold( - hosts, - init: FleetConvergeApplied { host_count: 0, all_converged: true }, - f: fn(acc, host_converge) { - match acc { - FleetConvergeApplyRefused { reason: _ } => acc - FleetConvergeApplied { host_count: n, all_converged: ok } => - match converge_apply_for_host(host_converge: host_converge, transport: transport) { - Present { value: r } => - FleetConvergeApplied { - host_count: n + 1, - all_converged: ok && reconciliation_converged(r: r), - } - Absent => - FleetConvergeApplyRefused { - reason: concat( - "fleet_converge_apply: unknown fleet host identity ", - host_converge.identity as String - ), - } - } - } - } - ) - folded - } - } -} - -fn converge_apply_fleet(transport: HostEffectTransport) -> FleetConvergeApplyResult { - converge_apply(policy: fleet_converge_policy(), transport: transport) -} - fn fleet_converge_thin_invocation(host: HostIdentity) -> ThinInvocation { ThinInvocation { argv: ["gunbc", "converge", "--host", host as String] } } - -fn fleet_converge_apply_result_converged(r: FleetConvergeApplyResult) -> Bool { - match r { - FleetConvergeApplied { host_count: _, all_converged: ok } => ok - FleetConvergeApplyRefused { reason: _ } => false - } -} diff --git a/dag/gunbc/fleet/fleet_converge_plan_cli.dag b/dag/gunbc/fleet/fleet_converge_plan_cli.dag index 0313996f401..d773e13158d 100644 --- a/dag/gunbc/fleet/fleet_converge_plan_cli.dag +++ b/dag/gunbc/fleet/fleet_converge_plan_cli.dag @@ -13,6 +13,12 @@ import extdeps.filesystem.filesystem_io { FilesystemEntryAbsent, FilesystemEntryListed, FilesystemEntryPresenceIndeterminate, + filesystem_file_observation, + filesystem_read_outcome, + FilesystemFileAbsent, + FilesystemFileRead, + FilesystemFileIndeterminate, + FilesystemFileObservationsDisagree, } import extdeps.exec.command { ArgvCommand, LocalExec } import extdeps.tools.hostname { hostname_short_read_command } @@ -23,6 +29,7 @@ import extdeps.posix.test_utility { test_regular_file_command } import extdeps.languages.bash.invocation { bash_program_command } import gunbc.command_runner { run_shell_command_capture, run_shell_commands, process_outcome_admitted } import gunbc.host_axis_caps { CapMember, caps_managed_dropin_paths } +import gunbc.runner_unit { runner_unit_dropin_dir } import gunbc.runner_slot_provision { RunnerSlotMember, RunnerSlotsObserved, @@ -281,17 +288,71 @@ func observe_timer_members_wet() -> List ) } -func observe_cap_members_wet() -> List +type CapMembersObservation + = CapMembersObserved { members: List } + | CapMembersAbsent + | CapMembersUnobservable { path: NonEmptyStr, cause: String } + +type CapMemberRead + = CapMemberReadObserved { member: CapMember } + | CapMemberReadAbsent + | CapMemberReadUnobservable { path: NonEmptyStr, cause: String } + +fn cap_members_observation(reads: List) -> CapMembersObservation { + fold(reads, init: CapMembersAbsent, f: fn(acc, read) { + match acc { + CapMembersUnobservable { path: _, cause: _ } => acc + CapMembersAbsent => + match read { + CapMemberReadObserved { member: member } => CapMembersObserved { members: [member] } + CapMemberReadAbsent => CapMembersAbsent + CapMemberReadUnobservable { path: path, cause: cause } => + CapMembersUnobservable { path: path, cause: cause } + } + CapMembersObserved { members: members } => + match read { + CapMemberReadObserved { member: member } => CapMembersObserved { + members: append(members, items: [member]), + } + CapMemberReadAbsent => acc + CapMemberReadUnobservable { path: path, cause: cause } => + CapMembersUnobservable { path: path, cause: cause } + } + } + }) +} + +func observe_cap_members_wet() -> CapMembersObservation uses net: Network { - flat_map(caps_managed_dropin_paths, path => { + let listed = Filesystem.List(path: runner_unit_dropin_dir as String) + let listing = filesystem_listing_observation( + directory: runner_unit_dropin_dir as String, + success: listed.success, + entries: listed.entries, + error: listed.error, + ) + cap_members_observation(reads: map(caps_managed_dropin_paths, path => { let read = Filesystem.Read(path: path as String) - if read.success { - [CapMember { dropin_path: path, content: read.content }] - } else { - [] + let name = match split(s: path as String, delimiter: "/").last() { + Present { value: value } => value + Absent => "" } - }) + match filesystem_file_observation( + listing: listing, + name: name, + path: path as String, + read: filesystem_read_outcome(content: read.content, success: read.success, error: read.error), + ) { + FilesystemFileAbsent(_) => CapMemberReadAbsent + FilesystemFileRead { path: _, content: content } => + CapMemberReadObserved { member: CapMember { dropin_path: path, content: content } } + FilesystemFileIndeterminate { cause: cause } => + CapMemberReadUnobservable { path: path, cause: cause } + FilesystemFileObservationsDisagree { path: _, cause: cause } => + CapMemberReadUnobservable { path: path, cause: cause } + } + })) } type FleetConvergeRequestObservation @@ -346,17 +407,34 @@ func observe_fleet_converge_request_wet(scope_wire: String, host: NonEmptyStr) - ApplySparkAxisAdmitted => match observe_runner_slot_members_wet() { RunnerSlotsUnobserved { reason: why } => FleetConvergeRequestUnobserved { reason: why as String } - RunnerSlotsObserved { members: observed_slots } => FleetConvergeRequestObserved { - request: FullHostConverge { - host: host, - observed_timers: observe_timer_members_wet(), - observed_caps: observe_cap_members_wet(), - observed_spark: [], - observed_slots: observed_slots, - observed_fabric_cells: admit_fabric_cell_probe(probe: fabric_cell_probe_wet(host: host as HostIdentity)), - observed_activation_readiness: runner_activation_readiness_unobserved_no_transaction, - }, - } + RunnerSlotsObserved { members: observed_slots } => + match observe_cap_members_wet() { + CapMembersUnobservable { path: path, cause: cause } => FleetConvergeRequestUnobserved { + reason: join(["CapMemberUnobservable path=", path as String, " — ", cause], ""), + } + CapMembersAbsent => FleetConvergeRequestObserved { + request: FullHostConverge { + host: host, + observed_timers: observe_timer_members_wet(), + observed_caps: [], + observed_spark: [], + observed_slots: observed_slots, + observed_fabric_cells: admit_fabric_cell_probe(probe: fabric_cell_probe_wet(host: host as HostIdentity)), + observed_activation_readiness: runner_activation_readiness_unobserved_no_transaction, + }, + } + CapMembersObserved { members: observed_caps } => FleetConvergeRequestObserved { + request: FullHostConverge { + host: host, + observed_timers: observe_timer_members_wet(), + observed_caps: observed_caps, + observed_spark: [], + observed_slots: observed_slots, + observed_fabric_cells: admit_fabric_cell_probe(probe: fabric_cell_probe_wet(host: host as HostIdentity)), + observed_activation_readiness: runner_activation_readiness_unobserved_no_transaction, + }, + } + } } } } diff --git a/dag/gunbc/repo/repo_ruleset.dag b/dag/gunbc/repo/repo_ruleset.dag index a99806ea023..9d00dc7d892 100644 --- a/dag/gunbc/repo/repo_ruleset.dag +++ b/dag/gunbc/repo/repo_ruleset.dag @@ -60,6 +60,15 @@ import extdeps.transports.rest { RestBodyUndecodable, } import v2.std.collection { Absent, Present } +import extdeps.github.github { Repository } +import gunbc.world_converge { + WorldObservation, WorldObserved, WorldUnobservable, + WorldDifference, WorldInAgreement, WorldDiverged, + WorldActuation, WorldActuationRefused, + WorldConvergenceHandler, + WorldConverged, WorldNotConverged, + world_converge, +} // WHAT THIS MODULE IS FOR, AND WHY IT IS NOT A DOCUMENTATION FIX. // @@ -78,12 +87,11 @@ import v2.std.collection { Absent, Present } // gates, flips enforcement to `evaluate`, or grants a bypass role, every gate inside this // repository stays green while nothing it produces can block a merge any more. // -// So this module is the ordinary converged-authority treatment, following gunbc.repo_local_git_config -// exactly: DESIRED state is declared, the live state is OBSERVED, the two are RECONCILED, drift is -// APPLIED, and the result is READ BACK and verified against desired again -- convergence is claimed -// only from the post-apply read, never from the apply's own success. The read-back is what makes it -// a converged authority rather than a fire-and-hope actuator, and it is the half a hand-run `gh api` -// command has never had. +// This module binds the repository subject to gunbc.world_converge: DESIRED state is declared, the +// live state is OBSERVED, and the two are RECONCILED through the one global convergence shape. Live +// drift currently reaches a typed actuation hold because the API read cannot carry the bypass-actor +// roster that a whole-ruleset PUT would overwrite. The separately signed #10204 change owns closing +// that boundary and the post-apply read-back; until then this binding is read-only by construction. // // THE ONE FACT THAT STOPS BEING A NICKNAME. The required context's name is imported from // gunbc.witness_floor_workflow witness_floor_workflow_job_id -- the same row the emitted workflow @@ -555,145 +563,133 @@ fn converged_evidence_of( } } -// THE CONVERGENCE CLAIM COMES FROM THE POST-APPLY READ AND FROM NOTHING ELSE. A 200 on the PUT says -// GitHub accepted a request; it does not say the ruleset now denies a merge that lacks the required -// context. So the apply's own success is never the evidence -- the second observation is, and it is -// judged by the same divergence fold the pre-apply read went through. That is the whole difference -// between this module and the `gh api` command it replaces. -fn repo_ruleset_converge_actuate_at( - owner: String, - repo: String, - ruleset_id: Int, -) -> Reconciliation { - let before = observe_repo_ruleset(owner: owner, repo: repo, ruleset_id: ruleset_id) - match before { - RulesetUnreadable { cause: c } => - NotConverged { reason: render_read_refusal(cause: c), applied: before } - RulesetObserved { - name: _, - enforcement: _, - target: _, - bypass: _, - include: _, - exclude: _, - rule_types: _, - standing: st, - } => { - let plan = required_context_reconcile(observed: observed_required_checks(standing: st)) - let refusals = membership_refusal_count(plan: plan) - if refusals > 0 { - NotConverged { - reason: render_divergence(d: RequiredContextUnactionable { count: refusals }), - applied: before, - } - } else { - let writes = membership_write_count(plan: plan) - let removals = membership_removal_count(plan: plan) - let divergences_before = observation_divergences(observation: before) - if length(divergences_before) == 0 { - Converged { - evidence: converged_evidence_of(writes: 0, removals: 0, observation: before), - applied: before, - } - } else { - match apply_desired_ruleset(owner: owner, repo: repo, ruleset_id: ruleset_id) { - Present { value: c } => - NotConverged { - reason: concat("ruleset apply refused: ", render_read_refusal(cause: c)), - applied: before, - } - Absent => { - let after = observe_repo_ruleset(owner: owner, repo: repo, ruleset_id: ruleset_id) - match after { - RulesetUnreadable { cause: rc } => - NotConverged { - reason: concat("ruleset read-back after apply: ", render_read_refusal(cause: rc)), - applied: after, - } - _ => { - let remaining = observation_divergences(observation: after) - if length(remaining) == 0 { - Converged { - evidence: converged_evidence_of( - writes: writes, - removals: removals, - observation: after, - ), - applied: after, - } - } else { - NotConverged { - reason: concat( - "ruleset read-back after apply still diverges: ", - render_divergences(ds: remaining), - ), - applied: after, - } - } - } - } - } - } - } - } - } +type RepositoryRulesetConvergenceSubject { + repository: Repository + ruleset_id: Int +} + +type RepositoryRulesetConvergenceRefusal + = RepositoryRulesetObservationRefused { cause: RulesetReadRefusal } + | RepositoryRulesetActuationUnbound { ruleset_id: Int } + | RepositoryRulesetUnmodeledLiveRuleActuationHeld { ruleset_id: Int } + | RepositoryRulesetUnmodeledBypassActorActuationHeld { ruleset_id: Int } + | RepositoryRulesetUnsignedDesireActuationHeld { ruleset_id: Int } + +type RepositoryRulesetActuationAdmission + = RepositoryRulesetActuationAdmitted + | RepositoryRulesetActuationRefused { cause: RepositoryRulesetConvergenceRefusal } + +// This is the permanent gate in front of the bare whole-ruleset PUT, not a handler binding that a +// later change swaps out. The current observation cannot carry the live bypass-actor roster, so no +// observed value can authorize an update. #10204 extends this admission when that roster and the +// signed desire are modeled; it does not replace the guarded handler. The current blanket cause is +// deliberately ActuationUnbound: this tree cannot observe the roster and therefore cannot honestly +// assert that a bypass actor exists, only that no admitted realization exists here. +fn repo_ruleset_actuation_admission( + subject: RepositoryRulesetConvergenceSubject, + _: RulesetObservation, +) -> RepositoryRulesetActuationAdmission { + RepositoryRulesetActuationRefused { + cause: RepositoryRulesetActuationUnbound { ruleset_id: subject.ruleset_id }, } } -fn repo_ruleset_verify_at( - owner: String, - repo: String, - ruleset_id: Int, -) -> Reconciliation { - let observed = observe_repo_ruleset(owner: owner, repo: repo, ruleset_id: ruleset_id) +fn world_repo_ruleset_observation( + observed: RulesetObservation, +) -> WorldObservation { match observed { - RulesetUnreadable { cause: c } => - NotConverged { reason: render_read_refusal(cause: c), applied: observed } - _ => { - let divergences = observation_divergences(observation: observed) - if length(divergences) == 0 { - Converged { - evidence: converged_evidence_of(writes: 0, removals: 0, observation: observed), - applied: observed, - } - } else { - NotConverged { reason: render_divergences(ds: divergences), applied: observed } - } + RulesetUnreadable { cause: cause } => WorldUnobservable { + cause: RepositoryRulesetObservationRefused { cause: cause }, } + RulesetObserved { name: _, enforcement: _, target: _, bypass: _, include: _, exclude: _, rule_types: _, standing: _ } => + WorldObserved { value: observed } } } -data repo_ruleset_converge_entry: String = "dag/gunbc/repo/repo_ruleset.dag" -data repo_ruleset_converge_function: String = "converge" -data repo_ruleset_verify_function: String = "verify" +fn world_observe_repo_ruleset( + subject: RepositoryRulesetConvergenceSubject, + _: Bool, +) -> WorldObservation { + let observed = observe_repo_ruleset( + owner: subject.repository.owner, + repo: subject.repository.name, + ruleset_id: subject.ruleset_id, + ) + world_repo_ruleset_observation(observed: observed) +} -// VERIFY IS READ-ONLY AND CONVERGE WRITES, AND THEY ARE TWO ENTRY POINTS RATHER THAN ONE WITH A -// FLAG. A flag whose effect is "do not actuate" is a mode on one operation; two entry points make -// the read-only path unable to write by construction, which is what a verifier enrolled in an -// automated run needs. NEITHER IS ENROLLED IN CI BY THIS CHANGE, and that is stated so the module -// is not read as wider than it is: what lands here is the authority, the read-back and the typed -// verdict. Enrolling `verify` as a required phase is a separate change with its own operator -// agreement, and until it happens the wall is a route somebody runs, not a gate. -func verify() -> ProcessExit { - match repo_ruleset_verify_at( - owner: gunbc_repository.owner, - repo: gunbc_repository.name, - ruleset_id: gunbc_passing_ci_ruleset_id, - ) { - Converged { evidence: _, applied: _ } => ExitSuccess - NotConverged { reason: r, applied: _ } => - exit_failure(reason: concat("repo ruleset verify refused: ", r)) +fn world_repo_ruleset_differences( + _: RepositoryRulesetConvergenceSubject, + _: Bool, + observed: RulesetObservation, +) -> WorldDifference { + let divergences = observation_divergences(observation: observed) + if length(divergences) == 0 { + WorldInAgreement + } else { + WorldDiverged { differences: divergences } + } +} + +fn world_repo_ruleset_apply( + subject: RepositoryRulesetConvergenceSubject, + _: Bool, + observed: RulesetObservation, + _: List, +) -> WorldActuation { + match repo_ruleset_actuation_admission(subject: subject, observed: observed) { + RepositoryRulesetActuationRefused { cause: cause } => WorldActuationRefused { cause: cause } + RepositoryRulesetActuationAdmitted => + // The admitted realization remains deliberately unbound on this tree. #10204 owns the + // read-back transaction after operator sign-off; admission cannot silently become a write. + WorldActuationRefused { + cause: RepositoryRulesetUnsignedDesireActuationHeld { ruleset_id: subject.ruleset_id }, + } } } -func converge() -> ProcessExit { - match repo_ruleset_converge_actuate_at( - owner: gunbc_repository.owner, - repo: gunbc_repository.name, - ruleset_id: gunbc_passing_ci_ruleset_id, +fn world_repo_ruleset_unchanged_receipt( + _: RepositoryRulesetConvergenceSubject, + _: Bool, + observed: RulesetObservation, +) -> RepoRulesetConvergedEvidence { + converged_evidence_of(writes: 0, removals: 0, observation: observed) +} + +type RepoRulesetWorldConvergenceHandler = WorldConvergenceHandler + +data repo_ruleset_world_convergence_handler: RepoRulesetWorldConvergenceHandler = WorldConvergenceHandler { + observe: world_observe_repo_ruleset, + differences: world_repo_ruleset_differences, + apply: world_repo_ruleset_apply, + unchanged_receipt: world_repo_ruleset_unchanged_receipt, +} + +data repo_ruleset_converge_entry: String = "dag/gunbc/repo/repo_ruleset.dag" +data repo_ruleset_verify_function: String = "verify" + +// The repository binding is read-only until the separately signed ruleset proposal can preserve +// every live bypass actor. Drift reaches RepositoryRulesetActuationHeld; there is no mutating entry +// point beside the world-convergence root. +func verify() -> ProcessExit { + match world_converge( + subject: RepositoryRulesetConvergenceSubject { + repository: gunbc_repository, + ruleset_id: gunbc_passing_ci_ruleset_id, + }, + context: false, + handler: repo_ruleset_world_convergence_handler, ) { - Converged { evidence: _, applied: _ } => ExitSuccess - NotConverged { reason: r, applied: _ } => - exit_failure(reason: concat("repo ruleset converge refused: ", r)) + WorldConverged { subject: _, observed: _, receipt: _ } => ExitSuccess + WorldNotConverged { subject: _, refusal: RepositoryRulesetObservationRefused { cause: cause } } => + exit_failure(reason: concat("repo ruleset verify refused: ", render_read_refusal(cause: cause))) + WorldNotConverged { subject: _, refusal: RepositoryRulesetActuationUnbound { ruleset_id: id } } => + exit_failure(reason: concat("repo ruleset verify refused: no admitted actuation realization for ruleset ", to_string(id))) + WorldNotConverged { subject: _, refusal: RepositoryRulesetUnmodeledLiveRuleActuationHeld { ruleset_id: id } } => + exit_failure(reason: concat("repo ruleset verify refused: live rule cannot be projected for ruleset ", to_string(id))) + WorldNotConverged { subject: _, refusal: RepositoryRulesetUnmodeledBypassActorActuationHeld { ruleset_id: id } } => + exit_failure(reason: concat("repo ruleset verify refused: live bypass actor cannot be projected for ruleset ", to_string(id))) + WorldNotConverged { subject: _, refusal: RepositoryRulesetUnsignedDesireActuationHeld { ruleset_id: id } } => + exit_failure(reason: concat("repo ruleset verify refused: desired ruleset is not signed for actuation ", to_string(id))) } } diff --git a/dag/gunbc/world_converge.dag b/dag/gunbc/world_converge.dag new file mode 100644 index 00000000000..f4bb0f60ea6 --- /dev/null +++ b/dag/gunbc/world_converge.dag @@ -0,0 +1,80 @@ +module gunbc.world_converge + +import std.types { List } + +// The one subject-agnostic convergence shape. Concrete modules bind their own observations, +// differences, actuation and receipts to these stages; this module neither enumerates subjects nor +// selects a realization. Adding a third subject therefore adds a binding at that subject and does +// not widen this authority. +type WorldObservation + = WorldObserved { value: Observed } + | WorldAbsent { value: Observed } + | WorldUnobservable { cause: Refusal } + +type WorldDifference + = WorldInAgreement + | WorldDiverged { differences: List } + +type WorldActuation + = WorldApplied { value: Applied } + | WorldActuationRefused { cause: Refusal } + +type WorldConvergenceHandler { + observe: fn(Subject, Context) -> WorldObservation + differences: fn(Subject, Context, Observed) -> WorldDifference + apply: fn(Subject, Context, Observed, List) -> WorldActuation + unchanged_receipt: fn(Subject, Context, Observed) -> Receipt +} + +type WorldConvergenceResult + = WorldConverged { subject: Subject, observed: Observed, receipt: Receipt } + | WorldNotConverged { subject: Subject, refusal: Refusal } + +fn world_converge_observed( + subject: Subject, + context: Context, + handler: WorldConvergenceHandler, + observed: Observed, +) -> WorldConvergenceResult { + let differences_of = handler.differences + let apply = handler.apply + let unchanged_receipt = handler.unchanged_receipt + match differences_of(subject, context, observed) { + WorldInAgreement => WorldConverged { + subject: subject, + observed: observed, + receipt: unchanged_receipt(subject, context, observed), + } + WorldDiverged { differences: differences } => + match apply(subject, context, observed, differences) { + WorldActuationRefused { cause: cause } => + WorldNotConverged { subject: subject, refusal: cause } + WorldApplied { value: receipt } => WorldConverged { + subject: subject, + observed: observed, + receipt: receipt, + } + } + } +} + +// This fold is the root answer. Unobservable is deliberately first: an unreadable subject has no +// differences to invent and can never become the empty, apparently-converged population. Absent is +// distinct but established, so it proceeds through the same subject-owned difference operation. +fn world_converge( + subject: Subject, + context: Context, + handler: WorldConvergenceHandler, +) -> WorldConvergenceResult { + let observe = handler.observe + match observe(subject, context) { + WorldUnobservable { cause: cause } => + WorldNotConverged { subject: subject, refusal: cause } + WorldAbsent { value: observed } => world_converge_observed( + subject: subject, context: context, handler: handler, observed: observed, + ) + WorldObserved { value: observed } => world_converge_observed( + subject: subject, context: context, handler: handler, observed: observed, + ) + } +} diff --git a/dag/test/claim/fleet/fleet_cap_observation_witness_test.dag b/dag/test/claim/fleet/fleet_cap_observation_witness_test.dag new file mode 100644 index 00000000000..90f56f9ca71 --- /dev/null +++ b/dag/test/claim/fleet/fleet_cap_observation_witness_test.dag @@ -0,0 +1,28 @@ +module test.claim.fleet_cap_observation_witness + +import std.types { String, NonEmptyStr } +import gunbc.fleet_converge_plan_cli { + CapMemberReadAbsent, + CapMemberReadUnobservable, + CapMembersAbsent, + CapMembersObserved, + CapMembersUnobservable, + cap_members_observation, +} + +test fn unreadable_cap_member_cannot_become_an_empty_observation() -> Bool { + match cap_members_observation(reads: [ + CapMemberReadUnobservable { path: "/fixture/20-width.conf" as NonEmptyStr, cause: "permission denied" }, + ]) { + CapMembersUnobservable { path: path, cause: _ } => path as String == "/fixture/20-width.conf" + CapMembersAbsent => false + CapMembersObserved { members: _ } => false + } +} + +test fn established_absence_is_distinct_from_unobservable_cap_population() -> Bool { + match cap_members_observation(reads: [CapMemberReadAbsent]) { + CapMembersAbsent => true + _ => false + } +} diff --git a/dag/test/claim/fleet/fleet_converge_apply_witness_test.dag b/dag/test/claim/fleet/fleet_converge_apply_witness_test.dag index 7c6df6451cd..0294033d611 100644 --- a/dag/test/claim/fleet/fleet_converge_apply_witness_test.dag +++ b/dag/test/claim/fleet/fleet_converge_apply_witness_test.dag @@ -29,23 +29,30 @@ fn fixture_srv3_host_converge() -> HostConverge { } test fn witness_unsound_policy_refused() -> Bool { - match converge_apply( - policy: ConvergePolicyUnsound { reason: "fixture" }, - transport: LocalShell + match world_converge( + subject: HostConvergenceSubject { identity: operator_host_srv3 }, + context: HostConvergenceContext { + policy: ConvergePolicyUnsound { reason: "fixture" }, + transport: LocalShell, + }, + handler: host_world_convergence_handler, ) { - FleetConvergeApplyRefused { reason: why } => string_contains(s: why, pattern: "policy unsound") - FleetConvergeApplied { host_count: _, all_converged: _ } => false + WorldNotConverged { subject: _, refusal: HostConvergencePolicyUnsound { reason: why } } => why == "fixture" + _ => false } } test fn witness_converge_apply_fail_closed_on_knob_frontier() -> Bool { - match converge_apply( - policy: ConvergePolicyDerived { hosts: [fixture_srv3_host_converge()] }, - transport: LocalShell + match world_converge( + subject: HostConvergenceSubject { identity: operator_host_srv3 }, + context: HostConvergenceContext { + policy: ConvergePolicyDerived { hosts: [fixture_srv3_host_converge()] }, + transport: LocalShell, + }, + handler: host_world_convergence_handler, ) { - FleetConvergeApplied { host_count: n, all_converged: ok } => - n == 1 && !ok - FleetConvergeApplyRefused { reason: _ } => false + WorldNotConverged { subject: _, refusal: HostConvergenceApplyRefused { reason: _ } } => true + _ => false } } @@ -69,13 +76,17 @@ test fn witness_emit_artifact_carriers_name_bash_emit_scaffolds() -> Bool { } } -test fn witness_emit_artifact_transport_rejects_shell_command() -> Bool { - !fleet_converge_apply_result_converged(r: converge_apply( - policy: ConvergePolicyDerived { hosts: [fixture_srv3_host_converge()] }, - transport: EmitArtifactThenThinRun { - bootstrap: BootstrapFragment { script: "true" }, - invocation: fleet_converge_thin_invocation(host: operator_host_srv3), - } - )) +test fn witness_unknown_subject_identity_refuses_before_realization() -> Bool { + match world_converge( + subject: HostConvergenceSubject { identity: "not-enrolled" as HostIdentity }, + context: HostConvergenceContext { + policy: ConvergePolicyDerived { hosts: [fixture_srv3_host_converge()] }, + transport: LocalShell, + }, + handler: host_world_convergence_handler, + ) { + WorldNotConverged { subject: _, refusal: HostConvergenceUnknownIdentity { identity: identity } } => + identity as String == "not-enrolled" + _ => false + } } - diff --git a/dag/test/claim/repo_ruleset_world_converge_witness_test.dag b/dag/test/claim/repo_ruleset_world_converge_witness_test.dag new file mode 100644 index 00000000000..80a572798ac --- /dev/null +++ b/dag/test/claim/repo_ruleset_world_converge_witness_test.dag @@ -0,0 +1,19 @@ +module test.claim.repo_ruleset_world_converge_witness + +import std.types { NonEmptyStr } +import gunbc.world_converge { WorldUnobservable } +import gunbc.repo_ruleset { + RulesetUnreadable, + RulesetReadTransportRefused, + RepositoryRulesetObservationRefused, + world_repo_ruleset_observation, +} + +test fn unreadable_ruleset_maps_to_world_unobservable() -> Bool { + match world_repo_ruleset_observation(observed: RulesetUnreadable { + cause: RulesetReadTransportRefused { cause: "fixture transport refusal" as NonEmptyStr }, + }) { + WorldUnobservable { cause: RepositoryRulesetObservationRefused { cause: _ } } => true + _ => false + } +} diff --git a/dag/test/claim/world_converge_witness_test.dag b/dag/test/claim/world_converge_witness_test.dag new file mode 100644 index 00000000000..b148f739cfa --- /dev/null +++ b/dag/test/claim/world_converge_witness_test.dag @@ -0,0 +1,59 @@ +module test.claim.world_converge_witness + +import std.types { String, Bool, List } +import gunbc.world_converge { + WorldObservation, WorldObserved, WorldUnobservable, + WorldDifference, WorldDiverged, + WorldActuation, WorldActuationRefused, + WorldConvergenceHandler, + WorldNotConverged, + world_converge, +} + +type FixtureSubject { key: String } +type FixtureObserved { value: String } +type FixtureDifference = FixtureDrift +type FixtureReceipt = FixtureApplied +type FixtureRefusal = FixtureUnreadable | FixtureApplyShouldNotRun + +fn fixture_observe(_: FixtureSubject, readable: Bool) -> WorldObservation { + if readable { + WorldObserved { value: FixtureObserved { value: "observed" } } + } else { + WorldUnobservable { cause: FixtureUnreadable } + } +} + +fn fixture_differences(_: FixtureSubject, _: Bool, _: FixtureObserved) -> WorldDifference { + WorldDiverged { differences: [FixtureDrift] } +} + +fn fixture_apply(_: FixtureSubject, _: Bool, _: FixtureObserved, _: List) -> WorldActuation { + WorldActuationRefused { cause: FixtureApplyShouldNotRun } +} + +fn fixture_unchanged(_: FixtureSubject, _: Bool, _: FixtureObserved) -> FixtureReceipt { + FixtureApplied +} + +type FixtureWorldHandler = WorldConvergenceHandler + +data fixture_world_handler: FixtureWorldHandler = WorldConvergenceHandler { + observe: fixture_observe, + differences: fixture_differences, + apply: fixture_apply, + unchanged_receipt: fixture_unchanged, +} + +// Discriminating RED: the apply handler deliberately carries a different refusal. If the generic +// root computes differences or actuation before guarding observation, this answers the wrong arm. +test fn unreadable_subject_refuses_the_run_before_apply() -> Bool { + match world_converge( + subject: FixtureSubject { key: "fixture" }, + context: false, + handler: fixture_world_handler, + ) { + WorldNotConverged { subject: _, refusal: FixtureUnreadable } => true + _ => false + } +}