Skip to main content

pgevolve_core/diff/
cluster.rs

1//! Cluster diffing.
2//!
3//! Pair-by-name on roles; emit one [`ClusterChange`] per difference. All ops
4//! are catalog-only metadata (DDL through `pg_authid`), so they're safe by
5//! default — except `DropRole`, which is intent-gated because it can orphan
6//! grants in other DBs.
7
8use std::collections::BTreeMap;
9use std::collections::BTreeSet;
10
11use crate::diff::destructiveness::Destructiveness;
12use crate::identifier::Identifier;
13use crate::ir::cluster::catalog::ClusterCatalog;
14use crate::ir::cluster::role::{Role, RoleAttributes};
15
16/// One change to apply to a cluster's role layout.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum ClusterChange {
19    /// Create a new role with the given definition.
20    CreateRole(Role),
21    /// Drop an existing role by name.
22    DropRole {
23        /// Name of the role to drop.
24        name: Identifier,
25    },
26    /// Alter the attributes of an existing role.
27    ///
28    /// Both `from` and `to` are carried so that downstream lint rules (e.g.
29    /// `role-loses-superuser`) can inspect the before state.
30    AlterRoleAttributes {
31        /// Name of the role being altered.
32        name: Identifier,
33        /// Attribute values before the change.
34        from: RoleAttributes,
35        /// Attribute values after the change.
36        to: RoleAttributes,
37    },
38    /// Grant membership of `role` to `member`.
39    GrantRoleMembership {
40        /// Who gains the membership.
41        member: Identifier,
42        /// Which role they become a member of.
43        role: Identifier,
44    },
45    /// Revoke membership of `role` from `member`.
46    RevokeRoleMembership {
47        /// Who loses the membership.
48        member: Identifier,
49        /// Which role they are removed from.
50        role: Identifier,
51    },
52    /// Set or clear the comment on a role.
53    CommentOnRole {
54        /// Name of the role.
55        name: Identifier,
56        /// New comment value; `None` clears the existing comment.
57        comment: Option<String>,
58    },
59}
60
61/// A single entry in a [`ClusterChangeSet`], pairing a change with its risk level.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ClusterChangeEntry {
64    /// The structural change.
65    pub change: ClusterChange,
66    /// Risk classification for the change.
67    pub destructiveness: Destructiveness,
68}
69
70/// The full set of changes needed to converge one [`ClusterCatalog`] to another.
71#[derive(Debug, Clone, Default, PartialEq, Eq)]
72pub struct ClusterChangeSet {
73    /// Ordered list of change entries.
74    pub entries: Vec<ClusterChangeEntry>,
75}
76
77impl ClusterChangeSet {
78    /// Returns `true` when no changes are present.
79    #[must_use]
80    pub const fn is_empty(&self) -> bool {
81        self.entries.is_empty()
82    }
83}
84
85/// Diff `source` against `target`. `target` = current live cluster state;
86/// `source` = desired state from `roles/*.sql`. Resulting ops applied to
87/// `target` produce `source`.
88#[must_use]
89pub fn diff_cluster(target: &ClusterCatalog, source: &ClusterCatalog) -> ClusterChangeSet {
90    let mut entries = Vec::new();
91
92    let target_map: BTreeMap<&Identifier, &Role> =
93        target.roles.iter().map(|r| (&r.name, r)).collect();
94    let source_map: BTreeMap<&Identifier, &Role> =
95        source.roles.iter().map(|r| (&r.name, r)).collect();
96
97    // Adds.
98    for (name, source_role) in &source_map {
99        if !target_map.contains_key(name) {
100            entries.push(ClusterChangeEntry {
101                change: ClusterChange::CreateRole((*source_role).clone()),
102                destructiveness: Destructiveness::Safe,
103            });
104        }
105    }
106
107    // Drops + alters.
108    for (name, target_role) in &target_map {
109        match source_map.get(name) {
110            None => entries.push(ClusterChangeEntry {
111                change: ClusterChange::DropRole {
112                    name: (*name).clone(),
113                },
114                destructiveness: Destructiveness::RequiresApprovalAndDataLossWarning {
115                    reason: format!("drops role {name} — may orphan grants in other DBs"),
116                },
117            }),
118            Some(source_role) => diff_role(target_role, source_role, &mut entries),
119        }
120    }
121
122    ClusterChangeSet { entries }
123}
124
125fn diff_role(target: &Role, source: &Role, out: &mut Vec<ClusterChangeEntry>) {
126    if target.attributes != source.attributes {
127        out.push(ClusterChangeEntry {
128            change: ClusterChange::AlterRoleAttributes {
129                name: target.name.clone(),
130                from: target.attributes.clone(),
131                to: source.attributes.clone(),
132            },
133            destructiveness: Destructiveness::Safe,
134        });
135    }
136
137    // Membership: emit one Grant per added edge, one Revoke per removed.
138    let target_membership: BTreeSet<&Identifier> = target.member_of.iter().collect();
139    let source_membership: BTreeSet<&Identifier> = source.member_of.iter().collect();
140
141    for added in source_membership.difference(&target_membership) {
142        out.push(ClusterChangeEntry {
143            change: ClusterChange::GrantRoleMembership {
144                member: target.name.clone(),
145                role: (*added).clone(),
146            },
147            destructiveness: Destructiveness::Safe,
148        });
149    }
150    for removed in target_membership.difference(&source_membership) {
151        out.push(ClusterChangeEntry {
152            change: ClusterChange::RevokeRoleMembership {
153                member: target.name.clone(),
154                role: (*removed).clone(),
155            },
156            destructiveness: Destructiveness::Safe,
157        });
158    }
159
160    if target.comment != source.comment {
161        out.push(ClusterChangeEntry {
162            change: ClusterChange::CommentOnRole {
163                name: target.name.clone(),
164                comment: source.comment.clone(),
165            },
166            destructiveness: Destructiveness::Safe,
167        });
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    fn id(s: &str) -> Identifier {
176        Identifier::from_unquoted(s).unwrap()
177    }
178
179    fn role(name: &str) -> Role {
180        Role {
181            name: id(name),
182            attributes: RoleAttributes::default(),
183            member_of: vec![],
184            comment: None,
185        }
186    }
187
188    #[test]
189    fn equal_catalogs_yield_no_changes() {
190        let c = ClusterCatalog {
191            roles: vec![role("a")],
192        };
193        let cs = diff_cluster(&c, &c);
194        assert!(cs.is_empty());
195    }
196
197    #[test]
198    fn added_role_creates() {
199        let target = ClusterCatalog::empty();
200        let source = ClusterCatalog {
201            roles: vec![role("a")],
202        };
203        let cs = diff_cluster(&target, &source);
204        assert_eq!(cs.entries.len(), 1);
205        assert!(matches!(cs.entries[0].change, ClusterChange::CreateRole(_)));
206        assert_eq!(cs.entries[0].destructiveness, Destructiveness::Safe);
207    }
208
209    #[test]
210    fn removed_role_drops_with_intent_gate() {
211        let target = ClusterCatalog {
212            roles: vec![role("a")],
213        };
214        let source = ClusterCatalog::empty();
215        let cs = diff_cluster(&target, &source);
216        assert_eq!(cs.entries.len(), 1);
217        assert!(matches!(
218            cs.entries[0].change,
219            ClusterChange::DropRole { .. }
220        ));
221        assert!(cs.entries[0].destructiveness.requires_approval());
222        assert!(cs.entries[0].destructiveness.data_loss_risk());
223    }
224
225    #[test]
226    fn attribute_change_emits_alter() {
227        let t = role("a");
228        let mut s = role("a");
229        s.attributes.login = true;
230        let cs = diff_cluster(
231            &ClusterCatalog { roles: vec![t] },
232            &ClusterCatalog { roles: vec![s] },
233        );
234        assert_eq!(cs.entries.len(), 1);
235        assert!(matches!(
236            cs.entries[0].change,
237            ClusterChange::AlterRoleAttributes { .. }
238        ));
239    }
240
241    #[test]
242    fn added_membership_emits_grant() {
243        let t = role("a");
244        let mut s = role("a");
245        s.member_of.push(id("readers"));
246        let cs = diff_cluster(
247            &ClusterCatalog { roles: vec![t] },
248            &ClusterCatalog { roles: vec![s] },
249        );
250        assert_eq!(cs.entries.len(), 1);
251        match &cs.entries[0].change {
252            ClusterChange::GrantRoleMembership { member, role } => {
253                assert_eq!(member.as_str(), "a");
254                assert_eq!(role.as_str(), "readers");
255            }
256            other => panic!("got {other:?}"),
257        }
258    }
259
260    #[test]
261    fn removed_membership_emits_revoke() {
262        let mut t = role("a");
263        let s = role("a");
264        t.member_of.push(id("readers"));
265        let cs = diff_cluster(
266            &ClusterCatalog { roles: vec![t] },
267            &ClusterCatalog { roles: vec![s] },
268        );
269        assert_eq!(cs.entries.len(), 1);
270        assert!(matches!(
271            cs.entries[0].change,
272            ClusterChange::RevokeRoleMembership { .. }
273        ));
274    }
275
276    #[test]
277    fn comment_change_emits_comment_op() {
278        let t = role("a");
279        let mut s = role("a");
280        s.comment = Some("hello".into());
281        let cs = diff_cluster(
282            &ClusterCatalog { roles: vec![t] },
283            &ClusterCatalog { roles: vec![s] },
284        );
285        assert_eq!(cs.entries.len(), 1);
286        assert!(matches!(
287            cs.entries[0].change,
288            ClusterChange::CommentOnRole { .. }
289        ));
290    }
291}