Skip to content

Commit aa8dbee

Browse files
committed
fix(bundler): scope ArgoCD ApplyOutOfSyncOnly to readiness-gate Application
Force=true alone deletes and recreates the readiness-gate Job on every ArgoCD sync, not just genuine spec diffs, needlessly rerunning readiness checks (CodeRabbit finding on PR #2408). ApplyOutOfSyncOnly only works as an Application-level spec.syncPolicy.syncOptions setting, not a per-resource annotation (silently ignored there). Add ApplicationData.ApplyOutOfSyncOnly, scoped to -readiness folders via a shared isReadinessFolder helper, rendered conditionally in application.yaml.tmpl. Verified on kind + real ArgoCD v3.5.1: no-op syncs now leave the Job's UID unchanged; a genuine diff still correctly replaces it. Signed-off-by: Kevin Hawkins <khawkins@nvidia.com>
1 parent 13a97de commit aa8dbee

8 files changed

Lines changed: 247 additions & 12 deletions

File tree

pkg/bundler/deployer/argocd/argocd.go

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,15 @@ type ApplicationData struct {
108108
// CascadeDelete adds ResourcesFinalizer to the rendered Application.
109109
// See #1628.
110110
CascadeDelete bool
111+
112+
// ApplyOutOfSyncOnly adds ApplyOutOfSyncOnly=true to spec.syncPolicy.syncOptions.
113+
// Set only for the -readiness folder's Application: paired with the Job-level
114+
// Replace=true,Force=true annotation (see gatemanifest.jobMetadataAnnotations),
115+
// this prevents ArgoCD from deleting/recreating the readiness-gate Job on every
116+
// sync when nothing actually changed — Replace+Force alone forces a
117+
// delete-and-recreate unconditionally. See #2367 and the CodeRabbit finding on
118+
// PR #2408.
119+
ApplyOutOfSyncOnly bool
111120
}
112121

113122
// AppOfAppsData contains data for rendering the App of Apps manifest.
@@ -790,13 +799,22 @@ func waveForFolder(f localformat.Folder, level int) int {
790799
return base
791800
case f.Parent + "-post":
792801
return base + 2
793-
case f.Parent + "-readiness":
794-
return base + 3
795-
default: // primary: Name == Parent
796-
return base + 1
802+
default:
803+
if isReadinessFolder(f) {
804+
return base + 3
805+
}
806+
return base + 1 // primary: Name == Parent
797807
}
798808
}
799809

810+
// isReadinessFolder reports whether f is the injected -readiness folder for
811+
// its parent component. Shared by waveForFolder (sync-wave banding) and
812+
// buildApplicationData (ApplyOutOfSyncOnly scoping) so the two can't
813+
// silently diverge on what counts as a readiness folder. See #2367.
814+
func isReadinessFolder(f localformat.Folder) bool {
815+
return f.Name == f.Parent+"-readiness"
816+
}
817+
800818
// buildApplicationData constructs ApplicationData for a single folder. The
801819
// FolderKind drives the Application shape — KindLocalHelm sets IsLocalChart
802820
// (path-based single-source); KindUpstreamHelm leaves it empty (multi-source
@@ -811,12 +829,13 @@ func waveForFolder(f localformat.Folder, level int) int {
811829
func buildApplicationData(comp recipe.ComponentRef, f localformat.Folder, syncWave int, repoURL, targetRevision string, values map[string]any, inline bool) (ApplicationData, error) {
812830
chart := comp.EffectiveChart()
813831
data := ApplicationData{
814-
Name: f.Name,
815-
Namespace: comp.Namespace,
816-
SyncWave: syncWave,
817-
RepoURL: repoURL,
818-
TargetRevision: targetRevision,
819-
BundleDir: f.Dir,
832+
Name: f.Name,
833+
Namespace: comp.Namespace,
834+
SyncWave: syncWave,
835+
RepoURL: repoURL,
836+
TargetRevision: targetRevision,
837+
BundleDir: f.Dir,
838+
ApplyOutOfSyncOnly: isReadinessFolder(f),
820839
}
821840
switch f.Kind {
822841
case localformat.KindLocalHelm:

pkg/bundler/deployer/argocd/argocd_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2343,3 +2343,128 @@ func TestGenerate_ChildNameLimits(t *testing.T) {
23432343
})
23442344
}
23452345
}
2346+
2347+
// TestBuildApplicationData_ApplyOutOfSyncOnly is the regression-proofing
2348+
// test for the SCOPING of ApplicationData.ApplyOutOfSyncOnly, not just its
2349+
// presence: it exercises all four folder kinds for the same parent
2350+
// component so a test that only checked the readiness case could not pass
2351+
// if the predicate degenerated to unconditionally true. Paired with the
2352+
// Job-level Replace=true,Force=true annotation in
2353+
// pkg/bundler/gatemanifest/manifest.go, ApplyOutOfSyncOnly (set only for
2354+
// the readiness Application) stops ArgoCD from delete-and-recreating the
2355+
// readiness-gate Job on every no-op resync. See #2367.
2356+
func TestBuildApplicationData_ApplyOutOfSyncOnly(t *testing.T) {
2357+
comp := recipe.ComponentRef{
2358+
Name: "gpu-operator",
2359+
Source: "https://helm.ngc.nvidia.com/nvidia",
2360+
Chart: "gpu-operator",
2361+
Version: "v25.3.3",
2362+
}
2363+
2364+
tests := []struct {
2365+
name string
2366+
folder localformat.Folder
2367+
want bool
2368+
}{
2369+
{
2370+
name: "primary folder",
2371+
folder: localformat.Folder{Name: "gpu-operator", Dir: "001-gpu-operator", Kind: localformat.KindUpstreamHelm, Parent: "gpu-operator"},
2372+
want: false,
2373+
},
2374+
{
2375+
name: "-pre folder",
2376+
folder: localformat.Folder{Name: "gpu-operator-pre", Dir: "001-gpu-operator-pre", Kind: localformat.KindLocalHelm, Parent: "gpu-operator"},
2377+
want: false,
2378+
},
2379+
{
2380+
name: "-post folder",
2381+
folder: localformat.Folder{Name: "gpu-operator-post", Dir: "003-gpu-operator-post", Kind: localformat.KindLocalHelm, Parent: "gpu-operator"},
2382+
want: false,
2383+
},
2384+
{
2385+
name: "-readiness folder",
2386+
folder: localformat.Folder{Name: "gpu-operator-readiness", Dir: "004-gpu-operator-readiness", Kind: localformat.KindLocalHelm, Parent: "gpu-operator"},
2387+
want: true,
2388+
},
2389+
{
2390+
// Adversarial: a primary folder whose NAME merely contains
2391+
// "-readiness" (not the synthetic suffix pattern relative to
2392+
// its own Parent) must not be scoped in. f.Parent+"-readiness"
2393+
// = "foo-readiness-readiness" != "foo-readiness", so the
2394+
// predicate correctly evaluates false.
2395+
name: "primary folder whose name happens to contain -readiness",
2396+
folder: localformat.Folder{Name: "foo-readiness", Dir: "005-foo-readiness", Kind: localformat.KindUpstreamHelm, Parent: "foo-readiness"},
2397+
want: false,
2398+
},
2399+
}
2400+
2401+
for _, tt := range tests {
2402+
t.Run(tt.name, func(t *testing.T) {
2403+
data, err := buildApplicationData(comp, tt.folder, 0, "https://github.com/example/repo.git", "main", nil, false)
2404+
if err != nil {
2405+
t.Fatalf("buildApplicationData() error = %v", err)
2406+
}
2407+
if data.ApplyOutOfSyncOnly != tt.want {
2408+
t.Errorf("ApplyOutOfSyncOnly = %v, want %v (folder=%+v)", data.ApplyOutOfSyncOnly, tt.want, tt.folder)
2409+
}
2410+
})
2411+
}
2412+
}
2413+
2414+
// TestGenerate_ApplyOutOfSyncOnlySyncOptions asserts the rendered
2415+
// application.yaml for a -readiness folder carries the exact
2416+
// "- ApplyOutOfSyncOnly=true" syncOptions entry, and that a non-readiness
2417+
// folder's rendered application.yaml does not mention ApplyOutOfSyncOnly
2418+
// anywhere. See #2367.
2419+
func TestGenerate_ApplyOutOfSyncOnlySyncOptions(t *testing.T) {
2420+
ctx := context.Background()
2421+
outputDir := t.TempDir()
2422+
2423+
recipeResult := &recipe.RecipeResult{}
2424+
recipeResult.Metadata.Version = testVersion
2425+
recipeResult.ComponentRefs = []recipe.ComponentRef{
2426+
{
2427+
Name: "gpu-operator",
2428+
Namespace: "gpu-operator",
2429+
Chart: "gpu-operator",
2430+
Version: "v25.3.3",
2431+
Type: recipe.ComponentTypeHelm,
2432+
Source: "https://helm.ngc.nvidia.com/nvidia",
2433+
},
2434+
}
2435+
recipeResult.DeploymentOrder = []string{"gpu-operator"}
2436+
2437+
g := &Generator{
2438+
RecipeResult: recipeResult,
2439+
ComponentValues: map[string]map[string]any{"gpu-operator": {}},
2440+
Version: "v0.0.0-test",
2441+
RepoURL: "https://github.com/example/aicr-bundles.git",
2442+
TargetRevision: "main",
2443+
ComponentReadiness: map[string]map[string][]byte{
2444+
"gpu-operator": {
2445+
"readiness.yaml": []byte("apiVersion: batch/v1\nkind: Job\nmetadata:\n" +
2446+
" name: gpu-operator-readiness-gate\n namespace: {{ .Release.Namespace }}\n"),
2447+
},
2448+
},
2449+
}
2450+
2451+
if _, err := g.Generate(ctx, outputDir); err != nil {
2452+
t.Fatalf("Generate() error = %v", err)
2453+
}
2454+
2455+
primary, err := os.ReadFile(filepath.Join(outputDir, "001-gpu-operator", "application.yaml"))
2456+
if err != nil {
2457+
t.Fatalf("read primary application.yaml: %v", err)
2458+
}
2459+
if strings.Contains(string(primary), "ApplyOutOfSyncOnly") {
2460+
t.Errorf("primary Application must not mention ApplyOutOfSyncOnly:\n%s", primary)
2461+
}
2462+
2463+
readiness, err := os.ReadFile(filepath.Join(outputDir, "002-gpu-operator-readiness", "application.yaml"))
2464+
if err != nil {
2465+
t.Fatalf("read readiness application.yaml: %v", err)
2466+
}
2467+
if !strings.Contains(string(readiness), "- ApplyOutOfSyncOnly=true") {
2468+
t.Errorf("readiness Application must contain \"- ApplyOutOfSyncOnly=true\":\n%s", readiness)
2469+
}
2470+
}

pkg/bundler/deployer/argocd/templates/application.yaml.tmpl

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,12 @@ spec:
5656
# per-Application template branching and a confusing mix of sync
5757
# strategies in a single bundle.
5858
- ServerSideApply=true
59+
{{- if .ApplyOutOfSyncOnly }}
60+
# ApplyOutOfSyncOnly: scoped to the readiness-gate Application only.
61+
# Paired with the Job's Replace=true,Force=true annotation
62+
# (pkg/bundler/gatemanifest/manifest.go), this skips resources ArgoCD's
63+
# diff already considers in-sync so a no-op resync does not
64+
# delete-and-recreate the readiness-gate Job (and rerun its checks)
65+
# when nothing changed. See #2367.
66+
- ApplyOutOfSyncOnly=true
67+
{{- end }}

pkg/bundler/deployer/argocd/testdata/readiness_gate/002-gpu-operator-readiness/application.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,10 @@ spec:
3131
# per-Application template branching and a confusing mix of sync
3232
# strategies in a single bundle.
3333
- ServerSideApply=true
34+
# ApplyOutOfSyncOnly: scoped to the readiness-gate Application only.
35+
# Paired with the Job's Replace=true,Force=true annotation
36+
# (pkg/bundler/gatemanifest/manifest.go), this skips resources ArgoCD's
37+
# diff already considers in-sync so a no-op resync does not
38+
# delete-and-recreate the readiness-gate Job (and rerun its checks)
39+
# when nothing changed. See #2367.
40+
- ApplyOutOfSyncOnly=true

pkg/bundler/deployer/argocdhelm/argocdhelm_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1949,6 +1949,63 @@ func TestBundleGolden_ReadinessGate(t *testing.T) {
19491949
}
19501950
}
19511951

1952+
// TestGenerate_ApplyOutOfSyncOnlySyncOptions is the empirical proof that
1953+
// transformApplication's generic map[string]any YAML round-trip preserves
1954+
// the ApplyOutOfSyncOnly=true syncOptions entry the delegated argocd.Generator
1955+
// adds to the readiness folder's Application, and that it stays scoped to
1956+
// only that folder's transformed Helm-chart-template output. See #2367.
1957+
func TestGenerate_ApplyOutOfSyncOnlySyncOptions(t *testing.T) {
1958+
ctx := context.Background()
1959+
outputDir := t.TempDir()
1960+
1961+
rr := newRecipeResult("v1.0.0", []recipe.ComponentRef{
1962+
{
1963+
Name: "gpu-operator",
1964+
Namespace: "gpu-operator",
1965+
Chart: "gpu-operator",
1966+
Version: "v25.3.3",
1967+
Type: recipe.ComponentTypeHelm,
1968+
Source: "https://helm.ngc.nvidia.com/nvidia",
1969+
},
1970+
})
1971+
rr.DeploymentOrder = []string{"gpu-operator"}
1972+
1973+
g := &Generator{
1974+
RecipeResult: rr,
1975+
ComponentValues: map[string]map[string]any{
1976+
"gpu-operator": {"driver": map[string]any{"version": "580"}},
1977+
},
1978+
Version: "v0.0.0-test",
1979+
RepoURL: "https://github.com/example/aicr-bundles.git",
1980+
TargetRevision: "main",
1981+
ComponentReadiness: map[string]map[string][]byte{
1982+
"gpu-operator": {
1983+
"readiness.yaml": readinessGateManifest(t, config.DeployerArgoCDHelm),
1984+
},
1985+
},
1986+
}
1987+
1988+
if _, err := g.Generate(ctx, outputDir); err != nil {
1989+
t.Fatalf("Generate() error = %v", err)
1990+
}
1991+
1992+
primary, err := os.ReadFile(filepath.Join(outputDir, "templates", "gpu-operator.yaml"))
1993+
if err != nil {
1994+
t.Fatalf("read primary template: %v", err)
1995+
}
1996+
if strings.Contains(string(primary), "ApplyOutOfSyncOnly") {
1997+
t.Errorf("primary child template must not mention ApplyOutOfSyncOnly:\n%s", primary)
1998+
}
1999+
2000+
readiness, err := os.ReadFile(filepath.Join(outputDir, "templates", "gpu-operator-readiness.yaml"))
2001+
if err != nil {
2002+
t.Fatalf("read readiness template: %v", err)
2003+
}
2004+
if !strings.Contains(string(readiness), "ApplyOutOfSyncOnly=true") {
2005+
t.Errorf("readiness child template must contain \"ApplyOutOfSyncOnly=true\":\n%s", readiness)
2006+
}
2007+
}
2008+
19522009
// TestHelmTemplate_RendersWithSetRepoURL is the live-render counterpart to
19532010
// the golden tests: goldens freeze the pre-render template bytes, this
19542011
// test verifies that running `helm template` against the generated bundle

pkg/bundler/deployer/argocdhelm/testdata/readiness_gate/templates/gpu-operator-readiness.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ spec:
2828
syncOptions:
2929
- CreateNamespace=true
3030
- ServerSideApply=true
31+
- ApplyOutOfSyncOnly=true

pkg/bundler/gatemanifest/manifest.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,19 @@ func jobMetadataAnnotations(deployer config.DeployerType) string {
179179
// API server rejects on any upgrade that changes the Job spec (e.g.
180180
// an image tag bump), leaving the Application permanently
181181
// OutOfSync. Force=true is ArgoCD's documented option to
182-
// delete-and-recreate when a replace fails. Deliberately NOT using a
183-
// Helm-style sync hook (helm.sh/hook) here: per
182+
// delete-and-recreate when a replace fails. This alone would
183+
// delete-and-recreate the Job on EVERY sync, including no-op
184+
// resyncs where nothing changed — see the ApplyOutOfSyncOnly=true
185+
// entry this deployer adds to the readiness folder's
186+
// Application-level spec.syncPolicy.syncOptions
187+
// (pkg/bundler/deployer/argocd/argocd.go's buildApplicationData),
188+
// which excludes already-in-sync resources from a sync operation
189+
// and stops the needless rerun. The two mechanisms are
190+
// complementary: Job-level Replace+Force handles genuine spec
191+
// diffs (e.g. an image tag bump); Application-level
192+
// ApplyOutOfSyncOnly prevents unnecessary reruns when there is no
193+
// diff at all. Deliberately NOT using a Helm-style sync hook
194+
// (helm.sh/hook) here: per
184195
// pkg/bundler/deployer/localformat/hooks.go's stripHelmHooks doc,
185196
// hook-annotated resources are excluded from ArgoCD's normal drift
186197
// detection, so an image-tag-only bump could silently go undetected.

pkg/bundler/gatemanifest/manifest_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ func TestRender_ArgoCDSyncOptions(t *testing.T) {
118118
// Application permanently OutOfSync (#2367). Force=true makes ArgoCD
119119
// delete-and-recreate on replace failure instead. Both ArgoCD deployer
120120
// branches (native and Helm-rendered) must emit the same annotation.
121+
// This Job-level annotation is only half the fix: see
122+
// TestBuildApplicationData_ApplyOutOfSyncOnly and
123+
// TestGenerate_ApplyOutOfSyncOnlySyncOptions in
124+
// pkg/bundler/deployer/argocd for the Application-level
125+
// ApplyOutOfSyncOnly=true entry that stops Force=true from
126+
// delete-and-recreating the Job on every no-op resync.
121127
tests := []struct {
122128
name string
123129
deployer config.DeployerType

0 commit comments

Comments
 (0)