This is an automated email from the ASF dual-hosted git repository.

villebro pushed a commit to branch main
in repository 
https://gitbox.apache.org/repos/asf/superset-kubernetes-operator.git


The following commit(s) were added to refs/heads/main by this push:
     new 35327d1  feat(lifecycle)!: run migrate on any image change, drop 
downgrade block (#173)
35327d1 is described below

commit 35327d1ab6ca38fbe0d7d2d5bd1afb236d581253
Author: Ville Brofeldt <[email protected]>
AuthorDate: Mon Jul 6 14:18:01 2026 -0700

    feat(lifecycle)!: run migrate on any image change, drop downgrade block 
(#173)
    
    * feat(lifecycle)!: run migrate on any image change, drop downgrade block
    
    * remove stale assertion
---
 AGENTS.md                                          |  6 +-
 Makefile                                           |  1 -
 api/v1alpha1/superset_types.go                     |  3 -
 .../crds/superset.apache.org_supersets.yaml        |  6 --
 .../crd/bases/superset.apache.org_supersets.yaml   |  6 --
 docs/architecture/internals.md                     |  2 +-
 docs/contributing/development-guidelines.md        | 14 +++-
 docs/reference/api-reference.md                    |  1 -
 docs/reference/releases.md                         |  9 +++
 docs/user-guide/lifecycle.md                       | 29 +++++---
 go.mod                                             |  2 +-
 internal/controller/lifecycle.go                   | 70 +++++++------------
 internal/controller/lifecycle_pipeline_test.go     | 42 ++----------
 internal/controller/lifecycle_settle_test.go       |  7 +-
 internal/controller/version.go                     | 40 -----------
 internal/controller/version_fuzz_test.go           | 78 ----------------------
 internal/controller/version_test.go                | 37 ----------
 test/e2e/superset_journey_test.go                  |  1 -
 18 files changed, 73 insertions(+), 281 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index 8bd07c7..d3a26a0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -79,7 +79,7 @@ The operator uses a **single public CRD architecture** where 
the parent `Superse
   - `component_descriptors.go` — table-driven component descriptors for parent 
resource reconciliation
   - `deployment_builder.go` — builds Deployment from FlatComponentSpec + 
DeploymentConfig
   - `initpod.go` — Task Job PodSpec helpers
-  - `version.go` — Version comparison logic (upgrade/downgrade detection)
+  - `version.go` — `ImageRef` helper (canonical repository:tag string)
   - `helpers.go` — componentLabels(), mergeLabels(), mergeAnnotations()
   - `status.go` — condition helpers
   - `scaling.go` — HPA (with custom metrics) + PDB reconciliation
@@ -115,11 +115,11 @@ The operator uses a **single public CRD architecture** 
where the parent `Superse
 - **HPA**: When `autoscaling` is set, Deployment replicas is nil (HPA 
manages). Supports custom metrics via `autoscalingv2.MetricSpec`. Top-level 
`autoscaling`/`podDisruptionBudget` provide defaults inherited by all scalable 
components; per-component values override (not merge). CeleryBeat and lifecycle 
tasks are excluded (singleton/one-off Jobs).
 - **Beat singleton**: CeleryBeat always forces replicas=1 regardless of spec.
 - **Gateway API**: Uses `sigs.k8s.io/gateway-api` types. Graceful handling of 
missing CRDs via `meta.IsNoMatchError`.
-- **Lifecycle tasks**: `spec.lifecycle` on the parent CRD (type 
`LifecycleSpec`) defines up to four sequential tasks: "seed" (database snapshot 
from external source), "migrate" (`superset db upgrade`), "rotate" (`superset 
re-encrypt-secrets` for secret key rotation), and "init" (`superset init`). The 
parent controller sequences them (migrate waits for seed, rotate waits for 
migrate, init waits for rotate), gates component deployment, and triggers 
re-runs by deleting and recreating determ [...]
+- **Lifecycle tasks**: `spec.lifecycle` on the parent CRD (type 
`LifecycleSpec`) defines up to four sequential tasks: "seed" (database snapshot 
from external source), "migrate" (`superset db upgrade`), "rotate" (`superset 
re-encrypt-secrets` for secret key rotation), and "init" (`superset init`). The 
parent controller sequences them (migrate waits for seed, rotate waits for 
migrate, init waits for rotate), gates component deployment, and triggers 
re-runs by deleting and recreating determ [...]
 - **CRD validation**: All validation uses CEL (`x-kubernetes-validations`) on 
CRD types — no admission webhooks. Rules cover: environment mode restrictions, 
secret mutual exclusivity, metastore/valkey validation, networking constraints, 
monitoring constraints. Defaults (repository, pullPolicy, environment) use 
kubebuilder default markers.
 - **Metrics**: Operator exposes controller-runtime default metrics (reconcile 
counts, durations, leader election) on HTTPS :8443 with Kubernetes auth/authz. 
No custom metrics — controller-runtime defaults are sufficient. Superset 
instance monitoring via optional `spec.monitoring.serviceMonitor` (creates a 
Prometheus ServiceMonitor targeting the web-server component using unstructured 
objects; gracefully skips if CRD is absent).
 - **Config mount path**: `/app/pythonpath` for superset_config.py.
-- **Fuzzing**: Go-native `go test -fuzz` targets in `*_fuzz_test.go` on pure 
string/codegen/merge functions (`CompareVersions`, `pyQuote`/`RenderConfig`, 
`MergeMaps`). `make fuzz` runs them bounded; seeds replay under `make 
test-unit`; scheduled `fuzz.yaml` runs them longer. Robustness, not a security 
boundary (CR input is trusted). See 
`docs/contributing/development-guidelines.md`.
+- **Fuzzing**: Go-native `go test -fuzz` targets in `*_fuzz_test.go` on pure 
string/codegen/merge functions (`pyQuote`/`RenderConfig`, `MergeMaps`). `make 
fuzz` runs them bounded; seeds replay under `make test-unit`; scheduled 
`fuzz.yaml` runs them longer. Robustness, not a security boundary (CR input is 
trusted). See `docs/contributing/development-guidelines.md`.
 - **All Go files must have the Apache 2.0 copyright header** (see 
`hack/boilerplate.go.txt`)
 
 ## Naming Conventions
diff --git a/Makefile b/Makefile
index 8eb4bfa..e7089d9 100644
--- a/Makefile
+++ b/Makefile
@@ -221,7 +221,6 @@ test-integration: manifests generate fmt vet setup-envtest 
## Run integration te
 fuzz: ## Run all fuzz targets for a bounded duration (FUZZTIME per target, 
default 30s).
        @FUZZTIME=$${FUZZTIME:-30s}; \
        set -e; \
-       go test ./internal/controller/ -run '^$$' -fuzz 
'^FuzzCompareVersions$$' -fuzztime $$FUZZTIME; \
        go test ./internal/config/     -run '^$$' -fuzz '^FuzzPyQuote$$'        
 -fuzztime $$FUZZTIME; \
        go test ./internal/config/     -run '^$$' -fuzz '^FuzzRenderConfig$$'   
 -fuzztime $$FUZZTIME; \
        go test ./internal/resolution/ -run '^$$' -fuzz '^FuzzMergeMaps$$'      
 -fuzztime $$FUZZTIME
diff --git a/api/v1alpha1/superset_types.go b/api/v1alpha1/superset_types.go
index 525bec4..af71f83 100644
--- a/api/v1alpha1/superset_types.go
+++ b/api/v1alpha1/superset_types.go
@@ -867,9 +867,6 @@ type UpgradeContext struct {
        FromVersion string `json:"fromVersion,omitempty"`
        // +optional
        ToVersion string `json:"toVersion,omitempty"`
-       // +optional
-       // +kubebuilder:validation:Enum=Upgrade;Downgrade;Unknown
-       Direction string `json:"direction,omitempty"`
        // ApprovalToken is the annotation value required to approve this exact 
upgrade transition.
        // +optional
        ApprovalToken string `json:"approvalToken,omitempty"`
diff --git a/charts/superset-operator/crds/superset.apache.org_supersets.yaml 
b/charts/superset-operator/crds/superset.apache.org_supersets.yaml
index fa95763..78f67fc 100644
--- a/charts/superset-operator/crds/superset.apache.org_supersets.yaml
+++ b/charts/superset-operator/crds/superset.apache.org_supersets.yaml
@@ -40045,12 +40045,6 @@ spec:
                     properties:
                       approvalToken:
                         type: string
-                      direction:
-                        enum:
-                        - Upgrade
-                        - Downgrade
-                        - Unknown
-                        type: string
                       fromVersion:
                         type: string
                       startedAt:
diff --git a/config/crd/bases/superset.apache.org_supersets.yaml 
b/config/crd/bases/superset.apache.org_supersets.yaml
index fa95763..78f67fc 100644
--- a/config/crd/bases/superset.apache.org_supersets.yaml
+++ b/config/crd/bases/superset.apache.org_supersets.yaml
@@ -40045,12 +40045,6 @@ spec:
                     properties:
                       approvalToken:
                         type: string
-                      direction:
-                        enum:
-                        - Upgrade
-                        - Downgrade
-                        - Unknown
-                        type: string
                       fromVersion:
                         type: string
                       startedAt:
diff --git a/docs/architecture/internals.md b/docs/architecture/internals.md
index 1a8ff53..7ca6715 100644
--- a/docs/architecture/internals.md
+++ b/docs/architecture/internals.md
@@ -446,7 +446,7 @@ The top-level `status.phase` reflects the overall instance 
state:
 | `Running` | All enabled components are ready and lifecycle is complete |
 | `Degraded` | One or more components are not fully ready |
 | `Suspended` | `spec.suspend: true` — all reconciliation paused |
-| `Blocked` | Downgrade detected — lifecycle tasks will not run (manual 
intervention required) |
+| `Blocked` | Configuration error (e.g. an invalid `seed.cronSchedule`) — 
lifecycle tasks will not run until corrected |
 | `AwaitingApproval` | Supervised upgrade mode — waiting for the target-bound 
approval annotation before proceeding |
 
 Drain progress is reported on `status.lifecycle.phase=Draining`; it does not
diff --git a/docs/contributing/development-guidelines.md 
b/docs/contributing/development-guidelines.md
index 75a0cb0..1dc2730 100644
--- a/docs/contributing/development-guidelines.md
+++ b/docs/contributing/development-guidelines.md
@@ -62,6 +62,14 @@ Across all tiers:
   helper or restructuring a package breaks tests, those tests were coupled
   to implementation, not behavior, and should be rewritten to assert on
   observable outputs or removed entirely.
+- **Don't test non-actions.** A test should pin a behavior the code actively
+  produces, not assert the absence of behavior the code never had (or no longer
+  has). When you delete a feature, delete its tests — don't invert them into
+  "X no longer happens" assertions. The positive tests for the surviving
+  behavior already cover it: e.g. after removing downgrade blocking, a
+  "downgrade proceeds" test is redundant because the general "an image change
+  proceeds and re-runs migrate" test already exercises that path. (Mirrors the
+  documentation rule: don't describe what the default already precludes.)
 
 ### Security and the threat model
 
@@ -224,8 +232,8 @@ make fuzz FUZZTIME=2m  # longer local run
 ```
 
 Each target seeds a corpus with `f.Add(...)` cases and asserts an invariant
-beyond "does not panic" — e.g. `CompareVersions` is antisymmetric, 
`RenderConfig`
-is deterministic, `pyQuote` round-trips, `MergeMaps` is a last-writer-wins 
union.
+beyond "does not panic" — e.g. `RenderConfig` is deterministic, `pyQuote`
+round-trips, `MergeMaps` is a last-writer-wins union.
 The seed corpus (plus any committed `testdata/fuzz/...` reproducers) replays as
 ordinary subtests during `make test-unit`, so regressions are caught on every 
PR;
 the scheduled `fuzz.yaml` workflow runs the targets for longer to explore new
@@ -445,7 +453,7 @@ logr/zap). Verbosity is a single knob, `--zap-log-level`, 
configured in
 
 - **Info / V(0)** — `log.Info(msg, kv...)`. Low-frequency, meaningful state
   *transitions* an operator wants at default verbosity (lifecycle task
-  created/completed/failed, downgrade blocked, awaiting approval, maintenance
+  created/completed/failed, awaiting approval, maintenance
   cleared, a component actually changed). **Must not fire on every reconcile.**
 - **V(1) / debug** — `log.V(1).Info(msg, kv...)`. Per-reconcile progress and
   decisions: reconcile entry, phase markers, checksum-skip decisions,
diff --git a/docs/reference/api-reference.md b/docs/reference/api-reference.md
index dd837fa..ed4379a 100644
--- a/docs/reference/api-reference.md
+++ b/docs/reference/api-reference.md
@@ -1159,7 +1159,6 @@ _Appears in:_
 | --- | --- | --- | --- |
 | `fromVersion` _string_ |  |  | Optional: \{\} <br /> |
 | `toVersion` _string_ |  |  | Optional: \{\} <br /> |
-| `direction` _string_ |  |  | Enum: [Upgrade Downgrade Unknown] <br 
/>Optional: \{\} <br /> |
 | `approvalToken` _string_ | ApprovalToken is the annotation value required to 
approve this exact upgrade transition. |  | Optional: \{\} <br /> |
 | `startedAt` 
_[Time](https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#Time)_ |  |  | 
Optional: \{\} <br /> |
 
diff --git a/docs/reference/releases.md b/docs/reference/releases.md
index 7e4ab14..894bfe3 100644
--- a/docs/reference/releases.md
+++ b/docs/reference/releases.md
@@ -26,6 +26,15 @@ releases.
 
 ### Changed
 
+- **Breaking:** downgrade blocking is removed. Any change to the lifecycle 
image
+  tag now re-runs the migrate task (`superset db upgrade`) regardless of
+  direction — the operator no longer performs semver comparison or sets
+  `status.phase: Blocked` on a version decrease, matching the official Superset
+  Helm chart. The migrate task only runs `superset db upgrade` (Superset's down
+  migrations are poorly tested and often break, so the operator never runs
+  them), so take a database backup before an upgrade if you may need to revert.
+  The `direction` field is removed from `status.lifecycle.upgrade`, and the
+  `VersionComparisonSkipped` warning event no longer fires.
 - **Breaking:** the lifecycle `clone` task is renamed to `seed`. Rename
   `spec.lifecycle.clone` to `spec.lifecycle.seed` (and its `postCloneSQL` field
   to `postSeedSQL`) in your Superset resources. The task Job name suffix 
changes
diff --git a/docs/user-guide/lifecycle.md b/docs/user-guide/lifecycle.md
index 76527ae..6c17187 100644
--- a/docs/user-guide/lifecycle.md
+++ b/docs/user-guide/lifecycle.md
@@ -174,16 +174,23 @@ You can monitor the upgrade status with:
 kubectl get superset my-superset -o jsonpath='{.status.lifecycle}'
 ```
 
-The operator also performs semver comparison on image tags and blocks 
downgrades
-to prevent accidental database corruption. A blocked downgrade sets
-`status.phase: Blocked` — revert the image tag to resolve.
-
-Downgrade protection requires semantically comparable (semver) image tags.
-Non-semver tags — such as `latest`, date stamps, or digest pins — cannot be
-ordered, so the operator cannot detect a downgrade and lifecycle tasks proceed
-normally. In that case it emits a `VersionComparisonSkipped` warning event
-rather than blocking. Use semver tags in Staging and Production if you rely on
-downgrade protection.
+### Version changes and downgrades
+
+Any change to the lifecycle image tag re-runs the migrate task
+(`superset db upgrade`), regardless of whether the new version is higher or
+lower than the previous one. The operator does not compare versions or block
+downgrades. This matches the behavior of the official Superset Helm chart, 
which
+runs `superset db upgrade` on every upgrade.
+
+The migrate task only ever runs `superset db upgrade`. Superset does provide
+`superset db downgrade`, but its down migrations are poorly tested and often
+break, so the operator never runs them — pinning back to an older image re-runs
+the forward migration rather than reversing the schema. You are responsible for
+ensuring the database is compatible with the target version (for example, by
+restoring a backup taken before the upgrade). Allowing the change means a
+deployment that needs to revert after a failed upgrade is never stranded in a
+blocked state. Take a database backup before every migration so a known-good
+dump exists if you need to revert.
 
 ## Drain Behavior
 
@@ -767,7 +774,7 @@ Task Job names are deterministic: `{parentName}-{taskType}` 
(e.g. `my-superset-m
 |---|---|
 | `Initializing` | First deployment — lifecycle tasks running for the first 
time |
 | `Upgrading` | Image change detected — lifecycle tasks running against new 
version |
-| `Blocked` | Downgrade detected — lifecycle tasks will not run (manual 
intervention required) |
+| `Blocked` | Configuration error (e.g. an invalid `seed.cronSchedule`) — 
lifecycle tasks will not run until corrected |
 | `AwaitingApproval` | Supervised upgrade mode — waiting for the target-bound 
approval annotation before proceeding |
 
 Drain progress appears in the `Lifecycle` column and
diff --git a/go.mod b/go.mod
index b715b00..8367e5f 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,6 @@ go 1.26.0
 toolchain go1.26.4
 
 require (
-       github.com/Masterminds/semver/v3 v3.5.0
        github.com/onsi/ginkgo/v2 v2.32.0
        github.com/onsi/gomega v1.42.1
        github.com/robfig/cron/v3 v3.0.1
@@ -21,6 +20,7 @@ require (
 
 require (
        cel.dev/expr v0.25.1 // indirect
+       github.com/Masterminds/semver/v3 v3.5.0 // indirect
        github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
        github.com/beorn7/perks v1.0.1 // indirect
        github.com/blang/semver/v4 v4.0.0 // indirect
diff --git a/internal/controller/lifecycle.go b/internal/controller/lifecycle.go
index 1cbcfd6..64a5bc3 100644
--- a/internal/controller/lifecycle.go
+++ b/internal/controller/lifecycle.go
@@ -183,9 +183,10 @@ func (r *SupersetReconciler) reconcileLifecycle(
        upgradeInProgress := lastImage != "" && currentImage != lastImage
        parentLifecyclePhase := lifecycleParentPhase(upgradeInProgress)
 
-       // Check upgrade gates (version comparison, downgrade blocking, 
supervised approval).
-       if gateResult, gated := r.checkUpgradeGates(ctx, superset, 
imageChanged, lastImage, currentImage); gated {
-               return gateResult, nil
+       // Enforce supervised upgrade approval. A gated change waits for the 
approval
+       // annotation (which re-triggers reconcile), so no timed requeue is 
needed.
+       if r.checkUpgradeGates(ctx, superset, imageChanged, lastImage, 
currentImage) {
+               return lifecycleResult{}, nil
        }
 
        // Determine which tasks are enabled and prune orphans for disabled 
ones.
@@ -411,64 +412,41 @@ func (r *SupersetReconciler) runLifecyclePipeline(
        return lifecycleComplete(), nil
 }
 
-// checkUpgradeGates handles version comparison, downgrade blocking, and 
supervised approval.
-// Returns (result, gated) — if gated is true, the caller should return early 
with result.
+// checkUpgradeGates records the in-flight image change and enforces supervised
+// approval. Version direction is not inspected — any image change proceeds and
+// re-runs migrate. Returns true if the change is gated (awaiting approval) and
+// the caller should stop reconciling until the approval annotation is set.
 func (r *SupersetReconciler) checkUpgradeGates(
        ctx context.Context,
        superset *supersetv1alpha1.Superset,
        imageChanged bool,
        lastImage, currentImage string,
-) (lifecycleResult, bool) {
+) bool {
        log := logf.FromContext(ctx)
 
        if !imageChanged || lastImage == "" {
-               return lifecycleResult{}, false
+               return false
        }
 
        oldTag := tagFromImageRef(lastImage)
        newTag := tagFromImageRef(currentImage)
-       direction := CompareVersions(oldTag, newTag)
        approvalToken := upgradeApprovalToken(lastImage, currentImage)
-       log.V(2).Info("Compared image versions", "from", oldTag, "to", newTag, 
"direction", direction)
-
-       if direction == DirectionDowngrade {
-               log.Info("Downgrade detected, blocking lifecycle", "from", 
oldTag, "to", newTag)
-               setCondition(&superset.Status.Conditions, 
supersetv1alpha1.ConditionTypeLifecycleComplete,
-                       metav1.ConditionFalse, "DowngradeBlocked",
-                       fmt.Sprintf("Downgrade from %s to %s is not supported. 
Alembic migrations are forward-only.", oldTag, newTag),
-                       superset.Generation)
-               superset.Status.Phase = phaseBlocked
-               superset.Status.Lifecycle.Phase = lifecyclePhaseBlocked
-               superset.Status.Lifecycle.Upgrade = 
&supersetv1alpha1.UpgradeContext{
-                       FromVersion:   oldTag,
-                       ToVersion:     newTag,
-                       Direction:     string(DirectionDowngrade),
-                       ApprovalToken: approvalToken,
-               }
-               r.Recorder.Eventf(superset, nil, corev1.EventTypeWarning, 
"DowngradeBlocked", "Lifecycle",
-                       "Downgrade from %s to %s is not supported", oldTag, 
newTag)
-               return lifecycleTerminal(), true
-       }
-
-       contextMatches := 
upgradeContextMatches(superset.Status.Lifecycle.Upgrade, oldTag, newTag, 
direction, approvalToken)
-
-       // Non-semver tags (e.g. "latest", date stamps, digest pins) cannot be
-       // ordered, so downgrade protection does not apply — a downgrade 
expressed
-       // with such tags would run forward-only migrations against an older 
image
-       // undetected. Surface this once per distinct image transition so 
operators
-       // can intervene; the !contextMatches guard prevents per-reconcile spam.
-       if direction == DirectionUnknown && !contextMatches {
-               log.Info("Image version change could not be compared 
(non-semver tags); downgrade protection skipped",
-                       "from", oldTag, "to", newTag)
-               r.Recorder.Eventf(superset, nil, corev1.EventTypeWarning, 
"VersionComparisonSkipped", "Lifecycle",
-                       "Cannot compare image tags %q and %q (non-semver); 
downgrade protection does not apply", oldTag, newTag)
-       }
+       log.V(2).Info("Detected lifecycle image change", "from", oldTag, "to", 
newTag)
+
+       // Any version change re-runs migrate (superset db upgrade); direction 
is
+       // not inspected. Superset supports `superset db downgrade`, but down
+       // migrations are poorly tested and frequently break, so the operator 
does
+       // not attempt to run them — pinning back to an older image just 
re-runs the
+       // forward migration. Blocking the change instead strands deployments 
that
+       // need to pin back after a failed upgrade. Matching the official 
Superset
+       // Helm chart, the operator runs the migration on every change and 
relies on
+       // the (optional) pre-upgrade backup as the safety net.
+       contextMatches := 
upgradeContextMatches(superset.Status.Lifecycle.Upgrade, oldTag, newTag, 
approvalToken)
 
        if !contextMatches {
                superset.Status.Lifecycle.Upgrade = 
&supersetv1alpha1.UpgradeContext{
                        FromVersion:   oldTag,
                        ToVersion:     newTag,
-                       Direction:     string(direction),
                        ApprovalToken: approvalToken,
                        StartedAt:     nowPtr(),
                }
@@ -488,24 +466,22 @@ func (r *SupersetReconciler) checkUpgradeGates(
                                superset.Generation)
                        superset.Status.Phase = phaseAwaitingApproval
                        superset.Status.Lifecycle.Phase = 
lifecyclePhaseAwaitingApproval
-                       return lifecycleResult{}, true
+                       return true
                }
        }
 
-       return lifecycleResult{}, false
+       return false
 }
 
 func upgradeContextMatches(
        upgrade *supersetv1alpha1.UpgradeContext,
        fromVersion string,
        toVersion string,
-       direction VersionDirection,
        approvalToken string,
 ) bool {
        return upgrade != nil &&
                upgrade.FromVersion == fromVersion &&
                upgrade.ToVersion == toVersion &&
-               upgrade.Direction == string(direction) &&
                upgrade.ApprovalToken == approvalToken
 }
 
diff --git a/internal/controller/lifecycle_pipeline_test.go 
b/internal/controller/lifecycle_pipeline_test.go
index e312c48..9cececa 100644
--- a/internal/controller/lifecycle_pipeline_test.go
+++ b/internal/controller/lifecycle_pipeline_test.go
@@ -299,28 +299,16 @@ func TestCheckUpgradeGates(t *testing.T) {
 
        t.Run("no image change is not gated", func(t *testing.T) {
                s := &supersetv1alpha1.Superset{Status: 
supersetv1alpha1.SupersetStatus{Lifecycle: &supersetv1alpha1.LifecycleStatus{}}}
-               _, gated := r.checkUpgradeGates(ctx, s, false, 
"apache/superset:1", "apache/superset:1")
+               gated := r.checkUpgradeGates(ctx, s, false, 
"apache/superset:1", "apache/superset:1")
                assert.False(t, gated)
        })
 
        t.Run("first install (empty lastImage) is not gated", func(t 
*testing.T) {
                s := &supersetv1alpha1.Superset{Status: 
supersetv1alpha1.SupersetStatus{Lifecycle: &supersetv1alpha1.LifecycleStatus{}}}
-               _, gated := r.checkUpgradeGates(ctx, s, true, "", 
"apache/superset:1")
+               gated := r.checkUpgradeGates(ctx, s, true, "", 
"apache/superset:1")
                assert.False(t, gated)
        })
 
-       t.Run("downgrade blocks with terminal result", func(t *testing.T) {
-               s := &supersetv1alpha1.Superset{
-                       ObjectMeta: metav1.ObjectMeta{Name: "test"},
-                       Status:     supersetv1alpha1.SupersetStatus{Lifecycle: 
&supersetv1alpha1.LifecycleStatus{}},
-               }
-               res, gated := r.checkUpgradeGates(ctx, s, true, 
"apache/superset:3.0.0", "apache/superset:2.0.0")
-               assert.True(t, gated)
-               assert.True(t, res.TerminalFailure)
-               assert.Equal(t, lifecyclePhaseBlocked, s.Status.Lifecycle.Phase)
-               assert.True(t, hasConditionReason(s.Status.Conditions, 
supersetv1alpha1.ConditionTypeLifecycleComplete, "DowngradeBlocked"))
-       })
-
        t.Run("supervised upgrade awaits approval", func(t *testing.T) {
                mode := upgradeModeSupervised
                s := &supersetv1alpha1.Superset{
@@ -330,9 +318,8 @@ func TestCheckUpgradeGates(t *testing.T) {
                        },
                        Status: supersetv1alpha1.SupersetStatus{Lifecycle: 
&supersetv1alpha1.LifecycleStatus{}},
                }
-               res, gated := r.checkUpgradeGates(ctx, s, true, 
"apache/superset:2.0.0", "apache/superset:3.0.0")
+               gated := r.checkUpgradeGates(ctx, s, true, 
"apache/superset:2.0.0", "apache/superset:3.0.0")
                assert.True(t, gated)
-               assert.False(t, res.TerminalFailure)
                assert.Equal(t, lifecyclePhaseAwaitingApproval, 
s.Status.Lifecycle.Phase)
                assert.Equal(t, phaseAwaitingApproval, s.Status.Phase)
        })
@@ -342,32 +329,11 @@ func TestCheckUpgradeGates(t *testing.T) {
                        ObjectMeta: metav1.ObjectMeta{Name: "test"},
                        Status:     supersetv1alpha1.SupersetStatus{Lifecycle: 
&supersetv1alpha1.LifecycleStatus{}},
                }
-               _, gated := r.checkUpgradeGates(ctx, s, true, 
"apache/superset:2.0.0", "apache/superset:3.0.0")
+               gated := r.checkUpgradeGates(ctx, s, true, 
"apache/superset:2.0.0", "apache/superset:3.0.0")
                assert.False(t, gated)
                // Upgrade context recorded for the in-flight upgrade.
                require.NotNil(t, s.Status.Lifecycle.Upgrade)
                assert.Equal(t, "2.0.0", s.Status.Lifecycle.Upgrade.FromVersion)
                assert.Equal(t, "3.0.0", s.Status.Lifecycle.Upgrade.ToVersion)
        })
-
-       t.Run("non-semver tags proceed but emit a warning", func(t *testing.T) {
-               rec := events.NewFakeRecorder(10)
-               nr := &SupersetReconciler{Recorder: rec}
-               s := &supersetv1alpha1.Superset{
-                       ObjectMeta: metav1.ObjectMeta{Name: "test"},
-                       Status:     supersetv1alpha1.SupersetStatus{Lifecycle: 
&supersetv1alpha1.LifecycleStatus{}},
-               }
-               // "latest" → "main" cannot be ordered: not a downgrade, so not 
gated.
-               _, gated := nr.checkUpgradeGates(ctx, s, true, 
"apache/superset:latest", "apache/superset:main")
-               assert.False(t, gated)
-               assert.NotEqual(t, lifecyclePhaseBlocked, 
s.Status.Lifecycle.Phase)
-               require.NotNil(t, s.Status.Lifecycle.Upgrade)
-               assert.Equal(t, string(DirectionUnknown), 
s.Status.Lifecycle.Upgrade.Direction)
-               select {
-               case ev := <-rec.Events:
-                       assert.Contains(t, ev, "VersionComparisonSkipped")
-               default:
-                       t.Fatal("expected a VersionComparisonSkipped warning 
event")
-               }
-       })
 }
diff --git a/internal/controller/lifecycle_settle_test.go 
b/internal/controller/lifecycle_settle_test.go
index a0c27db..90be6e0 100644
--- a/internal/controller/lifecycle_settle_test.go
+++ b/internal/controller/lifecycle_settle_test.go
@@ -118,7 +118,7 @@ func 
TestCheckUpgradeGates_SupervisedApprovalRequiresRecordedToken(t *testing.T)
        }
        r := &SupersetReconciler{}
 
-       _, gated := r.checkUpgradeGates(context.Background(), superset, true, 
lastImage, currentImage)
+       gated := r.checkUpgradeGates(context.Background(), superset, true, 
lastImage, currentImage)
        if !gated {
                t.Fatal("expected first reconcile to publish the approval token 
before accepting it")
        }
@@ -129,7 +129,7 @@ func 
TestCheckUpgradeGates_SupervisedApprovalRequiresRecordedToken(t *testing.T)
                t.Fatalf("expected approval token %q, got %q", token, got)
        }
 
-       _, gated = r.checkUpgradeGates(context.Background(), superset, true, 
lastImage, currentImage)
+       gated = r.checkUpgradeGates(context.Background(), superset, true, 
lastImage, currentImage)
        if gated {
                t.Fatal("expected matching recorded approval token to allow the 
upgrade")
        }
@@ -156,7 +156,6 @@ func 
TestCheckUpgradeGates_StaleApprovalDoesNotApproveChangedTarget(t *testing.T
                                Upgrade: &supersetv1alpha1.UpgradeContext{
                                        FromVersion:   "1.0.0",
                                        ToVersion:     "1.1.0",
-                                       Direction:     string(DirectionUpgrade),
                                        ApprovalToken: oldToken,
                                },
                        },
@@ -164,7 +163,7 @@ func 
TestCheckUpgradeGates_StaleApprovalDoesNotApproveChangedTarget(t *testing.T
        }
        r := &SupersetReconciler{}
 
-       _, gated := r.checkUpgradeGates(context.Background(), superset, true, 
lastImage, newTarget)
+       gated := r.checkUpgradeGates(context.Background(), superset, true, 
lastImage, newTarget)
        if !gated {
                t.Fatal("expected stale approval token to keep the changed 
target gated")
        }
diff --git a/internal/controller/version.go b/internal/controller/version.go
index 578506f..f78d4f2 100644
--- a/internal/controller/version.go
+++ b/internal/controller/version.go
@@ -19,46 +19,6 @@ under the License.
 
 package controller
 
-import (
-       "strings"
-
-       "github.com/Masterminds/semver/v3"
-)
-
-// VersionDirection represents the relationship between two image versions.
-type VersionDirection string
-
-const (
-       DirectionUpgrade   VersionDirection = "Upgrade"
-       DirectionDowngrade VersionDirection = "Downgrade"
-       DirectionRebuild   VersionDirection = "Rebuild"
-       DirectionUnknown   VersionDirection = "Unknown"
-)
-
-// CompareVersions determines the direction between two image tags.
-// Returns Upgrade if newTag > oldTag, Downgrade if newTag < oldTag,
-// Rebuild if equal, or Unknown if either tag is not valid semver.
-func CompareVersions(oldTag, newTag string) VersionDirection {
-       if oldTag == newTag {
-               return DirectionRebuild
-       }
-
-       oldVer, oldErr := semver.NewVersion(strings.TrimPrefix(oldTag, "v"))
-       newVer, newErr := semver.NewVersion(strings.TrimPrefix(newTag, "v"))
-
-       if oldErr != nil || newErr != nil {
-               return DirectionUnknown
-       }
-
-       if newVer.GreaterThan(oldVer) {
-               return DirectionUpgrade
-       }
-       if newVer.LessThan(oldVer) {
-               return DirectionDowngrade
-       }
-       return DirectionRebuild
-}
-
 // ImageRef returns the canonical "repository:tag" string for an image.
 func ImageRef(repository, tag string) string {
        return repository + ":" + tag
diff --git a/internal/controller/version_fuzz_test.go 
b/internal/controller/version_fuzz_test.go
deleted file mode 100644
index e01ab5a..0000000
--- a/internal/controller/version_fuzz_test.go
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
-Licensed to the Apache Software Foundation (ASF) under one
-or more contributor license agreements.  See the NOTICE file
-distributed with this work for additional information
-regarding copyright ownership.  The ASF licenses this file
-to you under the Apache License, Version 2.0 (the
-"License"); you may not use this file except in compliance
-with the License.  You may obtain a copy of the License at
-
-  http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing,
-software distributed under the License is distributed on an
-"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-KIND, either express or implied.  See the License for the
-specific language governing permissions and limitations
-under the License.
-*/
-
-package controller
-
-import "testing"
-
-// FuzzCompareVersions exercises CompareVersions with arbitrary image tags. The
-// tags come from the (trusted) Superset CR spec, so this is a robustness 
check:
-// the semver parsing must never panic and the result must obey the ordering
-// contract. See docs/contributing/development-guidelines.md (Fuzzing).
-func FuzzCompareVersions(f *testing.F) {
-       seeds := [][2]string{
-               {"4.0.0", "4.0.1"},
-               {"4.1.0", "4.0.0"},
-               {"4.0.0", "4.0.0"},
-               {"v4.0.0", "4.0.0"},
-               {"4.0.0-rc1", "4.0.0"},
-               {"latest", "4.0.0"},
-               {"sha-abc123", "sha-def456"},
-               {"", "x"},
-               {"", ""},
-       }
-       for _, s := range seeds {
-               f.Add(s[0], s[1])
-       }
-
-       isValid := func(d VersionDirection) bool {
-               switch d {
-               case DirectionUpgrade, DirectionDowngrade, DirectionRebuild, 
DirectionUnknown:
-                       return true
-               default:
-                       return false
-               }
-       }
-
-       f.Fuzz(func(t *testing.T, oldTag, newTag string) {
-               got := CompareVersions(oldTag, newTag)
-               if !isValid(got) {
-                       t.Fatalf("CompareVersions(%q, %q) = %q, not a defined 
VersionDirection", oldTag, newTag, got)
-               }
-
-               // Comparing a tag to itself is always a rebuild.
-               if self := CompareVersions(oldTag, oldTag); self != 
DirectionRebuild {
-                       t.Fatalf("CompareVersions(%q, %q) = %q, want Rebuild", 
oldTag, oldTag, self)
-               }
-
-               // Swapping the arguments must invert the direction: an upgrade 
one way is
-               // a downgrade the other, while Rebuild and Unknown are 
symmetric.
-               reverse := CompareVersions(newTag, oldTag)
-               want := map[VersionDirection]VersionDirection{
-                       DirectionUpgrade:   DirectionDowngrade,
-                       DirectionDowngrade: DirectionUpgrade,
-                       DirectionRebuild:   DirectionRebuild,
-                       DirectionUnknown:   DirectionUnknown,
-               }[got]
-               if reverse != want {
-                       t.Fatalf("CompareVersions(%q, %q) = %q but 
CompareVersions(%q, %q) = %q, want %q",
-                               oldTag, newTag, got, newTag, oldTag, reverse, 
want)
-               }
-       })
-}
diff --git a/internal/controller/version_test.go 
b/internal/controller/version_test.go
index a75b232..bada47f 100644
--- a/internal/controller/version_test.go
+++ b/internal/controller/version_test.go
@@ -25,43 +25,6 @@ import (
        "github.com/stretchr/testify/assert"
 )
 
-func TestCompareVersions(t *testing.T) {
-       tests := []struct {
-               name     string
-               oldTag   string
-               newTag   string
-               expected VersionDirection
-       }{
-               {name: "upgrade patch", oldTag: "4.0.0", newTag: "4.0.1", 
expected: DirectionUpgrade},
-               {name: "upgrade minor", oldTag: "4.0.0", newTag: "4.1.0", 
expected: DirectionUpgrade},
-               {name: "upgrade major", oldTag: "4.0.0", newTag: "5.0.0", 
expected: DirectionUpgrade},
-               {name: "downgrade patch", oldTag: "4.0.1", newTag: "4.0.0", 
expected: DirectionDowngrade},
-               {name: "downgrade minor", oldTag: "4.1.0", newTag: "4.0.0", 
expected: DirectionDowngrade},
-               {name: "downgrade major", oldTag: "5.0.0", newTag: "4.0.0", 
expected: DirectionDowngrade},
-               {name: "same version", oldTag: "4.0.0", newTag: "4.0.0", 
expected: DirectionRebuild},
-               {name: "v-prefix upgrade", oldTag: "v4.0.0", newTag: "v4.1.0", 
expected: DirectionUpgrade},
-               {name: "v-prefix downgrade", oldTag: "v4.1.0", newTag: 
"v4.0.0", expected: DirectionDowngrade},
-               {name: "v-prefix same", oldTag: "v4.0.0", newTag: "v4.0.0", 
expected: DirectionRebuild},
-               {name: "mixed v-prefix upgrade", oldTag: "4.0.0", newTag: 
"v4.1.0", expected: DirectionUpgrade},
-               {name: "pre-release to release", oldTag: "4.0.0-rc1", newTag: 
"4.0.0", expected: DirectionUpgrade},
-               {name: "release to pre-release", oldTag: "4.0.0", newTag: 
"4.1.0-rc1", expected: DirectionUpgrade},
-               {name: "pre-release ordering", oldTag: "4.0.0-rc1", newTag: 
"4.0.0-rc2", expected: DirectionUpgrade},
-               {name: "non-semver old", oldTag: "latest", newTag: "4.0.0", 
expected: DirectionUnknown},
-               {name: "non-semver new", oldTag: "4.0.0", newTag: "latest", 
expected: DirectionUnknown},
-               {name: "both non-semver", oldTag: "latest", newTag: 
"dev-abc123", expected: DirectionUnknown},
-               {name: "sha tags", oldTag: "sha-abc123", newTag: "sha-def456", 
expected: DirectionUnknown},
-               {name: "branch tags", oldTag: "main", newTag: "feature-x", 
expected: DirectionUnknown},
-               {name: "different non-semver", oldTag: "latest", newTag: 
"latest", expected: DirectionRebuild},
-       }
-
-       for _, tt := range tests {
-               t.Run(tt.name, func(t *testing.T) {
-                       result := CompareVersions(tt.oldTag, tt.newTag)
-                       assert.Equal(t, tt.expected, result)
-               })
-       }
-}
-
 func TestImageRef(t *testing.T) {
        assert.Equal(t, "docker.io/superset:4.0.0", 
ImageRef("docker.io/superset", "4.0.0"))
        assert.Equal(t, "ghcr.io/apache/superset:latest", 
ImageRef("ghcr.io/apache/superset", "latest"))
diff --git a/test/e2e/superset_journey_test.go 
b/test/e2e/superset_journey_test.go
index 2eef8d7..045e142 100644
--- a/test/e2e/superset_journey_test.go
+++ b/test/e2e/superset_journey_test.go
@@ -270,7 +270,6 @@ spec:
                expectJSONPath("superset", crName, "{.status.lifecycle.phase}", 
"AwaitingApproval", time.Minute)
                expectJSONPath("superset", crName, 
"{.status.lifecycle.upgrade.fromVersion}", "6.0.0", time.Minute)
                expectJSONPath("superset", crName, 
"{.status.lifecycle.upgrade.toVersion}", "6.1.0", time.Minute)
-               expectJSONPath("superset", crName, 
"{.status.lifecycle.upgrade.direction}", "Upgrade", time.Minute)
 
                By("approving the supervised upgrade with the recorded token")
                token, err := jsonPath("superset", crName, 
"{.status.lifecycle.upgrade.approvalToken}")


Reply via email to