neiam
/mutant-operator
An Experimental Operator
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Mutation: runtime variants on an Argo-synced Mutation, by opt-in
4932eb3 · gmorell · 2026-11-09 15:07
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(¤t)?; | |
| 322 | + if !variants_editable(¤t) { | |
| 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