Skip to content

Commit 911b40d

Browse files
authored
Feat | App-of-apps traversal support (#389)
* Feat | App of Apps Support Tests | Add appofapps_test.go and fix makeChildManifest helper Docs | Add App of Apps * Feat | Expand discovered ApplicationSets into child Applications during app-of-apps traversal * Feat | Patch child Applications and ApplicationSets during app-of-apps traversal using PatchApplication * Fix | Include Application/ApplicationSet objects in diff output for app-of-apps * Fix | Use namespace/name as visited key in app-of-apps traversal to prevent duplicate rendering * Fix | Include spec hash in app-of-apps visited key to handle same-name apps with different content * Chore | Make container restart/crash-loop warnings more visible * Fix | Use content similarity to pair duplicate-identity apps in MatchApps * Add integration_test branch-17/target-2 * Fix | Prevent deadlock when last in-flight app-of-apps item fails * Tests | Add direct unit tests for pairByContent greedy matching * Docs | Warn about application selection filters hiding app-of-apps children * Fix linting error * cleanup
1 parent 729d3a4 commit 911b40d

16 files changed

Lines changed: 2529 additions & 34 deletions

File tree

cmd/main.go

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -320,16 +320,31 @@ func run(cfg *Config) error {
320320

321321
// Extract resources by streaming source files directly to the Argo CD repo server via gRPC.
322322
// This bypasses the cluster reconciliation loop used by extract.RenderApplicationsFromBothBranches.
323-
baseManifests, targetManifests, extractDuration, err = reposerverextract.RenderApplicationsFromBothBranches(
324-
argocd,
325-
baseBranch,
326-
targetBranch,
327-
cfg.Timeout,
328-
cfg.Concurrency,
329-
baseApps.SelectedApps,
330-
targetApps.SelectedApps,
331-
cfg.Repo,
332-
)
323+
if cfg.TraverseAppOfApps {
324+
baseManifests, targetManifests, extractDuration, err = reposerverextract.RenderApplicationsFromBothBranchesWithAppOfApps(
325+
argocd,
326+
baseBranch,
327+
targetBranch,
328+
cfg.Timeout,
329+
cfg.Concurrency,
330+
baseApps.SelectedApps,
331+
targetApps.SelectedApps,
332+
cfg.Repo,
333+
appSelectionOptions,
334+
tempFolder,
335+
)
336+
} else {
337+
baseManifests, targetManifests, extractDuration, err = reposerverextract.RenderApplicationsFromBothBranches(
338+
argocd,
339+
baseBranch,
340+
targetBranch,
341+
cfg.Timeout,
342+
cfg.Concurrency,
343+
baseApps.SelectedApps,
344+
targetApps.SelectedApps,
345+
cfg.Repo,
346+
)
347+
}
333348
} else {
334349
// Extract resources from the cluster based on each branch, passing the manifests directly
335350
deleteAfterProcessing := !cfg.CreateCluster
@@ -354,7 +369,7 @@ func run(cfg *Config) error {
354369
ExtractDuration: extractDuration + convertAppSetsToAppsDuration,
355370
ArgoCDInstallationDuration: argocdInstallationDuration,
356371
ClusterCreationDuration: clusterCreationDuration,
357-
ApplicationCount: len(baseApps.SelectedApps) + len(targetApps.SelectedApps),
372+
ApplicationCount: len(baseManifests) + len(targetManifests),
358373
}
359374

360375
// Write manifest files if requested

cmd/options.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ var (
8282
DefaultArgocdConfigPath = "./argocd-config"
8383
DefaultOutputAppManifests = false
8484
DefaultOutputBranchManifests = false
85+
DefaultTraverseAppOfApps = false
8586
)
8687

8788
// RawOptions holds the raw CLI/env inputs - used only for parsing
@@ -130,6 +131,7 @@ type RawOptions struct {
130131
Concurrency uint `mapstructure:"concurrency"`
131132
OutputAppManifests bool `mapstructure:"output-app-manifests"`
132133
OutputBranchManifests bool `mapstructure:"output-branch-manifests"`
134+
TraverseAppOfApps bool `mapstructure:"traverse-app-of-apps"`
133135
}
134136

135137
// Config is the final, validated, ready-to-use configuration
@@ -173,6 +175,7 @@ type Config struct {
173175
Concurrency uint
174176
OutputAppManifests bool
175177
OutputBranchManifests bool
178+
TraverseAppOfApps bool
176179

177180
// Parsed/processed fields - no "parsed" prefix needed
178181
FileRegex *regexp.Regexp
@@ -268,6 +271,7 @@ func Parse() *Config {
268271
viper.SetDefault("argocd-config-dir", DefaultArgocdConfigPath)
269272
viper.SetDefault("output-app-manifests", DefaultOutputAppManifests)
270273
viper.SetDefault("output-branch-manifests", DefaultOutputBranchManifests)
274+
viper.SetDefault("traverse-app-of-apps", DefaultTraverseAppOfApps)
271275

272276
// Basic flags
273277
rootCmd.Flags().BoolP("debug", "d", false, "Activate debug mode")
@@ -325,6 +329,7 @@ func Parse() *Config {
325329
rootCmd.Flags().String("argocd-ui-url", DefaultArgocdUIURL, "Argo CD URL to generate application links in diff output (e.g., https://argocd.example.com)")
326330
rootCmd.Flags().Bool("output-app-manifests", DefaultOutputAppManifests, "Write per-application manifest files to the output folder (output/base/ and output/target/)")
327331
rootCmd.Flags().Bool("output-branch-manifests", DefaultOutputBranchManifests, "Write all application manifests per branch to a single file (output/base-branch.yaml and output/target-branch.yaml)")
332+
rootCmd.Flags().Bool("traverse-app-of-apps", DefaultTraverseAppOfApps, "Recursively render child Applications discovered in rendered manifests (app-of-apps pattern). Only supported with --render-method=repo-server-api")
328333

329334
// Check if version flag was specified directly
330335
for _, arg := range os.Args[1:] {
@@ -410,6 +415,7 @@ func (o *RawOptions) ToConfig() (*Config, error) {
410415
Concurrency: o.Concurrency,
411416
OutputAppManifests: o.OutputAppManifests,
412417
OutputBranchManifests: o.OutputBranchManifests,
418+
TraverseAppOfApps: o.TraverseAppOfApps,
413419
}
414420

415421
var err error
@@ -461,6 +467,11 @@ func (o *RawOptions) ToConfig() (*Config, error) {
461467
}
462468
}
463469

470+
// --traverse-app-of-apps is only supported with the repo-server-api render method
471+
if cfg.TraverseAppOfApps && cfg.RenderMethod != RenderMethodRepoServerAPI {
472+
return nil, fmt.Errorf("--traverse-app-of-apps requires --render-method=repo-server-api (current: %s)", cfg.RenderMethod)
473+
}
474+
464475
// Check if argocd CLI is installed when not using API mode
465476
if cfg.RenderMethod == RenderMethodCLI && !cfg.DryRun {
466477
if _, err := exec.LookPath("argocd"); err != nil {
@@ -736,4 +747,7 @@ func (o *Config) LogConfig() {
736747
if o.OutputBranchManifests {
737748
log.Info().Msgf("✨ - output-branch-manifests: %t", o.OutputBranchManifests)
738749
}
750+
if o.TraverseAppOfApps {
751+
log.Info().Msgf("✨ - traverse-app-of-apps: %t", o.TraverseAppOfApps)
752+
}
739753
}

docs/app-of-apps.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# App of Apps
2+
3+
!!! warning "🧪 Experimental"
4+
App of Apps support is an experimental feature. The behaviour and flags described on this page may change in future releases without a deprecation notice.
5+
6+
The [App of Apps pattern](https://argo-cd.readthedocs.io/en/stable/operator-manual/cluster-bootstrapping/) is a common Argo CD pattern where a parent Application renders child Application manifests. The parent application points to a directory of Application YAML files, and Argo CD creates those child applications automatically.
7+
8+
Without App of Apps support, `argocd-diff-preview` renders only the applications it discovers directly in your repository files. Child applications that are *generated* by a parent - and therefore never exist as files in the repo - are invisible to the tool.
9+
10+
With the `--traverse-app-of-apps` flag, `argocd-diff-preview` can discover and render those child applications automatically.
11+
12+
---
13+
14+
## Consider alternatives first
15+
16+
!!! tip "Prefer simpler alternatives when possible"
17+
The `--traverse-app-of-apps` feature is **slower** and **more limited** than the standard rendering flow. Before enabling it, consider whether one of the alternatives below covers your use case.
18+
19+
**Pre-render your Application manifests**
20+
21+
If your applications do not exist as plain manifests inside the repo, but are instead generated from Helm or Kustomize, you can pre-render them in your CI pipeline and place the output in the branch folder. `argocd-diff-preview` will then pick them up as regular files. See [Helm/Kustomize generated Argo CD applications](./generated-applications.md) for details and examples.
22+
23+
Only use `--traverse-app-of-apps` when the child Applications are *not* committed as plain manifests to the repository AND can *not* be pre-rendered easily.
24+
25+
---
26+
27+
## How it works
28+
29+
When `--traverse-app-of-apps` is enabled, the tool performs a breadth-first expansion:
30+
31+
1. **Render a parent application** - exactly as it normally would.
32+
2. **Scan the rendered manifests** for any resources of `kind: Application`.
33+
3. **Enqueue child applications** - each discovered child is added to the render queue as if it were a top-level application.
34+
4. **Repeat** - until no new child applications are found or the maximum depth is reached.
35+
36+
---
37+
38+
## Requirements
39+
40+
- **Render method:** `--traverse-app-of-apps` requires `--render-method=repo-server-api`. The flag will cause an error if used with any other render method.
41+
42+
---
43+
44+
## Usage
45+
46+
```bash
47+
argocd-diff-preview \
48+
--render-method=repo-server-api \
49+
--traverse-app-of-apps
50+
```
51+
52+
Or via environment variables:
53+
54+
```bash
55+
RENDER_METHOD=repo-server-api \
56+
TRAVERSE_APP_OF_APPS=true \
57+
argocd-diff-preview
58+
```
59+
60+
---
61+
62+
## Application selection
63+
64+
Child applications discovered through the App of Apps expansion are subject to the same [application selection](application-selection.md) filters as top-level applications:
65+
66+
| Filter | Applied to child apps? |
67+
|---|---|
68+
| Watch-pattern annotations (`--files-changed`) | ✅ Yes - the child app's own annotations are evaluated |
69+
| Label selectors (`--selector`) | ✅ Yes |
70+
| `--watch-if-no-watch-pattern-found` | ✅ Yes |
71+
| File path regex (`--file-regex`) | ❌ No - child apps have no physical file path |
72+
73+
!!! warning "Filters apply at every level of the tree"
74+
A child application is only discovered if its **parent is rendered**. If a parent application is excluded by a selector, watch-pattern, or any other filter, the tool never renders it - and therefore never sees its children. This means changes further down the tree can go undetected.
75+
76+
For example, if you use `--selector "team=frontend"` and your root app does not have the label `team: frontend`, none of its children will be processed - even if a child app *does* carry that label.
77+
78+
When using application selection filters together with `--traverse-app-of-apps`, make sure your **root and intermediate applications pass the filters**, not just the leaf applications you care about.
79+
80+
!!! tip "Watch patterns on child apps"
81+
You can add `argocd-diff-preview/watch-pattern` or `argocd.argoproj.io/manifest-generate-paths` annotations directly to your child Application manifests. These annotations are evaluated against the PR's changed files, just like they are for top-level applications.
82+
83+
### Recommended: use `--file-regex` to select only root applications
84+
85+
If you follow the App of Apps pattern, a practical approach is to use `--file-regex` to select only the root application files and let the tree traversal take care of the rest. This way the root apps are always rendered, and all children are discovered automatically.
86+
87+
For example, if your root application is defined in `apps/root.yaml`:
88+
89+
```bash
90+
argocd-diff-preview \
91+
--render-method=repo-server-api \
92+
--traverse-app-of-apps \
93+
--file-regex="^apps/root\.yaml$"
94+
```
95+
96+
This avoids the problem described above where filters accidentally exclude a parent and silently hide changes in its children.
97+
98+
---
99+
100+
## Cycle and diamond protection
101+
102+
The expansion engine tracks every `(app-id, branch)` pair it has already rendered. This means:
103+
104+
- **Cycles** (A → B → A) are detected and broken automatically.
105+
- **Diamond dependencies** (A → C and B → C) cause C to be rendered only once.
106+
107+
---
108+
109+
## Depth limit
110+
111+
The expansion stops after a maximum depth of **10 levels** to guard against runaway trees. If your App of Apps hierarchy is deeper than 10 levels, applications beyond that depth will not be rendered and a warning will be logged.
112+
113+
---
114+
115+
## Output
116+
117+
Diff output for child applications looks identical to that of top-level applications. The application name in the diff header includes a breadcrumb showing which parent generated it, making it easy to trace the app-of-apps tree.
118+
119+
For example, a diff generated with a two-level app-of-apps hierarchy might look like this:
120+
121+
```
122+
<details>
123+
<summary>child-app-1 (parent: my-root-app)</summary>
124+
<br>
125+
126+
#### ConfigMap: default/some-config
127+
...
128+
```

0 commit comments

Comments
 (0)