diff --git a/pkg/payload/crd_ordering_test.go b/pkg/payload/crd_ordering_test.go new file mode 100644 index 0000000000..7a8008d630 --- /dev/null +++ b/pkg/payload/crd_ordering_test.go @@ -0,0 +1,517 @@ +package payload + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/openshift/library-go/pkg/manifest" +) + +// This file guards a payload-wide invariant: a CustomResource (CR) must never be +// applied before the CustomResourceDefinition (CRD) that defines it. +// +// The CVO applies manifests ordered by run-level (the 0000_NN_ filename prefix, +// parsed here with the same reMatchPattern the CVO uses) and will not advance to +// a higher run-level until all lower run-levels complete. Within a single +// run-level, manifests of the *same* component apply serially in filename +// byte-order, but manifests of *different* components apply in parallel. A CR +// that is applied before (or concurrently with) its CRD fails to create; on the +// strict UpdatingPayload path that failure blocks its run-level and deadlocks +// the update (see OCPBUGS-99266, where the empty CRIOCredentialProviderConfig CR +// shipped at run-level 0000_05 while its CRD shipped at 0000_10). +// +// A CRD is guaranteed to be applied (and, thanks to the CVO's CRD-establishment +// wait, established) before a CR when any of the following hold: +// - the CRD is release.openshift.io/bootstrap-required (created at bootstrap, +// before the CVO's ordered apply runs, so it already exists in the cluster); +// - the CRD is at a lower run-level than the CR; +// - the CRD is at the same run-level and the SAME component, and its filename +// sorts before the CR's (same-component manifests apply serially). +// +// Same run-level + different component is NOT safe: those apply in parallel with +// no ordering edge between them. +// +// Caveats deliberately encoded here (see pkg/payload/payload.go and +// pkg/payload/task_graph.go): strict CRD-before-CR ordering only holds on the +// UpdatingPayload path; InitializingPayload flattens run-levels and +// ReconcilingPayload permutes, and both skip the establishment wait. The check +// below is intentionally gating-independent (it considers the union of shipped +// manifests) so that a latent deadlock reachable under *any* feature-gate / +// feature-set / cluster-profile combination is caught, not just the combination +// realized by one Include() filter. + +const bootstrapRequiredAnnotation = "release.openshift.io/bootstrap-required" + +// orderingManifest is the subset of a payload manifest relevant to CRD/CR +// apply-ordering. +type orderingManifest struct { + filename string + runLevel int // parsed from the 0000_NN_ prefix; -1 if unparseable + component string // the NAME in 0000_NN_NAME_; "" if unparseable + + // For a CRD manifest: + isCRD bool + crdGroup string + crdKind string + + // For a CR (or any non-CRD) manifest: + group string + kind string + + bootstrapRequired bool +} + +// reRunLevel parses just the 0000_NN run-level prefix. Unlike reMatchPattern it +// does not require an operatorOrdering segment, so it also parses single-token +// payload filenames such as 0000_90_openshift-cluster-image-policy.yaml (which +// reMatchPattern, and therefore the CVO's component splitter, cannot parse). +var reRunLevel = regexp.MustCompile(`^0000_(\d+)_`) + +// parseRunLevelComponent returns the run-level number (-1 if unparseable) and +// the component (empty if the operatorOrdering-style name is unparseable). +func parseRunLevelComponent(filename string) (int, string) { + rl := -1 + if m := reRunLevel.FindStringSubmatch(filename); m != nil { + if n, err := strconv.Atoi(m[1]); err == nil { + rl = n + } + } + comp := "" + if m := reMatchPattern.FindStringSubmatch(filename); m != nil { + comp = m[groupComponent] + } + return rl, comp +} + +// toOrderingManifest extracts ordering-relevant fields from a parsed manifest. +func toOrderingManifest(m manifest.Manifest) orderingManifest { + rl, comp := parseRunLevelComponent(m.OriginalFilename) + om := orderingManifest{ + filename: m.OriginalFilename, + runLevel: rl, + component: comp, + group: m.GVK.Group, + kind: m.GVK.Kind, + } + if m.Obj != nil { + anns := m.Obj.GetAnnotations() + om.bootstrapRequired = strings.EqualFold(anns[bootstrapRequiredAnnotation], "true") + } + if m.GVK.Group == "apiextensions.k8s.io" && m.GVK.Kind == "CustomResourceDefinition" && m.Obj != nil { + om.isCRD = true + om.crdGroup, _, _ = unstructured.NestedString(m.Obj.Object, "spec", "group") + om.crdKind, _, _ = unstructured.NestedString(m.Obj.Object, "spec", "names", "kind") + } + return om +} + +// severity classifies how bad a CR/CRD ordering is. +type severity int + +const ( + // severitySafe: the CRD is guaranteed applied and established before the CR. + severitySafe severity = iota + // severityNonBlocking: the CR and CRD share a run-level, so the CR may be + // applied before its CRD, but the run-level barrier lets the failed CR + // re-apply once the CRD is established later in the same run-level. This + // self-heals and does not deadlock, though it costs an extra apply pass. + severityNonBlocking + // severityBlocking: the CR's run-level is strictly lower than its CRD's, so + // on the strict UpdatingPayload path the CR's run-level can never complete + // (its CRD is created at a higher, never-reached run-level) — a deadlock. + severityBlocking +) + +func (s severity) String() string { + switch s { + case severitySafe: + return "SAFE" + case severityNonBlocking: + return "NONBLOCKING" + default: + return "BLOCKING" + } +} + +// classifyPair returns the ordering severity of a single CR against one of its +// CRD variants. +func classifyPair(crd, cr orderingManifest) severity { + if crd.bootstrapRequired { + return severitySafe + } + // If either run-level is unparseable we cannot prove a deadlock; downgrade to + // NONBLOCKING so the pair is surfaced as a warning rather than a hard failure. + if crd.runLevel < 0 || cr.runLevel < 0 { + return severityNonBlocking + } + switch { + case crd.runLevel < cr.runLevel: + return severitySafe + case crd.runLevel > cr.runLevel: + return severityBlocking + default: // same run-level + // Same component applies serially in filename byte-order (Go string + // comparison is byte-wise, matching the CVO's C-locale ordering). + if crd.component == cr.component && crd.filename < cr.filename { + return severitySafe + } + return severityNonBlocking + } +} + +type orderingViolation struct { + cr orderingManifest + crd orderingManifest // best (least-severe) matching CRD found in the payload + severity severity +} + +type groupKind struct{ group, kind string } + +// checkCRDOrdering classifies every CR whose CRD ships in the same manifest set +// by its best-case (least-severe) ordering against that CRD, and returns the +// pairs that are not SAFE. CRs whose CRD is not present in the set (e.g. provided +// out-of-band, or bootstrap-only and not shipped as a payload manifest) are not +// checked. +func checkCRDOrdering(manifests []orderingManifest) []orderingViolation { + crds := map[groupKind][]orderingManifest{} + for _, m := range manifests { + if m.isCRD && m.crdGroup != "" && m.crdKind != "" { + gk := groupKind{m.crdGroup, m.crdKind} + crds[gk] = append(crds[gk], m) + } + } + + var violations []orderingViolation + for _, cr := range manifests { + if cr.isCRD { + continue + } + variants, ok := crds[groupKind{cr.group, cr.kind}] + if !ok { + continue + } + best := severityBlocking + var bestCRD orderingManifest + for _, crd := range variants { + if s := classifyPair(crd, cr); s <= best { + best = s + bestCRD = crd + } + } + if best != severitySafe { + violations = append(violations, orderingViolation{cr: cr, crd: bestCRD, severity: best}) + } + } + return violations +} + +// parseOrderingManifests parses raw manifest bytes loaded from filename into +// ordering manifests (a single file may contain multiple documents). +func parseOrderingManifests(t *testing.T, filename string, raw []byte) []orderingManifest { + t.Helper() + parsed, err := manifest.ParseManifests(strings.NewReader(string(raw))) + if err != nil { + t.Fatalf("parse %s: %v", filename, err) + } + out := make([]orderingManifest, 0, len(parsed)) + for i := range parsed { + parsed[i].OriginalFilename = filepath.Base(filename) + out = append(out, toOrderingManifest(parsed[i])) + } + return out +} + +// fixtureManifest is a tiny helper for building an in-memory payload in tests. +type fixtureManifest struct { + filename string + yaml string +} + +func loadFixture(t *testing.T, fixtures []fixtureManifest) []orderingManifest { + t.Helper() + var all []orderingManifest + for _, f := range fixtures { + all = append(all, parseOrderingManifests(t, f.filename, []byte(f.yaml))...) + } + return all +} + +func crdManifest(filename, group, kind, plural string, bootstrap bool) fixtureManifest { + anns := "" + if bootstrap { + anns = " annotations:\n release.openshift.io/bootstrap-required: \"true\"\n" + } + return fixtureManifest{ + filename: filename, + yaml: fmt.Sprintf(`apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: %s.%s +%sspec: + group: %s + names: + kind: %s + plural: %s + scope: Cluster + versions: + - name: v1 + served: true + storage: true +`, plural, group, anns, group, kind, plural), + } +} + +func crManifest(filename, apiVersion, kind, name string) fixtureManifest { + return fixtureManifest{ + filename: filename, + yaml: fmt.Sprintf("apiVersion: %s\nkind: %s\nmetadata:\n name: %s\nspec: {}\n", + apiVersion, kind, name), + } +} + +func TestCheckCRDOrdering_Fixtures(t *testing.T) { + tests := []struct { + name string + fixtures []fixtureManifest + wantSeverity severity // severitySafe means "expect no violation" + }{ + { + name: "CRD before CR, same component, same run-level (SAFE)", + fixtures: []fixtureManifest{ + crdManifest("0000_10_config-operator_01_widgets.crd.yaml", "example.io", "Widget", "widgets", false), + crManifest("0000_10_config-operator_02_widget.cr.yaml", "example.io/v1", "Widget", "cluster"), + }, + wantSeverity: severitySafe, + }, + { + name: "CR before CRD, same component, same run-level (NONBLOCKING)", + fixtures: []fixtureManifest{ + crManifest("0000_10_config-operator_01_widget.cr.yaml", "example.io/v1", "Widget", "cluster"), + crdManifest("0000_10_config-operator_02_widgets.crd.yaml", "example.io", "Widget", "widgets", false), + }, + wantSeverity: severityNonBlocking, + }, + { + name: "CR lower run-level than CRD (BLOCKING, the OCPBUGS-99266 shape)", + fixtures: []fixtureManifest{ + crManifest("0000_05_config-operator_02_widget.cr.yaml", "example.io/v1", "Widget", "cluster"), + crdManifest("0000_10_config-operator_01_widgets.crd.yaml", "example.io", "Widget", "widgets", false), + }, + wantSeverity: severityBlocking, + }, + { + name: "CRD at lower run-level than CR (SAFE)", + fixtures: []fixtureManifest{ + crdManifest("0000_05_config-operator_01_widgets.crd.yaml", "example.io", "Widget", "widgets", false), + crManifest("0000_10_config-operator_02_widget.cr.yaml", "example.io/v1", "Widget", "cluster"), + }, + wantSeverity: severitySafe, + }, + { + name: "single-token CR filename, CRD at lower run-level (SAFE)", + fixtures: []fixtureManifest{ + crdManifest("0000_10_config-operator_01_widgets.crd.yaml", "example.io", "Widget", "widgets", false), + crManifest("0000_90_openshift-widget-config.yaml", "example.io/v1", "Widget", "cluster"), + }, + wantSeverity: severitySafe, + }, + { + name: "bootstrap-required CRD makes any-order CR safe (SAFE)", + fixtures: []fixtureManifest{ + crManifest("0000_05_config-operator_02_widget.cr.yaml", "example.io/v1", "Widget", "cluster"), + crdManifest("0000_10_config-operator_01_widgets.crd.yaml", "example.io", "Widget", "widgets", true), + }, + wantSeverity: severitySafe, + }, + { + name: "same run-level, different component = parallel (NONBLOCKING)", + fixtures: []fixtureManifest{ + crdManifest("0000_10_other-operator_01_widgets.crd.yaml", "example.io", "Widget", "widgets", false), + crManifest("0000_10_config-operator_02_widget.cr.yaml", "example.io/v1", "Widget", "cluster"), + }, + wantSeverity: severityNonBlocking, + }, + { + name: "CR whose CRD is not in the payload (not checked)", + fixtures: []fixtureManifest{ + crManifest("0000_10_config-operator_02_widget.cr.yaml", "example.io/v1", "Widget", "cluster"), + }, + wantSeverity: severitySafe, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := checkCRDOrdering(loadFixture(t, tc.fixtures)) + if tc.wantSeverity == severitySafe { + if len(got) != 0 { + t.Fatalf("expected no violation, got %d: %+v", len(got), got) + } + return + } + if len(got) != 1 { + t.Fatalf("expected exactly one violation, got %d: %+v", len(got), got) + } + if got[0].severity != tc.wantSeverity { + t.Fatalf("severity = %s, want %s", got[0].severity, tc.wantSeverity) + } + }) + } +} + +// TestPayloadCRDOrdering runs the ordering check against a real, extracted +// release payload when CVO_PAYLOAD_MANIFEST_DIR points at a directory of payload +// manifests (as produced by `oc adm release extract --to=