diff --git a/CODEBASE.md b/CODEBASE.md new file mode 100644 index 0000000..1171024 --- /dev/null +++ b/CODEBASE.md @@ -0,0 +1,92 @@ +# wrapper: Codebase Reference + +## 1. Purpose + +Wrapper is the pack delivery engine for the ONT platform. It manages the lifecycle of pre-compiled OCI artifact deliveries (`InfrastructureClusterPack`) to target clusters: enforcing 6 delivery gates (gates 0-5) before submitting a `pack-deploy` Kueue Job, tracking delivered state via `InfrastructurePackInstance`, and managing drift visibility via `InfrastructurePackReceipt`. Wrapper does NOT compile packs (conductor/compiler), sign packs (conductor agent on management cluster), own RBAC governance (guardian), or manage cluster lifecycle (platform). It does not apply Helm or Kustomize at runtime. + +Wrapper has NO own CRD type definitions. `api/v1alpha1/` contains only `.gitkeep`. All types consumed by wrapper (InfrastructureClusterPack, InfrastructurePackExecution, InfrastructurePackInstance, InfrastructurePackReceipt, PackOperationResult, DriftSignal) are defined in seam-core (Decision G). + +--- + +## 2. Key Files and Locations + +### Controllers (`internal/controller/`) + +#### `packexecution_reconciler.go` + +`PackExecutionReconciler` (L74 comment block, `Reconcile()` L121). Manages the 6-gate delivery pipeline. + +**Gate check flow** (all gates at L175-417): + +| Gate | Line | Condition | Blocks on | +|------|------|-----------|-----------| +| 0 | L176 | ConductorReady | `isConductorReadyForCluster()` L799 -- checks RunnerConfig in `ont-system` has `status.capabilities` non-empty | +| 1 | L221 | Signature | `ClusterPack.status.Signed=true` | +| 2 | L289 | Revocation | ClusterPack conditions Revoked != True | +| 3 | L306 | PermissionSnapshot | `isPermissionSnapshotCurrent()` L716 -- reads PermissionSnapshot via unstructured (no cross-operator type import) | +| 4 | L343 | RBACProfile | `isRBACProfileProvisioned()` L755 -- checks `provisioned=true` on the pack's RBACProfile | +| 5 | L378 | WrapperRunnerRBAC | `isWrapperRunnerRBACReady()` L849 -- SubjectAccessReview verifies wrapper-runner SA has required permissions | + +`gateRequeueInterval = 30 * time.Second` (L61). Failing a gate sets `ConditionTypePackExecutionPending=True` with `ReasonGatesClearing` and returns `RequeueAfter: gateRequeueInterval`. + +`RBACReadyChecker` type at L101: `func(ctx, *InfrastructurePackExecution) (bool, string, error)`. Production uses `isWrapperRunnerRBACReady`; test stub set via `r.RBACChecker` field (L107). + +`findLatestPOR()` at L1162: lists all PackOperationResult CRs in namespace labeled with `packExecutionRef`, returns the one with highest `Spec.Revision`. Called at L466 to check completion status. + +#### `clusterpack_reconciler.go` + +`ClusterPackReconciler.Reconcile()` L67. Called on ClusterPack create/update. + +`handleClusterPackDeletion()` L393: three steps + step 2.5: +1. L396: List all PackInstances cluster-wide, delete those where `spec.clusterPackRef == cp.Name`. +2. L415: List all PackExecutions cluster-wide, delete those where `spec.clusterPackRef.name == cp.Name`. +3. Step 2.5 (L434): Delete DriftSignal named `drift-{cp.Name}` in `seam-tenant-{clusterName}` for each target cluster. +4. L449: Remove finalizer `clusterPackFinalizer` so API server can delete the ClusterPack object. + +`handleRollback()` L306: SSA-patches ClusterPack spec back to a previous version. Normal reconcile then creates PackExecution for the rolled-back version. + +PackExecution creation (L230): for each cluster in `spec.targetClusters`, creates one PackExecution in `seam-tenant-{cluster}`. Skips if PackInstance with current version already exists (L243). Skips if PackExecution already exists (L258). + +--- + +## 3. Primary Data Flows + +**Pack deploy path**: ClusterPack created --> `ClusterPackReconciler` creates PackExecution in `seam-tenant-{cluster}` --> `PackExecutionReconciler` runs 6-gate check --> all gates pass --> Kueue Job (`pack-deploy`, `conductor-execute:dev` image) submitted --> conductor execute-mode `executeSplitPath()` applies RBAC + cluster-scoped + workload OCI layers --> writes PackOperationResult --> `PackExecutionReconciler` reads POR via `findLatestPOR()` L1162 --> creates PackInstance on management cluster. + +**ClusterPack deletion path**: Finalizer prevents deletion --> `handleClusterPackDeletion()` L393 runs 3 steps (PackInstances, PackExecutions, DriftSignals) --> removes finalizer --> API server deletes ClusterPack object. Conductor `teardownOrphanedReceipt()` then cleans up deployed resources on the tenant cluster. + +**Pack rollback**: `spec.rollbackToRevision` set on ClusterPack --> `handleRollback()` L306 patches spec --> `clearRollbackField()` L378 clears the field --> normal reconcile creates new PackExecution for rolled-back version. + +**Single-active-revision (POR)**: `conductor/internal/persistence/operationresult_writer.go` writes POR with `Revision` incremented. Predecessor labeled `ontai.dev/superseded=true`, retained max 10. `findLatestPOR()` L1162 selects highest revision. + +--- + +## 4. PackExecution naming and supersession + +PackExecution name: `{packName}-{targetCluster}`. PackInstance name: `{basePackName}-{targetCluster}`. Same base name enables supersession: when a newer ClusterPack version arrives, the existing PackInstance is replaced in-place (same name, new content) rather than creating a new object. This is the upgrade path. + +--- + +## 5. Invariants + +| ID | Rule | Location | +|----|------|----------| +| CP-INV-010 | Kueue is not used for any operation in platform. Pack-deploy Jobs are the only Kueue Jobs in wrapper. | `packexecution_reconciler.go` | +| Decision G | Wrapper has no own CRD type definitions | `api/v1alpha1/.gitkeep` | + +--- + +## 6. Open Items + +**PLATFORM-BL-WRAPPER-RUNNER-RBAC-LIFECYCLE (platform)**: `ensureWrapperRunnerResources()` in `platform/internal/controller/taloscluster_helpers.go` creates wrapper-runner SA/Role/RoleBinding/ClusterRoleBinding at tenant onboarding. `handleTalosClusterDeletion()` does NOT delete `ClusterRoleBinding wrapper-runner-{cluster}`. This is a platform open item, not a wrapper open item. + +**CLUSTERPACK-BL-VERSION-CLEANUP (conductor)**: `DeployedResources` field exists in `InfrastructurePackReceiptSpec` at `seam-core/api/v1alpha1/packreceipt_types.go:74`. When PackInstance version N+1 replaces N, resources present in N's PackReceipt but absent from N+1's manifests are NOT cleaned up. Version-upgrade orphan diff is absent from `conductor/internal/agent/packinstance_pull_loop.go`. No schema addition needed; only implementation missing. + +--- + +## 7. Test Contract + +| Package | Coverage | +|---------|----------| +| `test/unit/controller` | PackExecutionReconciler (all 6 gates, POR revision selection), ClusterPackReconciler (deletion, rollback) | +| `test/e2e` | Stub files; all skip when `MGMT_KUBECONFIG` absent; skip reasons reference backlog item IDs | diff --git a/docs/wrapper-schema.md b/docs/wrapper-schema.md index c12a2b1..931b877 100644 --- a/docs/wrapper-schema.md +++ b/docs/wrapper-schema.md @@ -310,10 +310,29 @@ Stateful defaults (require explicit human approval to override): ### 6.2 Rollback -PackExecution referencing a previous ClusterPack version. The previous version -must still be Available and not Revoked. Signing was already performed when the -version was first applied. Same diff engine and execution order apply. No special -reverse logic. +Rollback to any retained historical revision is triggered by setting +`spec.rollbackToRevision` on the ClusterPack CR to the target POR revision number. +N-step rollback is supported: any revision retained in the superseded POR history +is reachable in one operation. + +**Mechanism:** + +The POR writer retains superseded PORs by labeling them `ontai.dev/superseded=true` +instead of deleting them. Each superseded POR carries its original `clusterPackVersion`, +`rbacDigest`, and `workloadDigest` fields unchanged. Up to 10 superseded PORs are +retained per ClusterPack; the oldest (lowest revision) is pruned when the cap is exceeded. + +When `spec.rollbackToRevision > 0`, the ClusterPackReconciler: +1. Lists ALL PORs labeled `ontai.dev/cluster-pack={cp.Name}` in the ClusterPack namespace (both active and superseded). +2. Finds the POR where `spec.revision == rollbackToRevision`. If not found, clears the field without patching spec. +3. Reads `clusterPackVersion`, `rbacDigest`, `workloadDigest` directly from that POR. +4. Patches `ClusterPack.spec`: sets `version`, `rbacDigest`, `workloadDigest` to the target values; clears `rollbackToRevision`. +5. Removes the `infrastructure.ontai.dev/spec-checksum-snapshot` annotation so the immutability check re-records the rolled-back state on the next reconcile pass. +6. Returns -- the version change triggers normal PackExecution creation. + +The resulting PackExecution runs a normal pack-deploy Job against the target OCI +layer digests. The new POR records `upgradeDirection=Rollback`. The PackReceipt on +the tenant cluster is overwritten with the target version's resource inventory. --- diff --git a/internal/controller/clusterpack_reconciler.go b/internal/controller/clusterpack_reconciler.go index 82fcdff..40c334e 100644 --- a/internal/controller/clusterpack_reconciler.go +++ b/internal/controller/clusterpack_reconciler.go @@ -95,6 +95,15 @@ func (r *ClusterPackReconciler) Reconcile(ctx context.Context, req ctrl.Request) // Falling through ensures status conditions are set in this reconcile pass. } + // Step A2 — Rollback check. Evaluated before the spec-snapshot annotation so that + // a Governor-authorized rollback (spec.rollbackToRevision > 0) can patch the spec + // and clear the annotation without triggering the immutability gate. On the next + // reconcile pass the annotation is absent, gets re-recorded from the rolled-back + // spec, and normal reconciliation proceeds. wrapper-schema.md §6.2. + if cp.Spec.RollbackToRevision > 0 { + return r.handleRollback(ctx, cp) + } + // Step B — Record spec snapshot annotation on first reconcile, BEFORE setting // up the deferred status patch. This must happen first because calling // r.Client.Patch() after the status patch setup would overwrite the in-memory @@ -287,6 +296,94 @@ func (r *ClusterPackReconciler) Reconcile(ctx context.Context, req ctrl.Request) return ctrl.Result{}, nil } +// handleRollback processes a Governor-initiated rollback request (spec.rollbackToRevision > 0). +// It lists ALL PORs for this ClusterPack (both active and superseded), finds the one at +// spec.rollbackToRevision, reads its version/digest fields, patches the ClusterPack spec +// back to that artifact state, clears the spec-snapshot annotation (so the immutability check +// re-records the rolled-back state on the next reconcile), and clears rollbackToRevision. +// N-step rollback: any prior retained revision is reachable in one operation. +// wrapper-schema.md §6.2. seam-core-schema.md §7.8. +func (r *ClusterPackReconciler) handleRollback(ctx context.Context, cp *seamcorev1alpha1.InfrastructureClusterPack) (ctrl.Result, error) { + logger := log.FromContext(ctx) + targetRevision := cp.Spec.RollbackToRevision + + // List ALL PORs for this ClusterPack: active and superseded. + porList := &seamcorev1alpha1.PackOperationResultList{} + if err := r.Client.List(ctx, porList, + client.InNamespace(cp.Namespace), + client.MatchingLabels{"ontai.dev/cluster-pack": cp.Name}, + ); err != nil { + return ctrl.Result{}, fmt.Errorf("handleRollback: list PORs for %s: %w", cp.Name, err) + } + + if len(porList.Items) == 0 { + logger.Info("handleRollback: no POR found for ClusterPack — cannot rollback, clearing field", + "clusterPack", cp.Name, "namespace", cp.Namespace) + return r.clearRollbackField(ctx, cp) + } + + // Find the POR at exactly the requested revision. + var targetPOR *seamcorev1alpha1.PackOperationResult + for i := range porList.Items { + if porList.Items[i].Spec.Revision == targetRevision { + targetPOR = &porList.Items[i] + break + } + } + + if targetPOR == nil { + logger.Info("handleRollback: target revision not found in retained POR history — clearing field", + "clusterPack", cp.Name, "requestedRevision", targetRevision) + return r.clearRollbackField(ctx, cp) + } + + targetVersion := targetPOR.Spec.ClusterPackVersion + targetRBACDigest := targetPOR.Spec.RBACDigest + targetWorkloadDigest := targetPOR.Spec.WorkloadDigest + + if targetVersion == "" { + logger.Info("handleRollback: target POR has no version recorded — clearing field", + "clusterPack", cp.Name, "targetRevision", targetRevision) + return r.clearRollbackField(ctx, cp) + } + + logger.Info("handleRollback: rolling back ClusterPack", + "clusterPack", cp.Name, "fromVersion", cp.Spec.Version, + "toVersion", targetVersion, "targetRevision", targetRevision) + + // Patch spec back to target version + digests, clear rollbackToRevision, + // and remove the spec-snapshot annotation so the immutability check re-records + // the rolled-back state on the next reconcile pass. + const specSnapshotAnnotation = "infrastructure.ontai.dev/spec-checksum-snapshot" + patch := client.MergeFrom(cp.DeepCopy()) + cp.Spec.Version = targetVersion + cp.Spec.RBACDigest = targetRBACDigest + cp.Spec.WorkloadDigest = targetWorkloadDigest + cp.Spec.RollbackToRevision = 0 + if cp.Annotations != nil { + delete(cp.Annotations, specSnapshotAnnotation) + } + if err := r.Client.Patch(ctx, cp, patch); err != nil { + return ctrl.Result{}, fmt.Errorf("handleRollback: patch ClusterPack spec: %w", err) + } + + r.Recorder.Eventf(cp, nil, corev1.EventTypeNormal, "RollbackApplied", "RollbackApplied", + "ClusterPack rolled back from %s to %s (POR revision %d).", cp.Spec.Version, targetVersion, targetRevision) + logger.Info("handleRollback: spec patched, normal reconcile will create PackExecution for rolled-back version", + "clusterPack", cp.Name, "version", targetVersion) + return ctrl.Result{}, nil +} + +// clearRollbackField resets spec.rollbackToRevision to 0 when rollback cannot proceed. +func (r *ClusterPackReconciler) clearRollbackField(ctx context.Context, cp *seamcorev1alpha1.InfrastructureClusterPack) (ctrl.Result, error) { + patch := client.MergeFrom(cp.DeepCopy()) + cp.Spec.RollbackToRevision = 0 + if err := r.Client.Patch(ctx, cp, patch); err != nil { + return ctrl.Result{}, fmt.Errorf("clearRollbackField: patch ClusterPack: %w", err) + } + return ctrl.Result{}, nil +} + // handleClusterPackDeletion runs the cleanup sequence for a ClusterPack that has // a non-zero DeletionTimestamp: // 1. Delete all PackInstances whose spec.clusterPackRef matches cp.Name. @@ -334,6 +431,21 @@ func (r *ClusterPackReconciler) handleClusterPackDeletion(ctx context.Context, c "packExecution", pe.Name, "namespace", pe.Namespace, "clusterPack", cp.Name) } + // 2.5. Delete DriftSignals for each target cluster. + // Convention: DriftSignal name = "drift-{cp.Name}", namespace = "seam-tenant-{clusterName}". + for _, clusterName := range cp.Spec.TargetClusters { + tenantNS := "seam-tenant-" + clusterName + signalName := "drift-" + cp.Name + signal := &seamcorev1alpha1.DriftSignal{ + ObjectMeta: metav1.ObjectMeta{Name: signalName, Namespace: tenantNS}, + } + if err := r.Client.Delete(ctx, signal); err != nil && !apierrors.IsNotFound(err) { + return ctrl.Result{}, fmt.Errorf("delete DriftSignal %s/%s: %w", tenantNS, signalName, err) + } + logger.Info("deleted DriftSignal during ClusterPack cleanup", + "driftSignal", signalName, "namespace", tenantNS, "clusterPack", cp.Name) + } + // 3. Remove the finalizer so the API server can delete the ClusterPack object. cp.Finalizers = removeString(cp.Finalizers, clusterPackFinalizer) if err := r.Client.Update(ctx, cp); err != nil { diff --git a/test/e2e/ac3_rollback_test.go b/test/e2e/ac3_rollback_test.go new file mode 100644 index 0000000..ac4e067 --- /dev/null +++ b/test/e2e/ac3_rollback_test.go @@ -0,0 +1,133 @@ +package e2e_test + +// AC-3: ClusterPack N-step rollback acceptance contract. +// +// Scenario: A ClusterPack has a retained superseded POR in its history. +// Setting spec.rollbackToRevision to that POR's revision causes the wrapper +// ClusterPackReconciler to: +// 1. List all PORs labeled ontai.dev/cluster-pack={cp.Name} (active + superseded). +// 2. Find the POR at spec.revision == rollbackToRevision. +// 3. Patch ClusterPack.spec to the target version/rbacDigest/workloadDigest. +// 4. Clear spec.rollbackToRevision to 0. +// 5. Remove infrastructure.ontai.dev/spec-checksum-snapshot annotation. +// +// Validation approach: the test creates a synthetic superseded POR so no prior +// deployment history with the new conductor is required. It sets rollbackToRevision +// and confirms the handler ran by polling for the field to clear. +// +// Run: MGMT_KUBECONFIG=/home/saigha01/.kube/ccs-mgmt.yaml MGMT_CLUSTER_NAME=ccs-dev make e2e +// Wrapper must be at commit 51039d6+ (N-step handleRollback). + +import ( + "context" + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + e2ehelpers "github.com/ontai-dev/seam-core/pkg/e2e" +) + +const ( + rollbackTimeout = 2 * time.Minute + rollbackInterval = 5 * time.Second +) + +var _ = Describe("AC-3: ClusterPack N-step rollback", func() { + It("rollback to retained superseded revision: rollbackToRevision cleared by wrapper handler", func() { + ctx := context.Background() + clusterName := mgmtClusterName + ns := "seam-tenant-" + clusterName + + // Discover the first ClusterPack in the tenant namespace. + By(fmt.Sprintf("discovering ClusterPack in %s", ns)) + cpList, err := mgmtClient.Dynamic.Resource(clusterPackGVR).Namespace(ns). + List(ctx, metav1.ListOptions{}) + Expect(err).NotTo(HaveOccurred(), "failed to list ClusterPacks in %s", ns) + Expect(cpList.Items).NotTo(BeEmpty(), + "no ClusterPacks found in %s -- set MGMT_CLUSTER_NAME to a provisioned tenant cluster", ns) + + packName := cpList.Items[0].GetName() + spec := cpList.Items[0].Object["spec"].(map[string]interface{}) + currentVersion, _ := spec["version"].(string) + Expect(currentVersion).NotTo(BeEmpty(), "ClusterPack %s has no spec.version", packName) + + By(fmt.Sprintf("ClusterPack %s in %s at version %s", packName, ns, currentVersion)) + + // Create a synthetic superseded POR at revision 1. The version is set to the + // same value as the current ClusterPack so rollback does not attempt to deploy + // a non-existent OCI artifact. + fakePORName := fmt.Sprintf("pack-deploy-result-%s-%s-r1-rollback-test", packName, clusterName) + fakePORYAML := fmt.Sprintf(` +apiVersion: infrastructure.ontai.dev/v1alpha1 +kind: PackOperationResult +metadata: + name: %s + namespace: %s + labels: + ontai.dev/cluster-pack: %s + ontai.dev/pack-execution: %s-%s + ontai.dev/superseded: "true" +spec: + revision: 1 + clusterPackRef: %s + clusterPackVersion: %s + capability: pack-deploy + status: Succeeded + targetClusterRef: %s +`, fakePORName, ns, packName, packName, clusterName, packName, currentVersion, clusterName) + + By(fmt.Sprintf("applying synthetic superseded POR %s", fakePORName)) + applier := e2ehelpers.NewCRApplier(mgmtClient) + _, err = applier.Apply(ctx, packOperationResultGVR, []byte(fakePORYAML)) + Expect(err).NotTo(HaveOccurred(), "failed to create synthetic superseded POR %s", fakePORName) + + DeferCleanup(func() { + By("cleanup: deleting synthetic superseded POR") + _ = mgmtClient.Dynamic.Resource(packOperationResultGVR).Namespace(ns). + Delete(context.Background(), fakePORName, metav1.DeleteOptions{}) + }) + + // Set spec.rollbackToRevision=1 via merge patch. + By(fmt.Sprintf("patching ClusterPack %s: spec.rollbackToRevision=1", packName)) + rollbackPatch := []byte(`{"spec":{"rollbackToRevision":1}}`) + _, err = mgmtClient.Dynamic.Resource(clusterPackGVR).Namespace(ns). + Patch(ctx, packName, types.MergePatchType, rollbackPatch, metav1.PatchOptions{}) + Expect(err).NotTo(HaveOccurred(), "failed to set rollbackToRevision on ClusterPack %s", packName) + + DeferCleanup(func() { + By("cleanup: clearing rollbackToRevision on ClusterPack in case of test failure") + clearPatch := []byte(`{"spec":{"rollbackToRevision":0}}`) + _, _ = mgmtClient.Dynamic.Resource(clusterPackGVR).Namespace(ns). + Patch(context.Background(), packName, types.MergePatchType, clearPatch, metav1.PatchOptions{}) + }) + + // Poll until the wrapper's N-step handleRollback clears the field. + // Clearing to 0 (or absent) proves the handler ran and completed. wrapper-schema.md §6.2. + By("polling for spec.rollbackToRevision to clear to 0 (handler executed)") + Eventually(func() int64 { + obj, err := mgmtClient.Dynamic.Resource(clusterPackGVR).Namespace(ns). + Get(ctx, packName, metav1.GetOptions{}) + if err != nil { + return -1 + } + s, _ := obj.Object["spec"].(map[string]interface{}) + rev, _ := s["rollbackToRevision"].(int64) + return rev + }, rollbackTimeout, rollbackInterval).Should(Equal(int64(0)), + "spec.rollbackToRevision did not clear within %s -- wrapper may not have N-step handler (commit 51039d6+)", rollbackTimeout) + + // Verify spec.version equals the rollback target recorded in the synthetic POR. + By("verifying spec.version matches the rollback target version") + finalCP, err := mgmtClient.Dynamic.Resource(clusterPackGVR).Namespace(ns). + Get(ctx, packName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + finalSpec, _ := finalCP.Object["spec"].(map[string]interface{}) + finalVersion, _ := finalSpec["version"].(string) + Expect(finalVersion).To(Equal(currentVersion), + "spec.version=%q, want %q (rollback target from synthetic POR revision 1)", finalVersion, currentVersion) + }) +}) diff --git a/test/e2e/ac4_day2_ops_test.go b/test/e2e/ac4_day2_ops_test.go new file mode 100644 index 0000000..76a6218 --- /dev/null +++ b/test/e2e/ac4_day2_ops_test.go @@ -0,0 +1,215 @@ +package e2e_test + +// AC-4: Day-2 operations acceptance contract for tenant cluster (ccs-dev). +// +// Day-2 operations verified here: +// D2-1 Current ClusterPack state -- live: nginx-ccs-dev deployed, POR exists +// D2-2 DriftSignal lifecycle -- live: DriftSignal for nginx-ccs-dev exists, state valid +// D2-3 DriftSignal correlationID clear -- live: when drift cleared, correlationID must be cleared +// D2-4 Upgrade (version bump) -- stub: requires new OCI image for target version +// D2-5 Active drift injection -- stub: requires DRIFT_INJECTION=true +// +// Coverage gaps (D2-4, D2-5) are stubs with explicit skip conditions per e2e CI contract. +// +// Run: MGMT_KUBECONFIG=/home/saigha01/.kube/ccs-mgmt.yaml +// TENANT_KUBECONFIG=/home/saigha01/.kube/ccs-dev.yaml +// MGMT_CLUSTER_NAME=ccs-dev TENANT_CLUSTER_NAME=ccs-dev +// make e2e + +import ( + "context" + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var ( + driftSignalGVR = schema.GroupVersionResource{ + Group: "infrastructure.ontai.dev", + Version: "v1alpha1", + Resource: "driftsignals", + } +) + +const ( + day2Timeout = 3 * time.Minute + day2Interval = 5 * time.Second +) + +// D2-1: Current ClusterPack state validation. +var _ = Describe("D2-1: Tenant ClusterPack deployed state", func() { + It("ClusterPack exists in seam-tenant-{cluster} with version and Signed=true", func() { + ctx := context.Background() + ns := "seam-tenant-" + mgmtClusterName + + cpList, err := mgmtClient.Dynamic.Resource(clusterPackGVR).Namespace(ns). + List(ctx, metav1.ListOptions{}) + Expect(err).NotTo(HaveOccurred(), "failed to list ClusterPacks in %s", ns) + Expect(cpList.Items).NotTo(BeEmpty(), "no ClusterPacks found in %s", ns) + + cp := cpList.Items[0] + spec, _ := cp.Object["spec"].(map[string]interface{}) + version, _ := spec["version"].(string) + Expect(version).NotTo(BeEmpty(), "ClusterPack %s has no spec.version", cp.GetName()) + + status, _ := cp.Object["status"].(map[string]interface{}) + signed, _ := status["signed"].(bool) + Expect(signed).To(BeTrue(), "ClusterPack %s is not signed (status.signed=false)", cp.GetName()) + }) + + It("PackOperationResult exists for the ClusterPack with status=Succeeded", func() { + ctx := context.Background() + ns := "seam-tenant-" + mgmtClusterName + + cpList, err := mgmtClient.Dynamic.Resource(clusterPackGVR).Namespace(ns). + List(ctx, metav1.ListOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cpList.Items).NotTo(BeEmpty()) + packName := cpList.Items[0].GetName() + + Eventually(func() bool { + list, err := mgmtClient.Dynamic.Resource(packOperationResultGVR).Namespace(ns). + List(ctx, metav1.ListOptions{}) + if err != nil { + return false + } + for _, item := range list.Items { + s, _ := item.Object["spec"].(map[string]interface{}) + ref, _ := s["clusterPackRef"].(string) + status, _ := s["status"].(string) + if (ref == packName || ref == "") && status == "Succeeded" { + return true + } + } + return false + }, day2Timeout, day2Interval).Should(BeTrue(), + "no Succeeded PackOperationResult found for %s in %s", packName, ns) + }) + + It("PackInstance exists in seam-tenant-{cluster} for the deployed pack", func() { + ctx := context.Background() + ns := "seam-tenant-" + mgmtClusterName + + Eventually(func() bool { + list, err := mgmtClient.Dynamic.Resource(packInstanceGVR).Namespace(ns). + List(ctx, metav1.ListOptions{}) + return err == nil && len(list.Items) > 0 + }, day2Timeout, day2Interval).Should(BeTrue(), + "no PackInstance found in %s", ns) + }) +}) + +// D2-2: DriftSignal lifecycle validation. +var _ = Describe("D2-2: DriftSignal exists and has valid state", func() { + It("at least one DriftSignal exists in seam-tenant-{cluster} with a recognized state", func() { + ctx := context.Background() + ns := "seam-tenant-" + mgmtClusterName + + validStates := map[string]bool{"pending": true, "acknowledged": true, "confirmed": true} + + Eventually(func() bool { + list, err := mgmtClient.Dynamic.Resource(driftSignalGVR).Namespace(ns). + List(ctx, metav1.ListOptions{}) + if err != nil || len(list.Items) == 0 { + return false + } + for _, item := range list.Items { + s, _ := item.Object["spec"].(map[string]interface{}) + state, _ := s["state"].(string) + if validStates[state] { + return true + } + } + return false + }, day2Timeout, day2Interval).Should(BeTrue(), + "no DriftSignal with valid state (pending|acknowledged|confirmed) found in %s", ns) + }) + + It("confirmed DriftSignal has correlationID cleared (drift lifecycle complete)", func() { + ctx := context.Background() + ns := "seam-tenant-" + mgmtClusterName + + list, err := mgmtClient.Dynamic.Resource(driftSignalGVR).Namespace(ns). + List(ctx, metav1.ListOptions{}) + Expect(err).NotTo(HaveOccurred(), "failed to list DriftSignals in %s", ns) + + for _, item := range list.Items { + s, _ := item.Object["spec"].(map[string]interface{}) + state, _ := s["state"].(string) + if state != "confirmed" { + continue + } + correlationID, _ := s["correlationID"].(string) + Expect(correlationID).To(BeEmpty(), + "DriftSignal %s is confirmed but correlationID=%q is not cleared -- lifecycle invariant violated", + item.GetName(), correlationID) + } + }) +}) + +// D2-3: DriftSignal correlationID clear contract (detailed). +var _ = Describe("D2-3: DriftSignal correlationID lifecycle", func() { + It("pending DriftSignal carries a non-empty correlationID", func() { + ctx := context.Background() + ns := "seam-tenant-" + mgmtClusterName + + list, err := mgmtClient.Dynamic.Resource(driftSignalGVR).Namespace(ns). + List(ctx, metav1.ListOptions{}) + if err != nil { + Skip(fmt.Sprintf("DriftSignal list failed: %v -- skipping correlationID check", err)) + } + + for _, item := range list.Items { + s, _ := item.Object["spec"].(map[string]interface{}) + state, _ := s["state"].(string) + if state != "pending" { + continue + } + correlationID, _ := s["correlationID"].(string) + Expect(correlationID).NotTo(BeEmpty(), + "pending DriftSignal %s must carry a non-empty correlationID", item.GetName()) + } + }) + + It("acknowledged DriftSignal transitions correlationID before it reaches confirmed", func() { + Skip("requires DRIFT_INJECTION=true and active drift cycle -- DRIFT-LIFECYCLE-E2E") + }) +}) + +// D2-4: Upgrade (ClusterPack version bump). +var _ = Describe("D2-4: ClusterPack upgrade (version bump)", func() { + It("bumping spec.version triggers new PackExecution and POR at revision N+1", func() { + Skip("requires new OCI image for target version and UPGRADE-E2E closed") + }) + + It("after upgrade, previous POR is labeled ontai.dev/superseded=true (retained for rollback)", func() { + Skip("requires new OCI image for target version and UPGRADE-E2E closed") + }) + + It("PackInstance shows new version after upgrade Job succeeds", func() { + Skip("requires new OCI image for target version and UPGRADE-E2E closed") + }) +}) + +// D2-5: Active drift injection (resource deletion on tenant cluster). +var _ = Describe("D2-5: Active drift injection and detection", func() { + It("deleting a managed resource on the tenant cluster triggers a new DriftSignal", func() { + Skip("requires DRIFT_INJECTION=true env var and DRIFT-INJECTION-E2E closed") + }) + + It("conductor agent on tenant detects deletion within one drift-loop period", func() { + Skip("requires DRIFT_INJECTION=true env var and DRIFT-INJECTION-E2E closed") + }) + + It("DriftSignal state progresses pending → acknowledged → confirmed", func() { + Skip("requires DRIFT_INJECTION=true env var and DRIFT-INJECTION-E2E closed") + }) + + It("conductor on management cluster re-deploys drifted resources after confirmation", func() { + Skip("requires DRIFT_INJECTION=true env var and DRIFT-INJECTION-E2E closed") + }) +}) diff --git a/test/e2e/clusterpack_e2e_test.go b/test/e2e/clusterpack_e2e_test.go index 87250d8..f1eff07 100644 --- a/test/e2e/clusterpack_e2e_test.go +++ b/test/e2e/clusterpack_e2e_test.go @@ -91,16 +91,16 @@ var _ = Describe("Step 3: ClusterPack deployment end-to-end", func() { }) }) -var _ = Describe("Step 4: PackOperationResult single-active-revision", func() { +var _ = Describe("Step 4: PackOperationResult N-step retention", func() { It("second deployment writes POR revision=2 with previousRevisionRef pointing to -r1", func() { validatePORRevision2(context.Background(), mgmtClient, mgmtClusterName, certManagerPackName, deployTimeout, pollInterval) }) - It("revision=1 POR is deleted after revision=2 is written", func() { - validatePORPredecessorDeleted(context.Background(), mgmtClient, mgmtClusterName, deployTimeout, pollInterval) + It("revision=1 POR is labeled ontai.dev/superseded=true after revision=2 is written (N-step retention)", func() { + validatePORPredecessorSuperseded(context.Background(), mgmtClient, mgmtClusterName, deployTimeout, pollInterval) }) - It("exactly one PackOperationResult exists in seam-tenant-{cluster} after second deployment", func() { + It("exactly one non-superseded PackOperationResult exists in seam-tenant-{cluster} after second deployment", func() { validateSingleActivePOR(context.Background(), mgmtClient, mgmtClusterName, deployTimeout, pollInterval) }) }) @@ -303,36 +303,40 @@ func validatePORRevision2( "revision=2 POR name must follow pack-deploy-result-{peName}-r2 convention") } -// validatePORPredecessorDeleted confirms that after revision=2 is written, the -// revision=1 CR is deleted (single-active-revision invariant). -func validatePORPredecessorDeleted( +// validatePORPredecessorSuperseded confirms that after revision=2 is written, the +// revision=1 CR is labeled ontai.dev/superseded=true and retained for N-step rollback. +// seam-core-schema.md §7.8: superseded PORs are never deleted by the writer. +func validatePORPredecessorSuperseded( ctx context.Context, cl *e2ehelpers.ClusterClient, clusterName string, timeout, interval time.Duration, ) { tenantNS := "seam-tenant-" + clusterName - By(fmt.Sprintf("confirming revision=1 POR deleted after revision=2 written in %s", tenantNS)) + By(fmt.Sprintf("confirming revision=1 POR retained with ontai.dev/superseded=true in %s", tenantNS)) Eventually(func() bool { list, err := cl.Dynamic.Resource(packOperationResultGVR).Namespace(tenantNS). - List(ctx, metav1.ListOptions{}) - if err != nil { + List(ctx, metav1.ListOptions{ + LabelSelector: "ontai.dev/superseded=true", + }) + if err != nil || len(list.Items) == 0 { return false } for _, item := range list.Items { spec, _ := item.Object["spec"].(map[string]interface{}) rev, _ := spec["revision"].(int64) if rev == 1 { - return false + return true } } - return true + return false }, timeout, interval).Should(BeTrue(), - "revision=1 POR still present after revision=2 written -- single-active-revision invariant violated in %s", tenantNS) + "revision=1 POR with ontai.dev/superseded=true not found in %s -- N-step retention invariant violated", tenantNS) } -// validateSingleActivePOR confirms exactly one PackOperationResult exists in the -// namespace after the second deployment cycle completes. +// validateSingleActivePOR confirms exactly one non-superseded PackOperationResult +// exists in the namespace after the second deployment cycle. Superseded PORs are +// retained for N-step rollback and are excluded from this count. func validateSingleActivePOR( ctx context.Context, cl *e2ehelpers.ClusterClient, @@ -340,14 +344,16 @@ func validateSingleActivePOR( timeout, interval time.Duration, ) { tenantNS := "seam-tenant-" + clusterName - By(fmt.Sprintf("confirming exactly one POR exists in %s on cluster %s", tenantNS, cl.Name)) + By(fmt.Sprintf("confirming exactly one active (non-superseded) POR in %s on cluster %s", tenantNS, cl.Name)) Eventually(func() int { list, err := cl.Dynamic.Resource(packOperationResultGVR).Namespace(tenantNS). - List(ctx, metav1.ListOptions{}) + List(ctx, metav1.ListOptions{ + LabelSelector: "!ontai.dev/superseded", + }) if err != nil { return -1 } return len(list.Items) }, timeout, interval).Should(Equal(1), - "single-active-revision violated: expected 1 POR in %s, got != 1", tenantNS) + "single-active-POR violated: expected 1 non-superseded POR in %s, got != 1", tenantNS) } diff --git a/test/unit/clusterpack_reconciler_test.go b/test/unit/clusterpack_reconciler_test.go index 29addd0..d946bb0 100644 --- a/test/unit/clusterpack_reconciler_test.go +++ b/test/unit/clusterpack_reconciler_test.go @@ -6,6 +6,7 @@ import ( "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -217,6 +218,56 @@ func TestClusterPackReconciler_ImmutabilityViolation(t *testing.T) { } } +// TestClusterPackReconciler_DeletionCascadesDriftSignal verifies that handleClusterPackDeletion +// deletes the DriftSignal "drift-{cp.Name}" from "seam-tenant-{clusterName}" for each +// target cluster, alongside PackInstances and PackExecutions. +func TestClusterPackReconciler_DeletionCascadesDriftSignal(t *testing.T) { + s := newClusterPackScheme(t) + + clusterName := "ccs-dev" + tenantNS := "seam-tenant-" + clusterName + cpName := "nginx-pack" + + cp := newClusterPack(cpName, "infra-system", "v1.0.0") + cp.Spec.TargetClusters = []string{clusterName} + now := metav1.Now() + cp.DeletionTimestamp = &now + + signal := &seamcorev1alpha1.DriftSignal{ + ObjectMeta: metav1.ObjectMeta{ + Name: "drift-" + cpName, + Namespace: tenantNS, + }, + Spec: seamcorev1alpha1.DriftSignalSpec{ + State: seamcorev1alpha1.DriftSignalStatePending, + }, + } + + fakeClient := fake.NewClientBuilder().WithScheme(s). + WithObjects(cp, signal). + WithStatusSubresource(&seamcorev1alpha1.InfrastructureClusterPack{}). + Build() + r := &controller.ClusterPackReconciler{ + Client: fakeClient, + Scheme: s, + Recorder: clientevents.NewFakeRecorder(10), + } + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: cp.Name, Namespace: cp.Namespace}, + }) + if err != nil { + t.Fatalf("Reconcile returned unexpected error: %v", err) + } + + remaining := &seamcorev1alpha1.DriftSignal{} + getErr := fakeClient.Get(context.Background(), + client.ObjectKey{Name: "drift-" + cpName, Namespace: tenantNS}, remaining) + if !apierrors.IsNotFound(getErr) { + t.Errorf("expected DriftSignal to be deleted, got: %v", getErr) + } +} + // TestClusterPackReconciler_RevokedNoRequeue verifies that a revoked ClusterPack // stops reconciliation without requeue. func TestClusterPackReconciler_RevokedNoRequeue(t *testing.T) { diff --git a/test/unit/controller/rollback_test.go b/test/unit/controller/rollback_test.go new file mode 100644 index 0000000..249f19e --- /dev/null +++ b/test/unit/controller/rollback_test.go @@ -0,0 +1,275 @@ +package controller_test + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + clientevents "k8s.io/client-go/tools/events" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + seamv1alpha1 "github.com/ontai-dev/seam-core/api/v1alpha1" + "github.com/ontai-dev/wrapper/internal/controller" +) + +// fakePOR builds a PackOperationResult representing a deploy at the given revision. +// If superseded is true the ontai.dev/superseded=true label is set (retained history). +func fakePOR(name, namespace, cpName string, revision int64, version, rbacDigest, workloadDigest string, superseded bool) *seamv1alpha1.PackOperationResult { + labels := map[string]string{ + "ontai.dev/cluster-pack": cpName, + "ontai.dev/pack-execution": cpName + "-ccs-dev", + } + if superseded { + labels["ontai.dev/superseded"] = "true" + } + return &seamv1alpha1.PackOperationResult{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: labels, + }, + Spec: seamv1alpha1.PackOperationResultSpec{ + Revision: revision, + ClusterPackRef: cpName, + ClusterPackVersion: version, + RBACDigest: rbacDigest, + WorkloadDigest: workloadDigest, + Capability: "pack-deploy", + Status: seamv1alpha1.PackResultSucceeded, + }, + } +} + +// buildRollbackCP creates a ClusterPack with RollbackToRevision set to targetRevision. +func buildRollbackCP(name, version, namespace string, targetRevision int64, rbacDigest, workloadDigest string) *seamv1alpha1.InfrastructureClusterPack { + cp := newSignedCP(name, version, namespace) + cp.Spec.RBACDigest = rbacDigest + cp.Spec.WorkloadDigest = workloadDigest + cp.Spec.TargetClusters = []string{"ccs-dev"} + cp.Spec.RollbackToRevision = targetRevision + cp.Annotations["infrastructure.ontai.dev/spec-checksum-snapshot"] = "stale-checksum" + return cp +} + +// TestClusterPackReconciler_Rollback_OneStep verifies that rolling back one step (N to N-1) +// patches the spec from the superseded POR at revision N-1. wrapper-schema.md §6.2. +func TestClusterPackReconciler_Rollback_OneStep(t *testing.T) { + scheme := buildTestScheme(t) + + // Active POR at revision 2. + activePOR := fakePOR( + "pack-deploy-result-nginx-ccs-dev-ccs-dev-r2", + "seam-tenant-ccs-dev", "nginx-ccs-dev", + 2, "v4.10.0-r1", "sha256:cccc", "sha256:dddd", false, + ) + // Superseded POR at revision 1 (retained for rollback). + supersededPOR := fakePOR( + "pack-deploy-result-nginx-ccs-dev-ccs-dev-r1", + "seam-tenant-ccs-dev", "nginx-ccs-dev", + 1, "v4.9.0-r1", "sha256:aaaa", "sha256:bbbb", true, + ) + + // ClusterPack at current version v4.10.0-r1, requesting rollback to revision 1. + cp := buildRollbackCP("nginx-ccs-dev", "v4.10.0-r1", "seam-tenant-ccs-dev", 1, "sha256:cccc", "sha256:dddd") + cp.UID = types.UID("uid-cp-nginx") + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cp, activePOR, supersededPOR). + WithStatusSubresource(cp). + Build() + + reconciler := &controller.ClusterPackReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: clientevents.NewFakeRecorder(10), + } + + _, err := reconciler.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "nginx-ccs-dev", Namespace: "seam-tenant-ccs-dev"}, + }) + if err != nil { + t.Fatalf("Reconcile: %v", err) + } + + updated := &seamv1alpha1.InfrastructureClusterPack{} + if err := fakeClient.Get(context.Background(), + client.ObjectKey{Name: "nginx-ccs-dev", Namespace: "seam-tenant-ccs-dev"}, + updated); err != nil { + t.Fatalf("Get ClusterPack after rollback: %v", err) + } + + if updated.Spec.Version != "v4.9.0-r1" { + t.Errorf("spec.version=%q, want v4.9.0-r1", updated.Spec.Version) + } + if updated.Spec.RBACDigest != "sha256:aaaa" { + t.Errorf("spec.rbacDigest=%q, want sha256:aaaa", updated.Spec.RBACDigest) + } + if updated.Spec.WorkloadDigest != "sha256:bbbb" { + t.Errorf("spec.workloadDigest=%q, want sha256:bbbb", updated.Spec.WorkloadDigest) + } + if updated.Spec.RollbackToRevision != 0 { + t.Errorf("spec.rollbackToRevision=%d, want 0 (cleared)", updated.Spec.RollbackToRevision) + } + if _, ok := updated.Annotations["infrastructure.ontai.dev/spec-checksum-snapshot"]; ok { + t.Error("spec-checksum-snapshot annotation should be removed after rollback") + } +} + +// TestClusterPackReconciler_Rollback_NStep verifies that N-step rollback works -- +// rolling back from revision 3 directly to revision 1 reads the correct artifact +// state from the retained superseded POR. wrapper-schema.md §6.2. +func TestClusterPackReconciler_Rollback_NStep(t *testing.T) { + scheme := buildTestScheme(t) + + // Active POR at revision 3. + activePOR := fakePOR( + "pack-deploy-result-nginx-ccs-dev-ccs-dev-r3", + "seam-tenant-ccs-dev", "nginx-ccs-dev", + 3, "v4.11.0-r1", "sha256:eeee", "sha256:ffff", false, + ) + // Superseded POR at revision 2. + supersededR2 := fakePOR( + "pack-deploy-result-nginx-ccs-dev-ccs-dev-r2", + "seam-tenant-ccs-dev", "nginx-ccs-dev", + 2, "v4.10.0-r1", "sha256:cccc", "sha256:dddd", true, + ) + // Superseded POR at revision 1. + supersededR1 := fakePOR( + "pack-deploy-result-nginx-ccs-dev-ccs-dev-r1", + "seam-tenant-ccs-dev", "nginx-ccs-dev", + 1, "v4.9.0-r1", "sha256:aaaa", "sha256:bbbb", true, + ) + + // ClusterPack at v4.11.0-r1, requesting direct rollback to revision 1 (two steps). + cp := buildRollbackCP("nginx-ccs-dev", "v4.11.0-r1", "seam-tenant-ccs-dev", 1, "sha256:eeee", "sha256:ffff") + cp.UID = types.UID("uid-cp-nginx") + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cp, activePOR, supersededR2, supersededR1). + WithStatusSubresource(cp). + Build() + + reconciler := &controller.ClusterPackReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: clientevents.NewFakeRecorder(10), + } + + _, err := reconciler.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "nginx-ccs-dev", Namespace: "seam-tenant-ccs-dev"}, + }) + if err != nil { + t.Fatalf("Reconcile: %v", err) + } + + updated := &seamv1alpha1.InfrastructureClusterPack{} + if err := fakeClient.Get(context.Background(), + client.ObjectKey{Name: "nginx-ccs-dev", Namespace: "seam-tenant-ccs-dev"}, + updated); err != nil { + t.Fatalf("Get ClusterPack after N-step rollback: %v", err) + } + + if updated.Spec.Version != "v4.9.0-r1" { + t.Errorf("spec.version=%q, want v4.9.0-r1", updated.Spec.Version) + } + if updated.Spec.RBACDigest != "sha256:aaaa" { + t.Errorf("spec.rbacDigest=%q, want sha256:aaaa", updated.Spec.RBACDigest) + } + if updated.Spec.WorkloadDigest != "sha256:bbbb" { + t.Errorf("spec.workloadDigest=%q, want sha256:bbbb", updated.Spec.WorkloadDigest) + } + if updated.Spec.RollbackToRevision != 0 { + t.Errorf("spec.rollbackToRevision=%d, want 0 (cleared)", updated.Spec.RollbackToRevision) + } +} + +// TestClusterPackReconciler_Rollback_NoPOR_ClearsField verifies that when no POR +// exists for the ClusterPack, the reconciler clears rollbackToRevision without error. +func TestClusterPackReconciler_Rollback_NoPOR_ClearsField(t *testing.T) { + scheme := buildTestScheme(t) + + cp := buildRollbackCP("nginx-ccs-dev", "v4.10.0-r1", "seam-tenant-ccs-dev", 1, "sha256:cccc", "sha256:dddd") + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cp). + WithStatusSubresource(cp). + Build() + + reconciler := &controller.ClusterPackReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: clientevents.NewFakeRecorder(10), + } + + _, err := reconciler.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "nginx-ccs-dev", Namespace: "seam-tenant-ccs-dev"}, + }) + if err != nil { + t.Fatalf("Reconcile: %v", err) + } + + updated := &seamv1alpha1.InfrastructureClusterPack{} + if err := fakeClient.Get(context.Background(), + client.ObjectKey{Name: "nginx-ccs-dev", Namespace: "seam-tenant-ccs-dev"}, + updated); err != nil { + t.Fatalf("Get ClusterPack: %v", err) + } + if updated.Spec.RollbackToRevision != 0 { + t.Errorf("rollbackToRevision=%d, want 0", updated.Spec.RollbackToRevision) + } +} + +// TestClusterPackReconciler_Rollback_RevisionNotFound_ClearsField verifies that when +// rollbackToRevision references a revision not present in the retained POR history +// (e.g., already pruned or never written), the field is cleared without patching spec. +func TestClusterPackReconciler_Rollback_RevisionNotFound_ClearsField(t *testing.T) { + scheme := buildTestScheme(t) + + // Only revision 3 exists (active). Revisions 1 and 2 are not retained. + activePOR := fakePOR( + "pack-deploy-result-nginx-ccs-dev-ccs-dev-r3", + "seam-tenant-ccs-dev", "nginx-ccs-dev", + 3, "v4.11.0-r1", "sha256:eeee", "sha256:ffff", false, + ) + // Request rollback to revision 1 which does not exist in history. + cp := buildRollbackCP("nginx-ccs-dev", "v4.11.0-r1", "seam-tenant-ccs-dev", 1, "sha256:eeee", "sha256:ffff") + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cp, activePOR). + WithStatusSubresource(cp). + Build() + + reconciler := &controller.ClusterPackReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: clientevents.NewFakeRecorder(10), + } + + _, err := reconciler.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "nginx-ccs-dev", Namespace: "seam-tenant-ccs-dev"}, + }) + if err != nil { + t.Fatalf("Reconcile: %v", err) + } + + updated := &seamv1alpha1.InfrastructureClusterPack{} + if err := fakeClient.Get(context.Background(), + client.ObjectKey{Name: "nginx-ccs-dev", Namespace: "seam-tenant-ccs-dev"}, + updated); err != nil { + t.Fatalf("Get ClusterPack: %v", err) + } + if updated.Spec.RollbackToRevision != 0 { + t.Errorf("rollbackToRevision=%d, want 0", updated.Spec.RollbackToRevision) + } + // Version must be unchanged (no spec patch should have happened). + if updated.Spec.Version != "v4.11.0-r1" { + t.Errorf("spec.version=%q, want v4.11.0-r1 (unchanged)", updated.Spec.Version) + } +}