Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ spec:
✨ - base-branch: integration-test/branch-3/base
✨ - target-branch: integration-test/branch-3/target
✨ - secrets-folder: ./secrets
✨ - preinstall-folder: ./preinstall
✨ - output-folder: ./output
✨ - argocd-namespace: argocd
✨ - repo: dag-andersen/argocd-diff-preview
Expand Down
2 changes: 1 addition & 1 deletion cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ func run(cfg *Config) error {
var argocdInstallationDuration time.Duration
if cfg.CreateCluster {
// Install Argo CD
duration, err := argocd.Install(cfg.Debug, cfg.SecretsFolder)
duration, err := argocd.Install(cfg.Debug, cfg.PreinstallFolder, cfg.SecretsFolder)
if err != nil {
log.Error().Msgf("❌ Failed to install Argo CD")
return err
Expand Down
7 changes: 7 additions & 0 deletions cmd/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ var (
DefaultBaseBranch = "main"
DefaultOutputFolder = "./output"
DefaultSecretsFolder = "./secrets"
DefaultPreinstallFolder = "./preinstall"
DefaultCluster = "auto"
DefaultClusterName = "argocd-diff-preview"
DefaultKindOptions = ""
Expand Down Expand Up @@ -103,6 +104,7 @@ type RawOptions struct {
RepoRegex string `mapstructure:"repo-regex"`
OutputFolder string `mapstructure:"output-folder"`
SecretsFolder string `mapstructure:"secrets-folder"`
PreinstallFolder string `mapstructure:"preinstall-folder"`
CreateCluster bool `mapstructure:"create-cluster"`
ClusterType string `mapstructure:"cluster"`
ClusterName string `mapstructure:"cluster-name"`
Expand Down Expand Up @@ -153,6 +155,7 @@ type Config struct {
RepoSelector repository.Selector
OutputFolder string
SecretsFolder string
PreinstallFolder string
CreateCluster bool
ClusterName string
KindOptions string
Expand Down Expand Up @@ -252,6 +255,7 @@ func Parse() *Config {
viper.SetDefault("base-branch", DefaultBaseBranch)
viper.SetDefault("output-folder", DefaultOutputFolder)
viper.SetDefault("secrets-folder", DefaultSecretsFolder)
viper.SetDefault("preinstall-folder", DefaultPreinstallFolder)
viper.SetDefault("create-cluster", DefaultCreateCluster)
viper.SetDefault("watch-if-no-watch-pattern-found", DefaultWatchIfNoWatchPatternFound)
viper.SetDefault("ignore-invalid-watch-pattern", DefaultIgnoreInvalidWatchPattern)
Expand Down Expand Up @@ -312,6 +316,7 @@ func Parse() *Config {
// Folders
rootCmd.Flags().StringP("output-folder", "o", DefaultOutputFolder, "Output folder where the diff will be saved")
rootCmd.Flags().StringP("secrets-folder", "s", DefaultSecretsFolder, "Secrets folder where the secrets are read from")
rootCmd.Flags().String("preinstall-folder", DefaultPreinstallFolder, "Folder containing Kubernetes manifests to apply before Argo CD is installed")

// Cluster related
rootCmd.Flags().Bool("create-cluster", DefaultCreateCluster, "Create a new cluster if it doesn't exist")
Expand Down Expand Up @@ -397,6 +402,7 @@ func (o *RawOptions) ToConfig() (*Config, error) {
TargetBranch: o.TargetBranch,
OutputFolder: o.OutputFolder,
SecretsFolder: o.SecretsFolder,
PreinstallFolder: o.PreinstallFolder,
CreateCluster: o.CreateCluster,
ClusterName: o.ClusterName,
KindOptions: o.KindOptions,
Expand Down Expand Up @@ -702,6 +708,7 @@ func (o *Config) LogConfig() {
log.Info().Msgf("✨ - base-branch: %s", o.BaseBranch)
log.Info().Msgf("✨ - target-branch: %s", o.TargetBranch)
log.Info().Msgf("✨ - secrets-folder: %s", o.SecretsFolder)
log.Info().Msgf("✨ - preinstall-folder: %s", o.PreinstallFolder)
log.Info().Msgf("✨ - output-folder: %s", o.OutputFolder)
log.Info().Msgf("✨ - argocd-namespace: %s", o.ArgocdNamespace)
if o.ArgocdConfigPath != DefaultArgocdConfigPath {
Expand Down
78 changes: 78 additions & 0 deletions docs/getting-started/pre-installing-crds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Pre-installing CRDs

Some applications need certain Custom Resource Definitions (CRDs) to be installed before a proper render can happen. The ephemeral cluster created by `argocd-diff-preview` only contains the Kubernetes APIs provided by the cluster itself and the resources installed with Argo CD. It does not automatically contain the CRDs from your destination cluster.

Use the preinstall folder to apply CRDs and other cluster prerequisites before Argo CD is installed and Applications are rendered.

## Prepare the preinstall folder

Create a directory and place the required CRD manifests in it:

```bash
mkdir -p preinstall
cp external-secrets-crd.yaml preinstall/
```

Every file directly inside the directory is applied in filename order. Files may contain multiple YAML documents. Subdirectories are not traversed.

If resources depend on one another, use filename prefixes to control their order:

```text
preinstall/
├── 00-external-secrets-crd.yaml
├── 10-cert-manager-crds.yaml
└── 20-other-prerequisites.yaml
```

## Docker

Mount the directory at `/preinstall`:

```bash
docker run \
--network=host \
-v /var/run/docker.sock:/var/run/docker.sock \
-v $(pwd)/main:/base-branch \
-v $(pwd)/pull-request:/target-branch \
-v $(pwd)/preinstall:/preinstall \
-v $(pwd)/output:/output \
-e TARGET_BRANCH=refs/pull/123/merge \
-e REPO=example/repository \
dagandersen/argocd-diff-preview:latest
```

The default preinstall path inside the container is `/preinstall`, so no additional option is required.

## Standalone binary

The standalone binary reads manifests from `./preinstall` by default:

```bash
argocd-diff-preview \
--repo example/repository \
--base-branch main \
--target-branch feature/my-change
```

Use `--preinstall-folder` to select a different directory:

```bash
argocd-diff-preview \
--repo example/repository \
--base-branch main \
--target-branch feature/my-change \
--preinstall-folder ./cluster-prerequisites
```

The equivalent environment variable is `PREINSTALL_FOLDER`.

## Why install CRDs?

Installing the CRDs makes their APIs available to rendering and Kubernetes discovery in the ephemeral cluster. This is useful when:

- A Helm chart checks `.Capabilities.APIVersions` before rendering custom resources.
- Custom resources use the same name in different namespaces.
- The tool needs to determine whether a custom resource is namespaced or cluster-scoped.
- Other resources must exist before Argo CD or an Application can be rendered correctly.

Without an installed CRD, an unknown custom resource is treated as namespaced. This safe default prevents namespaces from being removed and same-named resources from being silently combined. Pre-installing the CRD still provides the most accurate result because the tool can discover its actual scope.
35 changes: 27 additions & 8 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ For each Application or ApplicationSet found, it applies the following modificat

---

!!! note "Steps 4-6: Ephemeral Cluster Mode Only"
Steps 4, 5, and 6 only apply when using the default ephemeral cluster mode. If you're connecting to a pre-installed Argo CD instance with `--create-cluster=false`, these steps are skipped and the tool connects directly to your pre-installed Argo CD.
!!! note "Steps 4-7: Ephemeral Cluster Mode Only"
Steps 4, 5, 6, and 7 only apply when using the default ephemeral cluster mode. If you're connecting to a pre-installed Argo CD instance with `--create-cluster=false`, these steps are skipped and the tool connects directly to your pre-installed Argo CD.

---

Expand Down Expand Up @@ -162,7 +162,26 @@ kubectl apply -f /secrets

---

## Step 7: Generate Applications from ApplicationSets
## Step 7: Apply preinstall manifests

Some applications need certain Custom Resource Definitions (CRDs) to be installed before a proper render can happen. The ephemeral cluster does not automatically contain the CRDs from your destination cluster.

Kubernetes manifests in the folder configured by `--preinstall-folder` are applied before Argo CD is installed and before Applications are rendered. The default is `./preinstall` for the standalone binary and `/preinstall` in the container.

Installing the required CRDs makes their APIs available to rendering and lets the tool discover whether each custom resource is namespaced or cluster-scoped. The folder can also contain other cluster prerequisites needed during rendering.

See [Pre-installing CRDs](./getting-started/pre-installing-crds.md) for setup instructions, Docker and binary examples, and details about file ordering.

`argocd-diff-preview` practically performs:

```bash
kubectl apply -f /preinstall
```


---

## Step 8: Generate Applications from ApplicationSets

For each ApplicationSet found, it generates the applications using the Argo CD CLI (or API depending on the `--render-method`):

Expand All @@ -176,7 +195,7 @@ The newly generated applications also go through `Step 2` and `Step 3` (filterin

---

## Step 8: Apply applications to the cluster
## Step 9: Apply applications to the cluster

The patched applications are applied to the cluster.

Expand All @@ -190,7 +209,7 @@ At this point, Argo CD starts processing each application - rendering the applic

---

## Step 9: Wait for Argo CD to render
## Step 10: Wait for Argo CD to render

The tool will repeatedly check the status of each application and extract the rendered manifests as they become ready.

Expand All @@ -204,7 +223,7 @@ It practically just waits for the Application to look like this:

---

## Step 10: Extract rendered manifests
## Step 11: Extract rendered manifests

Once applications are ready, the tool extracts the rendered manifests using the Argo CD CLI (or API depending on the `--render-method`):

Expand All @@ -216,7 +235,7 @@ This returns the exact YAML generated by the applications - fully rendered with

---

## Step 11: Generate the diff
## Step 12: Generate the diff

<p align="center">
<img src="../assets/example-4.png" width="600">
Expand All @@ -233,7 +252,7 @@ The diff respects the `--diff-ignore` option to filter out noisy changes. Docume

---

## Step 12: Output the results
## Step 13: Output the results

The tool writes several files to the output folder (`./output/` by default):

Expand Down
1 change: 1 addition & 0 deletions docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ argocd-diff-preview [FLAGS] [OPTIONS] (--repo <repo> | --repo-regex <regex>) --t
| `--log-format <format>` | `LOG_FORMAT` | `human` | Log format. Options: `human`, `json` |
| `--max-diff-length <length>` | `MAX_DIFF_LENGTH` | `65536` | Max diff message character count (only limits the generated Markdown file) |
| `--output-folder <folder>`, `-o` | `OUTPUT_FOLDER` | `./output` | Output folder where the diff will be saved |
| `--preinstall-folder <folder>` | `PREINSTALL_FOLDER` | `./preinstall` | Folder containing Kubernetes manifests to apply before Argo CD is installed |
| `--redirect-target-revisions <revs>` | `REDIRECT_TARGET_REVISIONS` | - | Comma-separated source targetRevision values to redirect to the target branch. Example: main,HEAD. By default, every targetRevision in matching repositories is redirected |
| `--render-method <method>` | `RENDER_METHOD` | `server-api` | Manifest rendering method. Options: `cli`, `server-api`, `repo-server-api` |
| `--repo-regex <regex>` | `REPO_REGEX` | - | Advanced repository matcher for templated Argo CD repoURL values. Mutually exclusive with `--repo` |
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ nav:
- getting-started/gitlab-cicd.md
- Local Installation: getting-started/installation.md
- getting-started/custom-argo-cd-installation.md
- Pre-installing CRDs: getting-started/pre-installing-crds.md
- Self-hosted Github Runners: getting-started/self-hosted-gh-runner.md
- Reusing clusters:
- Connect to cluster with Argo CD pre-installed: reusing-clusters/connecting.md
Expand Down
8 changes: 7 additions & 1 deletion pkg/argocd/argocd.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func (a *ArgoCDInstallation) RenderMethod() vars.RenderMethod {
return a.renderMode
}

func (a *ArgoCDInstallation) Install(debug bool, secretsFolder string) (time.Duration, error) {
func (a *ArgoCDInstallation) Install(debug bool, preinstallFolder string, secretsFolder string) (time.Duration, error) {
startTime := time.Now()
log.Debug().Msgf("Creating namespace: %s", a.Namespace)

Expand All @@ -97,6 +97,12 @@ func (a *ArgoCDInstallation) Install(debug bool, secretsFolder string) (time.Dur
log.Debug().Msgf("Namespace already exists: %s", a.Namespace)
}

// Apply cluster prerequisites before installing Argo CD. This allows users
// to install CRDs and other resources needed during application rendering.
if err := ApplyPreinstallFromFolder(a.K8sClient, preinstallFolder, a.Namespace); err != nil {
return time.Since(startTime), fmt.Errorf("failed to apply manifests from preinstall folder %s: %w", preinstallFolder, err)
}

// Apply secrets before installing ArgoCD
if err := ApplySecretsFromFolder(a.K8sClient, secretsFolder, a.Namespace); err != nil {
return time.Since(startTime), fmt.Errorf("failed to apply secrets from folder: %s: %w", secretsFolder, err)
Expand Down
66 changes: 47 additions & 19 deletions pkg/argocd/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,39 +9,67 @@ import (
"github.com/rs/zerolog/log"
)

// ApplySecretsFromFolder applies all secret manifests from a folder using the Kubernetes API
// ApplyPreinstallFromFolder applies Kubernetes manifests needed by the ephemeral
// cluster before Argo CD is installed and applications are rendered.
func ApplyPreinstallFromFolder(client *k8s.Client, preinstallFolder string, namespace string) error {
count, found, err := applyManifestsFromFolder(preinstallFolder, "preinstall", func(path string) (int, error) {
return client.ApplyManifestFromFile(path, namespace)
})
if err != nil {
return err
}
if count > 0 {
log.Info().Msgf("📦 Applied %d preinstall manifests", count)
} else if found {
log.Info().Msgf("🤷 No preinstall manifests found in %s", preinstallFolder)
}
return nil
}

// ApplySecretsFromFolder applies all secret manifests from a folder using the Kubernetes API.
func ApplySecretsFromFolder(client *k8s.Client, secretsFolder string, namespace string) error {
// Check if folder exists
if _, err := os.Stat(secretsFolder); os.IsNotExist(err) {
count, found, err := applyManifestsFromFolder(secretsFolder, "secret", func(path string) (int, error) {
return client.ApplyManifestFromFile(path, namespace)
})
if err != nil {
return err
}
if !found {
log.Info().Msgf("🤷 No secrets folder found at %s", secretsFolder)
return nil
} else if count > 0 {
log.Info().Msgf("🤫 Applied %d secrets", count)
} else {
log.Info().Msgf("🤷 No secrets found in %s", secretsFolder)
}
return nil
}

// returns the number of manifests applied, whether the folder was found, and an error if any.
func applyManifestsFromFolder(folder string, manifestType string, apply func(path string) (int, error)) (int, bool, error) {
if _, err := os.Stat(folder); err != nil {
if os.IsNotExist(err) {
return 0, false, nil
}
return 0, false, fmt.Errorf("failed to access %s folder: %w", manifestType, err)
}

// Apply all files in the secrets folder
files, err := os.ReadDir(secretsFolder)
files, err := os.ReadDir(folder)
if err != nil {
return fmt.Errorf("failed to read secrets folder: %w", err)
return 0, true, fmt.Errorf("failed to read %s folder: %w", manifestType, err)
}

secretCount := 0
manifestCount := 0
for _, file := range files {
if file.IsDir() {
continue
}

// Use the existing ApplyManifestFromFile method to apply each secret
count, err := client.ApplyManifestFromFile(filepath.Join(secretsFolder, file.Name()), namespace)
count, err := apply(filepath.Join(folder, file.Name()))
if err != nil {
return fmt.Errorf("failed to apply secret %s: %w", file.Name(), err)
return manifestCount, true, fmt.Errorf("failed to apply %s %s: %w", manifestType, file.Name(), err)
}
secretCount += count
manifestCount += count
}

if secretCount > 0 {
log.Info().Msgf("🤫 Applied %d secrets", secretCount)
} else {
log.Info().Msgf("🤷 No secrets found in %s", secretsFolder)
}

return nil
return manifestCount, true, nil
}
Loading
Loading