Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 26 additions & 11 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,16 +320,31 @@ func run(cfg *Config) error {

// Extract resources by streaming source files directly to the Argo CD repo server via gRPC.
// This bypasses the cluster reconciliation loop used by extract.RenderApplicationsFromBothBranches.
baseManifests, targetManifests, extractDuration, err = reposerverextract.RenderApplicationsFromBothBranches(
argocd,
baseBranch,
targetBranch,
cfg.Timeout,
cfg.Concurrency,
baseApps.SelectedApps,
targetApps.SelectedApps,
cfg.Repo,
)
if cfg.TraverseAppOfApps {
baseManifests, targetManifests, extractDuration, err = reposerverextract.RenderApplicationsFromBothBranchesWithAppOfApps(
argocd,
baseBranch,
targetBranch,
cfg.Timeout,
cfg.Concurrency,
baseApps.SelectedApps,
targetApps.SelectedApps,
cfg.Repo,
appSelectionOptions,
tempFolder,
)
} else {
baseManifests, targetManifests, extractDuration, err = reposerverextract.RenderApplicationsFromBothBranches(
argocd,
baseBranch,
targetBranch,
cfg.Timeout,
cfg.Concurrency,
baseApps.SelectedApps,
targetApps.SelectedApps,
cfg.Repo,
)
}
} else {
// Extract resources from the cluster based on each branch, passing the manifests directly
deleteAfterProcessing := !cfg.CreateCluster
Expand All @@ -354,7 +369,7 @@ func run(cfg *Config) error {
ExtractDuration: extractDuration + convertAppSetsToAppsDuration,
ArgoCDInstallationDuration: argocdInstallationDuration,
ClusterCreationDuration: clusterCreationDuration,
ApplicationCount: len(baseApps.SelectedApps) + len(targetApps.SelectedApps),
ApplicationCount: len(baseManifests) + len(targetManifests),
}

// Write manifest files if requested
Expand Down
14 changes: 14 additions & 0 deletions cmd/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ var (
DefaultArgocdConfigPath = "./argocd-config"
DefaultOutputAppManifests = false
DefaultOutputBranchManifests = false
DefaultTraverseAppOfApps = false
)

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

// Config is the final, validated, ready-to-use configuration
Expand Down Expand Up @@ -173,6 +175,7 @@ type Config struct {
Concurrency uint
OutputAppManifests bool
OutputBranchManifests bool
TraverseAppOfApps bool

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

// Basic flags
rootCmd.Flags().BoolP("debug", "d", false, "Activate debug mode")
Expand Down Expand Up @@ -325,6 +329,7 @@ func Parse() *Config {
rootCmd.Flags().String("argocd-ui-url", DefaultArgocdUIURL, "Argo CD URL to generate application links in diff output (e.g., https://argocd.example.com)")
rootCmd.Flags().Bool("output-app-manifests", DefaultOutputAppManifests, "Write per-application manifest files to the output folder (output/base/ and output/target/)")
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)")
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")

// Check if version flag was specified directly
for _, arg := range os.Args[1:] {
Expand Down Expand Up @@ -410,6 +415,7 @@ func (o *RawOptions) ToConfig() (*Config, error) {
Concurrency: o.Concurrency,
OutputAppManifests: o.OutputAppManifests,
OutputBranchManifests: o.OutputBranchManifests,
TraverseAppOfApps: o.TraverseAppOfApps,
}

var err error
Expand Down Expand Up @@ -461,6 +467,11 @@ func (o *RawOptions) ToConfig() (*Config, error) {
}
}

// --traverse-app-of-apps is only supported with the repo-server-api render method
if cfg.TraverseAppOfApps && cfg.RenderMethod != RenderMethodRepoServerAPI {
return nil, fmt.Errorf("--traverse-app-of-apps requires --render-method=repo-server-api (current: %s)", cfg.RenderMethod)
}

// Check if argocd CLI is installed when not using API mode
if cfg.RenderMethod == RenderMethodCLI && !cfg.DryRun {
if _, err := exec.LookPath("argocd"); err != nil {
Expand Down Expand Up @@ -736,4 +747,7 @@ func (o *Config) LogConfig() {
if o.OutputBranchManifests {
log.Info().Msgf("✨ - output-branch-manifests: %t", o.OutputBranchManifests)
}
if o.TraverseAppOfApps {
log.Info().Msgf("✨ - traverse-app-of-apps: %t", o.TraverseAppOfApps)
}
}
132 changes: 132 additions & 0 deletions docs/app-of-apps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# App of Apps

!!! warning "🧪 Experimental"
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.

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.

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.

With the `--traverse-app-of-apps` flag, `argocd-diff-preview` can discover and render those child applications automatically.

---

## Consider alternatives first

!!! tip "Prefer simpler alternatives when possible"
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.

**Alternative 1 - Pre-render your Application manifests**

If your child Application manifests are stored in a Git repository (which is the common case), `argocd-diff-preview` will find and render them automatically without any special flags. The tool scans your repository for all `kind: Application` and `kind: ApplicationSet` files and renders them directly.

Only use `--traverse-app-of-apps` when the child Application manifests are *not* committed to the repository and exist only as rendered output from a parent application.

**Alternative 2 - Helm or Kustomize generated Applications**

If your parent application uses Helm or Kustomize to generate child Application manifests, 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.

---

## How it works

When `--traverse-app-of-apps` is enabled, the tool performs a breadth-first expansion:

1. **Render a parent application** - exactly as it normally would.
2. **Scan the rendered manifests** for any resources of `kind: Application`.
3. **Enqueue child applications** - each discovered child is added to the render queue as if it were a top-level application.
4. **Repeat** - until no new child applications are found or the maximum depth is reached.

---

## Requirements

- **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.

---

## Usage

```bash
argocd-diff-preview \
--render-method=repo-server-api \
--traverse-app-of-apps
```

Or via environment variables:

```bash
RENDER_METHOD=repo-server-api \
TRAVERSE_APP_OF_APPS=true \
argocd-diff-preview
```

---

## Application selection

Child applications discovered through the App of Apps expansion are subject to the same [application selection](application-selection.md) filters as top-level applications:

| Filter | Applied to child apps? |
|---|---|
| Watch-pattern annotations (`--files-changed`) | ✅ Yes - the child app's own annotations are evaluated |
| Label selectors (`--selector`) | ✅ Yes |
| `--watch-if-no-watch-pattern-found` | ✅ Yes |
| File path regex (`--file-regex`) | ❌ No - child apps have no physical file path |

!!! warning "Filters apply at every level of the tree"
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.

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.

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.

!!! tip "Watch patterns on child apps"
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.

### Recommended: use `--file-regex` to select only root applications

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.

For example, if your root application is defined in `apps/root.yaml`:

```bash
argocd-diff-preview \
--render-method=repo-server-api \
--traverse-app-of-apps \
--file-regex="^apps/root\.yaml$"
```

This avoids the problem described above where filters accidentally exclude a parent and silently hide changes in its children.

---

## Cycle and diamond protection

The expansion engine tracks every `(app-id, branch)` pair it has already rendered. This means:

- **Cycles** (A → B → A) are detected and broken automatically.
- **Diamond dependencies** (A → C and B → C) cause C to be rendered only once.

---

## Depth limit

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.

---

## Output

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.

For example, a diff generated with a two-level app-of-apps hierarchy might look like this:

```
<details>
<summary>child-app-1 (parent: my-root-app)</summary>
<br>

#### ConfigMap: default/some-config
...
```
Loading
Loading