config PRs: an operator's Forgejo merge deploys the merged rev
A config PR merged in the Forgejo UI changed nothing on the hive: the hive's webhook ignores `closed`, its poll then cancels the dashboard card, and `applied/main` stays where it was. swarm-controller reads `merged`/`merge_commit_sha` off the `pull_request` delivery it already receives for `agent-configs`, finds the hive placing the agent by scanning every hive's wanted state (the scan `declarations_elsewhere` already ran, factored out), and queues a `TriggerDeploy` carrying the rev. Zero or several claimants deploy nothing and log the claimants. `DeployRequest` gains `rev: Option<String>` with `serde(default)`, so rev-less payloads from either side keep decoding. hive-c0re, given a rev for an agent it runs: a no-op when `applied/main` already is the rev (a dashboard merge deploys its own PR); otherwise it fetches the forge `main` with the core token, requires the rev to descend from `applied/main` (the ancestry gate, factored out of `run_deploy_merge_verify`), fast-forwards by CAS and queues the usual relocking rebuild. No eval-verify on this path, per mara (#4850 c90075). A refusal is commented on the PR that merged the rev, found by commit. swarm-controller's forge-objects pass converges every config repo's `main` rule to merge whitelist `operators` + `core` and approval whitelist `operators`. The hive's boot PATCH stops forcing `enable_approvals_whitelist` off, so the two do not fight. Refs #4850
This commit is contained in:
parent
9224c0bd15
commit
f1c695c212
11 changed files with 732 additions and 76 deletions
|
|
@ -1,8 +1,8 @@
|
|||
//! The swarm-wide forge objects: the three seeded orgs (plus every mirror's
|
||||
//! owner org), the `operators` merge-gate team in `agents` and
|
||||
//! `agent-configs`, the operator-declared pull-mirrors, `internal/docs`,
|
||||
//! `internal/knowledge` (public, README-seeded) and the `agent-configs` org
|
||||
//! avatar.
|
||||
//! `agent-configs`, the merge gate on every `agent-configs` repo's `main`, the
|
||||
//! operator-declared pull-mirrors, `internal/docs`, `internal/knowledge`
|
||||
//! (public, README-seeded) and the `agent-configs` org avatar.
|
||||
//!
|
||||
//! Every hive's `hive-c0re` used to ensure these in its boot sweep, but only
|
||||
//! the one hive co-located with the forge container ever did (the sweep bails
|
||||
|
|
@ -24,15 +24,16 @@ use std::sync::Arc;
|
|||
use anyhow::{Context, Result};
|
||||
use base64::Engine as _;
|
||||
use forgejo_api::structs::{
|
||||
ChangeFileOperation, ChangeFileOperationOperation, ChangeFilesOptions, CreateOrgOption,
|
||||
CreateTeamOption, CreateTeamOptionPermission, EditRepoOption, EditTeamOption,
|
||||
EditTeamOptionPermission, MigrateRepoOptions, MigrateRepoOptionsService, Team, TeamPermission,
|
||||
UpdateUserAvatarOption,
|
||||
BranchProtection, ChangeFileOperation, ChangeFileOperationOperation, ChangeFilesOptions,
|
||||
CreateOrgOption, CreateTeamOption, CreateTeamOptionPermission, EditBranchProtectionOption,
|
||||
EditRepoOption, EditTeamOption, EditTeamOptionPermission, MigrateRepoOptions,
|
||||
MigrateRepoOptionsService, Team, TeamPermission, UpdateUserAvatarOption,
|
||||
};
|
||||
use forgejo_api::{ApiErrorKind, ForgejoError};
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::legacy_tokens::CORE_USER;
|
||||
use super::{
|
||||
CONFIG_ORG, Client, KNOWLEDGE_ORG, KNOWLEDGE_REPO, OPERATORS_TEAM, base64_encode,
|
||||
folds_into_success, is_ambiguous_validation_failure, is_confirmed_conflict,
|
||||
|
|
@ -177,6 +178,8 @@ pub struct Desired {
|
|||
avatar_png: Option<PathBuf>,
|
||||
/// Where [`AVATAR_MARKER`] lives.
|
||||
state_dir: PathBuf,
|
||||
/// Converge every config repo's `main` rule on [`config_rule_edit`].
|
||||
config_rules: bool,
|
||||
}
|
||||
|
||||
impl Desired {
|
||||
|
|
@ -211,6 +214,7 @@ impl Desired {
|
|||
mirrors,
|
||||
avatar_png,
|
||||
state_dir,
|
||||
config_rules: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -242,6 +246,7 @@ impl Desired {
|
|||
mirrors: Vec::new(),
|
||||
avatar_png: None,
|
||||
state_dir: PathBuf::new(),
|
||||
config_rules: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -319,6 +324,12 @@ struct Observed {
|
|||
teams: BTreeMap<String, Seen<TeamState>>,
|
||||
repos: BTreeMap<(String, String), Seen<RepoState>>,
|
||||
avatar_set: bool,
|
||||
/// Per config repo, whether its `main` rule already matches
|
||||
/// [`config_rule_edit`]. `Absent` is a repo with no `main` rule.
|
||||
config_rules: BTreeMap<String, Seen<bool>>,
|
||||
/// The config org's repo list could not be read, so `config_rules` is
|
||||
/// empty without meaning every rule matches.
|
||||
config_repos_unread: bool,
|
||||
}
|
||||
|
||||
impl Observed {
|
||||
|
|
@ -368,6 +379,10 @@ enum Action {
|
|||
org: String,
|
||||
id: i64,
|
||||
},
|
||||
/// PATCH a config repo's `main` rule to [`config_rule_edit`].
|
||||
ConvergeConfigRule {
|
||||
repo: String,
|
||||
},
|
||||
CreateRepo {
|
||||
owner: String,
|
||||
name: String,
|
||||
|
|
@ -409,7 +424,7 @@ impl Action {
|
|||
| Self::SeedReadme { owner, .. }
|
||||
| Self::CreateMirror { owner, .. }
|
||||
| Self::SetMirrorInterval { owner, .. } => Some(owner),
|
||||
Self::SetConfigOrgAvatar { .. } => Some(CONFIG_ORG),
|
||||
Self::ConvergeConfigRule { .. } | Self::SetConfigOrgAvatar { .. } => Some(CONFIG_ORG),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -436,6 +451,12 @@ fn plan(desired: &Desired, observed: &Observed) -> Vec<Action> {
|
|||
Seen::Present(_) | Seen::Unknown => {}
|
||||
}
|
||||
}
|
||||
// After the teams: the rule names the config org's `operators` team.
|
||||
for (repo, rule) in &observed.config_rules {
|
||||
if *rule == Seen::Present(false) {
|
||||
actions.push(Action::ConvergeConfigRule { repo: repo.clone() });
|
||||
}
|
||||
}
|
||||
for r in &desired.repos {
|
||||
let (owner, name) = (r.owner.to_owned(), r.name.to_owned());
|
||||
let (create, set_public, seed) = match observed.repo(r.owner, r.name) {
|
||||
|
|
@ -501,6 +522,50 @@ fn team_matches(t: &Team) -> bool {
|
|||
&& t.description.as_deref() == Some(OPERATORS_TEAM_DESCRIPTION)
|
||||
}
|
||||
|
||||
/// The merge gate on every config repo's `main`: the `operators` team approves
|
||||
/// and merges, and `core` merges too, for the hive's dashboard approval.
|
||||
///
|
||||
/// Forgejo replaces each list this sends wholesale and keeps every field left
|
||||
/// `None`, `required_approvals` included — the hive's own boot PATCH sets
|
||||
/// that one.
|
||||
fn config_rule_edit() -> EditBranchProtectionOption {
|
||||
EditBranchProtectionOption {
|
||||
apply_to_admins: None,
|
||||
approvals_whitelist_teams: Some(vec![OPERATORS_TEAM.to_owned()]),
|
||||
approvals_whitelist_username: None,
|
||||
block_on_official_review_requests: None,
|
||||
block_on_outdated_branch: None,
|
||||
block_on_rejected_reviews: None,
|
||||
dismiss_stale_approvals: None,
|
||||
enable_approvals_whitelist: Some(true),
|
||||
enable_merge_whitelist: Some(true),
|
||||
enable_push: None,
|
||||
enable_push_whitelist: None,
|
||||
enable_status_check: None,
|
||||
ignore_stale_approvals: None,
|
||||
merge_whitelist_teams: Some(vec![OPERATORS_TEAM.to_owned()]),
|
||||
merge_whitelist_usernames: Some(vec![CORE_USER.to_owned()]),
|
||||
protected_file_patterns: None,
|
||||
push_whitelist_deploy_keys: None,
|
||||
push_whitelist_teams: None,
|
||||
push_whitelist_usernames: None,
|
||||
require_signed_commits: None,
|
||||
required_approvals: None,
|
||||
status_check_contexts: None,
|
||||
unprotected_file_patterns: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `rule` already has every field [`config_rule_edit`] sets.
|
||||
fn config_rule_matches(rule: &BranchProtection) -> bool {
|
||||
let only = |list: &Option<Vec<String>>, want: &str| matches!(list.as_deref(), Some([entry]) if entry == want);
|
||||
rule.enable_merge_whitelist == Some(true)
|
||||
&& only(&rule.merge_whitelist_teams, OPERATORS_TEAM)
|
||||
&& only(&rule.merge_whitelist_usernames, CORE_USER)
|
||||
&& rule.enable_approvals_whitelist == Some(true)
|
||||
&& only(&rule.approvals_whitelist_teams, OPERATORS_TEAM)
|
||||
}
|
||||
|
||||
/// Whether an error is Forgejo saying 404: the object is absent, as opposed
|
||||
/// to a transport, auth or server failure.
|
||||
fn is_not_found(e: &ForgejoError) -> bool {
|
||||
|
|
@ -630,9 +695,34 @@ impl Client {
|
|||
observed.repos.insert((owner, name), repo);
|
||||
}
|
||||
observed.avatar_set = desired.avatar_png.is_some() && desired.avatar_marker().exists();
|
||||
if desired.config_rules && *observed.org(CONFIG_ORG) == Seen::Present(()) {
|
||||
self.observe_config_rules(&mut observed).await;
|
||||
}
|
||||
observed
|
||||
}
|
||||
|
||||
/// Read every config repo's `main` rule into `observed.config_rules`.
|
||||
async fn observe_config_rules(&self, observed: &mut Observed) {
|
||||
let repos = match self.api.org_list_repos(CONFIG_ORG).all().await {
|
||||
Ok(repos) => repos,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "swarm forge objects: listing {CONFIG_ORG} repos failed; skipping their branch rules this pass");
|
||||
observed.config_repos_unread = true;
|
||||
return;
|
||||
}
|
||||
};
|
||||
for name in repos.into_iter().filter_map(|r| r.name) {
|
||||
let rule = seen(
|
||||
self.api
|
||||
.repo_get_branch_protection(CONFIG_ORG, &name, "main")
|
||||
.await,
|
||||
&format!("branch protection of {CONFIG_ORG}/{name}"),
|
||||
|rule| config_rule_matches(&rule),
|
||||
);
|
||||
observed.config_rules.insert(name, rule);
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute `actions` in order. An action whose org failed to be created
|
||||
/// this pass is skipped: it would fail anyway, and its org's failure is
|
||||
/// the line worth reading.
|
||||
|
|
@ -666,6 +756,14 @@ impl Client {
|
|||
Action::CreateOrg { org } => self.create_org(org).await,
|
||||
Action::CreateTeam { org } => self.create_operators_team(org).await,
|
||||
Action::ReconcileTeam { org, id } => self.reconcile_operators_team(org, *id).await,
|
||||
Action::ConvergeConfigRule { repo } => {
|
||||
self.api
|
||||
.repo_edit_branch_protection(CONFIG_ORG, repo, "main", config_rule_edit())
|
||||
.await
|
||||
.with_context(|| format!("converge {CONFIG_ORG}/{repo} main branch rule"))?;
|
||||
tracing::info!(%repo, "swarm forge objects: config repo merge gate converged");
|
||||
Ok(())
|
||||
}
|
||||
Action::CreateRepo {
|
||||
owner,
|
||||
name,
|
||||
|
|
@ -939,7 +1037,13 @@ impl Client {
|
|||
.repos
|
||||
.values()
|
||||
.filter(|s| **s == Seen::Unknown)
|
||||
.count();
|
||||
.count()
|
||||
+ observed
|
||||
.config_rules
|
||||
.values()
|
||||
.filter(|s| **s == Seen::Unknown)
|
||||
.count()
|
||||
+ usize::from(observed.config_repos_unread);
|
||||
let actions = plan(desired, &observed);
|
||||
let mut outcome = self.apply(desired, &actions).await;
|
||||
outcome.failed += unknown;
|
||||
|
|
@ -1184,6 +1288,60 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_config_rule_that_does_not_match_is_converged() {
|
||||
let mut o = converged();
|
||||
o.config_rules.insert(s("atlas"), Seen::Present(true));
|
||||
o.config_rules.insert(s("damocles"), Seen::Present(false));
|
||||
o.config_rules.insert(s("iris"), Seen::Absent);
|
||||
o.config_rules.insert(s("argus"), Seen::Unknown);
|
||||
assert_eq!(
|
||||
plan(&desired(), &o),
|
||||
vec![Action::ConvergeConfigRule {
|
||||
repo: s("damocles")
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
fn rule(merge_users: &[&str]) -> BranchProtection {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"enable_merge_whitelist": true,
|
||||
"merge_whitelist_teams": [OPERATORS_TEAM],
|
||||
"merge_whitelist_usernames": merge_users,
|
||||
"enable_approvals_whitelist": true,
|
||||
"approvals_whitelist_teams": [OPERATORS_TEAM],
|
||||
}))
|
||||
.expect("branch protection json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_rule_matches_only_with_operators_and_core() {
|
||||
assert!(config_rule_matches(&rule(&[CORE_USER])));
|
||||
assert!(!config_rule_matches(&rule(&[])));
|
||||
assert!(!config_rule_matches(&rule(&[CORE_USER, "mallory"])));
|
||||
let mut core_only = rule(&[CORE_USER]);
|
||||
core_only.merge_whitelist_teams = None;
|
||||
assert!(!config_rule_matches(&core_only));
|
||||
let mut approvals_off = rule(&[CORE_USER]);
|
||||
approvals_off.enable_approvals_whitelist = Some(false);
|
||||
assert!(!config_rule_matches(&approvals_off));
|
||||
}
|
||||
|
||||
/// The edit is what the match checks for, so one PATCH converges a rule.
|
||||
#[test]
|
||||
fn the_config_rule_edit_produces_a_matching_rule() {
|
||||
let edit = config_rule_edit();
|
||||
let applied: BranchProtection = serde_json::from_value(serde_json::json!({
|
||||
"enable_merge_whitelist": edit.enable_merge_whitelist,
|
||||
"merge_whitelist_teams": edit.merge_whitelist_teams,
|
||||
"merge_whitelist_usernames": edit.merge_whitelist_usernames,
|
||||
"enable_approvals_whitelist": edit.enable_approvals_whitelist,
|
||||
"approvals_whitelist_teams": edit.approvals_whitelist_teams,
|
||||
}))
|
||||
.expect("branch protection json");
|
||||
assert!(config_rule_matches(&applied));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_owners_join_the_seeded_orgs_once() {
|
||||
let d = Desired::new(
|
||||
|
|
|
|||
Loading…
Reference in a new issue