diff --git a/cmd/main.go b/cmd/main.go index 3f346a2f..522af0e3 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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 @@ -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 diff --git a/cmd/options.go b/cmd/options.go index 290778ad..86d20a2b 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -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 @@ -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 @@ -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 @@ -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") @@ -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:] { @@ -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 @@ -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 { @@ -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) + } } diff --git a/docs/app-of-apps.md b/docs/app-of-apps.md new file mode 100644 index 00000000..e393cdf0 --- /dev/null +++ b/docs/app-of-apps.md @@ -0,0 +1,128 @@ +# 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. + +**Pre-render your Application manifests** + +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. + +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. + +--- + +## 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: + +``` +
+child-app-1 (parent: my-root-app) +
+ +#### ConfigMap: default/some-config +... +``` diff --git a/integration-test/branch-17/target-1/output.html b/integration-test/branch-17/target-1/output.html new file mode 100644 index 00000000..07a0bf6b --- /dev/null +++ b/integration-test/branch-17/target-1/output.html @@ -0,0 +1,279 @@ + + + + + + +
+

Argo CD Diff Preview

+ +

Summary:

+
Added (2):
++ level-1c-staging-app (+8)
++ level-2c-app (+8)
+
+Modified (6):
+Β± level-1a-app (+13)
+Β± level-1b-app (+1|-1)
+Β± level-1c-prod-app (+1|-1)
+Β± level-2a-app (+2|-1)
+Β± level-2b-app (+1)
+Β± root-app (+1)
+ +
+
+ +level-1a-app (parent: root-app) + + +

Application: argocd/level-2c-app

+
+ + + + + + + + + + + + + + + +
+apiVersion: argoproj.io/v1alpha1
+kind: Application
+metadata:
+  name: level-2c-app
+  namespace: argocd
+spec:
+  destination:
+    name: in-cluster
+    namespace: default
+  project: default
+  source:
+    path: examples/app-of-apps/apps/level-2c
+    repoURL: https://github.com/dag-andersen/argocd-diff-preview
+
+ +
+ +
+ +level-1b-app (parent: root-app) + + +

ConfigMap: default/level-1b-config

+
+ + + + + + + + + + + +
 apiVersion: v1
 data:
   app: level-1b
-  color: blue
+  color: purple
 kind: ConfigMap
 metadata:
   name: level-1b-config
   namespace: default
+
+ +
+ +
+ +level-1c-prod-app (parent: root-app (appset: level-1c-appset)) + + +

ConfigMap: default/level-1c-prod-config

+
+ + + + + + + + + + + +
 apiVersion: v1
 data:
   app: level-1c-prod
-  color: blue
+  color: purple
 kind: ConfigMap
 metadata:
   name: level-1c-prod-config
   namespace: default
+
+ +
+ +
+ +level-2a-app (parent: level-1a-app) + + +

ConfigMap: default/level-2a-config

+
+ + + + + + + + + + + + +
 apiVersion: v1
 data:
   app: level-2a
-  color: green
+  color: yellow
+  environment: production
 kind: ConfigMap
 metadata:
   name: level-2a-config
   namespace: default
+
+ +
+ +
+ +level-2b-app (parent: level-1a-app) + + +

ConfigMap: default/level-2b-config

+
+ + + + + + + + + + + +
 apiVersion: v1
 data:
   app: level-2b
   color: red
+  replicas: "3"
 kind: ConfigMap
 metadata:
   name: level-2b-config
   namespace: default
+
+ +
+ +
+ +root-app (examples/app-of-apps/root-app.yaml) + + +

ApplicationSet: argocd/level-1c-appset

+
+ + + + + + + + + + + + + + + + + + + + + + + +
 apiVersion: argoproj.io/v1alpha1
 kind: ApplicationSet
 metadata:
   name: level-1c-appset
   namespace: argocd
 spec:
   generators:
   - list:
       elements:
       - env: prod
+      - env: staging
   template:
     metadata:
       name: level-1c-{{env}}-app
       namespace: argocd
     spec:
       destination:
         name: in-cluster
         namespace: default
       project: default
       source:
+
+ +
+ +
+ +level-1c-staging-app (parent: root-app (appset: level-1c-appset)) + + +

ConfigMap: default/level-1c-staging-config

+
+ + + + + + + + + + +
+apiVersion: v1
+data:
+  app: level-1c-staging
+  color: orange
+kind: ConfigMap
+metadata:
+  name: level-1c-staging-config
+  namespace: default
+
+ +
+ +
+ +level-2c-app (parent: level-1a-app) + + +

ConfigMap: default/level-2c-config

+
+ + + + + + + + + + +
+apiVersion: v1
+data:
+  app: level-2c
+  color: orange
+kind: ConfigMap
+metadata:
+  name: level-2c-config
+  namespace: default
+
+ +
+
+ +
_Stats_:
+[Applications: 14], [Full Run: Xs], [Rendering: Xs], [Cluster: Xs], [Argo CD: Xs]
+
+ + diff --git a/integration-test/branch-17/target-1/output.md b/integration-test/branch-17/target-1/output.md new file mode 100644 index 00000000..23cdeff1 --- /dev/null +++ b/integration-test/branch-17/target-1/output.md @@ -0,0 +1,178 @@ +## Argo CD Diff Preview + +Summary: +```yaml +Added (2): ++ level-1c-staging-app (+8) ++ level-2c-app (+8) + +Modified (6): +Β± level-1a-app (+13) +Β± level-1b-app (+1|-1) +Β± level-1c-prod-app (+1|-1) +Β± level-2a-app (+2|-1) +Β± level-2b-app (+1) +Β± root-app (+1) +``` + +
+level-1a-app (parent: root-app) +
+ +#### Application: argocd/level-2c-app +```diff ++apiVersion: argoproj.io/v1alpha1 ++kind: Application ++metadata: ++ name: level-2c-app ++ namespace: argocd ++spec: ++ destination: ++ name: in-cluster ++ namespace: default ++ project: default ++ source: ++ path: examples/app-of-apps/apps/level-2c ++ repoURL: https://github.com/dag-andersen/argocd-diff-preview +``` +
+ +
+level-1b-app (parent: root-app) +
+ +#### ConfigMap: default/level-1b-config +```diff + apiVersion: v1 + data: + app: level-1b +- color: blue ++ color: purple + kind: ConfigMap + metadata: + name: level-1b-config + namespace: default +``` +
+ +
+level-1c-prod-app (parent: root-app (appset: level-1c-appset)) +
+ +#### ConfigMap: default/level-1c-prod-config +```diff + apiVersion: v1 + data: + app: level-1c-prod +- color: blue ++ color: purple + kind: ConfigMap + metadata: + name: level-1c-prod-config + namespace: default +``` +
+ +
+level-2a-app (parent: level-1a-app) +
+ +#### ConfigMap: default/level-2a-config +```diff + apiVersion: v1 + data: + app: level-2a +- color: green ++ color: yellow ++ environment: production + kind: ConfigMap + metadata: + name: level-2a-config + namespace: default +``` +
+ +
+level-2b-app (parent: level-1a-app) +
+ +#### ConfigMap: default/level-2b-config +```diff + apiVersion: v1 + data: + app: level-2b + color: red ++ replicas: "3" + kind: ConfigMap + metadata: + name: level-2b-config + namespace: default +``` +
+ +
+root-app (examples/app-of-apps/root-app.yaml) +
+ +#### ApplicationSet: argocd/level-1c-appset +```diff + apiVersion: argoproj.io/v1alpha1 + kind: ApplicationSet + metadata: + name: level-1c-appset + namespace: argocd + spec: + generators: + - list: + elements: + - env: prod ++ - env: staging + template: + metadata: + name: level-1c-{{env}}-app + namespace: argocd + spec: + destination: + name: in-cluster + namespace: default + project: default + source: +``` +
+ +
+level-1c-staging-app (parent: root-app (appset: level-1c-appset)) +
+ +#### ConfigMap: default/level-1c-staging-config +```diff ++apiVersion: v1 ++data: ++ app: level-1c-staging ++ color: orange ++kind: ConfigMap ++metadata: ++ name: level-1c-staging-config ++ namespace: default +``` +
+ +
+level-2c-app (parent: level-1a-app) +
+ +#### ConfigMap: default/level-2c-config +```diff ++apiVersion: v1 ++data: ++ app: level-2c ++ color: orange ++kind: ConfigMap ++metadata: ++ name: level-2c-config ++ namespace: default +``` +
+ +_Stats_: +[Applications: 14], [Full Run: Xs], [Rendering: Xs], [Cluster: Xs], [Argo CD: Xs] diff --git a/integration-test/branch-17/target-2/output.html b/integration-test/branch-17/target-2/output.html new file mode 100644 index 00000000..f80b573a --- /dev/null +++ b/integration-test/branch-17/target-2/output.html @@ -0,0 +1,279 @@ + + + + + + +
+

Argo CD Diff Preview

+ +

Summary:

+
Added (2):
++ level-1c-staging-app (+8)
++ level-2c-app (+8)
+
+Modified (6):
+Β± level-1a-app (+13)
+Β± level-1b-app (+1|-1)
+Β± level-1c-prod-app (+1|-1)
+Β± level-2a-app (+2|-1)
+Β± level-2b-app (+1)
+Β± root-app (+1)
+ +
+
+ +level-1a-app (examples/app-of-apps/apps/root/level-1a.yaml) + + +

Application: argocd/level-2c-app

+
+ + + + + + + + + + + + + + + +
+apiVersion: argoproj.io/v1alpha1
+kind: Application
+metadata:
+  name: level-2c-app
+  namespace: argocd
+spec:
+  destination:
+    name: in-cluster
+    namespace: default
+  project: default
+  source:
+    path: examples/app-of-apps/apps/level-2c
+    repoURL: https://github.com/dag-andersen/argocd-diff-preview
+
+ +
+ +
+ +level-1b-app (examples/app-of-apps/apps/root/level-1b.yaml) + + +

ConfigMap: default/level-1b-config

+
+ + + + + + + + + + + +
 apiVersion: v1
 data:
   app: level-1b
-  color: blue
+  color: purple
 kind: ConfigMap
 metadata:
   name: level-1b-config
   namespace: default
+
+ +
+ +
+ +level-1c-prod-app (examples/app-of-apps/apps/root/level-1c-appset.yaml) + + +

ConfigMap: default/level-1c-prod-config

+
+ + + + + + + + + + + +
 apiVersion: v1
 data:
   app: level-1c-prod
-  color: blue
+  color: purple
 kind: ConfigMap
 metadata:
   name: level-1c-prod-config
   namespace: default
+
+ +
+ +
+ +level-2a-app (examples/app-of-apps/apps/level-1a/level-2a.yaml) + + +

ConfigMap: default/level-2a-config

+
+ + + + + + + + + + + + +
 apiVersion: v1
 data:
   app: level-2a
-  color: green
+  color: yellow
+  environment: production
 kind: ConfigMap
 metadata:
   name: level-2a-config
   namespace: default
+
+ +
+ +
+ +level-2b-app (examples/app-of-apps/apps/level-1a/level-2b.yaml) + + +

ConfigMap: default/level-2b-config

+
+ + + + + + + + + + + +
 apiVersion: v1
 data:
   app: level-2b
   color: red
+  replicas: "3"
 kind: ConfigMap
 metadata:
   name: level-2b-config
   namespace: default
+
+ +
+ +
+ +root-app (examples/app-of-apps/root-app.yaml) + + +

ApplicationSet: argocd/level-1c-appset

+
+ + + + + + + + + + + + + + + + + + + + + + + +
 apiVersion: argoproj.io/v1alpha1
 kind: ApplicationSet
 metadata:
   name: level-1c-appset
   namespace: argocd
 spec:
   generators:
   - list:
       elements:
       - env: prod
+      - env: staging
   template:
     metadata:
       name: level-1c-{{env}}-app
       namespace: argocd
     spec:
       destination:
         name: in-cluster
         namespace: default
       project: default
       source:
+
+ +
+ +
+ +level-1c-staging-app (examples/app-of-apps/apps/root/level-1c-appset.yaml) + + +

ConfigMap: default/level-1c-staging-config

+
+ + + + + + + + + + +
+apiVersion: v1
+data:
+  app: level-1c-staging
+  color: orange
+kind: ConfigMap
+metadata:
+  name: level-1c-staging-config
+  namespace: default
+
+ +
+ +
+ +level-2c-app (examples/app-of-apps/apps/level-1a/level-2c.yaml) + + +

ConfigMap: default/level-2c-config

+
+ + + + + + + + + + +
+apiVersion: v1
+data:
+  app: level-2c
+  color: orange
+kind: ConfigMap
+metadata:
+  name: level-2c-config
+  namespace: default
+
+ +
+
+ +
_Stats_:
+[Applications: 14], [Full Run: Xs], [Rendering: Xs], [Cluster: Xs], [Argo CD: Xs]
+
+ + diff --git a/integration-test/branch-17/target-2/output.md b/integration-test/branch-17/target-2/output.md new file mode 100644 index 00000000..cea1a724 --- /dev/null +++ b/integration-test/branch-17/target-2/output.md @@ -0,0 +1,178 @@ +## Argo CD Diff Preview + +Summary: +```yaml +Added (2): ++ level-1c-staging-app (+8) ++ level-2c-app (+8) + +Modified (6): +Β± level-1a-app (+13) +Β± level-1b-app (+1|-1) +Β± level-1c-prod-app (+1|-1) +Β± level-2a-app (+2|-1) +Β± level-2b-app (+1) +Β± root-app (+1) +``` + +
+level-1a-app (examples/app-of-apps/apps/root/level-1a.yaml) +
+ +#### Application: argocd/level-2c-app +```diff ++apiVersion: argoproj.io/v1alpha1 ++kind: Application ++metadata: ++ name: level-2c-app ++ namespace: argocd ++spec: ++ destination: ++ name: in-cluster ++ namespace: default ++ project: default ++ source: ++ path: examples/app-of-apps/apps/level-2c ++ repoURL: https://github.com/dag-andersen/argocd-diff-preview +``` +
+ +
+level-1b-app (examples/app-of-apps/apps/root/level-1b.yaml) +
+ +#### ConfigMap: default/level-1b-config +```diff + apiVersion: v1 + data: + app: level-1b +- color: blue ++ color: purple + kind: ConfigMap + metadata: + name: level-1b-config + namespace: default +``` +
+ +
+level-1c-prod-app (examples/app-of-apps/apps/root/level-1c-appset.yaml) +
+ +#### ConfigMap: default/level-1c-prod-config +```diff + apiVersion: v1 + data: + app: level-1c-prod +- color: blue ++ color: purple + kind: ConfigMap + metadata: + name: level-1c-prod-config + namespace: default +``` +
+ +
+level-2a-app (examples/app-of-apps/apps/level-1a/level-2a.yaml) +
+ +#### ConfigMap: default/level-2a-config +```diff + apiVersion: v1 + data: + app: level-2a +- color: green ++ color: yellow ++ environment: production + kind: ConfigMap + metadata: + name: level-2a-config + namespace: default +``` +
+ +
+level-2b-app (examples/app-of-apps/apps/level-1a/level-2b.yaml) +
+ +#### ConfigMap: default/level-2b-config +```diff + apiVersion: v1 + data: + app: level-2b + color: red ++ replicas: "3" + kind: ConfigMap + metadata: + name: level-2b-config + namespace: default +``` +
+ +
+root-app (examples/app-of-apps/root-app.yaml) +
+ +#### ApplicationSet: argocd/level-1c-appset +```diff + apiVersion: argoproj.io/v1alpha1 + kind: ApplicationSet + metadata: + name: level-1c-appset + namespace: argocd + spec: + generators: + - list: + elements: + - env: prod ++ - env: staging + template: + metadata: + name: level-1c-{{env}}-app + namespace: argocd + spec: + destination: + name: in-cluster + namespace: default + project: default + source: +``` +
+ +
+level-1c-staging-app (examples/app-of-apps/apps/root/level-1c-appset.yaml) +
+ +#### ConfigMap: default/level-1c-staging-config +```diff ++apiVersion: v1 ++data: ++ app: level-1c-staging ++ color: orange ++kind: ConfigMap ++metadata: ++ name: level-1c-staging-config ++ namespace: default +``` +
+ +
+level-2c-app (examples/app-of-apps/apps/level-1a/level-2c.yaml) +
+ +#### ConfigMap: default/level-2c-config +```diff ++apiVersion: v1 ++data: ++ app: level-2c ++ color: orange ++kind: ConfigMap ++metadata: ++ name: level-2c-config ++ namespace: default +``` +
+ +_Stats_: +[Applications: 14], [Full Run: Xs], [Rendering: Xs], [Cluster: Xs], [Argo CD: Xs] diff --git a/integration-test/integration_test.go b/integration-test/integration_test.go index f7862dda..e5a61992 100644 --- a/integration-test/integration_test.go +++ b/integration-test/integration_test.go @@ -63,6 +63,7 @@ type TestCase struct { DisableClusterRoles string // Use no-cluster-roles/values.yaml (sets createClusterRoles: false) ArgocdConfigDir string // Custom argocd-config directory (relative to integration-test/); overrides auto-derived path ArgocdUIURL string // Argo CD URL for generating application links in diff output + TraverseAppOfApps string // If "true", enables recursive child app discovery (--traverse-app-of-apps) ExpectFailure bool // If true, the test is expected to fail } @@ -272,6 +273,30 @@ var testCases = []TestCase{ CreateCluster: "true", WatchIfNoWatchPatternFound: "false", }, + // Tests the app-of-apps pattern with the repo-server-api render method. + // A single root Application renders child Application YAMLs, which are + // discovered recursively (BFS) and each rendered independently. + { + Name: "branch-17/target-1", + TargetBranch: "integration-test/branch-17/target", + BaseBranch: "integration-test/branch-17/base", + Suffix: "-1", + RenderMethod: "repo-server-api", + FileRegex: "examples/app-of-apps/root-app\\.yaml", + TraverseAppOfApps: "true", + }, + // Same as branch-17/target but watches the entire examples/app-of-apps folder + // instead of only the root-app.yaml file. This exercises the watch pattern + // against all files under the folder (app YAMLs, configmaps, etc.). + { + Name: "branch-17/target-2", + TargetBranch: "integration-test/branch-17/target", + BaseBranch: "integration-test/branch-17/base", + Suffix: "-2", + RenderMethod: "repo-server-api", + FileRegex: "examples/app-of-apps/.*", + TraverseAppOfApps: "true", + }, // This test verifies that disabling cluster roles without using the API fails. // When createClusterRoles: false is set but --render-method=cli is used, // the tool should fail because it can't access cluster resources via CLI. @@ -865,6 +890,10 @@ func runWithDocker(tc TestCase, createCluster bool) error { args = append(args, "-e", fmt.Sprintf("ARGOCD_UI_URL=%s", tc.ArgocdUIURL)) } + if tc.TraverseAppOfApps == "true" { + args = append(args, "-e", "TRAVERSE_APP_OF_APPS=true") + } + // Add image (no additional args needed - all config is via env vars) args = append(args, *dockerImage) @@ -956,6 +985,10 @@ func buildArgs(tc TestCase, createCluster bool) []string { args = append(args, "--argocd-ui-url", tc.ArgocdUIURL) } + if tc.TraverseAppOfApps == "true" { + args = append(args, "--traverse-app-of-apps") + } + // When the test requires cluster roles to be disabled (API mode or DisableClusterRoles flag), // pass --argocd-config-dir pointing at the no-cluster-roles directory (createClusterRoles: false). // If ArgocdConfigDir is explicitly set, use that directory instead. diff --git a/mkdocs.yml b/mkdocs.yml index 596a44d9..3c56cfaf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,6 +40,7 @@ nav: - Multi-repo: multi-repo.md - application-selection.md - Rendering Methods: rendering-methods.md +- App of Apps: app-of-apps.md - Filter Output: filter-output.md - Output formats: output.md - All Options: options.md diff --git a/pkg/argoapplication/patching.go b/pkg/argoapplication/patching.go index eac8e33d..58c74366 100644 --- a/pkg/argoapplication/patching.go +++ b/pkg/argoapplication/patching.go @@ -20,7 +20,7 @@ func patchApplications( var patchedApps []ArgoResource for _, app := range applications { - patchedApp, err := patchApplication( + patchedApp, err := PatchApplication( argocdNamespace, app, branch, @@ -36,8 +36,8 @@ func patchApplications( return patchedApps, nil } -// patchApplication patches a single ArgoResource -func patchApplication( +// PatchApplication patches a single ArgoResource +func PatchApplication( argocdNamespace string, app ArgoResource, branch *git.Branch, diff --git a/pkg/argoapplication/patching_test.go b/pkg/argoapplication/patching_test.go index 00b837be..f8bcb612 100644 --- a/pkg/argoapplication/patching_test.go +++ b/pkg/argoapplication/patching_test.go @@ -4,6 +4,7 @@ import ( "fmt" "testing" + "github.com/dag-andersen/argocd-diff-preview/pkg/git" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -805,6 +806,444 @@ spec:` return string(yamlBytes) } +// TestPatchApplication verifies the full PatchApplication pipeline on a single +// ArgoResource. Each sub-test exercises one or more of the patches that +// PatchApplication chains together (namespace, project, destination, sync +// policy, finalizers, source redirect). +func TestPatchApplication(t *testing.T) { + zerolog.SetGlobalLevel(zerolog.FatalLevel) + + const ( + argocdNamespace = "argocd" + prRepo = "https://github.com/org/repo.git" + branchName = "my-feature" + ) + + branch := git.NewBranch(branchName, git.Target) + + tests := []struct { + name string + kind ApplicationKind + inputYAML string + wantYAML string + redirectRevisions []string + }{ + { + name: "namespace is set to argocd", + kind: Application, + inputYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: my-app + namespace: some-other-namespace +spec: + project: my-project + source: + repoURL: https://github.com/org/repo.git + targetRevision: HEAD + path: apps/my-app + destination: + server: https://kubernetes.default.svc + namespace: default +`, + wantYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: my-app + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/org/repo.git + targetRevision: my-feature + path: apps/my-app + destination: + server: https://kubernetes.default.svc + namespace: default +`, + }, + { + name: "sync policy is removed", + kind: Application, + inputYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: sync-app + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/org/repo.git + targetRevision: HEAD + path: apps/sync-app + destination: + server: https://kubernetes.default.svc + namespace: default + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true +`, + wantYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: sync-app + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/org/repo.git + targetRevision: my-feature + path: apps/sync-app + destination: + server: https://kubernetes.default.svc + namespace: default +`, + }, + { + name: "project is reset to default", + kind: Application, + inputYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: proj-app + namespace: argocd +spec: + project: production + source: + repoURL: https://github.com/org/repo.git + targetRevision: HEAD + path: apps/proj-app + destination: + server: https://kubernetes.default.svc + namespace: default +`, + wantYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: proj-app + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/org/repo.git + targetRevision: my-feature + path: apps/proj-app + destination: + server: https://kubernetes.default.svc + namespace: default +`, + }, + { + name: "destination server is redirected to in-cluster", + kind: Application, + inputYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: dest-app + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/org/repo.git + targetRevision: HEAD + path: apps/dest-app + destination: + name: remote-cluster + namespace: production +`, + wantYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: dest-app + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/org/repo.git + targetRevision: my-feature + path: apps/dest-app + destination: + server: https://kubernetes.default.svc + namespace: production +`, + }, + { + name: "argocd finalizer is removed", + kind: Application, + inputYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: final-app + namespace: argocd + finalizers: + - resources-finalizer.argocd.argoproj.io + - some-other-finalizer +spec: + project: default + source: + repoURL: https://github.com/org/repo.git + targetRevision: HEAD + path: apps/final-app + destination: + server: https://kubernetes.default.svc + namespace: default +`, + wantYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: final-app + namespace: argocd + finalizers: + - some-other-finalizer +spec: + project: default + source: + repoURL: https://github.com/org/repo.git + targetRevision: my-feature + path: apps/final-app + destination: + server: https://kubernetes.default.svc + namespace: default +`, + }, + { + name: "source targetRevision is redirected to branch", + kind: Application, + inputYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: src-app + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/org/repo.git + targetRevision: main + path: apps/src-app + destination: + server: https://kubernetes.default.svc + namespace: default +`, + wantYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: src-app + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/org/repo.git + targetRevision: my-feature + path: apps/src-app + destination: + server: https://kubernetes.default.svc + namespace: default +`, + }, + { + name: "source revision not redirected when repoURL does not match", + kind: Application, + inputYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: external-app + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/other/unrelated.git + targetRevision: HEAD + path: apps/external-app + destination: + server: https://kubernetes.default.svc + namespace: default +`, + wantYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: external-app + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/other/unrelated.git + targetRevision: HEAD + path: apps/external-app + destination: + server: https://kubernetes.default.svc + namespace: default +`, + }, + { + name: "specific redirectRevisions: only matching revision is redirected", + kind: Application, + inputYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: selective-app + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/org/repo.git + targetRevision: HEAD + path: apps/selective-app + destination: + server: https://kubernetes.default.svc + namespace: default +`, + wantYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: selective-app + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/org/repo.git + targetRevision: my-feature + path: apps/selective-app + destination: + server: https://kubernetes.default.svc + namespace: default +`, + redirectRevisions: []string{"HEAD", "main"}, + }, + { + name: "specific redirectRevisions: non-matching revision is left unchanged", + kind: Application, + inputYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: pinned-app + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/org/repo.git + targetRevision: v1.2.3 + path: apps/pinned-app + destination: + server: https://kubernetes.default.svc + namespace: default +`, + wantYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: pinned-app + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/org/repo.git + targetRevision: v1.2.3 + path: apps/pinned-app + destination: + server: https://kubernetes.default.svc + namespace: default +`, + redirectRevisions: []string{"HEAD", "main"}, + }, + { + name: "ApplicationSet namespace and project patched", + kind: ApplicationSet, + inputYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: ApplicationSet +metadata: + name: my-appset + namespace: some-namespace +spec: + generators: + - git: + repoURL: https://github.com/org/repo.git + revision: HEAD + template: + metadata: + namespace: argocd + spec: + project: platform + destination: + server: https://kubernetes.default.svc + namespace: default +`, + wantYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: ApplicationSet +metadata: + name: my-appset + namespace: argocd +spec: + generators: + - git: + repoURL: https://github.com/org/repo.git + revision: my-feature + template: + metadata: + namespace: argocd + spec: + project: default + destination: + server: https://kubernetes.default.svc + namespace: default +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var obj map[string]any + err := yaml.Unmarshal([]byte(tt.inputYAML), &obj) + assert.NoError(t, err) + + resource := &ArgoResource{ + Yaml: &unstructured.Unstructured{Object: obj}, + Kind: tt.kind, + Id: "test-id", + Name: "test-name", + FileName: "parent: root-app", + } + + redirectRevisions := tt.redirectRevisions + + // ── Normal cases ──────────────────────────────────────────────── + patched, err := PatchApplication(argocdNamespace, *resource, branch, prRepo, redirectRevisions) + assert.NoError(t, err) + assert.NotNil(t, patched) + + // FileName breadcrumb must be preserved unchanged. + assert.Equal(t, resource.FileName, patched.FileName, + "PatchApplication must preserve FileName") + + got, err := patched.AsString() + assert.NoError(t, err) + + assert.Equal(t, normalizeYAML(tt.wantYAML), normalizeYAML(got)) + }) + } +} + func TestRedirectSourceHydrator(t *testing.T) { zerolog.SetGlobalLevel(zerolog.FatalLevel) diff --git a/pkg/k8s/crash_watcher.go b/pkg/k8s/crash_watcher.go index 6b8e7064..1bcd4544 100644 --- a/pkg/k8s/crash_watcher.go +++ b/pkg/k8s/crash_watcher.go @@ -117,12 +117,12 @@ func (c *Client) WatchForContainerRestarts(namespace, labelSelector string, poll switch event.EventType { case "restart": log.Warn().Msgf( - "⚠️ Container '%s' in pod '%s' has restarted (restarts: %d -> %d). This may cause rendering failures or timeouts.", + "🚨🚨🚨🚨🚨 Container '%s' in pod '%s' has restarted (restarts: %d -> %d). This may cause rendering failures or timeouts.", event.ContainerName, event.PodName, event.PrevRestarts, event.CurrRestarts, ) case "crash-loop": log.Warn().Msgf( - "⚠️ Container '%s' in pod '%s' is in CrashLoopBackOff. ArgoCD may not be functioning correctly.", + "🚨🚨🚨🚨🚨 Container '%s' in pod '%s' is in CrashLoopBackOff. ArgoCD may not be functioning correctly.", event.ContainerName, event.PodName, ) } diff --git a/pkg/matching/apps.go b/pkg/matching/apps.go index a905fe3b..587e44e7 100644 --- a/pkg/matching/apps.go +++ b/pkg/matching/apps.go @@ -61,15 +61,8 @@ func MatchApps(baseApps, targetApps []extract.ExtractedApp) []Pair { for _, key := range sortedIdentityKeys { baseIdxs := baseByIdentity[key] targetIdxs := targetByIdentity[key] - - matchLen := min(len(baseIdxs), len(targetIdxs)) - for i := range matchLen { - bi := baseIdxs[i] - ti := targetIdxs[i] - pairs = append(pairs, Pair{ - Base: &baseApps[bi], - Target: &targetApps[ti], - }) + for bi, ti := range pairByContent(baseApps, targetApps, baseIdxs, targetIdxs) { + pairs = append(pairs, Pair{Base: &baseApps[bi], Target: &targetApps[ti]}) matchedBase[bi] = true matchedTarget[ti] = true } @@ -102,15 +95,8 @@ func MatchApps(baseApps, targetApps []extract.ExtractedApp) []Pair { for _, name := range sortedBaseNames { baseIdxs := baseByName[name] targetIdxs := targetByName[name] - - matchLen := min(len(baseIdxs), len(targetIdxs)) - for i := range matchLen { - bi := baseIdxs[i] - ti := targetIdxs[i] - pairs = append(pairs, Pair{ - Base: &baseApps[bi], - Target: &targetApps[ti], - }) + for bi, ti := range pairByContent(baseApps, targetApps, baseIdxs, targetIdxs) { + pairs = append(pairs, Pair{Base: &baseApps[bi], Target: &targetApps[ti]}) matchedBase[bi] = true matchedTarget[ti] = true } @@ -165,6 +151,65 @@ func MatchApps(baseApps, targetApps []extract.ExtractedApp) []Pair { return pairs } +// pairByContent pairs base and target indices using content similarity when +// there are multiple candidates sharing the same identity key or name. +// +// When there is only one base and one target candidate the result is trivial +// (no similarity computation needed). When there are multiple candidates on +// either side, greedy similarity matching is used so that e.g. two apps that +// share a name and SourcePath but render completely different resources (such +// as a prod and a test variant discovered via app-of-apps traversal from two +// different root apps) are still paired correctly rather than arbitrarily by +// insertion order. +// +// Returns a map of baseIdx β†’ targetIdx for each matched pair. +func pairByContent(baseApps, targetApps []extract.ExtractedApp, baseIdxs, targetIdxs []int) map[int]int { + result := make(map[int]int) + if len(baseIdxs) == 0 || len(targetIdxs) == 0 { + return result + } + + // Fast path: single candidate on each side - no need for similarity. + if len(baseIdxs) == 1 && len(targetIdxs) == 1 { + result[baseIdxs[0]] = targetIdxs[0] + return result + } + + // Multiple candidates: rank all pairs by content similarity and greedily + // pick the best non-overlapping matches. + type candidate struct { + bi, ti int + score float64 + } + var candidates []candidate + for _, bi := range baseIdxs { + for _, ti := range targetIdxs { + score := resourceSetSimilarity(baseApps[bi].Manifests, targetApps[ti].Manifests) + candidates = append(candidates, candidate{bi, ti, score}) + } + } + sort.SliceStable(candidates, func(i, j int) bool { + if math.Abs(candidates[i].score-candidates[j].score) > 1e-9 { + return candidates[i].score > candidates[j].score + } + if candidates[i].bi != candidates[j].bi { + return candidates[i].bi < candidates[j].bi + } + return candidates[i].ti < candidates[j].ti + }) + + usedBase := make(map[int]bool) + usedTarget := make(map[int]bool) + for _, c := range candidates { + if !usedBase[c.bi] && !usedTarget[c.ti] { + result[c.bi] = c.ti + usedBase[c.bi] = true + usedTarget[c.ti] = true + } + } + return result +} + // matchAppsBySimilarity finds best matches for apps using content similarity func matchAppsBySimilarity( baseApps, targetApps []extract.ExtractedApp, diff --git a/pkg/matching/matching_test.go b/pkg/matching/matching_test.go index 733410fd..a8e7676f 100644 --- a/pkg/matching/matching_test.go +++ b/pkg/matching/matching_test.go @@ -2139,3 +2139,215 @@ func TestDiffAction_String(t *testing.T) { }) } } + +// --------------------------------------------------------------------------- +// pairByContent unit tests +// --------------------------------------------------------------------------- + +func TestPairByContent_EmptyBaseIndices(t *testing.T) { + app := makeApp("a", "app", nil) + result := pairByContent([]extract.ExtractedApp{app}, []extract.ExtractedApp{app}, nil, []int{0}) + if len(result) != 0 { + t.Errorf("expected no pairs for empty base indices, got %d", len(result)) + } +} + +func TestPairByContent_EmptyTargetIndices(t *testing.T) { + app := makeApp("a", "app", nil) + result := pairByContent([]extract.ExtractedApp{app}, []extract.ExtractedApp{app}, []int{0}, nil) + if len(result) != 0 { + t.Errorf("expected no pairs for empty target indices, got %d", len(result)) + } +} + +func TestPairByContent_SingleCandidate(t *testing.T) { + // Fast path: one base, one target β†’ paired without similarity computation. + base := makeApp("a", "app", []unstructured.Unstructured{ + makeResource("apps/v1", "Deployment", "default", "d1", nil), + }) + target := makeApp("a", "app", []unstructured.Unstructured{ + makeResource("apps/v1", "Deployment", "default", "d1", nil), + }) + result := pairByContent([]extract.ExtractedApp{base}, []extract.ExtractedApp{target}, []int{0}, []int{0}) + if len(result) != 1 { + t.Fatalf("expected 1 pair, got %d", len(result)) + } + if ti, ok := result[0]; !ok || ti != 0 { + t.Errorf("expected base[0]β†’target[0], got %v", result) + } +} + +func TestPairByContent_GreedyMatchByContent(t *testing.T) { + // Two base apps and two target apps with the same identity key but + // different content. The greedy matcher should pair them by similarity + // (base[0]↔target[0] identical, base[1]↔target[1] identical) rather than + // mixing them up. + deployA := makeResource("apps/v1", "Deployment", "default", "frontend", map[string]any{ + "spec": map[string]any{"replicas": int64(3), "image": "nginx"}, + }) + deployB := makeResource("apps/v1", "Deployment", "default", "backend", map[string]any{ + "spec": map[string]any{"replicas": int64(1), "image": "redis"}, + }) + + baseApps := []extract.ExtractedApp{ + makeApp("shared-id", "app", []unstructured.Unstructured{deployA}), + makeApp("shared-id", "app", []unstructured.Unstructured{deployB}), + } + targetApps := []extract.ExtractedApp{ + makeApp("shared-id", "app", []unstructured.Unstructured{deployA}), + makeApp("shared-id", "app", []unstructured.Unstructured{deployB}), + } + + result := pairByContent(baseApps, targetApps, []int{0, 1}, []int{0, 1}) + + if len(result) != 2 { + t.Fatalf("expected 2 pairs, got %d", len(result)) + } + // base[0] has deployA content β†’ should match target[0] (also deployA) + if result[0] != 0 { + t.Errorf("expected base[0]β†’target[0], got base[0]β†’target[%d]", result[0]) + } + // base[1] has deployB content β†’ should match target[1] (also deployB) + if result[1] != 1 { + t.Errorf("expected base[1]β†’target[1], got base[1]β†’target[%d]", result[1]) + } +} + +func TestPairByContent_GreedyMatchSwappedOrder(t *testing.T) { + // Same as above but target apps are in reversed order. + // Greedy matcher should still pair by content, not by position. + deployA := makeResource("apps/v1", "Deployment", "default", "frontend", map[string]any{ + "spec": map[string]any{"replicas": int64(3), "image": "nginx"}, + }) + deployB := makeResource("apps/v1", "Deployment", "default", "backend", map[string]any{ + "spec": map[string]any{"replicas": int64(1), "image": "redis"}, + }) + + baseApps := []extract.ExtractedApp{ + makeApp("shared-id", "app", []unstructured.Unstructured{deployA}), + makeApp("shared-id", "app", []unstructured.Unstructured{deployB}), + } + targetApps := []extract.ExtractedApp{ + makeApp("shared-id", "app", []unstructured.Unstructured{deployB}), // swapped + makeApp("shared-id", "app", []unstructured.Unstructured{deployA}), // swapped + } + + result := pairByContent(baseApps, targetApps, []int{0, 1}, []int{0, 1}) + + if len(result) != 2 { + t.Fatalf("expected 2 pairs, got %d", len(result)) + } + // base[0] (deployA) should match target[1] (deployA) + if result[0] != 1 { + t.Errorf("expected base[0]β†’target[1], got base[0]β†’target[%d]", result[0]) + } + // base[1] (deployB) should match target[0] (deployB) + if result[1] != 0 { + t.Errorf("expected base[1]β†’target[0], got base[1]β†’target[%d]", result[1]) + } +} + +func TestPairByContent_UnevenCandidates_MoreBase(t *testing.T) { + // 3 base candidates, 2 target candidates. The best 2 base apps should be + // matched; the third has no partner. + deployA := makeResource("apps/v1", "Deployment", "default", "frontend", map[string]any{ + "spec": map[string]any{"replicas": int64(3), "image": "nginx"}, + }) + deployB := makeResource("apps/v1", "Deployment", "default", "backend", map[string]any{ + "spec": map[string]any{"replicas": int64(1), "image": "redis"}, + }) + deployC := makeResource("apps/v1", "Deployment", "default", "worker", map[string]any{ + "spec": map[string]any{"replicas": int64(5), "image": "python"}, + }) + + baseApps := []extract.ExtractedApp{ + makeApp("id", "app", []unstructured.Unstructured{deployA}), + makeApp("id", "app", []unstructured.Unstructured{deployB}), + makeApp("id", "app", []unstructured.Unstructured{deployC}), + } + targetApps := []extract.ExtractedApp{ + makeApp("id", "app", []unstructured.Unstructured{deployA}), + makeApp("id", "app", []unstructured.Unstructured{deployB}), + } + + result := pairByContent(baseApps, targetApps, []int{0, 1, 2}, []int{0, 1}) + + if len(result) != 2 { + t.Fatalf("expected 2 pairs, got %d", len(result)) + } + // base[0] (deployA) β†’ target[0] (deployA), base[1] (deployB) β†’ target[1] (deployB) + if result[0] != 0 { + t.Errorf("expected base[0]β†’target[0], got base[0]β†’target[%d]", result[0]) + } + if result[1] != 1 { + t.Errorf("expected base[1]β†’target[1], got base[1]β†’target[%d]", result[1]) + } + // base[2] should NOT be paired + if _, ok := result[2]; ok { + t.Errorf("expected base[2] to be unpaired, but it was matched to target[%d]", result[2]) + } +} + +func TestPairByContent_UnevenCandidates_MoreTarget(t *testing.T) { + // 2 base candidates, 3 target candidates. + deployA := makeResource("apps/v1", "Deployment", "default", "frontend", map[string]any{ + "spec": map[string]any{"replicas": int64(3), "image": "nginx"}, + }) + deployB := makeResource("apps/v1", "Deployment", "default", "backend", map[string]any{ + "spec": map[string]any{"replicas": int64(1), "image": "redis"}, + }) + deployC := makeResource("apps/v1", "Deployment", "default", "worker", map[string]any{ + "spec": map[string]any{"replicas": int64(5), "image": "python"}, + }) + + baseApps := []extract.ExtractedApp{ + makeApp("id", "app", []unstructured.Unstructured{deployA}), + makeApp("id", "app", []unstructured.Unstructured{deployB}), + } + targetApps := []extract.ExtractedApp{ + makeApp("id", "app", []unstructured.Unstructured{deployC}), + makeApp("id", "app", []unstructured.Unstructured{deployA}), + makeApp("id", "app", []unstructured.Unstructured{deployB}), + } + + result := pairByContent(baseApps, targetApps, []int{0, 1}, []int{0, 1, 2}) + + if len(result) != 2 { + t.Fatalf("expected 2 pairs, got %d", len(result)) + } + // base[0] (deployA) β†’ target[1] (deployA) + if result[0] != 1 { + t.Errorf("expected base[0]β†’target[1], got base[0]β†’target[%d]", result[0]) + } + // base[1] (deployB) β†’ target[2] (deployB) + if result[1] != 2 { + t.Errorf("expected base[1]β†’target[2], got base[1]β†’target[%d]", result[1]) + } +} + +func TestPairByContent_DeterministicTieBreaking(t *testing.T) { + // When all candidates have the same similarity score (all empty manifests), + // the tie-breaking should be deterministic: lowest base index first, then + // lowest target index. + baseApps := []extract.ExtractedApp{ + makeApp("id", "app", nil), + makeApp("id", "app", nil), + } + targetApps := []extract.ExtractedApp{ + makeApp("id", "app", nil), + makeApp("id", "app", nil), + } + + // Run multiple times to verify determinism. + for i := range 10 { + result := pairByContent(baseApps, targetApps, []int{0, 1}, []int{0, 1}) + if len(result) != 2 { + t.Fatalf("iteration %d: expected 2 pairs, got %d", i, len(result)) + } + // With tie-breaking: base[0]β†’target[0] picked first (lowest bi, lowest ti), + // then base[1]β†’target[1] (next available). + if result[0] != 0 || result[1] != 1 { + t.Errorf("iteration %d: expected deterministic {0β†’0, 1β†’1}, got %v", i, result) + } + } +} diff --git a/pkg/reposerverextract/appofapps.go b/pkg/reposerverextract/appofapps.go new file mode 100644 index 00000000..d73a9ebe --- /dev/null +++ b/pkg/reposerverextract/appofapps.go @@ -0,0 +1,530 @@ +// Package reposerverextract - app-of-apps expansion. +// +// This file contains all logic for recursively discovering and rendering child +// Applications that appear in a parent application's rendered manifests +// (the "app-of-apps" pattern). It is intentionally isolated so the feature +// can be removed cleanly in the future if it is no longer needed. +// +// The feature is only active when --traverse-app-of-apps is set. +package reposerverextract + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/rs/zerolog/log" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/dag-andersen/argocd-diff-preview/pkg/argoapplication" + argocdPkg "github.com/dag-andersen/argocd-diff-preview/pkg/argocd" + "github.com/dag-andersen/argocd-diff-preview/pkg/extract" + "github.com/dag-andersen/argocd-diff-preview/pkg/git" + "github.com/dag-andersen/argocd-diff-preview/pkg/reposerver" +) + +// maxAppOfAppsDepth is the maximum recursion depth allowed when following +// child Applications discovered in rendered manifests (app-of-apps pattern). +// A depth of 0 means the seed apps themselves; depth 1 means their children, +// and so on. This prevents infinite loops in circular app-of-apps graphs. +const maxAppOfAppsDepth = 10 + +// workItem is a single unit of rendering work, carrying the app to render and +// how deep in the app-of-apps hierarchy it sits. +type workItem struct { + app argoapplication.ArgoResource + depth int +} + +// renderResult captures a single rendered application together with any child +// Application resources that were discovered in its manifests. +type renderResult struct { + // extracted is the ExtractedApp for the rendered application. Its + // Manifests slice already has Application resources stripped out. + extracted extract.ExtractedApp + + // childApps are the ArgoResource values built from Application manifests + // that were discovered inside the rendered output. They have been patched + // and are ready to be enqueued for rendering. + childApps []argoapplication.ArgoResource + + // depth is the depth of the app that produced this result, used to decide + // whether to enqueue its children. + depth int + + err error +} + +// visitedKey returns a unique string key for an application, used to track +// which applications have already been rendered during app-of-apps expansion. +// +// The key is based on (namespace, name, branch, specHash) where specHash is a +// SHA-256 of the app's spec field. This handles two distinct scenarios: +// +// 1. Same k8s identity, same content - a child app discovered via traversal +// that matches a top-level seed app (even if the seed's Id was deduplicated +// from "root" to "root-1"). The namespace/name/branch/specHash will all +// match, so it is correctly recognised as already-visited. +// +// 2. Same k8s identity, different content - two different files both define an +// Application named "root" but with different spec.source.path. These must +// be rendered separately; the differing specHash ensures they get distinct +// keys. +func visitedKey(yaml *unstructured.Unstructured, branch git.BranchType) string { + namespace := yaml.GetNamespace() + name := yaml.GetName() + + // Hash the spec field so that two apps with the same namespace/name but + // different source configurations are treated as distinct entries. + // Fall back to an empty hash if spec is absent or cannot be marshalled. + specHash := specHashOf(yaml) + + // Use \x00 (null byte) as separator: it cannot appear in Kubernetes + // namespace or name values, so there is no risk of prefix collision. + return namespace + "\x00" + name + "\x00" + string(branch) + "\x00" + specHash +} + +// specHashOf returns a short hex-encoded SHA-256 hash of the "spec" field of +// the given unstructured object. If the spec is absent or cannot be marshalled +// to JSON, an empty string is returned (callers treat it as a valid hash). +func specHashOf(yaml *unstructured.Unstructured) string { + if yaml == nil { + return "" + } + spec, found, _ := unstructured.NestedMap(yaml.Object, "spec") + if !found { + return "" + } + b, err := json.Marshal(spec) + if err != nil { + return "" + } + sum := sha256.Sum256(b) + return fmt.Sprintf("%x", sum[:8]) // 16 hex chars - enough for deduplication +} + +// RenderApplicationsFromBothBranchesWithAppOfApps is like +// RenderApplicationsFromBothBranches but additionally discovers and renders +// child Applications found in rendered manifests (the app-of-apps pattern). +// +// When a rendered app's manifests contain argoproj.io/Application resources, +// those children are patched and enqueued for rendering recursively β€” up to +// maxAppOfAppsDepth levels deep. Child Application YAML manifests are excluded +// from the parent's diff output; each child gets its own ExtractedApp entry. +// +// A visited set prevents re-rendering the same app twice, guarding against +// cycles (Aβ†’Bβ†’A) and diamond dependencies (Aβ†’C, Bβ†’C). +// +// Child apps are filtered by Selector, FilesChanged (via watch-pattern annotations), +// IgnoreInvalidWatchPattern, and WatchIfNoWatchPatternFound β€” the same as top-level +// apps. FileRegex is excluded because it filters by physical file path, and child +// apps have no file path (their FileName is a breadcrumb like "parent: "). +func RenderApplicationsFromBothBranchesWithAppOfApps( + argocd *argocdPkg.ArgoCDInstallation, + baseBranch *git.Branch, + targetBranch *git.Branch, + timeout uint64, + maxConcurrency uint, + baseApps []argoapplication.ArgoResource, + targetApps []argoapplication.ArgoResource, + prRepo string, + appSelectionOptions argoapplication.ApplicationSelectionOptions, + tempFolder string, +) ([]extract.ExtractedApp, []extract.ExtractedApp, time.Duration, error) { + startTime := time.Now() + + branchFolderByType := map[git.BranchType]string{ + git.Base: baseBranch.FolderName(), + git.Target: targetBranch.FolderName(), + } + + branchByType := map[git.BranchType]*git.Branch{ + git.Base: baseBranch, + git.Target: targetBranch, + } + + log.Info().Msgf("πŸ“Œ Final number of Applications planned to be rendered via repo server: [Base: %d], [Target: %d]", + len(baseApps), len(targetApps)) + + if err := extract.VerifyNoApplicationSets(baseApps); err != nil { + return nil, nil, time.Since(startTime), err + } + + if err := extract.VerifyNoApplicationSets(targetApps); err != nil { + return nil, nil, time.Since(startTime), err + } + + namespacedScopedResources, err := argocd.K8sClient.GetListOfNamespacedScopedResources() + if err != nil { + return nil, nil, time.Since(startTime), fmt.Errorf("failed to get list of namespaced scoped resources: %w", err) + } + + // Collect all unique repository URLs referenced by the Applications so that + // FetchRepoCreds can enrich them with credentials from repo-creds templates. + appRepoURLs := collectRepoURLs(baseApps, targetApps) + + // Fetch all repository credentials from the cluster once, upfront. + // The repo server has no access to Kubernetes secrets - credentials must be + // provided by the caller in every ManifestRequest. We mirror what the + // ArgoCD app controller does in controller/state.go before calling the repo server. + creds, err := FetchRepoCreds(context.Background(), argocd.K8sClient, argocd.Namespace, appRepoURLs) + if err != nil { + return nil, nil, time.Since(startTime), fmt.Errorf("failed to fetch repository credentials: %w", err) + } + + // Create a single repo server client shared across all goroutines. + // EnsurePortForward is idempotent and mutex-protected inside the client. + repoClient := reposerver.NewClient(argocd.K8sClient, argocd.Namespace) + defer repoClient.Cleanup() + + if err := repoClient.EnsurePortForward(); err != nil { + return nil, nil, time.Since(startTime), fmt.Errorf("failed to set up port forward to repo server: %w", err) + } + + log.Info().Msgf("πŸ€– Rendering Applications via repo server with app-of-apps traversal (timeout in %d seconds)", timeout) + + remainingTime := func() int { + return max(0, int(timeout)-int(time.Since(startTime).Seconds())) + } + + // ── Single-pool expansion ──────────────────────────────────────────────── + // All apps (seed + discovered children) go through the same worker pool. + // A pending counter tracks how many items are in-flight or queued; when it + // reaches zero every goroutine has finished and all results are collected. + // A visited set (mutex-protected) prevents re-rendering the same app twice. + + var ( + extractedBaseApps []extract.ExtractedApp + extractedTargetApps []extract.ExtractedApp + renderedApps atomic.Int32 + pending atomic.Int32 + firstError error + visitedMu sync.Mutex + ) + + visited := make(map[string]bool) + + semSize := int(maxConcurrency) + if semSize == 0 { + semSize = 1 + } + sem := make(chan struct{}, semSize) + + // work is a buffered channel; workers send newly discovered children back + // onto it. We size it generously so senders are never blocked. + work := make(chan workItem, 1024) + results := make(chan renderResult, 1024) + + // enqueue increments pending before sending so the counter is always >= + // actual in-flight count. + enqueue := func(app argoapplication.ArgoResource, depth int) { + pending.Add(1) + work <- workItem{app: app, depth: depth} + } + + // Seed the queue with the initial base + target apps (depth 0). + visitedMu.Lock() + for _, app := range append(baseApps, targetApps...) { + visited[visitedKey(app.Yaml, app.Branch)] = true + enqueue(app, 0) + } + visitedMu.Unlock() + + progressDone := make(chan bool) + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + log.Info().Msgf("πŸ€– Rendered %d applications via repo server so far (timeout in %d seconds)", + renderedApps.Load(), remainingTime()) + case <-progressDone: + return + } + } + }() + + // Single collector goroutine: reads results, collects extracted apps, and + // enqueues newly discovered children back onto the work channel. + // + // INVARIANT: This is the only goroutine that decrements or reads `pending`. + // The enqueue helper (which increments pending) is only called from this + // goroutine (for child apps) or from the main goroutine during seeding + // (which completes before any results are processed). Because decrement + // and zero-check both happen here, there is no TOCTOU race: when + // pending.Load() == 0 it truly means no items are queued or in-flight. + collectorDone := make(chan struct{}) + go func() { + defer close(collectorDone) + for r := range results { + if r.err != nil { + if firstError == nil { + firstError = r.err + } + log.Error().Err(r.err).Msg("❌ Failed to render application via repo server:") + } else { + switch r.extracted.Branch { + case git.Base: + extractedBaseApps = append(extractedBaseApps, r.extracted) + case git.Target: + extractedTargetApps = append(extractedTargetApps, r.extracted) + default: + if firstError == nil { + firstError = fmt.Errorf("unknown branch type: '%s'", r.extracted.Branch) + } + } + + // Enqueue children that haven't been seen yet and pass the selection filter. + // Child apps are filtered by Selector, FilesChanged (via watch-pattern annotations), + // IgnoreInvalidWatchPattern, and WatchIfNoWatchPatternFound β€” exactly as top-level apps are. + // FilesChanged works correctly here: the PR diff is the same regardless of whether an + // app was discovered from a file or from a parent's rendered output; the watch pattern + // on the child app is what determines whether it is affected. + // + // FileRegex is intentionally excluded because it filters by the physical filename of + // the Application YAML file. Child apps don't come from a file; their FileName is + // "parent: " (a breadcrumb), which would give false matches against any regex. + if r.depth < maxAppOfAppsDepth { + childSelectionOptions := argoapplication.ApplicationSelectionOptions{ + Selector: appSelectionOptions.Selector, + FilesChanged: appSelectionOptions.FilesChanged, + IgnoreInvalidWatchPattern: appSelectionOptions.IgnoreInvalidWatchPattern, + WatchIfNoWatchPatternFound: appSelectionOptions.WatchIfNoWatchPatternFound, + // FileRegex intentionally omitted: child apps have no real file path + } + selection := argoapplication.ApplicationSelection(r.childApps, childSelectionOptions) + for _, skipped := range selection.SkippedApps { + log.Debug().Str("App", skipped.GetLongName()).Msg("Skipping child Application excluded by ApplicationSelectionOptions") + } + visitedMu.Lock() + for _, child := range selection.SelectedApps { + key := visitedKey(child.Yaml, child.Branch) + if visited[key] { + log.Debug().Str("App", child.GetLongName()).Msg("Skipping already-visited child Application") + continue + } + visited[key] = true + enqueue(child, r.depth+1) + } + visitedMu.Unlock() + } else if len(r.childApps) > 0 { + log.Warn().Msgf("⚠️ App-of-apps depth limit (%d) reached; not enqueuing %d child(ren) of %s", + maxAppOfAppsDepth, len(r.childApps), r.extracted.Name) + } + } + + // Decrement pending for both success and error paths. + // When all pending work is done, close the work channel so workers exit. + pending.Add(-1) + if pending.Load() == 0 { + close(work) + } + } + }() + + // Workers: pull from work channel, render, send result. + var wg sync.WaitGroup + for item := range work { + sem <- struct{}{} + wg.Add(1) + go func(item workItem) { + defer wg.Done() + defer func() { <-sem }() + + if remainingTime() <= 0 { + results <- renderResult{err: fmt.Errorf("timeout reached before starting to render application: %s", item.app.GetLongName())} + return + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(remainingTime())*time.Second) + defer cancel() + + manifests, childApps, err := renderAppWithChildDiscovery(ctx, repoClient, argocd, item.app, branchFolderByType, branchByType, namespacedScopedResources, creds, prRepo, argocd.Namespace, tempFolder, item.depth) + if err != nil { + results <- renderResult{err: fmt.Errorf("failed to render app %s: %w", item.app.GetLongName(), err)} + return + } + + renderedApps.Add(1) + results <- renderResult{ + extracted: extract.CreateExtractedApp(item.app.Id, item.app.Name, item.app.FileName, manifests, item.app.Branch), + childApps: childApps, + depth: item.depth, + } + }(item) + } + + // All work items have been dequeued. Wait for in-flight workers to finish + // sending their results before closing the results channel. + wg.Wait() + close(results) + <-collectorDone + + close(progressDone) + + if firstError != nil { + return nil, nil, time.Since(startTime), firstError + } + + duration := time.Since(startTime) + log.Info().Msgf("πŸŽ‰ Rendered all %d applications via repo server in %s", + renderedApps.Load(), duration.Round(time.Second)) + log.Info().Msgf("πŸ€– Got %d resources from %s-branch and %d from %s-branch via repo server", + len(extractedBaseApps), git.Base, len(extractedTargetApps), git.Target) + + return extractedBaseApps, extractedTargetApps, time.Since(startTime), nil +} + +// renderAppWithChildDiscovery renders a single application and returns: +// +// 1. All rendered manifests to include in the diff (returned as the first +// value). This is the original allManifests slice returned by the repo +// server, including Application/ApplicationSet objects as-is so that +// changes to those objects (e.g. annotation changes) are visible in the +// diff output. +// +// 2. Child ArgoResource values to enqueue for recursive rendering (returned as +// the second value). Application resources are deep-copied, patched, and +// enqueued directly. ApplicationSet resources are deep-copied, patched, and +// then expanded into their generated Applications via +// argocd.AppsetGenerateWithRetry. In both cases the deep copy ensures +// PatchApplication does not mutate the originals in allManifests. +// +// A visited set in the caller prevents re-rendering the same child twice. +func renderAppWithChildDiscovery( + ctx context.Context, + repoClient *reposerver.Client, + argocd *argocdPkg.ArgoCDInstallation, + app argoapplication.ArgoResource, + branchFolderByType map[git.BranchType]string, + branchByType map[git.BranchType]*git.Branch, + namespacedScopedResources map[schema.GroupKind]bool, + creds *RepoCreds, + prRepo string, + argocdNamespace string, + tempFolder string, + depth int, +) ([]unstructured.Unstructured, []argoapplication.ArgoResource, error) { + allManifests, err := renderApp(ctx, repoClient, app, branchFolderByType, namespacedScopedResources, creds, prRepo) + if err != nil { + return nil, nil, err + } + + // Discover child Application/ApplicationSet resources in the rendered manifests. + // Non-argoproj.io resources are left untouched in allManifests. + // For Application/ApplicationSet entries we deep-copy the slot in allManifests + // so the diff sees the original unmodified YAML, then use the original m freely + // for patching (PatchApplication mutates the Yaml pointer in-place). + var childApps []argoapplication.ArgoResource + + for _, m := range allManifests { + if !strings.HasPrefix(m.GetAPIVersion(), "argoproj.io/") { + continue + } + + switch m.GetKind() { + case "Application": + name := m.GetName() + if name == "" { + log.Warn().Str("parentApp", app.Name).Msg("⚠️ Discovered child Application has no name; skipping child rendering") + continue + } + fileName := fmt.Sprintf("parent: %s", app.Name) + // Deep copy so PatchApplication mutates the copy, leaving m in + // allManifests (the diff) untouched. + resource := argoapplication.NewArgoResource(m.DeepCopy(), argoapplication.Application, name, name, fileName, app.Branch) + child, err := argoapplication.PatchApplication(argocdNamespace, *resource, branchByType[app.Branch], prRepo, nil) + if err != nil { + log.Warn().Err(err). + Str("parentApp", app.Name). + Str("childName", name). + Msg("⚠️ Could not patch child Application; skipping child rendering") + continue + } + childApps = append(childApps, *child) + log.Debug(). + Str("parentApp", app.Name). + Str("childApp", child.GetLongName()). + Msg("Discovered child Application via app-of-apps pattern") + + case "ApplicationSet": + appSetName := m.GetName() + log.Info(). + Str("parentApp", app.Name). + Str("appSet", appSetName). + Msgf("πŸ” Discovered child ApplicationSet in rendered manifests; expanding to Applications") + + appSetTempFolder := fmt.Sprintf("%s/appsets/depth-%d", tempFolder, depth) + branch := branchByType[app.Branch] + + // Patch before sending to the API. This strips spec.template.metadata.namespace + // (e.g. "argocd") which ArgoCD's /api/v1/applicationsets/generate endpoint rejects. + // Deep copy so PatchApplication mutates the copy, leaving m in + // allManifests (the diff) untouched. + appSetResource := argoapplication.NewArgoResource(m.DeepCopy(), argoapplication.ApplicationSet, appSetName, appSetName, app.FileName, app.Branch) + patchedAppSet, err := argoapplication.PatchApplication(argocdNamespace, *appSetResource, branch, prRepo, nil) + if err != nil { + log.Warn().Err(err). + Str("parentApp", app.Name). + Str("appSet", appSetName). + Msg("⚠️ Failed to patch child ApplicationSet before expansion; skipping expansion") + continue + } + + generatedManifests, err := argocd.AppsetGenerateWithRetry(patchedAppSet.Yaml, appSetTempFolder, 5) + if err != nil { + log.Warn().Err(err). + Str("parentApp", app.Name). + Str("appSet", appSetName). + Msg("⚠️ Could not expand child ApplicationSet; skipping expansion") + continue + } + + breadcrumb := fmt.Sprintf("parent: %s (appset: %s)", app.Name, appSetName) + for _, genDoc := range generatedManifests { + if genDoc.GetKind() != "Application" { + log.Warn(). + Str("appSet", appSetName). + Str("kind", genDoc.GetKind()). + Msg("⚠️ ApplicationSet generated unexpected non-Application resource; skipping") + continue + } + name := genDoc.GetName() + if name == "" { + log.Warn().Str("appSet", appSetName).Msg("⚠️ ApplicationSet-generated Application has no name; skipping") + continue + } + resource := argoapplication.NewArgoResource(&genDoc, argoapplication.Application, name, name, breadcrumb, app.Branch) + child, err := argoapplication.PatchApplication(argocdNamespace, *resource, branch, prRepo, nil) + if err != nil { + log.Warn().Err(err). + Str("parentApp", app.Name). + Str("appSet", appSetName). + Msg("⚠️ Could not patch ApplicationSet-generated Application; skipping") + continue + } + childApps = append(childApps, *child) + log.Debug(). + Str("parentApp", app.Name). + Str("appSet", appSetName). + Str("childApp", child.GetLongName()). + Msg("Discovered child Application via ApplicationSet expansion") + } + } + } + + if len(childApps) > 0 { + log.Info(). + Str("parentApp", app.Name). + Msgf("πŸ” Discovered %d child Application(s) in rendered manifests", len(childApps)) + } + + return allManifests, childApps, nil +} diff --git a/pkg/reposerverextract/appofapps_test.go b/pkg/reposerverextract/appofapps_test.go new file mode 100644 index 00000000..f5c841e2 --- /dev/null +++ b/pkg/reposerverextract/appofapps_test.go @@ -0,0 +1,164 @@ +package reposerverextract + +// Tests for the app-of-apps expansion logic in appofapps.go. +// +// RenderApplicationsFromBothBranchesWithAppOfApps requires a live Argo CD repo +// server and is covered by integration tests. This file focuses on the pure +// helper functions that can be exercised without any network or cluster: +// +// - visitedKey – produces a unique deduplication key. +// - specHashOf – stable content hash of the spec field. +// +// Patching logic for discovered child Applications and ApplicationSets is +// delegated entirely to argoapplication.PatchApplication, which is tested in +// pkg/argoapplication. + +import ( + "testing" + + "github.com/dag-andersen/argocd-diff-preview/pkg/git" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// makeUnstructuredApp builds a minimal *unstructured.Unstructured representing +// an ArgoCD Application with the given namespace, name, and spec fields. It is +// used to construct test inputs for visitedKey and specHashOf. +func makeUnstructuredApp(namespace, name string, spec map[string]any) *unstructured.Unstructured { + obj := map[string]any{ + "apiVersion": "argoproj.io/v1alpha1", + "kind": "Application", + "metadata": map[string]any{ + "name": name, + "namespace": namespace, + }, + } + if spec != nil { + obj["spec"] = spec + } + return &unstructured.Unstructured{Object: obj} +} + +func specA() map[string]any { + return map[string]any{ + "source": map[string]any{ + "repoURL": "https://github.com/example/repo", + "path": "apps/path-a", + "targetRevision": "main", + }, + } +} + +func specB() map[string]any { + return map[string]any{ + "source": map[string]any{ + "repoURL": "https://github.com/example/repo", + "path": "apps/path-b", + "targetRevision": "main", + }, + } +} + +// ── visitedKey ──────────────────────────────────────────────────────────────── + +func TestVisitedKey_Format(t *testing.T) { + app := makeUnstructuredApp("argocd", "my-app", specA()) + key := visitedKey(app, git.Base) + assert.Contains(t, key, "argocd", "key must contain the namespace") + assert.Contains(t, key, "my-app", "key must contain the app name") + assert.Contains(t, key, string(git.Base), "key must contain the branch type") +} + +func TestVisitedKey_DifferentNames(t *testing.T) { + key1 := visitedKey(makeUnstructuredApp("argocd", "app-a", specA()), git.Base) + key2 := visitedKey(makeUnstructuredApp("argocd", "app-b", specA()), git.Base) + assert.NotEqual(t, key1, key2, + "different app names in the same namespace and branch must produce different keys") +} + +func TestVisitedKey_DifferentNamespaces(t *testing.T) { + key1 := visitedKey(makeUnstructuredApp("argocd", "my-app", specA()), git.Base) + key2 := visitedKey(makeUnstructuredApp("other", "my-app", specA()), git.Base) + assert.NotEqual(t, key1, key2, + "same app name in different namespaces must produce different keys") +} + +func TestVisitedKey_DifferentBranches(t *testing.T) { + key1 := visitedKey(makeUnstructuredApp("argocd", "my-app", specA()), git.Base) + key2 := visitedKey(makeUnstructuredApp("argocd", "my-app", specA()), git.Target) + assert.NotEqual(t, key1, key2, + "same app in different branches must produce different keys") +} + +func TestVisitedKey_Deterministic(t *testing.T) { + key1 := visitedKey(makeUnstructuredApp("argocd", "my-app", specA()), git.Base) + key2 := visitedKey(makeUnstructuredApp("argocd", "my-app", specA()), git.Base) + assert.Equal(t, key1, key2, "visitedKey must be deterministic") +} + +// TestVisitedKey_NoPrefixCollision guards against naive concatenation bugs where +// two different (namespace, name) pairs produce the same key if no separator is used. +func TestVisitedKey_NoPrefixCollision(t *testing.T) { + // namespace="argo-cd", name="app" must not equal namespace="argo", name="cd-app" + key1 := visitedKey(makeUnstructuredApp("argo-cd", "app", specA()), git.Base) + key2 := visitedKey(makeUnstructuredApp("argo", "cd-app", specA()), git.Base) + assert.NotEqual(t, key1, key2, + "visitedKey must use separators that prevent prefix collisions between namespace and name") +} + +// TestVisitedKey_SameKubernetesIdentity verifies that two apps with identical +// namespace/name/branch/spec produce the same key regardless of their +// deduplicated Id. This is the core property that prevents the triple-root +// duplicate bug: a child app discovered via traversal is recognised as +// already-visited even when the same seed app had its Id renamed (e.g. +// "root" -> "root-1"). +func TestVisitedKey_SameKubernetesIdentity(t *testing.T) { + key1 := visitedKey(makeUnstructuredApp("argocd", "root", specA()), git.Target) + key2 := visitedKey(makeUnstructuredApp("argocd", "root", specA()), git.Target) + assert.Equal(t, key1, key2, + "apps with the same namespace/name/branch/spec must share a visited key even if their Ids differ") +} + +// TestVisitedKey_SameNameDifferentSpec is the core test for the new behaviour: +// two apps with the same namespace and name but different spec content must +// produce different visited keys so that both get rendered. +func TestVisitedKey_SameNameDifferentSpec(t *testing.T) { + key1 := visitedKey(makeUnstructuredApp("argocd", "root", specA()), git.Base) + key2 := visitedKey(makeUnstructuredApp("argocd", "root", specB()), git.Base) + assert.NotEqual(t, key1, key2, + "apps with the same namespace/name/branch but different spec must have different visited keys") +} + +// ── specHashOf ──────────────────────────────────────────────────────────────── + +func TestSpecHashOf_Deterministic(t *testing.T) { + app := makeUnstructuredApp("argocd", "my-app", specA()) + assert.Equal(t, specHashOf(app), specHashOf(app), "specHashOf must be deterministic") +} + +func TestSpecHashOf_DifferentSpec(t *testing.T) { + appA := makeUnstructuredApp("argocd", "my-app", specA()) + appB := makeUnstructuredApp("argocd", "my-app", specB()) + assert.NotEqual(t, specHashOf(appA), specHashOf(appB), + "different spec fields must produce different hashes") +} + +func TestSpecHashOf_SameSpec(t *testing.T) { + appA := makeUnstructuredApp("argocd", "my-app", specA()) + appB := makeUnstructuredApp("other-ns", "other-name", specA()) + assert.Equal(t, specHashOf(appA), specHashOf(appB), + "identical spec fields must produce the same hash regardless of namespace/name") +} + +func TestSpecHashOf_NoSpec(t *testing.T) { + app := makeUnstructuredApp("argocd", "my-app", nil) + // Should not panic and should return a consistent (empty) value. + hash1 := specHashOf(app) + hash2 := specHashOf(app) + assert.Equal(t, hash1, hash2, "specHashOf with no spec must be deterministic") +} + +func TestSpecHashOf_NilYaml(t *testing.T) { + // Should not panic. + assert.NotPanics(t, func() { specHashOf(nil) }) +}