diff --git a/.github/workflows/fleet-converge.yml b/.github/workflows/fleet-converge.yml index 67c923e8205..0b650447f69 100644 --- a/.github/workflows/fleet-converge.yml +++ b/.github/workflows/fleet-converge.yml @@ -1102,7 +1102,7 @@ jobs: WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} FLEET_CONVERGE_EXPECTED_HOST: ${{ github.event.inputs.host }} if: github.event.inputs.mode == 'microvm_runner_group_ensure' - timeout-minutes: 19 + timeout-minutes: 36 - name: Upload the microVM runner group ensure receipt id: microvm_runner_group_ensure_receipt_upload uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f diff --git a/dag/gunbc/auth/heal_publisher_federation.dag b/dag/gunbc/auth/heal_publisher_federation.dag index 0a0378bc296..959ab57e251 100644 --- a/dag/gunbc/auth/heal_publisher_federation.dag +++ b/dag/gunbc/auth/heal_publisher_federation.dag @@ -86,6 +86,7 @@ type HealPublisherFederation { service_account: ServiceAccountEmail service_account_display_name: NonEmptyStr provision_remedy: NonEmptyStr + runner_environment: String } data heal_publisher_wif_pool_id: NonEmptyStr = "github-heal-publisher" @@ -115,6 +116,7 @@ fn public_heal_publisher_federation() -> HealPublisherFederation { service_account_id: heal_publisher_service_account_id, service_account: heal_publisher_service_account, service_account_display_name: heal_publisher_service_account_display_name, + runner_environment: heal_publisher_wif_runner_environment, provision_remedy: "run gunbc.auth.heal_publisher_provision heal_publisher_federation_provision_with_supplied_token, which creates that identity and binds roles/secretmanager.secretAccessor on the key for that service account only after reading the federation back.", } } @@ -138,6 +140,7 @@ fn private_heal_publisher_federation() -> HealPublisherFederation { service_account_id: "heal-publisher-private", service_account: "heal-publisher-private@gunbai-secrets.iam.gserviceaccount.com", service_account_display_name: "Private heal publisher (WIF-only, one secret, no keys)", + runner_environment: heal_publisher_wif_runner_environment, provision_remedy: "dispatch fleet-converge in mode gcp_iam_converge and approve the diff it files; gunbc.auth.gcp_iam_converge creates that identity and binds roles/secretmanager.secretAccessor on the key for that service account under that one approval.", } } @@ -169,6 +172,19 @@ data heal_publisher_wif_event_name: String = "workflow_run" data heal_publisher_wif_runner_environment: String = "github-hosted" +// THE PRIVATE PUBLISHER IS HEADED FOR OUR microVMs, AND ITS PIN STAYS github-hosted UNTIL THE WALL +// THAT WOULD JUSTIFY WIDENING IT EXISTS (operator 2026-09-29; review 72445). GitHub's +// runner_environment claim is only github-hosted or self-hosted, so pinning self-hosted admits every +// self-hosted runner, including the srv slots that execute pull-request code. What would keep +// pull-request code off the machine is the runner group gunbc.runner_group_restriction_ensure +// private_heal_publisher_runner_group_subject -- restricted to exactly this workflow and repository +// -- together with a controller that mints one guest per job into that group only after reading the +// restriction back. The group is modelled; the minting path is not. So the field is per record and +// the private record still says github-hosted: a publish job placed on a microVM label today cannot +// federate at all, which refuses rather than widens. The flip to self-hosted lands in ONE change with +// the controller path that mints into that group, and with the live provider's condition update. + + // job_workflow_ref IS DELIBERATELY NOT PINNED, AND ITS EARLIER PRESENCE WAS A DEFECT THAT WOULD HAVE // REFUSED THE VERY JOB THIS ADMITS. GitHub documents that claim as "For jobs using a reusable // workflow, the ref path to the reusable workflow" -- heal-publish declares an ordinary job directly, @@ -189,7 +205,7 @@ fn heal_publisher_federation_attribute_condition(f: HealPublisherFederation) -> " && assertion.workflow_ref == '", heal_publisher_federation_workflow_ref(f: f), "'", " && assertion.ref == 'refs/heads/", f.repository.default_branch, "'", " && assertion.event_name == '", heal_publisher_wif_event_name, "'", - " && assertion.runner_environment == '", heal_publisher_wif_runner_environment, "'" + " && assertion.runner_environment == '", f.runner_environment, "'" ], "") } diff --git a/dag/gunbc/runner/runner_group_restriction_ensure.dag b/dag/gunbc/runner/runner_group_restriction_ensure.dag index f566a4546c9..4e1862fd786 100644 --- a/dag/gunbc/runner/runner_group_restriction_ensure.dag +++ b/dag/gunbc/runner/runner_group_restriction_ensure.dag @@ -3,11 +3,12 @@ module gunbc.runner_group_restriction_ensure import std.types { NonEmptyStr, String, Bool, Int, List } import extdeps.github.org_actions { RunnerGroupVisibility, SelectedRepositories, runner_group_visibility_wire } import gunbc.repository { gunbc_repository_numeric_identity } +import gunbc.auth.heal_publisher_federation { private_heal_publisher_federation, heal_publisher_federation_workflow_ref } import gunbc.fleet.org_actions_inspection { org_actions_organization, RunnerGroupListDecode } import gunbc.runner_group_restriction_observation { - ShakedownRunnerGroupRestriction, MicrovmRunnerGroupStanding, - RunnerGroupRestrictedToShakedownWorkflow, RunnerGroupRestrictionRefused, - RunnerGroupAbsent, observe_shakedown_runner_group_restriction, microvm_shakedown_runner_group_name, + RestrictedRunnerGroup, MicrovmRunnerGroupStanding, + RunnerGroupRestrictedToWorkflow, RunnerGroupRestrictionRefused, + RunnerGroupAbsent, observe_runner_group_restriction, microvm_shakedown_runner_group_name, microvm_shakedown_selected_workflow, runner_group_restriction_refusal_text, } @@ -16,7 +17,7 @@ import gunbc.runner_group_restriction_observation { // The group gunbc.runner_jit_mint plan_jit_mint requires is one the organization does not have // until it is created, and creating it is an org-admin effect. This module owns the PLAN -- what // the group must be, and which of three things a read of the organization's groups says to do -- -// so the effect is never performed on a guess: a group already restricted to exactly the shakedown +// so the effect is never performed on a guess: a group already restricted to exactly its // workflow is left alone (no request is filed, no operator is asked), an absent one is created, and // a group holding the name with any other restriction is REFUSED rather than edited, because the // update is a different effect that nobody has modeled or approved. After a create, the @@ -38,18 +39,57 @@ type RunnerGroupCreateSubject { selected_workflow: NonEmptyStr } -fn microvm_runner_group_create_subject() -> RunnerGroupCreateSubject { +// EVERY RESTRICTED GROUP IS THIS SHAPE: selected visibility with one repository, no public +// repositories, restricted to exactly one workflow. Only the name, the repository and the workflow +// differ, so they are the constructor's arguments and the three walls are not re-spelled per group. +fn restricted_runner_group_subject(name: NonEmptyStr, repository_id: Int, selected_workflow: NonEmptyStr) -> RunnerGroupCreateSubject { RunnerGroupCreateSubject { organization: org_actions_organization, - name: microvm_shakedown_runner_group_name, + name: name, visibility: SelectedRepositories, - selected_repository_id: gunbc_repository_numeric_identity.repository_id, + selected_repository_id: repository_id, allows_public_repositories: false, restricted_to_workflows: true, - selected_workflow: microvm_shakedown_selected_workflow(), + selected_workflow: selected_workflow, } } +fn microvm_runner_group_create_subject() -> RunnerGroupCreateSubject { + restricted_runner_group_subject( + name: microvm_shakedown_runner_group_name, + repository_id: gunbc_repository_numeric_identity.repository_id, + selected_workflow: microvm_shakedown_selected_workflow(), + ) +} + +// GUNBC-PRIVATE'S HEAL PUBLISHER IS HEADED FOR microVMs, IN A GROUP ONLY ITS PUBLISHER MAY USE +// (operator 2026-09-29). Its identity (gunbc.auth.heal_publisher_federation +// private_heal_publisher_federation) will admit a self-hosted runner, and the OIDC token cannot say +// WHICH one -- so the wall that keeps pull-request code off the machine is this group: restricted to +// exactly the publisher's workflow at the default branch, and to gunbc-private alone. The selected +// workflow IS the federation's workflow_ref, one string for both. The group name is also the runner +// label the publish job selects (strategy.private_heal_publisher_workflow). +// +// CREATING THE GROUP NARROWS AND WIDENS NOTHING, so it is ensured now. The federation's pin stays +// github-hosted until a controller mints guests into this group after reading its restriction back +// (review 72445): the flip and that minting path land together, in one change. +data private_heal_publisher_runner_group_name: NonEmptyStr = "heal-publisher-private" + +fn private_heal_publisher_runner_group_subject() -> RunnerGroupCreateSubject { + restricted_runner_group_subject( + name: private_heal_publisher_runner_group_name, + repository_id: private_heal_publisher_federation().numeric_identity.repository_id, + selected_workflow: heal_publisher_federation_workflow_ref(f: private_heal_publisher_federation()) as NonEmptyStr, + ) +} + +// THE ROSTER THE ENSURE RUN WALKS, like gcp_iam_converge_targets: each group is read, left alone when +// already restricted, created under its own approval when absent, and refused when it holds the +// name with any other restriction. +fn restricted_runner_groups() -> List { + [microvm_runner_group_create_subject(), private_heal_publisher_runner_group_subject()] +} + // The subject as one line, the text the operator reads and the intent a request digests. fn runner_group_create_subject_text(s: RunnerGroupCreateSubject) -> String { join([ @@ -63,7 +103,7 @@ fn runner_group_create_subject_text(s: RunnerGroupCreateSubject) -> String { } type RunnerGroupEnsurePlan - = GroupAlreadyRestricted { restriction: ShakedownRunnerGroupRestriction } + = GroupAlreadyRestricted { restriction: RestrictedRunnerGroup } | GroupCreateRequired { subject: RunnerGroupCreateSubject } | GroupEnsureRefused { cause: NonEmptyStr } @@ -71,8 +111,8 @@ type RunnerGroupEnsurePlan // unreadable list, a duplicated name, a group holding the name with another restriction -- refuses // the ensure, because the update is a different effect nobody has modeled or approved. fn plan_runner_group_ensure(subject: RunnerGroupCreateSubject, listed: RunnerGroupListDecode) -> RunnerGroupEnsurePlan { - match observe_shakedown_runner_group_restriction(name: subject.name, listed: listed) { - RunnerGroupRestrictedToShakedownWorkflow { restriction } => GroupAlreadyRestricted { restriction: restriction } + match observe_runner_group_restriction(name: subject.name, selected_workflow: subject.selected_workflow, listed: listed) { + RunnerGroupRestrictedToWorkflow { restriction } => GroupAlreadyRestricted { restriction: restriction } RunnerGroupRestrictionRefused { name: _, cause: RunnerGroupAbsent } => GroupCreateRequired { subject: subject } RunnerGroupRestrictionRefused { name, cause } => GroupEnsureRefused { cause: join([ @@ -85,7 +125,7 @@ fn plan_runner_group_ensure(subject: RunnerGroupCreateSubject, listed: RunnerGro // THE READBACK AFTER A CREATE. The only admitted outcome is the sealed restriction; an absent group // after a 201 is as much a refusal as a wrongly restricted one. type RunnerGroupCreateReadback - = CreatedGroupRestricted { restriction: ShakedownRunnerGroupRestriction } + = CreatedGroupRestricted { restriction: RestrictedRunnerGroup } | CreatedGroupNotRestricted { cause: NonEmptyStr } fn readback_after_create(subject: RunnerGroupCreateSubject, listed: RunnerGroupListDecode) -> RunnerGroupCreateReadback { diff --git a/dag/gunbc/runner/runner_group_restriction_ensure_run.dag b/dag/gunbc/runner/runner_group_restriction_ensure_run.dag index 1fa510abeb6..8a2b8d10aac 100644 --- a/dag/gunbc/runner/runner_group_restriction_ensure_run.dag +++ b/dag/gunbc/runner/runner_group_restriction_ensure_run.dag @@ -24,7 +24,7 @@ import gunbc.auth.approval_gate { import gunbc.auth.approval_request_client { stored_request_from_authorization } import gunbc.auth.approval_keyring_pins { approval_submission_mac_key_path_env } import gunbc.runner_group_restriction_ensure { - RunnerGroupCreateSubject, microvm_runner_group_create_subject, runner_group_create_subject_text, + RunnerGroupCreateSubject, restricted_runner_groups, runner_group_create_subject_text, RunnerGroupEnsurePlan, GroupAlreadyRestricted, GroupCreateRequired, GroupEnsureRefused, plan_runner_group_ensure, CreatedGroupRestricted, CreatedGroupNotRestricted, readback_after_create, } @@ -43,7 +43,7 @@ import gunbc.runner_group_restriction_ensure { // one_credential_mint_classified_at_two_reaches). // // THE ORDER IS READ, THEN ASK, THEN ACT, THEN READ. The organization is read before anything is filed, -// so a group already restricted to exactly the shakedown workflow ends the run with no request and +// so a group already restricted to exactly its workflow ends the run with no request and // no tap, and a group holding the name with any other restriction refuses before the operator is // asked to approve something this run would not do. Only a planned create is filed. The POST runs // only on GateAdmitted, and the run succeeds only when the organization, read again, holds the @@ -73,10 +73,12 @@ fn microvm_runner_group_approval_window() -> Second { second(count: microvm_runner_group_poll_tick_budget * (second_count(s: microvm_runner_group_poll_cadence) as Int)) } +// EVERY GROUP IN THE ROSTER MAY WAIT OUT ITS OWN APPROVAL, one after another, so the step covers +// one window and one effect per group rather than one for the run. fn microvm_runner_group_step_timeout_minutes() -> Minute { let secs = second_count(s: microvm_runner_group_prelude_allowance) - + second_count(s: microvm_runner_group_approval_window()) - + second_count(s: microvm_runner_group_effect_allowance) + + count(restricted_runner_groups()) * (second_count(s: microvm_runner_group_approval_window()) + + second_count(s: microvm_runner_group_effect_allowance)) minute(count: (secs + 59) / 60) } @@ -109,17 +111,18 @@ fn microvm_runner_group_create_intent_hash(a: MicrovmRunnerGroupCreateApproval) } fn microvm_runner_group_create_request( + group: RunnerGroupCreateSubject, escalation_id: NonEmptyStr, attempt: AttemptIdentity, execution_revision: NonEmptyStr, ) -> AuthorizationRequest { - let subject = MicrovmRunnerGroupCreateApproval { group: microvm_runner_group_create_subject(), execution_revision: execution_revision } + let subject = MicrovmRunnerGroupCreateApproval { group: group, execution_revision: execution_revision } AuthorizationRequest { escalation_id: escalation_id, scopes: [microvm_runner_group_create_scope], subject: subject, purpose: join([ - "Create the GitHub runner group the microVM shakedown mints into, restricted so only the shakedown workflow can use it: ", + "Create a GitHub runner group microVM runners are minted into, restricted so only its one workflow can use it: ", microvm_runner_group_create_intent_text(a: subject), ], "") as NonEmptyStr, attempt: attempt, @@ -140,8 +143,13 @@ fn read_org_runner_groups(organization: NonEmptyStr) -> RunnerGroupListDecode us } } -fn write_receipt(line: String) -> ProcessExit { - let kept = Filesystem.Write(path: microvm_runner_group_ensure_receipt_path, content: concat(line, "\n")) +// ONE LINE PER GROUP IN THE ROSTER, written once when every group has been ensured. +type GroupEnsureOutcome + = GroupEnsuredLine { line: String } + | GroupEnsureExit { exit: ProcessExit } + +fn write_receipt(lines: List) -> ProcessExit { + let kept = Filesystem.Write(path: microvm_runner_group_ensure_receipt_path, content: concat(join(lines, "\n"), "\n")) if kept.success { ExitSuccess } else { exit_failure(reason: concat("microvm runner group: receipt write refused: ", kept.error)) } } @@ -149,18 +157,22 @@ fn write_receipt(line: String) -> ProcessExit { // admitted the grant when the poll returned, and a grant whose window closed since then must not // act. The subject posted is the one the grant names, not a fresh derivation, so the POST cannot // differ from what was approved. +fn failed(reason: String) -> GroupEnsureOutcome { + GroupEnsureExit { exit: exit_failure(reason: reason) } +} + fn microvm_runner_group_after_gate( gate: ApprovalGate, request: AuthorizationRequest, -) -> ProcessExit { +) -> GroupEnsureOutcome { match gate { GateRefused { cause: c } => - exit_failure(reason: concat("microvm runner group: not created; the approval was not admitted: ", approval_refusal_text(cause: c))) + failed(reason: concat("microvm runner group: not created; the approval was not admitted: ", approval_refusal_text(cause: c))) GateAdmitted { authorization: auth } => match approval_instant_now() { - Absent => exit_failure(reason: "microvm runner group: clock unreadable after admission; not created") + Absent => failed(reason: "microvm runner group: clock unreadable after admission; not created") Present { value: at } => match authorize(authorization: auth, request: request, observed_at: at) { AuthorizationRefused { cause: c } => - exit_failure(reason: concat("microvm runner group: not created: ", authorization_refusal_reason(cause: c))) + failed(reason: concat("microvm runner group: not created: ", authorization_refusal_reason(cause: c))) AuthorizationPermitted { grant: g } => microvm_runner_group_create_under( s: g.subject.group, escalation: g.escalation_id, @@ -171,7 +183,7 @@ fn microvm_runner_group_after_gate( } } -fn microvm_runner_group_create_under(s: RunnerGroupCreateSubject, escalation: NonEmptyStr, decided_by: NonEmptyStr) -> ProcessExit { +fn microvm_runner_group_create_under(s: RunnerGroupCreateSubject, escalation: NonEmptyStr, decided_by: NonEmptyStr) -> GroupEnsureOutcome { let created = github.CliOrgRunnerGroups.CreateRunnerGroupJson( org: s.organization, name: s.name, @@ -182,32 +194,33 @@ fn microvm_runner_group_create_under(s: RunnerGroupCreateSubject, escalation: No selected_workflow: s.selected_workflow, ) if !created.success { - exit_failure(reason: concat("microvm runner group: POST /orgs/\{org\}/actions/runner-groups refused: ", created.stderr)) + failed(reason: concat("microvm runner group: POST /orgs/\{org\}/actions/runner-groups refused: ", created.stderr)) } else { match readback_after_create(subject: s, listed: read_org_runner_groups(organization: s.organization)) { CreatedGroupNotRestricted { cause } => - exit_failure(reason: concat("microvm runner group: created but the readback refuses: ", cause as String)) + failed(reason: concat("microvm runner group: created but the readback refuses: ", cause as String)) CreatedGroupRestricted { restriction } => - write_receipt(line: join([ - "microvm-runner-group=created escalation_id=", escalation as String, + GroupEnsuredLine { line: join([ + "microvm-runner-group=created name=", restriction.name as String, " escalation_id=", escalation as String, " decided_by=", decided_by as String, " group_id=", to_string(restriction.group.value), " selected_workflow=", restriction.selected_workflow as String, - ], "")) + ], "") } } } } -fn microvm_runner_group_file_and_create(escalation: NonEmptyStr, sha: String) -> ProcessExit +fn microvm_runner_group_file_and_create(group: RunnerGroupCreateSubject, escalation: NonEmptyStr, sha: String) -> GroupEnsureOutcome uses net: std.resources.Network { let request = microvm_runner_group_create_request( + group: group, escalation_id: escalation, attempt: escalation as AttemptIdentity, execution_revision: sha as NonEmptyStr, ) match approval_instant_now() { - Absent => exit_failure(reason: "microvm runner group: clock unreadable; cannot mint an approval window; nothing filed") + Absent => failed(reason: "microvm runner group: clock unreadable; cannot mint an approval window; nothing filed") Present { value: issued } => { let expires = approval_expires_at(issued: issued, window: microvm_runner_group_approval_window()) match approval_file_stored_request( @@ -215,7 +228,7 @@ fn microvm_runner_group_file_and_create(escalation: NonEmptyStr, sha: String) -> body_file_name: microvm_runner_group_submit_body_file_name, submit_timeout: microvm_runner_group_submit_timeout, ) { - ApprovalFilingRefused { reason: r } => exit_failure(reason: concat("microvm runner group: not filed: ", r)) + ApprovalFilingRefused { reason: r } => failed(reason: concat("microvm runner group: not filed: ", r)) ApprovalFiled => microvm_runner_group_after_gate( gate: approval_gate_from_outcome( outcome: approval_poll_ticks( @@ -250,19 +263,44 @@ fn microvm_runner_group_ensure_on_srv1() -> ProcessExit if run_id == "" || sha == "" { return exit_failure(reason: "microvm runner group: GITHUB_RUN_ID or GITHUB_SHA is unset; the escalation id and the approved revision bind them") } - let subject = microvm_runner_group_create_subject() + let outcome = fold(restricted_runner_groups(), init: GroupEnsuredLine { line: "" }, f: fn(acc, subject) { + match acc { + GroupEnsureExit { exit } => GroupEnsureExit { exit: exit } + GroupEnsuredLine { line: prior } => + match ensure_one_group(subject: subject, run_id: run_id, sha: sha) { + GroupEnsureExit { exit } => GroupEnsureExit { exit: exit } + GroupEnsuredLine { line } => GroupEnsuredLine { line: if prior == "" { line } else { join([prior, line], "\n") } } + } + } + }) + match outcome { + GroupEnsureExit { exit } => exit + GroupEnsuredLine { line } => write_receipt(lines: [line]) + } +} + +// ONE GROUP: read, then leave alone, create under its own approval, or refuse. Each group files its +// own escalation, named for the group, so an approval of one group is never spent on another. +fn ensure_one_group(subject: RunnerGroupCreateSubject, run_id: String, sha: String) -> GroupEnsureOutcome + uses net: std.resources.Network +{ match plan_runner_group_ensure(subject: subject, listed: read_org_runner_groups(organization: subject.organization)) { - GroupEnsureRefused { cause } => exit_failure(reason: concat("microvm runner group: ", cause as String)) + GroupEnsureRefused { cause } => failed(reason: concat("microvm runner group: ", cause as String)) GroupAlreadyRestricted { restriction } => - write_receipt(line: join([ - "microvm-runner-group=already-restricted group_id=", to_string(restriction.group.value), + GroupEnsuredLine { line: join([ + "microvm-runner-group=already-restricted name=", restriction.name as String, + " group_id=", to_string(restriction.group.value), " selected_workflow=", restriction.selected_workflow as String, " (nothing filed)", - ], "")) + ], "") } GroupCreateRequired { subject: _ } => if actions_variable_trimmed(name: approval_submission_mac_key_path_env) == "" { - exit_failure(reason: concat("microvm runner group: the create is required and ", concat(approval_submission_mac_key_path_env as String, " is unset, so no approval can be filed"))) + failed(reason: concat("microvm runner group: the create is required and ", concat(approval_submission_mac_key_path_env as String, " is unset, so no approval can be filed"))) } else { - microvm_runner_group_file_and_create(escalation: concat("microvm-runner-group-", run_id) as NonEmptyStr, sha: sha) + microvm_runner_group_file_and_create( + group: subject, + escalation: join(["microvm-runner-group-", subject.name as String, "-", run_id], "") as NonEmptyStr, + sha: sha, + ) } } } diff --git a/dag/gunbc/runner/runner_group_restriction_observation.dag b/dag/gunbc/runner/runner_group_restriction_observation.dag index abeb6e09aa6..7a2030881a8 100644 --- a/dag/gunbc/runner/runner_group_restriction_observation.dag +++ b/dag/gunbc/runner/runner_group_restriction_observation.dag @@ -15,7 +15,7 @@ import gunbc.fleet.org_actions_inspection { // default labels and a runner in an unrestricted group would then match witnesses.yml, heal.yml and // fleet-converge jobs. That authorization is only as good as the path that constructs it, so the // restriction is a sole_constructor record: nothing outside this module can write a -// ShakedownRunnerGroupRestriction by literal or cast, and the only function here that mints one is +// RestrictedRunnerGroup by literal or cast, and the only function here that mints one is // the observer below, which does so only on a decoded GitHub reading. A witness or a controller that // wants the authorized arm has to hand this observer a body GitHub could have sent. // @@ -46,7 +46,7 @@ fn microvm_shakedown_selected_workflow() -> NonEmptyStr { // resolved to either. data microvm_shakedown_runner_group_name: NonEmptyStr = "microvm-shakedown" -type ShakedownRunnerGroupRestriction sole_constructor { +type RestrictedRunnerGroup sole_constructor { group: JitRunnerGroupId name: NonEmptyStr selected_workflow: NonEmptyStr @@ -61,7 +61,7 @@ type RunnerGroupRestrictionRefusal | RunnerGroupAbsent | RunnerGroupDuplicated { count: Int } | RunnerGroupNotRestrictedToWorkflows { group: JitRunnerGroupId } - | RunnerGroupSelectsOtherWorkflows { group: JitRunnerGroupId, selected: List } + | RunnerGroupSelectsOtherWorkflows { group: JitRunnerGroupId, selected: List, expected: NonEmptyStr } fn runner_group_restriction_refusal_text(cause: RunnerGroupRestrictionRefusal) -> String { match cause { @@ -71,36 +71,41 @@ fn runner_group_restriction_refusal_text(cause: RunnerGroupRestrictionRefusal) - RunnerGroupDuplicated { count } => concat("RunnerGroupDuplicated: groups with this name: ", to_string(count)) RunnerGroupNotRestrictedToWorkflows { group } => join(["RunnerGroupNotRestrictedToWorkflows: group ", to_string(group.value), " has restricted_to_workflows=false, so every workflow may use it"], "") - RunnerGroupSelectsOtherWorkflows { group, selected } => - join(["RunnerGroupSelectsOtherWorkflows: group ", to_string(group.value), " selected_workflows=[", join(map(selected, s => s as String), ","), "] expected=[", microvm_shakedown_selected_workflow() as String, "]"], "") + RunnerGroupSelectsOtherWorkflows { group, selected, expected } => + join(["RunnerGroupSelectsOtherWorkflows: group ", to_string(group.value), " selected_workflows=[", join(map(selected, s => s as String), ","), "] expected=[", expected as String, "]"], "") } } type MicrovmRunnerGroupStanding - = RunnerGroupRestrictedToShakedownWorkflow { restriction: ShakedownRunnerGroupRestriction } + = RunnerGroupRestrictedToWorkflow { restriction: RestrictedRunnerGroup } | RunnerGroupRestrictionRefused { name: NonEmptyStr, cause: RunnerGroupRestrictionRefusal } // A group that selects the shakedown workflow AND another one is refused, not admitted: exactly one // entry is the whole restriction, and a second entry is a second workflow whose jobs the guest takes. -fn restriction_of(name: NonEmptyStr, row: RunnerGroupIdentity) -> MicrovmRunnerGroupStanding { +// THE EXPECTED WORKFLOW IS THE CALLER'S, because the restricted groups are a roster +// (gunbc.runner_group_restriction_ensure restricted_runner_groups): the shakedown's and gunbc-private's +// heal publisher's. The authorization record still has one constructor -- this observer -- and it +// carries the workflow it was observed restricted to, so a mint cannot be handed a group observed +// for another workflow. +fn restriction_of(name: NonEmptyStr, selected_workflow: NonEmptyStr, row: RunnerGroupIdentity) -> MicrovmRunnerGroupStanding { let group = JitRunnerGroupId { value: row.id } if !row.restricted_to_workflows { RunnerGroupRestrictionRefused { name: name, cause: RunnerGroupNotRestrictedToWorkflows { group: group } } } else { - let expected = microvm_shakedown_selected_workflow() as String + let expected = selected_workflow as String if count(row.selected_workflows) == 1 && count(filter(row.selected_workflows, w => (w as String) == expected)) == 1 { - RunnerGroupRestrictedToShakedownWorkflow { restriction: ShakedownRunnerGroupRestriction { + RunnerGroupRestrictedToWorkflow { restriction: RestrictedRunnerGroup { group: group, name: name, - selected_workflow: microvm_shakedown_selected_workflow(), + selected_workflow: selected_workflow, } } } else { - RunnerGroupRestrictionRefused { name: name, cause: RunnerGroupSelectsOtherWorkflows { group: group, selected: row.selected_workflows } } + RunnerGroupRestrictionRefused { name: name, cause: RunnerGroupSelectsOtherWorkflows { group: group, selected: row.selected_workflows, expected: selected_workflow } } } } } -fn observe_shakedown_runner_group_restriction(name: NonEmptyStr, listed: RunnerGroupListDecode) -> MicrovmRunnerGroupStanding { +fn observe_runner_group_restriction(name: NonEmptyStr, selected_workflow: NonEmptyStr, listed: RunnerGroupListDecode) -> MicrovmRunnerGroupStanding { match listed { RunnerGroupListDecodeRefused { reason } => RunnerGroupRestrictionRefused { name: name, cause: RunnerGroupListUnreadable { reason: reason } } @@ -111,13 +116,18 @@ fn observe_shakedown_runner_group_restriction(name: NonEmptyStr, listed: RunnerG } else { match rows.first() { Absent => RunnerGroupRestrictionRefused { name: name, cause: RunnerGroupAbsent } - Present { value: row } => restriction_of(name: name, row: row) + Present { value: row } => restriction_of(name: name, selected_workflow: selected_workflow, row: row) } } } } } +// The shakedown's own reading, which the microVM mint and controller consume. +fn observe_shakedown_runner_group_restriction(name: NonEmptyStr, listed: RunnerGroupListDecode) -> MicrovmRunnerGroupStanding { + observe_runner_group_restriction(name: name, selected_workflow: microvm_shakedown_selected_workflow(), listed: listed) +} + // THE STANDING A CALLER HOLDS BEFORE ANY READ. It is a refusal, named, so a caller that has not // read the groups still carries a cause through to its operator rather than a bare "no". fn runner_group_restriction_not_yet_observed(name: NonEmptyStr) -> MicrovmRunnerGroupStanding { diff --git a/dag/gunbc/runner/runner_jit_mint.dag b/dag/gunbc/runner/runner_jit_mint.dag index 28dfb13a003..19ec92e4996 100644 --- a/dag/gunbc/runner/runner_jit_mint.dag +++ b/dag/gunbc/runner/runner_jit_mint.dag @@ -8,7 +8,7 @@ import extdeps.github.actions_jit_runner { JitRunnerConfigRequest, JitRunnerName, JitRunnerGroupId, JitRunnerLabel, JitWorkFolder, } import gunbc.runner_group_restriction_observation { - MicrovmRunnerGroupStanding, RunnerGroupRestrictedToShakedownWorkflow, RunnerGroupRestrictionRefused, + MicrovmRunnerGroupStanding, RunnerGroupRestrictedToWorkflow, RunnerGroupRestrictionRefused, RunnerGroupRestrictionRefusal, } import gunbc.runner.runner_jit_admission { @@ -134,7 +134,7 @@ fn plan_jit_mint( match runner_group { RunnerGroupRestrictionRefused { name: n, cause: c } => JitMintRefusedRunnerGroupUnrestricted { group: n, cause: c } - RunnerGroupRestrictedToShakedownWorkflow { restriction: r } => + RunnerGroupRestrictedToWorkflow { restriction: r } => JitMintAuthorized { attempt: attempt, request: jit_config_request_for(attempt: attempt, runner_group_id: r.group), diff --git a/dag/test/claim/runner/runner_group_restriction_ensure_witness_test.dag b/dag/test/claim/runner/runner_group_restriction_ensure_witness_test.dag index e4884d49241..a2691d31971 100644 --- a/dag/test/claim/runner/runner_group_restriction_ensure_witness_test.dag +++ b/dag/test/claim/runner/runner_group_restriction_ensure_witness_test.dag @@ -9,8 +9,10 @@ import gunbc.runner_group_restriction_ensure { RunnerGroupEnsurePlan, GroupAlreadyRestricted, GroupCreateRequired, GroupEnsureRefused, CreatedGroupRestricted, CreatedGroupNotRestricted, plan_runner_group_ensure, readback_after_create, microvm_runner_group_create_subject, - runner_group_create_subject_text, + runner_group_create_subject_text, private_heal_publisher_runner_group_subject, + private_heal_publisher_runner_group_name, restricted_runner_groups, } +import gunbc.auth.heal_publisher_federation { private_heal_publisher_federation, heal_publisher_federation_workflow_ref } // THE PLAN'S SUBJECT IS ONE INTERFACE: the organization's runner-groups wire body as GitHub's REST // read returns it, projected by the one production fold, in; which of three things to do, out. @@ -108,3 +110,38 @@ test fn witness_create_subject_text_names_every_member() -> Bool { && string_contains(s: t, pattern: shakedown()) && string_contains(s: t, pattern: "repository_id=1143718943") } + +// ── gunbc-private's heal publisher group: the same three walls, its own workflow ────────────── + +fn private_publisher_workflow() -> String { + heal_publisher_federation_workflow_ref(f: private_heal_publisher_federation()) +} + +fn private_group(id: Int, selected: List) -> OrgRunnerGroupWire { + group_wire(id: id, name: private_heal_publisher_runner_group_name as String, restricted: true, selected: selected) +} + +// THE GROUP AND THE IDENTITY NAME ONE FILE. The group's selected workflow is the federation's +// workflow_ref, so the only workflow that may run on the group is the only one that may federate. +test fn witness_private_publisher_group_selects_the_federations_workflow() -> Bool { + let s = private_heal_publisher_runner_group_subject() + (s.selected_workflow as String) == private_publisher_workflow() + && private_publisher_workflow() == "gunb-ai/gunbc-private/.github/workflows/heal-publish.yml@refs/heads/main" + && s.selected_repository_id == private_heal_publisher_federation().numeric_identity.repository_id + && s.restricted_to_workflows && !s.allows_public_repositories + && count(filter(restricted_runner_groups(), g => g.name == private_heal_publisher_runner_group_name)) == 1 +} + +// ADMITTED ONLY FOR ITS OWN WORKFLOW: the same group restricted to the shakedown workflow is +// refused for the publisher. The observer compares against the subject's workflow, not a constant. +test fn witness_private_publisher_group_is_admitted_for_its_workflow_and_refused_for_another() -> Bool { + let admitted = match plan_runner_group_ensure(subject: private_heal_publisher_runner_group_subject(), listed: listed(groups: [default_group(), private_group(id: 21, selected: [private_publisher_workflow()])])) { + GroupAlreadyRestricted { restriction: r } => r.group.value == 21 && (r.selected_workflow as String) == private_publisher_workflow() + _ => false + } + let refused = match plan_runner_group_ensure(subject: private_heal_publisher_runner_group_subject(), listed: listed(groups: [private_group(id: 21, selected: [shakedown()])])) { + GroupEnsureRefused { cause: c } => string_contains(s: c as String, pattern: "RunnerGroupSelectsOtherWorkflows") && string_contains(s: c as String, pattern: private_publisher_workflow()) + _ => false + } + admitted && refused +} diff --git a/dag/test/claim/runner/runner_group_restriction_observation_witness_test.dag b/dag/test/claim/runner/runner_group_restriction_observation_witness_test.dag index 274b550447c..eddd320eb46 100644 --- a/dag/test/claim/runner/runner_group_restriction_observation_witness_test.dag +++ b/dag/test/claim/runner/runner_group_restriction_observation_witness_test.dag @@ -6,7 +6,7 @@ import gunbc.fleet.org_actions_inspection { runner_groups_of_rest_read } import extdeps.transports.rest { RestOutcome, RestOk, RestStatusRefused, RestBodyUndecodable } import extdeps.github.org_actions { OrgRunnerGroupWire, OrgRunnerGroupListWire } import gunbc.runner_group_restriction_observation { - MicrovmRunnerGroupStanding, RunnerGroupRestrictedToShakedownWorkflow, RunnerGroupRestrictionRefused, + MicrovmRunnerGroupStanding, RunnerGroupRestrictedToWorkflow, RunnerGroupRestrictionRefused, RunnerGroupListUnreadable, RunnerGroupAbsent, RunnerGroupDuplicated, RunnerGroupNotRestrictedToWorkflows, RunnerGroupSelectsOtherWorkflows, observe_shakedown_runner_group_restriction, microvm_shakedown_selected_workflow, @@ -52,7 +52,7 @@ fn shakedown() -> String { microvm_shakedown_selected_workflow() as String } // POSITIVE CONTROL, and the route: the authorized arm names the group read and the exact workflow. test fn witness_runner_group_restricted_to_exactly_the_shakedown_workflow_is_held() -> Bool { match observed(body: listed(groups: [group(id: 3, restricted: false, selected: []), group(id: 7, restricted: true, selected: [shakedown()])])) { - RunnerGroupRestrictedToShakedownWorkflow { restriction: r } => + RunnerGroupRestrictedToWorkflow { restriction: r } => r.group.value == 7 && (r.name as String) == "microvm-shakedown" && (r.selected_workflow as String) == "gunb-ai/gunbc/.github/workflows/microvm-shakedown.yml@refs/heads/main" _ => false } @@ -67,7 +67,7 @@ test fn witness_runner_group_not_restricted_refuses_even_when_it_lists_the_workf test fn witness_runner_group_selecting_a_second_workflow_refuses() -> Bool { match observed(body: listed(groups: [group(id: 7, restricted: true, selected: [shakedown(), "gunb-ai/gunbc/.github/workflows/witnesses.yml@refs/heads/main"])])) { - RunnerGroupRestrictionRefused { name: _, cause: RunnerGroupSelectsOtherWorkflows { group: _, selected: s } } => count(s) == 2 + RunnerGroupRestrictionRefused { name: _, cause: RunnerGroupSelectsOtherWorkflows { group: _, selected: s, expected: _ } } => count(s) == 2 _ => false } } @@ -75,14 +75,14 @@ test fn witness_runner_group_selecting_a_second_workflow_refuses() -> Bool { // The same file at another ref is another workflow: a branch could carry an edited copy. test fn witness_runner_group_selecting_the_workflow_at_another_ref_refuses() -> Bool { match observed(body: listed(groups: [group(id: 7, restricted: true, selected: ["gunb-ai/gunbc/.github/workflows/microvm-shakedown.yml@refs/heads/feature"])])) { - RunnerGroupRestrictionRefused { name: _, cause: RunnerGroupSelectsOtherWorkflows { group: _, selected: _ } } => true + RunnerGroupRestrictionRefused { name: _, cause: RunnerGroupSelectsOtherWorkflows { group: _, selected: _, expected: _ } } => true _ => false } } test fn witness_runner_group_restricted_to_nothing_refuses() -> Bool { match observed(body: listed(groups: [group(id: 7, restricted: true, selected: [])])) { - RunnerGroupRestrictionRefused { name: _, cause: RunnerGroupSelectsOtherWorkflows { group: _, selected: s } } => count(s) == 0 + RunnerGroupRestrictionRefused { name: _, cause: RunnerGroupSelectsOtherWorkflows { group: _, selected: s, expected: _ } } => count(s) == 0 _ => false } } @@ -124,7 +124,7 @@ test fn witness_runner_group_body_undecodable_refuses_as_unreadable() -> Bool { test fn witness_wire_body_reaches_the_restricted_arm() -> Bool { match observed(body: OrgRunnerGroupListWire { total_count: 2, runner_groups: [OrgRunnerGroupWire { id: 1, name: "Default", visibility: "all", allows_public_repositories: false, restricted_to_workflows: false, selected_workflows: [], workflow_restrictions_read_only: false }, group(id: 7, restricted: true, selected: [shakedown()])] }) { - RunnerGroupRestrictedToShakedownWorkflow { restriction: r } => r.group.value == 7 + RunnerGroupRestrictedToWorkflow { restriction: r } => r.group.value == 7 _ => false } }