Mutation: runtime variants on an Argo-synced Mutation, by opt-in

4932eb3 · gmorell · 2026-11-09 15:07

4 files +59 -10
Message
{commit_body(@commit)}

Files changed

modified README.md
+22 −4
@@ -112,10 +112,28 @@ browser-snap browser-snap 2/2 variants ready browser-snap-secret 1m
112 112
113 113 ### Argo CD
114 114
115 −Mutations are meant to be created at runtime, so they do not go in git.
116 −The operator API refuses to change or delete a Mutation that Argo CD syncs
117 −(one with its `argocd.argoproj.io/tracking-id` annotation or
118 −`app.kubernetes.io/instance` label), because the next sync would undo it.
115 +A Mutation made entirely at runtime does not go in git. One whose base
116 +should live in git, with its variants still managed at runtime, can be
117 +synced by Argo CD, if it opts in and leaves `variants` out of its manifest:
118 +
119 +```yaml
120 +apiVersion: neiam.org/v1
121 +kind: Mutation
122 +metadata:
123 + name: browser-snap
124 + annotations:
125 + mutantops.neiam.org/runtime-variants: "true"
126 +spec:
127 + deployment: { ... } # no `variants`: those belong to the API
128 +```
129 +
130 +Argo CD only applies and diffs the fields its manifest declares, so it
131 +neither reverts nor reports the variants the API adds. The API recognises a
132 +synced Mutation by Argo CD's `argocd.argoproj.io/tracking-id` annotation or
133 +`app.kubernetes.io/instance` label and never deletes one. Without the
134 +annotation it refuses variant changes too (`readOnly: true`), because they
135 +would belong in git.
136 +
119 137 To show Mutation health in Argo CD, add to `argocd-cm`:
120 138
121 139 ```yaml
modified src/api.rs
+17 −5
@@ -14,11 +14,14 @@
14 14 //! ```
15 15 //!
16 16 //! A Mutation is a Kubernetes object, so the operator keeps no state of its
17 −//! own. Ones synced by Argo CD are listed with `readOnly: true` and cannot be
18 −//! changed here: the next sync would put them back.
17 +//! own. One synced by Argo CD cannot be deleted here, since the next sync
18 +//! would put it back, and its variants are `readOnly` too unless it carries
19 +//! `mutantops.neiam.org/runtime-variants: "true"`: git holds the base, the
20 +//! API the variants.
19 21
20 22 use crate::crd::{
21 23 insert_variant, is_gitops_managed, remove_variant, validate_base, validate_label, validate_variants,
24 + variants_editable,
22 25 Mutation, MutationSpec, Variant, VariantState, VariantStatus,
23 26 };
24 27 use axum::{
@@ -151,8 +154,10 @@ struct MutationView {
151 154 secret: Option<String>,
152 155 summary: Option<String>,
153 156 message: Option<String>,
154 − /// Synced by Argo CD: changes in git, not here.
157 + /// Synced by Argo CD: the Mutation itself changes in git, not here.
155 158 read_only: bool,
159 + /// Whether variants can be added and removed here.
160 + variants_editable: bool,
156 161 }
157 162
158 163 /// A variant: its definition and its last reported state.
@@ -176,6 +181,7 @@ struct CreateMutation {
176 181 impl From<Mutation> for MutationView {
177 182 fn from(m: Mutation) -> Self {
178 183 let read_only = is_gitops_managed(&m);
184 + let editable = variants_editable(&m);
179 185 let status = m.status.clone().unwrap_or_default();
180 186 let variants = m
181 187 .spec
@@ -187,7 +193,7 @@ impl From<Mutation> for MutationView {
187 193 VariantStatus::rejected(&v.label, VariantState::Progressing, "not yet reconciled".into())
188 194 }),
189 195 variant: v.clone(),
190 − read_only,
196 + read_only: !editable,
191 197 })
192 198 .collect();
193 199 MutationView {
@@ -199,6 +205,7 @@ impl From<Mutation> for MutationView {
199 205 summary: status.summary,
200 206 message: status.message,
201 207 read_only,
208 + variants_editable: editable,
202 209 }
203 210 }
204 211 }
@@ -312,7 +319,12 @@ where
312 319 {
313 320 for _ in 0..MAX_ATTEMPTS {
314 321 let current = api.get(name).await?;
315 − refuse_if_gitops(&current)?;
322 + if !variants_editable(&current) {
323 + return Err(ApiError::Conflict(format!(
324 + "mutation {name} is synced by Argo CD without {}: \"true\"; change its variants in git instead",
325 + crate::crd::RUNTIME_VARIANTS_ANNOTATION
326 + )));
327 + }
316 328 let mut variants = current.spec.variants.clone();
317 329 edit(&mut variants)?;
318 330 let patch = json!({
modified src/crd.rs
+20 −1
@@ -242,12 +242,24 @@ pub fn summarize(variants: &[VariantStatus]) -> String {
242 242 const ARGO_TRACKING_ANNOTATION: &str = "argocd.argoproj.io/tracking-id";
243 243 const ARGO_TRACKING_LABEL: &str = "app.kubernetes.io/instance";
244 244
245 +/// Opts a GitOps-managed Mutation's variants back into the API: git holds
246 +/// the base, the API the variants. Its manifest must then leave `variants`
247 +/// out, so the sync never touches them.
248 +pub const RUNTIME_VARIANTS_ANNOTATION: &str = "mutantops.neiam.org/runtime-variants";
249 +
245 250 /// Whether a GitOps tool owns this Mutation, so it changes in git and the
246 −/// API must not change or delete it (the sync would only put it back).
251 +/// API must not delete it (the sync would only put it back).
247 252 pub fn is_gitops_managed(m: &Mutation) -> bool {
248 253 m.annotations().contains_key(ARGO_TRACKING_ANNOTATION) || m.labels().contains_key(ARGO_TRACKING_LABEL)
249 254 }
250 255
256 +/// Whether the API may add and remove this Mutation's variants: any Mutation
257 +/// made at runtime, and a GitOps-managed one that opts in with
258 +/// `mutantops.neiam.org/runtime-variants: "true"`.
259 +pub fn variants_editable(m: &Mutation) -> bool {
260 + !is_gitops_managed(m) || m.annotations().get(RUNTIME_VARIANTS_ANNOTATION).is_some_and(|v| v == "true")
261 +}
262 +
251 263 /// Name of the Secret generated for the Mutation named `mutation`.
252 264 pub fn secret_name(mutation: &str) -> String {
253 265 format!("{mutation}-secret")
@@ -455,6 +467,13 @@ mod tests {
455 467 let mut m = mutation("b");
456 468 m.labels_mut().insert(ARGO_TRACKING_LABEL.into(), "crib".into());
457 469 assert!(is_gitops_managed(&m));
470 +
471 + // Runtime-made: editable. Argo-synced: not, unless it opts in.
472 + assert!(variants_editable(&mutation("c")));
473 + assert!(!variants_editable(&m));
474 + m.annotations_mut().insert(RUNTIME_VARIANTS_ANNOTATION.into(), "true".into());
475 + assert!(variants_editable(&m));
476 + assert!(is_gitops_managed(&m), "still not deletable through the API");
458 477 }
459 478
460 479 #[test]

Parents: f38ff4e