diff --git a/cmd/main.go b/cmd/main.go index 522af0e3..6300eb81 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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) diff --git a/cmd/options.go b/cmd/options.go index 86d20a2b..5bae910b 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -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 @@ -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 @@ -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:] { @@ -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 @@ -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 { @@ -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) + } } diff --git a/cmd/options_test.go b/cmd/options_test.go new file mode 100644 index 00000000..8b67f676 --- /dev/null +++ b/cmd/options_test.go @@ -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") + }) + } +} diff --git a/docs/options.md b/docs/options.md index adfb8a7a..7f3a80e5 100644 --- a/docs/options.md +++ b/docs/options.md @@ -64,6 +64,7 @@ argocd-diff-preview [FLAGS] [OPTIONS] --repo --target-branch `, `-o` | `OUTPUT_FOLDER` | `./output` | Output folder where the diff will be saved | | `--redirect-target-revisions ` | `REDIRECT_TARGET_REVISIONS` | - | List of target revisions to redirect | | `--render-method ` | `RENDER_METHOD` | `server-api` | Manifest rendering method. Options: `cli`, `server-api`, `repo-server-api` | +| `--repo-server-address ` | `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 `, `-s` | `SECRETS_FOLDER` | `./secrets` | Secrets folder where the secrets are read from | | `--selector `, `-l` | `SELECTOR` | - | Label selector to filter on (e.g., `key1=value1,key2=value2`) | | `--timeout ` | `TIMEOUT` | `180` | Set timeout in seconds | diff --git a/docs/rendering-methods.md b/docs/rendering-methods.md index f4dc8b31..f052097c 100644 --- a/docs/rendering-methods.md +++ b/docs/rendering-methods.md @@ -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"] +``` diff --git a/docs/reusing-clusters/self-hosted-gh-runner.md b/docs/reusing-clusters/self-hosted-gh-runner.md index e275cb52..25ec21bc 100644 --- a/docs/reusing-clusters/self-hosted-gh-runner.md +++ b/docs/reusing-clusters/self-hosted-gh-runner.md @@ -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 diff --git a/pkg/argocd/argocd.go b/pkg/argocd/argocd.go index cc88c367..967e91c9 100644 --- a/pkg/argocd/argocd.go +++ b/pkg/argocd/argocd.go @@ -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, @@ -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), } diff --git a/pkg/reposerver/client.go b/pkg/reposerver/client.go index d1692581..7ecc48cb 100644 --- a/pkg/reposerver/client.go +++ b/pkg/reposerver/client.go @@ -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() diff --git a/pkg/reposerver/client_test.go b/pkg/reposerver/client_test.go new file mode 100644 index 00000000..20b8b6e7 --- /dev/null +++ b/pkg/reposerver/client_test.go @@ -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: 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") +} diff --git a/pkg/reposerverextract/appofapps.go b/pkg/reposerverextract/appofapps.go index d73a9ebe..efd37059 100644 --- a/pkg/reposerverextract/appofapps.go +++ b/pkg/reposerverextract/appofapps.go @@ -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 { diff --git a/pkg/reposerverextract/extract.go b/pkg/reposerverextract/extract.go index b484b43f..612efc84 100644 --- a/pkg/reposerverextract/extract.go +++ b/pkg/reposerverextract/extract.go @@ -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 {