Skip to content
Open
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 cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ func run(cfg *Config) error {
cfg.RenderMethod,
cfg.ArgocdAuthToken,
cfg.ArgocdConfigPath,
cfg.RepoServerAddress,
)

// Ensure cleanup is performed when we exit (e.g., stopping port forwards)
Expand Down
12 changes: 12 additions & 0 deletions cmd/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ type RawOptions struct {
OutputAppManifests bool `mapstructure:"output-app-manifests"`
OutputBranchManifests bool `mapstructure:"output-branch-manifests"`
TraverseAppOfApps bool `mapstructure:"traverse-app-of-apps"`
RepoServerAddress string `mapstructure:"repo-server-address"`
}

// Config is the final, validated, ready-to-use configuration
Expand Down Expand Up @@ -176,6 +177,7 @@ type Config struct {
OutputAppManifests bool
OutputBranchManifests bool
TraverseAppOfApps bool
RepoServerAddress string

// Parsed/processed fields - no "parsed" prefix needed
FileRegex *regexp.Regexp
Expand Down Expand Up @@ -330,6 +332,7 @@ func Parse() *Config {
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")
rootCmd.Flags().String("repo-server-address", "", "Address of the Argo CD repo server (host:port). When set, --render-method=repo-server-api connects directly instead of setting up a port-forward. Useful when running inside the cluster or when the repo server is otherwise reachable from the current host.")

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

var err error
Expand Down Expand Up @@ -472,6 +476,11 @@ func (o *RawOptions) ToConfig() (*Config, error) {
return nil, fmt.Errorf("--traverse-app-of-apps requires --render-method=repo-server-api (current: %s)", cfg.RenderMethod)
}

// --repo-server-address is only applicable with the repo-server-api render method
if cfg.RepoServerAddress != "" && cfg.RenderMethod != RenderMethodRepoServerAPI {
return nil, fmt.Errorf("--repo-server-address requires --render-method=repo-server-api (current: %s)", cfg.RenderMethod)
}

// Check if argocd CLI is installed when not using API mode
if cfg.RenderMethod == RenderMethodCLI && !cfg.DryRun {
if _, err := exec.LookPath("argocd"); err != nil {
Expand Down Expand Up @@ -750,4 +759,7 @@ func (o *Config) LogConfig() {
if o.TraverseAppOfApps {
log.Info().Msgf("✨ - traverse-app-of-apps: %t", o.TraverseAppOfApps)
}
if o.RepoServerAddress != "" {
log.Info().Msgf("✨ - repo-server-address: %s", o.RepoServerAddress)
}
}
59 changes: 59 additions & 0 deletions cmd/options_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package main

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// baseRawOptions returns a RawOptions with the minimum fields set so that
// ToConfig() reaches our validation logic without failing on unrelated checks.
func baseRawOptions() *RawOptions {
return &RawOptions{
BaseBranch: "main",
TargetBranch: "feature",
Repo: "owner/repo",
RenderMethod: string(RenderMethodRepoServerAPI),
}
}

func TestToConfig_RepoServerAddress_ValidWithRepoServerAPI(t *testing.T) {
o := baseRawOptions()
o.RepoServerAddress = "argocd-repo-server.argocd.svc.cluster.local:8081"

cfg, err := o.ToConfig()
require.NoError(t, err)
assert.Equal(t, o.RepoServerAddress, cfg.RepoServerAddress)
}

func TestToConfig_RepoServerAddress_EmptyIsValid(t *testing.T) {
o := baseRawOptions()
o.RepoServerAddress = ""

cfg, err := o.ToConfig()
require.NoError(t, err)
assert.Empty(t, cfg.RepoServerAddress)
}

func TestToConfig_RepoServerAddress_RequiresRepoServerAPIMode(t *testing.T) {
tests := []struct {
name string
renderMethod string
}{
{"server-api", string(RenderMethodServerAPI)},
{"cli", string(RenderMethodCLI)},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
o := baseRawOptions()
o.RenderMethod = tt.renderMethod
o.RepoServerAddress = "argocd-repo-server.argocd.svc.cluster.local:8081"
o.DryRun = true // skip argocd CLI binary check for cli mode

_, err := o.ToConfig()
assert.ErrorContains(t, err, "--repo-server-address requires --render-method=repo-server-api")
})
}
}
1 change: 1 addition & 0 deletions docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ argocd-diff-preview [FLAGS] [OPTIONS] --repo <repo> --target-branch <target-bran
| `--output-folder <folder>`, `-o` | `OUTPUT_FOLDER` | `./output` | Output folder where the diff will be saved |
| `--redirect-target-revisions <revs>` | `REDIRECT_TARGET_REVISIONS` | - | List of target revisions to redirect |
| `--render-method <method>` | `RENDER_METHOD` | `server-api` | Manifest rendering method. Options: `cli`, `server-api`, `repo-server-api` |
| `--repo-server-address <host:port>` | `REPO_SERVER_ADDRESS` | - | Address of the Argo CD repo server. When set, `--render-method=repo-server-api` connects directly instead of setting up a port-forward. Useful when running inside the same cluster as Argo CD. Only valid with `--render-method=repo-server-api`. |
| `--secrets-folder <folder>`, `-s` | `SECRETS_FOLDER` | `./secrets` | Secrets folder where the secrets are read from |
| `--selector <selector>`, `-l` | `SELECTOR` | - | Label selector to filter on (e.g., `key1=value1,key2=value2`) |
| `--timeout <seconds>` | `TIMEOUT` | `180` | Set timeout in seconds |
Expand Down
24 changes: 24 additions & 0 deletions docs/rendering-methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,27 @@ The `repo-server-api` method is an experimental fast-path that bypasses the clus
- **How it works:** It connects directly to the Argo CD `repo-server` component via gRPC, asking it to generate the manifests synchronously.
- **Characteristics:** The fastest method available. No cluster-side Application objects are created, and no polling of the reconciliation loop is needed.
- **Lockdown mode:** Compatible with [lockdown mode](reusing-clusters/lockdown-mode.md) (namespace-scoped Argo CD).

### Skipping the port-forward with `--repo-server-address`

By default, `repo-server-api` reaches the repo server by setting up a port-forward through the Kubernetes API server. If your runner is already running inside the same cluster as Argo CD (e.g. a self-hosted CI runner), the repo server is directly reachable via Kubernetes DNS — no port-forward needed.

Use `--repo-server-address` to connect directly:

```bash
argocd-diff-preview \
--render-method repo-server-api \
--repo-server-address argocd-repo-server.argocd.svc.cluster.local:8081 \
--argocd-namespace argocd \
--create-cluster false \
...
```

This eliminates the `pods/portforward` RBAC requirement — that rule is only needed for the default port-forward path. The minimum RBAC for the runner service account becomes:

```yaml
rules:
- apiGroups: [""]
resources: ["pods", "services"]
verbs: ["get", "list"]
```
47 changes: 46 additions & 1 deletion docs/reusing-clusters/self-hosted-gh-runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,52 @@ helm install arc-runner-set arc-runners/gha-runner-scale-set \

### Step 3: Configure RBAC

The runner service account needs permissions to access Argo CD resources (in the namespace `argocd-diff-preview`). Create the following RBAC configuration:
The runner service account needs permissions to access Argo CD resources (in the namespace `argocd-diff-preview`).

#### Option A: Minimal RBAC with `--repo-server-address` (recommended)

If you use `--render-method=repo-server-api` and `--repo-server-address` to connect directly to the repo server (see [rendering methods](../rendering-methods.md#skipping-the-port-forward-with---repo-server-address)), no `pods/portforward` permission is needed:

```yaml title="arc-runner-rbac.yaml"
apiVersion: v1
kind: ServiceAccount
metadata:
name: arc-runner
namespace: arc-runners
---
kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: arc-runner-diff-preview
namespace: argocd-diff-preview
rules:
- apiGroups: [""]
resources: ["pods", "services"]
verbs: ["get", "list"]
- apiGroups: [""]
resources: ["secrets", "configmaps"]
verbs: ["get", "list"]
---
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: arc-runner-diff-preview
namespace: argocd-diff-preview
subjects:
- kind: ServiceAccount
name: arc-runner
namespace: arc-runners
roleRef:
kind: Role
name: arc-runner-diff-preview
apiGroup: rbac.authorization.k8s.io
```

Pass `--repo-server-address argocd-repo-server.argocd-diff-preview.svc.cluster.local:8081` when running the tool to skip the port-forward entirely.

#### Option B: Broad RBAC (simpler setup)

If you want a simpler setup without worrying about exact permissions:

```yaml title="arc-runner-rbac.yaml"
apiVersion: v1
Expand Down
6 changes: 5 additions & 1 deletion pkg/argocd/argocd.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,14 @@ type ArgoCDInstallation struct {
ChartRepoUsername string
ChartRepoPassword string
LoginOptions string
// RepoServerAddress, when non-empty, is used by repo-server-api mode to
// connect directly to the repo server instead of setting up a port-forward.
RepoServerAddress string
renderMode vars.RenderMethod
operations Operations // CLI or API implementation
}

func New(client *k8s.Client, namespace string, version string, repoName string, repoURL string, repoUsername string, repoPassword string, loginOptions string, renderMode vars.RenderMethod, authToken string, configPath string) *ArgoCDInstallation {
func New(client *k8s.Client, namespace string, version string, repoName string, repoURL string, repoUsername string, repoPassword string, loginOptions string, renderMode vars.RenderMethod, authToken string, configPath string, repoServerAddress string) *ArgoCDInstallation {
return &ArgoCDInstallation{
K8sClient: client,
Namespace: namespace,
Expand All @@ -57,6 +60,7 @@ func New(client *k8s.Client, namespace string, version string, repoName string,
ChartRepoUsername: repoUsername,
ChartRepoPassword: repoPassword,
LoginOptions: loginOptions,
RepoServerAddress: repoServerAddress,
renderMode: renderMode,
operations: NewOperations(renderMode, client, namespace, loginOptions, authToken),
}
Expand Down
3 changes: 2 additions & 1 deletion pkg/reposerver/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,10 @@ func NewClientWithAddress(address string, disableTLS bool, insecureSkipVerify bo

// EnsurePortForward starts a port-forward to the Argo CD repo server if one is
// not already running. It is idempotent and safe to call concurrently.
// No-op when k8sClient is nil (client created via NewClientWithAddress).
func (c *Client) EnsurePortForward() error {
if c.k8sClient == nil {
return fmt.Errorf("no k8s client configured – cannot port-forward")
return nil
}

c.portForwardMutex.Lock()
Expand Down
54 changes: 54 additions & 0 deletions pkg/reposerver/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package reposerver

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNewClientWithAddress_SetsAddress(t *testing.T) {
addr := "argocd-repo-server.argocd.svc.cluster.local:8081"
c := NewClientWithAddress(addr, false, true)

assert.Equal(t, addr, c.address)
assert.False(t, c.disableTLS)
assert.True(t, c.insecureSkipVerify)
assert.Nil(t, c.k8sClient, "NewClientWithAddress should not set a k8s client")
}

func TestNewClientWithAddress_PlaintextMode(t *testing.T) {
c := NewClientWithAddress("localhost:8081", true, false)

assert.True(t, c.disableTLS)
assert.False(t, c.insecureSkipVerify)
assert.Nil(t, c.k8sClient)
}

func TestEnsurePortForward_NilK8sClient_IsNoop(t *testing.T) {
c := NewClientWithAddress("argocd-repo-server.argocd.svc.cluster.local:8081", false, true)
require.Nil(t, c.k8sClient)

err := c.EnsurePortForward()
assert.NoError(t, err, "EnsurePortForward should be a no-op when k8sClient is nil")
}

func TestEnsurePortForward_NilK8sClient_Idempotent(t *testing.T) {
c := NewClientWithAddress("localhost:8081", true, false)

for range 3 {
assert.NoError(t, c.EnsurePortForward())
}
}

func TestCleanup_NilK8sClient_DoesNotPanic(t *testing.T) {
c := NewClientWithAddress("localhost:8081", false, true)
assert.NotPanics(t, func() { c.Cleanup() })
}

func TestNewClient_SetsLocalAddress(t *testing.T) {
// NewClient always binds to localhost:<repoServerLocalPort> for the port-forward.
c := NewClient(nil, "argocd") // k8sClient nil is fine for this field-check
assert.Contains(t, c.address, "localhost:")
assert.True(t, c.insecureSkipVerify, "should skip verify for cluster-internal self-signed cert")
}
10 changes: 7 additions & 3 deletions pkg/reposerverextract/appofapps.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,13 @@ func RenderApplicationsFromBothBranchesWithAppOfApps(
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)
var repoClient *reposerver.Client
if argocd.RepoServerAddress != "" {
log.Info().Msgf("🔌 Connecting to repo server directly at %s (no port-forward)", argocd.RepoServerAddress)
repoClient = reposerver.NewClientWithAddress(argocd.RepoServerAddress, false, true)
} else {
repoClient = reposerver.NewClient(argocd.K8sClient, argocd.Namespace)
}
defer repoClient.Cleanup()

if err := repoClient.EnsurePortForward(); err != nil {
Expand Down
13 changes: 11 additions & 2 deletions pkg/reposerverextract/extract.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,17 @@ func RenderApplicationsFromBothBranches(
}

// 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)
// When --repo-server-address is set the caller can already reach the repo
// server directly (e.g. running inside the same cluster), so we skip the
// port-forward entirely. Otherwise EnsurePortForward is idempotent and
// mutex-protected inside the client.
var repoClient *reposerver.Client
if argocd.RepoServerAddress != "" {
log.Info().Msgf("🔌 Connecting to repo server directly at %s (no port-forward)", argocd.RepoServerAddress)
repoClient = reposerver.NewClientWithAddress(argocd.RepoServerAddress, false, true)
} else {
repoClient = reposerver.NewClient(argocd.K8sClient, argocd.Namespace)
}
defer repoClient.Cleanup()

if err := repoClient.EnsurePortForward(); err != nil {
Expand Down