Skip to content

Commit f124683

Browse files
committed
fix(bundler): compare the resolved chart name, not the registry default
Review found gatekeeper never emitted CreateReplace despite being enrolled in ownsCRDs, and reproduced it on all three enrolled components. ApplyRegistryDefaults strips a defaultChart to the segment after its last "/" when defaulting ref.Chart. gatekeeper's registry entry is "gatekeeper/gatekeeper", so a stock recipe resolves ref.Chart="gatekeeper" while usesRegistryChart compared against the unstripped value. One of the three headline components was silently inert, and the PR body, the registry comment, and the component catalog all claimed otherwise. It failed into the safe direction -- Skip, no destructive replace -- but it did not do what was documented. usesRegistryChart now normalizes the registry value the same way, and version comparison runs both sides through deployer.NormalizeVersion so an overlay re-pinning "v1.3.0" against a registry "1.3.0" does not silently disable the policy for an audited chart. The test could not have caught this. It built refs by copying registry fields directly, which produces a shape the resolver never emits, and its comment claimed that shape was "what a stock recipe resolves to". Refs are now built by running ApplyRegistryDefaults over an empty ref, and the owner cases are discovered from the registry rather than hardcoded, so gatekeeper and nvsentinel are covered and a future enrollment is covered without editing the test. Only k8s-aibom had been exercised. The chartRef render assertion was replaced by a direct usesRegistryChart test. Attempting the render route produced a sourceRef HelmRelease, so a render test would have passed without exercising the path -- the ambiguous-condition shape this suite avoids elsewhere. The property test asserts refs without registry coordinates are rejected, with a resolved ref as the control so the negative cases cannot pass vacuously. Adds TestOwnsCRDsPinsMatchAuditedVersions, which pins the chart versions the CRD-ownership audit actually ran against. usesRegistryChart covers a recipe overriding coordinates; nothing covered the other direction, where bumping defaultVersion in the registry carries ownsCRDs forward to a chart nobody re-checked, which may by then share a CRD or use webhook conversion. The guard caught two wrong versions on its first run. Related: #2264 Signed-off-by: Mark Chmarny <mark@chmarny.com>
1 parent 9c458ba commit f124683

3 files changed

Lines changed: 268 additions & 100 deletions

File tree

pkg/bundler/deployer/flux/flux.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,8 +1018,23 @@ func usesRegistryChart(ref recipe.ComponentRef, cfg *recipe.ComponentConfig) boo
10181018
return false
10191019
}
10201020
return ref.Source == cfg.Helm.DefaultRepository &&
1021-
ref.EffectiveChart() == cfg.Helm.DefaultChart &&
1022-
ref.Version == cfg.Helm.DefaultVersion
1021+
ref.EffectiveChart() == registryChartName(cfg.Helm.DefaultChart) &&
1022+
deployer.NormalizeVersion(ref.Version) == deployer.NormalizeVersion(cfg.Helm.DefaultVersion)
1023+
}
1024+
1025+
// registryChartName reduces a registry defaultChart to the form a resolved
1026+
// ComponentRef actually carries.
1027+
//
1028+
// ApplyRegistryDefaults strips everything before the last "/" when defaulting
1029+
// ref.Chart, so a registry entry like "gatekeeper/gatekeeper" resolves to
1030+
// "gatekeeper". Comparing against the unstripped value silently fails for every
1031+
// component whose defaultChart carries a repo-alias prefix, which is how
1032+
// gatekeeper was enrolled in ownsCRDs and never emitted the policy.
1033+
func registryChartName(defaultChart string) string {
1034+
if idx := strings.LastIndex(defaultChart, "/"); idx >= 0 {
1035+
return defaultChart[idx+1:]
1036+
}
1037+
return defaultChart
10231038
}
10241039

10251040
// ownsCRDs reports whether the named component may replace its CRDs on

pkg/bundler/deployer/flux/flux_test.go

Lines changed: 165 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -3113,134 +3113,201 @@ func TestGenerate_SourceOnlyRefChartFallsBackToName(t *testing.T) {
31133113
// base.yaml and all ship the NodeFeature CRDs, so replacing unconditionally
31143114
// would have several HelmReleases rewrite the same CRD every reconcile.
31153115
//
3116-
// Three directions are asserted, and each fails for a different reason:
3116+
// Refs are built by running ApplyRegistryDefaults over an empty ref, which is
3117+
// what a stock recipe actually resolves to. Constructing them by copying
3118+
// registry fields directly is what an earlier revision did, and it silently
3119+
// diverged: ApplyRegistryDefaults strips a defaultChart to its bare name, so a
3120+
// hand-built ref carrying "gatekeeper/gatekeeper" never matched the real
3121+
// resolved "gatekeeper" and the test could not see that gatekeeper emitted no
3122+
// policy at all. Do not hand-build these refs.
31173123
//
3118-
// owner -> emits the policy
3119-
// sharer -> must not, because replacing would race another release
3120-
// coordinates moved -> must not, because ownsCRDs records an audit of the
3121-
// registry's pinned chart and a ref that overrides
3122-
// source/chart/version points somewhere unaudited
3123-
//
3124-
// A positive-only test would pass against an unconditional template, which is
3125-
// the defect this replaced, so the negative cases are the load-bearing ones.
3124+
// The owner cases are discovered from the registry rather than hardcoded, so a
3125+
// component enrolled in ownsCRDs later is covered without touching this test.
31263126
//
31273127
// NOTE ON COVERAGE: this exercises the sourceRef template only. An upstream
31283128
// Helm component renders spec.chart.spec.sourceRef regardless of
3129-
// OCISourceName; the chartRef template is reached by local, vendored and
3130-
// manifest-backed components, and those carry no registry chart coordinates,
3131-
// so they cannot satisfy usesRegistryChart and cannot emit the policy today.
3132-
// Earlier revisions of this test claimed chartRef coverage by setting
3133-
// OCISourceName on a normal Helm ref, which renders sourceRef and tested the
3134-
// same path twice. Do not reintroduce that shape.
3129+
// OCISourceName. TestUsesRegistryChartRejectsRefsWithoutCoordinates covers
3130+
// why the chartRef shape cannot emit the policy today.
31353131
func TestGenerate_HelmReleaseCRDUpgradePolicyIsOptIn(t *testing.T) {
31363132
registry, regErr := recipe.GetComponentRegistry()
31373133
if regErr != nil {
31383134
t.Fatalf("GetComponentRegistry: %v", regErr)
31393135
}
31403136

3141-
// refFor builds a ref at the component's exact registry coordinates, which
3142-
// is what a stock recipe resolves to.
3143-
refFor := func(t *testing.T, name string) recipe.ComponentRef {
3137+
// resolvedRef mirrors stock resolution: an empty ref, then registry
3138+
// defaults. Anything else risks testing a shape the resolver never emits.
3139+
resolvedRef := func(t *testing.T, name string) recipe.ComponentRef {
31443140
t.Helper()
31453141
cfg := registry.Get(name)
31463142
if cfg == nil {
31473143
t.Fatalf("%s missing from registry", name)
31483144
}
3149-
return recipe.ComponentRef{
3150-
Name: name,
3151-
Namespace: name,
3152-
Type: recipe.ComponentTypeHelm,
3153-
Source: cfg.Helm.DefaultRepository,
3154-
Chart: cfg.Helm.DefaultChart,
3155-
Version: cfg.Helm.DefaultVersion,
3145+
ref := recipe.ComponentRef{Name: name, Namespace: name, Type: recipe.ComponentTypeHelm}
3146+
ref.ApplyRegistryDefaults(cfg)
3147+
return ref
3148+
}
3149+
3150+
// Every enrolled owner, discovered rather than listed.
3151+
var owners []string
3152+
for _, name := range registry.Names() {
3153+
if cfg := registry.Get(name); cfg != nil && cfg.OwnsCRDs {
3154+
owners = append(owners, name)
3155+
}
3156+
}
3157+
if len(owners) == 0 {
3158+
t.Fatal("no ownsCRDs components in the registry; this test would prove nothing")
3159+
}
3160+
3161+
t.Run("owners emit the policy", func(t *testing.T) {
3162+
for _, name := range owners {
3163+
t.Run(name, func(t *testing.T) {
3164+
assertCRDPolicy(t, resolvedRef(t, name), name, "CreateReplace")
3165+
})
31563166
}
3167+
})
3168+
3169+
// nfd shares the NodeFeature CRDs with gpu-operator and network-operator,
3170+
// all three of which co-exist in base.yaml.
3171+
t.Run("sharer does not", func(t *testing.T) {
3172+
assertCRDPolicy(t, resolvedRef(t, "nfd"), "nfd", "")
3173+
})
3174+
3175+
// ownsCRDs records an audit of the registry's pinned chart. A ref pointing
3176+
// anywhere else is unaudited, so the policy must not carry over.
3177+
t.Run("coordinate overrides disable it", func(t *testing.T) {
3178+
owner := owners[0]
3179+
for _, tc := range []struct {
3180+
name string
3181+
mutate func(*recipe.ComponentRef)
3182+
}{
3183+
{"version", func(r *recipe.ComponentRef) { r.Version = "0.0.1-unaudited" }},
3184+
{"chart", func(r *recipe.ComponentRef) { r.Chart = "some-fork" }},
3185+
{"source", func(r *recipe.ComponentRef) { r.Source = "oci://example.invalid/charts" }},
3186+
} {
3187+
t.Run(tc.name, func(t *testing.T) {
3188+
ref := resolvedRef(t, owner)
3189+
tc.mutate(&ref)
3190+
assertCRDPolicy(t, ref, owner, "")
3191+
})
3192+
}
3193+
})
3194+
}
3195+
3196+
// assertCRDPolicy renders one component and asserts spec.upgrade.crds.
3197+
//
3198+
// Decoded as a map, not a typed struct: a string field cannot tell an absent
3199+
// crds key from one explicitly rendered as "", so a negative case would pass
3200+
// against a template emitting an empty value. Presence is the assertion.
3201+
func assertCRDPolicy(t *testing.T, ref recipe.ComponentRef, component, want string) {
3202+
t.Helper()
3203+
outputDir := t.TempDir()
3204+
3205+
recipeResult := &recipe.RecipeResult{}
3206+
recipeResult.Metadata.Version = testVersion
3207+
recipeResult.ComponentRefs = []recipe.ComponentRef{ref}
3208+
3209+
g := &Generator{
3210+
RecipeResult: recipeResult,
3211+
ComponentValues: map[string]map[string]any{component: {}},
3212+
Version: "v0.9.0",
3213+
}
3214+
if _, err := g.Generate(context.Background(), outputDir); err != nil {
3215+
t.Fatalf("Generate() error = %v", err)
3216+
}
3217+
3218+
raw := readFile(t, filepath.Join(outputDir, component, "helmrelease.yaml"))
3219+
var doc struct {
3220+
Spec map[string]any `yaml:"spec"`
3221+
}
3222+
if err := yaml.Unmarshal([]byte(raw), &doc); err != nil {
3223+
t.Fatalf("parse HelmRelease: %v", err)
3224+
}
3225+
upgrade, hasUpgrade := doc.Spec["upgrade"]
3226+
3227+
if want == "" {
3228+
if hasUpgrade {
3229+
t.Errorf("spec.upgrade present (%v), want the key absent entirely\n%s", upgrade, raw)
3230+
}
3231+
return
3232+
}
3233+
if !hasUpgrade {
3234+
t.Fatalf("spec.upgrade absent, want crds = %q\n%s", want, raw)
3235+
}
3236+
upgradeMap, ok := upgrade.(map[string]any)
3237+
if !ok {
3238+
t.Fatalf("spec.upgrade is %T, want a mapping\n%s", upgrade, raw)
3239+
}
3240+
crds, hasCRDs := upgradeMap["crds"]
3241+
if !hasCRDs {
3242+
t.Fatalf("spec.upgrade.crds absent, want %q\n%s", want, raw)
3243+
}
3244+
if crds != want {
3245+
t.Errorf("spec.upgrade.crds = %v, want %q\n%s", crds, want, raw)
3246+
}
3247+
}
3248+
3249+
// TestUsesRegistryChartRejectsRefsWithoutCoordinates pins the property that
3250+
// makes the chartRef template's UpgradeCRDs block unreachable today.
3251+
//
3252+
// chartRef is reached by local, vendored and manifest-backed components. Those
3253+
// carry no registry chart coordinates, so usesRegistryChart is false for them
3254+
// and the policy cannot render. Asserting that directly is preferable to a
3255+
// render test: an attempt at the render route produced a sourceRef HelmRelease
3256+
// instead, so such a test would have passed without exercising anything, which
3257+
// is the ambiguous-condition shape this suite already avoids elsewhere.
3258+
//
3259+
// If a future chartRef path does carry registry coordinates, this test still
3260+
// holds but the block becomes reachable — at which point the chartRef template
3261+
// needs its own render coverage.
3262+
func TestUsesRegistryChartRejectsRefsWithoutCoordinates(t *testing.T) {
3263+
registry, regErr := recipe.GetComponentRegistry()
3264+
if regErr != nil {
3265+
t.Fatalf("GetComponentRegistry: %v", regErr)
3266+
}
3267+
cfg := registry.Get("k8s-aibom")
3268+
if cfg == nil || !cfg.OwnsCRDs {
3269+
t.Fatal("k8s-aibom must be an ownsCRDs component for this test to mean anything")
31573270
}
31583271

31593272
tests := []struct {
3160-
name string
3161-
component string
3162-
mutate func(*recipe.ComponentRef)
3163-
want string
3273+
name string
3274+
ref recipe.ComponentRef
3275+
want bool
31643276
}{
31653277
{
3166-
// k8s-aibom is registry ownsCRDs: sole owner, no webhook conversion.
3167-
name: "owner at registry coordinates", component: "k8s-aibom", want: "CreateReplace",
3168-
},
3169-
{
3170-
// nfd shares the NodeFeature CRDs with gpu-operator and
3171-
// network-operator, all three of which co-exist in base.yaml.
3172-
name: "sharer", component: "nfd", want: "",
3173-
},
3174-
{
3175-
name: "owner with overridden version", component: "k8s-aibom",
3176-
mutate: func(r *recipe.ComponentRef) { r.Version = "1.2.0" }, want: "",
3278+
name: "manifest-only ref has no chart or source",
3279+
ref: recipe.ComponentRef{
3280+
Name: "k8s-aibom", Type: recipe.ComponentTypeHelm,
3281+
ManifestFiles: []string{"components/k8s-aibom/values.yaml"},
3282+
},
3283+
want: false,
31773284
},
31783285
{
3179-
name: "owner with overridden chart", component: "k8s-aibom",
3180-
mutate: func(r *recipe.ComponentRef) { r.Chart = "some-fork" }, want: "",
3286+
name: "local chart path is not the registry chart",
3287+
ref: recipe.ComponentRef{
3288+
Name: "k8s-aibom", Type: recipe.ComponentTypeHelm,
3289+
Chart: "./k8s-aibom", Version: cfg.Helm.DefaultVersion,
3290+
},
3291+
want: false,
31813292
},
31823293
{
3183-
name: "owner with overridden source", component: "k8s-aibom",
3184-
mutate: func(r *recipe.ComponentRef) { r.Source = "oci://example.invalid/charts" }, want: "",
3294+
// The control: without this, every case above could pass because
3295+
// usesRegistryChart always returns false.
3296+
name: "fully resolved registry ref matches",
3297+
ref: func() recipe.ComponentRef {
3298+
r := recipe.ComponentRef{Name: "k8s-aibom", Type: recipe.ComponentTypeHelm}
3299+
r.ApplyRegistryDefaults(cfg)
3300+
return r
3301+
}(),
3302+
want: true,
31853303
},
31863304
}
31873305

31883306
for _, tt := range tests {
31893307
t.Run(tt.name, func(t *testing.T) {
3190-
outputDir := t.TempDir()
3191-
ref := refFor(t, tt.component)
3192-
if tt.mutate != nil {
3193-
tt.mutate(&ref)
3194-
}
3195-
3196-
recipeResult := &recipe.RecipeResult{}
3197-
recipeResult.Metadata.Version = testVersion
3198-
recipeResult.ComponentRefs = []recipe.ComponentRef{ref}
3199-
3200-
g := &Generator{
3201-
RecipeResult: recipeResult,
3202-
ComponentValues: map[string]map[string]any{tt.component: {}},
3203-
Version: "v0.9.0",
3204-
}
3205-
if _, err := g.Generate(context.Background(), outputDir); err != nil {
3206-
t.Fatalf("Generate() error = %v", err)
3207-
}
3208-
3209-
raw := readFile(t, filepath.Join(outputDir, tt.component, "helmrelease.yaml"))
3210-
3211-
// Decoded as a map, not a typed struct: a struct field of type
3212-
// string cannot tell an absent `crds` key from one explicitly
3213-
// rendered as "", so a negative case would pass against a
3214-
// template that emits an empty value. Presence is the assertion.
3215-
var doc struct {
3216-
Spec map[string]any `yaml:"spec"`
3217-
}
3218-
if err := yaml.Unmarshal([]byte(raw), &doc); err != nil {
3219-
t.Fatalf("parse HelmRelease: %v", err)
3220-
}
3221-
upgrade, hasUpgrade := doc.Spec["upgrade"]
3222-
3223-
if tt.want == "" {
3224-
if hasUpgrade {
3225-
t.Errorf("spec.upgrade present (%v), want the key absent entirely\n%s",
3226-
upgrade, raw)
3227-
}
3228-
return
3229-
}
3230-
3231-
if !hasUpgrade {
3232-
t.Fatalf("spec.upgrade absent, want crds = %q\n%s", tt.want, raw)
3233-
}
3234-
upgradeMap, ok := upgrade.(map[string]any)
3235-
if !ok {
3236-
t.Fatalf("spec.upgrade is %T, want a mapping\n%s", upgrade, raw)
3237-
}
3238-
crds, hasCRDs := upgradeMap["crds"]
3239-
if !hasCRDs {
3240-
t.Fatalf("spec.upgrade.crds absent, want %q\n%s", tt.want, raw)
3241-
}
3242-
if crds != tt.want {
3243-
t.Errorf("spec.upgrade.crds = %v, want %q\n%s", crds, tt.want, raw)
3308+
if got := usesRegistryChart(tt.ref, cfg); got != tt.want {
3309+
t.Errorf("usesRegistryChart() = %v, want %v (chart=%q source=%q version=%q)",
3310+
got, tt.want, tt.ref.EffectiveChart(), tt.ref.Source, tt.ref.Version)
32443311
}
32453312
})
32463313
}

0 commit comments

Comments
 (0)