From b860c55ac92040305b91db5e68e6707398fdf0d6 Mon Sep 17 00:00:00 2001 From: Matthiator Date: Tue, 9 Jun 2026 19:32:01 +0200 Subject: [PATCH 1/7] feat: add Argo CD Git auth modes --- .../1_getting_started/bootstrapping.md | 31 +++ .../add_spoke_cluster.md | 3 +- .../argocd/add_app_repository.md | 11 +- src/cmd/generate_test.go | 6 +- .../helm/example/argo-cd/values.yaml.tplt | 12 +- src/internal/cmd/bootstrap/secrets.go | 48 +++-- src/internal/cmd/bootstrap/secrets_test.go | 66 +++++++ src/internal/config/defaults_test.go | 9 +- src/internal/config/factory.go | 8 +- src/internal/config/factory_test.go | 22 ++- src/internal/config/store.go | 66 ++++++- src/internal/config/store_test.go | 114 ++++++++++- src/internal/config/types.go | 9 +- src/internal/envconfig/env.go | 179 +++++++++++++++--- src/internal/envconfig/env_test.go | 99 ++++++++++ src/internal/envconfig/store_test.go | 9 + src/internal/render/render_test.go | 4 +- src/internal/workflow/orchestrator.go | 6 +- src/internal/workflow/orchestrator_test.go | 52 ++++- 19 files changed, 675 insertions(+), 79 deletions(-) diff --git a/docs/content/1_getting_started/bootstrapping.md b/docs/content/1_getting_started/bootstrapping.md index 2e07c49d..1e9e96b2 100644 --- a/docs/content/1_getting_started/bootstrapping.md +++ b/docs/content/1_getting_started/bootstrapping.md @@ -52,6 +52,34 @@ The easiest way is to run `kubara` inside the repository (but do not add the bin Keep in mind that weak passwords such as `123456` for `ARGOCD_WIZARD_ACCOUNT_PASSWORD` are a bad idea, since your platform will be publicly available by default via your DNS zone. +#### Git repository authentication + +kubara creates the initial Argo CD repository secret during `kubara bootstrap`. +`ARGOCD_GIT_AUTH_MODE` controls which credential fields are written: + +| Mode | Required values | Notes | +| --- | --- | --- | +| `https` | `ARGOCD_GIT_USERNAME`, `ARGOCD_GIT_PAT_OR_PASSWORD`, and `ARGOCD_GIT_URL` or legacy `ARGOCD_GIT_HTTPS_URL` | Backward-compatible default. `PAT` usually means Personal Access Token and is often tied to a user account. Prefer a technical or machine account and use that account name for `ARGOCD_GIT_USERNAME`; exact username behavior is provider-dependent. | +| `ssh` | `ARGOCD_GIT_URL`, `ARGOCD_GIT_SSH_PRIVATE_KEY` | Use an SSH repository URL such as `git@github.com:org/repo.git`. Argo CD must know the SSH host key before it can connect securely. | +| `github-app` | `ARGOCD_GIT_URL`, `ARGOCD_GIT_GITHUB_APP_ID`, `ARGOCD_GIT_GITHUB_APP_INSTALLATION_ID`, `ARGOCD_GIT_GITHUB_APP_PRIVATE_KEY` | Use GitHub App authentication for organization-owned automation. For GitHub Enterprise, set `ARGOCD_GIT_GITHUB_APP_ENTERPRISE_BASE_URL` as well. | + +For new setups, prefer `ARGOCD_GIT_URL`. +`ARGOCD_GIT_HTTPS_URL` is still supported for existing HTTPS/PAT setups. + +For SSH, keep strict host verification enabled. +The bundled Argo CD Helm chart already includes known hosts for common public providers. +For private Git hosts, add trusted host keys to the generated Argo CD values before bootstrapping, for example in `customer-service-catalog/helm//argo-cd/additional-values.yaml`: + +```yaml +argo-cd: + configs: + ssh: + extraHosts: | + git.example.com ssh-ed25519 +``` + +If the required host key is missing, Argo CD will reject the SSH connection as an unknown SSH host. + ### 1.3 Generate Base Configuration @@ -64,6 +92,9 @@ kubara init This command creates a `config.yaml` file based on the values from your `.env`. If you make changes to `.env` later, you can re-run the command with `--overwrite` to update the configuration. +The generated Argo CD repository config records the selected Git auth mode in `argocd.repo.authMode`. +Repository URLs are stored under `argocd.repo.git`. +Existing configs that still use the old `argocd.repo.https` key are migrated to `argocd.repo.git` when kubara loads and saves the config. When using `--overwrite`, only values from `.env` are replaced. Additional settings in your existing `config.yaml` are preserved and merged. diff --git a/docs/content/2_managing_your_platform/add_spoke_cluster.md b/docs/content/2_managing_your_platform/add_spoke_cluster.md index c0702b2a..64c8ee4e 100644 --- a/docs/content/2_managing_your_platform/add_spoke_cluster.md +++ b/docs/content/2_managing_your_platform/add_spoke_cluster.md @@ -31,7 +31,8 @@ clusters: email: platform@example.com argocd: repo: - https: + authMode: https + git: customer: url: https://git.example.com/platform/repo.git targetRevision: main diff --git a/docs/content/2_managing_your_platform/argocd/add_app_repository.md b/docs/content/2_managing_your_platform/argocd/add_app_repository.md index cbb451f8..942f945f 100644 --- a/docs/content/2_managing_your_platform/argocd/add_app_repository.md +++ b/docs/content/2_managing_your_platform/argocd/add_app_repository.md @@ -7,11 +7,18 @@ For more information check: https://argo-cd.readthedocs.io/en/stable/user-guide/private-repositories/ ## **Add credentials to vault** -Add the repository credentials to your vault. This can be a `password` or a `PAT`. +Add the repository credentials to your vault. +The example below uses HTTPS username + password/PAT authentication. +For most Git providers, `PAT` means Personal Access Token and is often tied to a user account. +For platform automation, prefer a technical or machine account instead of a personal user account. +Set `username` to the account name expected by your Git provider; the exact value is provider-dependent. +The helper shown on this page currently covers HTTPS username + password/PAT repositories. +For SSH deploy keys or GitHub App authentication on additional app repositories, create the Argo CD repository Secret manually according to the Argo CD documentation until the helper supports those modes as well. + ```json { "repo_pat": { - "pat": "" + "pat": "" } } ``` diff --git a/src/cmd/generate_test.go b/src/cmd/generate_test.go index 7675b605..9ae903fb 100644 --- a/src/cmd/generate_test.go +++ b/src/cmd/generate_test.go @@ -233,7 +233,7 @@ func TestGenerateCmd(t *testing.T) { }, ArgoCD: config.ArgoCD{ Repo: config.RepoProto{ - HTTPS: &config.RepoType{ + Git: &config.RepoType{ Customer: config.Repository{ URL: "https://github.com/example/customer", TargetRevision: "main", @@ -318,7 +318,7 @@ func TestGenerateCmd_MissingProviderUsesDefault(t *testing.T) { }, ArgoCD: config.ArgoCD{ Repo: config.RepoProto{ - HTTPS: &config.RepoType{ + Git: &config.RepoType{ Customer: config.Repository{URL: "https://github.com/example/customer", TargetRevision: "main"}, Managed: config.Repository{URL: "https://github.com/example/managed", TargetRevision: "main"}, }, @@ -376,7 +376,7 @@ func TestGenerateCmd_PlaceholderProviderFailsWithHint(t *testing.T) { }, ArgoCD: config.ArgoCD{ Repo: config.RepoProto{ - HTTPS: &config.RepoType{ + Git: &config.RepoType{ Customer: config.Repository{URL: "https://github.com/example/customer", TargetRevision: "main"}, Managed: config.Repository{URL: "https://github.com/example/managed", TargetRevision: "main"}, }, diff --git a/src/internal/catalog/built-in/customer-service-catalog/helm/example/argo-cd/values.yaml.tplt b/src/internal/catalog/built-in/customer-service-catalog/helm/example/argo-cd/values.yaml.tplt index e9bbccad..8204e51e 100644 --- a/src/internal/catalog/built-in/customer-service-catalog/helm/example/argo-cd/values.yaml.tplt +++ b/src/internal/catalog/built-in/customer-service-catalog/helm/example/argo-cd/values.yaml.tplt @@ -31,13 +31,13 @@ bootstrapValues: {{ .cluster.name }}-{{ .cluster.stage }}: projectName: "{{ .cluster.name }}-{{ .cluster.stage }}" managedServices: - repoURL: "{{ .cluster.argocd.repo.https.managed.url }}" - path: "{{ .cluster.argocd.repo.https.managed.path | default "managed-service-catalog/helm" }}" - targetRevision: "{{ .cluster.argocd.repo.https.managed.targetRevision }}" + repoURL: "{{ .cluster.argocd.repo.git.managed.url }}" + path: "{{ .cluster.argocd.repo.git.managed.path | default "managed-service-catalog/helm" }}" + targetRevision: "{{ .cluster.argocd.repo.git.managed.targetRevision }}" customerServices: - repoURL: "{{ .cluster.argocd.repo.https.customer.url }}" - path: "{{ .cluster.argocd.repo.https.customer.path | default "customer-service-catalog/helm" }}" - targetRevision: "{{ .cluster.argocd.repo.https.customer.targetRevision }}" + repoURL: "{{ .cluster.argocd.repo.git.customer.url }}" + path: "{{ .cluster.argocd.repo.git.customer.path | default "customer-service-catalog/helm" }}" + targetRevision: "{{ .cluster.argocd.repo.git.customer.targetRevision }}" apps: {{- range $serviceName := keys .cluster.services | sortAlpha }} {{ $serviceName }}: diff --git a/src/internal/cmd/bootstrap/secrets.go b/src/internal/cmd/bootstrap/secrets.go index 8d9f8fea..b6cd1062 100644 --- a/src/internal/cmd/bootstrap/secrets.go +++ b/src/internal/cmd/bootstrap/secrets.go @@ -88,13 +88,46 @@ func (sm *SecretManager) CreateHubSecrets(ctx context.Context, o *Options) error // createGitRepositorySecret creates the ArgoCD git repository secret func (sm *SecretManager) createGitRepositorySecret(em *envconfig.EnvMap) *corev1.Secret { + secretName := "https-init-repo-access" + if em.GitAuthMode() == envconfig.GitAuthModeSSH { + secretName = "ssh-init-repo-access" + } + if em.GitAuthMode() == envconfig.GitAuthModeGitHubApp { + secretName = "github-app-init-repo-access" + } + + stringData := map[string]string{ + "enableLfs": "true", + "insecure": "false", + "name": secretName, + "project": fmt.Sprintf("%s-%s", em.ProjectName, em.ProjectStage), + "type": "git", + "url": em.GitRepositoryURL(), + } + + switch em.GitAuthMode() { + case envconfig.GitAuthModeSSH: + stringData["sshPrivateKey"] = em.ArgocdGitSshPrivateKey + case envconfig.GitAuthModeGitHubApp: + stringData["githubAppID"] = em.ArgocdGitGithubAppID + stringData["githubAppInstallationID"] = em.ArgocdGitGithubAppInstallationID + stringData["githubAppPrivateKey"] = em.ArgocdGitGithubAppPrivateKey + if envconfig.IsConfiguredEnvValue(em.ArgocdGitGithubAppEnterpriseBaseUrl) { + stringData["githubAppEnterpriseBaseUrl"] = em.ArgocdGitGithubAppEnterpriseBaseUrl + } + default: + stringData["forceHttpBasicAuth"] = "true" + stringData["password"] = em.ArgocdGitPatOrPassword + stringData["username"] = em.ArgocdGitUsername + } + return &corev1.Secret{ TypeMeta: metav1.TypeMeta{ APIVersion: "v1", Kind: "Secret", }, ObjectMeta: metav1.ObjectMeta{ - Name: "https-init-repo-access", + Name: secretName, Namespace: argocdNamespace, Labels: map[string]string{ "argocd.argoproj.io/secret-type": "repository", @@ -103,17 +136,8 @@ func (sm *SecretManager) createGitRepositorySecret(em *envconfig.EnvMap) *corev1 "managed-by": "argocd.argoproj.io", }, }, - Type: corev1.SecretTypeOpaque, - StringData: map[string]string{ - "enableLfs": "true", - "forceHttpBasicAuth": "true", - "insecure": "false", - "password": em.ArgocdGitPatOrPassword, - "username": em.ArgocdGitUsername, - "name": "https-init-repo-access", - "url": em.ArgocdGitHttpsUrl, - "project": fmt.Sprintf("%s-%s", em.ProjectName, em.ProjectStage), - }, + Type: corev1.SecretTypeOpaque, + StringData: stringData, } } diff --git a/src/internal/cmd/bootstrap/secrets_test.go b/src/internal/cmd/bootstrap/secrets_test.go index 785b8e1c..a804a404 100644 --- a/src/internal/cmd/bootstrap/secrets_test.go +++ b/src/internal/cmd/bootstrap/secrets_test.go @@ -9,6 +9,72 @@ import ( "github.com/stretchr/testify/require" ) +func TestCreateGitRepositorySecret(t *testing.T) { + sm := &SecretManager{} + + t.Run("creates legacy HTTPS repository secret", func(t *testing.T) { + secret := sm.createGitRepositorySecret(&envconfig.EnvMap{ + ProjectName: "test", + ProjectStage: "dev", + ArgocdGitHttpsUrl: "https://github.com/example/repo.git", + ArgocdGitPatOrPassword: "token", + ArgocdGitUsername: "machine-user", + }) + + require.NotNil(t, secret) + assert.Equal(t, "https-init-repo-access", secret.Name) + assert.Equal(t, "https://github.com/example/repo.git", secret.StringData["url"]) + assert.Equal(t, "machine-user", secret.StringData["username"]) + assert.Equal(t, "token", secret.StringData["password"]) + assert.Equal(t, "true", secret.StringData["forceHttpBasicAuth"]) + assert.Equal(t, "git", secret.StringData["type"]) + _, hasSSHKey := secret.StringData["sshPrivateKey"] + assert.False(t, hasSSHKey) + }) + + t.Run("creates SSH repository secret", func(t *testing.T) { + secret := sm.createGitRepositorySecret(&envconfig.EnvMap{ + ProjectName: "test", + ProjectStage: "dev", + ArgocdGitAuthMode: envconfig.GitAuthModeSSH, + ArgocdGitUrl: "git@github.com:example/repo.git", + ArgocdGitSshPrivateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----", + }) + + require.NotNil(t, secret) + assert.Equal(t, "ssh-init-repo-access", secret.Name) + assert.Equal(t, "git@github.com:example/repo.git", secret.StringData["url"]) + assert.Equal(t, "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----", secret.StringData["sshPrivateKey"]) + _, hasUsername := secret.StringData["username"] + assert.False(t, hasUsername) + _, hasPassword := secret.StringData["password"] + assert.False(t, hasPassword) + }) + + t.Run("creates GitHub App repository secret", func(t *testing.T) { + secret := sm.createGitRepositorySecret(&envconfig.EnvMap{ + ProjectName: "test", + ProjectStage: "dev", + ArgocdGitAuthMode: envconfig.GitAuthModeGitHubApp, + ArgocdGitUrl: "https://github.com/example/repo.git", + ArgocdGitGithubAppID: "123", + ArgocdGitGithubAppInstallationID: "456", + ArgocdGitGithubAppPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nkey\n-----END RSA PRIVATE KEY-----", + ArgocdGitGithubAppEnterpriseBaseUrl: "https://github.example.com/api/v3", + }) + + require.NotNil(t, secret) + assert.Equal(t, "github-app-init-repo-access", secret.Name) + assert.Equal(t, "https://github.com/example/repo.git", secret.StringData["url"]) + assert.Equal(t, "123", secret.StringData["githubAppID"]) + assert.Equal(t, "456", secret.StringData["githubAppInstallationID"]) + assert.Equal(t, "-----BEGIN RSA PRIVATE KEY-----\nkey\n-----END RSA PRIVATE KEY-----", secret.StringData["githubAppPrivateKey"]) + assert.Equal(t, "https://github.example.com/api/v3", secret.StringData["githubAppEnterpriseBaseUrl"]) + _, hasForceHTTPBasicAuth := secret.StringData["forceHttpBasicAuth"] + assert.False(t, hasForceHTTPBasicAuth) + }) +} + func TestCreateHelmRepositorySecret(t *testing.T) { sm := &SecretManager{} diff --git a/src/internal/config/defaults_test.go b/src/internal/config/defaults_test.go index 598e72f3..9f097bac 100644 --- a/src/internal/config/defaults_test.go +++ b/src/internal/config/defaults_test.go @@ -89,7 +89,7 @@ func TestApplyDefaults_RepositoryTargetRevision(t *testing.T) { { ArgoCD: ArgoCD{ Repo: RepoProto{ - HTTPS: &RepoType{ + Git: &RepoType{ Customer: Repository{URL: "https://github.com/customer/repo.git"}, Managed: Repository{URL: "https://github.com/managed/repo.git", TargetRevision: "release"}, }, @@ -101,9 +101,10 @@ func TestApplyDefaults_RepositoryTargetRevision(t *testing.T) { applyDefaults(cfg) - https := cfg.Clusters[0].ArgoCD.Repo.HTTPS - assert.Equal(t, "main", https.Customer.TargetRevision, "empty TargetRevision should default to main") - assert.Equal(t, "release", https.Managed.TargetRevision, "explicit TargetRevision should not be overwritten") + gitRepo := cfg.Clusters[0].ArgoCD.Repo.Git + assert.Equal(t, "https", cfg.Clusters[0].ArgoCD.Repo.AuthMode, "empty repo AuthMode should default to https") + assert.Equal(t, "main", gitRepo.Customer.TargetRevision, "empty TargetRevision should default to main") + assert.Equal(t, "release", gitRepo.Managed.TargetRevision, "explicit TargetRevision should not be overwritten") } func TestApplyDefaults_MultipleSliceElements(t *testing.T) { diff --git a/src/internal/config/factory.go b/src/internal/config/factory.go index a3d064dd..917ebb44 100644 --- a/src/internal/config/factory.go +++ b/src/internal/config/factory.go @@ -10,6 +10,7 @@ import ( func NewClusterFromEnvWithCatalog(e *envconfig.EnvMap, catalogOptions catalog.LoadOptions) (Cluster, error) { dnsName := e.ProjectName + "-" + e.ProjectStage + "." + e.DomainName + gitRepoURL := e.GitRepositoryURL() services, err := createServicesFromCatalogWithOptions(catalogOptions, "") if err != nil { return Cluster{}, fmt.Errorf("create services from catalog: %w", err) @@ -17,13 +18,14 @@ func NewClusterFromEnvWithCatalog(e *envconfig.EnvMap, catalogOptions catalog.Lo argoCD := ArgoCD{ Repo: RepoProto{ - HTTPS: &RepoType{ + AuthMode: e.GitAuthMode(), + Git: &RepoType{ Customer: Repository{ - URL: e.ArgocdGitHttpsUrl, + URL: gitRepoURL, TargetRevision: "main", }, Managed: Repository{ - URL: e.ArgocdGitHttpsUrl, + URL: gitRepoURL, TargetRevision: "main", }, }, diff --git a/src/internal/config/factory_test.go b/src/internal/config/factory_test.go index 5aee62c6..471e4c67 100644 --- a/src/internal/config/factory_test.go +++ b/src/internal/config/factory_test.go @@ -34,6 +34,13 @@ func TestNewClusterFromEnv(t *testing.T) { ArgocdGitHttpsUrl: "https://github.com/org/repo.git", ArgocdHelmRepoUrl: "oci://registry-1.docker.io/bitnamicharts", } + sampleEnvMapWithGenericGitURL := &envconfig.EnvMap{ + ProjectName: "kubara-test", + ProjectStage: "dev", + DomainName: "example.com", + ArgocdGitUrl: "git@github.com:org/repo.git", + ArgocdGitHttpsUrl: "https://github.com/org/repo.git", + } // 2. Manually construct the expected Cluster struct based on the sampleEnvMap. // This is what we expect the function to return. @@ -58,7 +65,8 @@ func TestNewClusterFromEnv(t *testing.T) { }, ArgoCD: ArgoCD{ Repo: RepoProto{ - HTTPS: &RepoType{ + AuthMode: envconfig.GitAuthModeHTTPS, + Git: &RepoType{ Customer: Repository{ URL: "https://github.com/org/repo.git", TargetRevision: "main", @@ -107,6 +115,11 @@ func TestNewClusterFromEnv(t *testing.T) { expectedClusterWithOCIHelmRepo.ArgoCD.HelmRepo = &HelmRepository{ URL: "registry-1.docker.io/bitnamicharts", } + expectedClusterWithGenericGitURL := expectedClusterWithoutHelmRepo + genericGitRepo := *expectedClusterWithoutHelmRepo.ArgoCD.Repo.Git + expectedClusterWithGenericGitURL.ArgoCD.Repo.Git = &genericGitRepo + expectedClusterWithGenericGitURL.ArgoCD.Repo.Git.Customer.URL = "git@github.com:org/repo.git" + expectedClusterWithGenericGitURL.ArgoCD.Repo.Git.Managed.URL = "git@github.com:org/repo.git" // --- Test Cases Definition --- type args struct { @@ -138,6 +151,13 @@ func TestNewClusterFromEnv(t *testing.T) { }, want: expectedClusterWithOCIHelmRepo, }, + { + name: "should prefer generic git repo URL over legacy HTTPS URL", + args: args{ + e: sampleEnvMapWithGenericGitURL, + }, + want: expectedClusterWithGenericGitURL, + }, } // --- Test Execution --- diff --git a/src/internal/config/store.go b/src/internal/config/store.go index 715e1cb6..1c7f9bee 100644 --- a/src/internal/config/store.go +++ b/src/internal/config/store.go @@ -55,6 +55,10 @@ func (cs *ConfigStore) Load() error { return fmt.Errorf("migrate legacy config: %w", err) } } + shapeMigrated, err := migrateConfigShape(raw) + if err != nil { + return fmt.Errorf("migrate config shape: %w", err) + } dc := &mapstructure.DecoderConfig{ TagName: "yaml", @@ -79,7 +83,7 @@ func (cs *ConfigStore) Load() error { return fmt.Errorf("validate config: %w", err) } - if legacyConfig { + if legacyConfig || shapeMigrated { if err := cs.SaveToFile(); err != nil { return fmt.Errorf("persist migrated config: %w", err) } @@ -88,6 +92,66 @@ func (cs *ConfigStore) Load() error { return nil } +func migrateConfigShape(raw map[string]any) (bool, error) { + clustersRaw, ok := raw["clusters"] + if !ok { + return false, nil + } + + clusters, ok := clustersRaw.([]any) + if !ok { + return false, nil + } + + migrated := false + for i, clusterRaw := range clusters { + cluster, ok := clusterRaw.(map[string]any) + if !ok { + continue + } + + changed, err := migrateRepoHTTPSKey(cluster, i) + if err != nil { + return false, err + } + migrated = migrated || changed + } + + return migrated, nil +} + +func migrateRepoHTTPSKey(cluster map[string]any, clusterIndex int) (bool, error) { + argocdRaw, ok := cluster["argocd"] + if !ok || argocdRaw == nil { + return false, nil + } + argocd, ok := argocdRaw.(map[string]any) + if !ok { + return false, fmt.Errorf("%s.argocd must be an object", legacyClusterLabel(cluster, clusterIndex)) + } + + repoRaw, ok := argocd["repo"] + if !ok || repoRaw == nil { + return false, nil + } + repo, ok := repoRaw.(map[string]any) + if !ok { + return false, fmt.Errorf("%s.argocd.repo must be an object", legacyClusterLabel(cluster, clusterIndex)) + } + + httpsRepo, hasHTTPS := repo["https"] + if !hasHTTPS { + return false, nil + } + if _, hasGit := repo["git"]; hasGit { + return false, fmt.Errorf("%s.argocd.repo has both legacy https and git repositories", legacyClusterLabel(cluster, clusterIndex)) + } + + repo["git"] = httpsRepo + delete(repo, "https") + return true, nil +} + func isLegacyConfig(raw map[string]any) bool { _, hasVersion := raw["version"] return !hasVersion diff --git a/src/internal/config/store_test.go b/src/internal/config/store_test.go index 7dc07415..6a4021f8 100644 --- a/src/internal/config/store_test.go +++ b/src/internal/config/store_test.go @@ -39,7 +39,8 @@ func newValidTestConfig() *Config { }, ArgoCD: ArgoCD{ Repo: RepoProto{ - HTTPS: &RepoType{ + AuthMode: "https", + Git: &RepoType{ Customer: Repository{ URL: "https://github.com/customer/repo.git", TargetRevision: "main", @@ -165,7 +166,7 @@ clusters: type: hub argocd: repo: - https: + git: customer: url: "https://github.com/customer/repo.git" managed: @@ -206,6 +207,9 @@ clusters: cluster := loaded.Clusters[0] assert.Equal(t, cluster.Type, "hub") assert.Contains(t, cluster.Services, "argocd") + require.NotNil(t, cluster.ArgoCD.Repo.Git) + assert.Equal(t, "https://github.com/customer/repo.git", cluster.ArgoCD.Repo.Git.Customer.URL) + assert.Equal(t, "https://github.com/managed/repo.git", cluster.ArgoCD.Repo.Git.Managed.URL) certManager := cluster.Services["cert-manager"] clusterIssuer, ok := certManager.Config["clusterIssuer"].(map[string]any) @@ -228,6 +232,8 @@ clusters: assert.Contains(t, savedContent, "version: v1alpha1") assert.Contains(t, savedContent, "cert-manager:") assert.Contains(t, savedContent, "argocd:") + assert.Contains(t, savedContent, "git:") + assert.NotContains(t, savedContent, "\n https:") assert.NotContains(t, savedContent, "certManager:") assert.NotContains(t, savedContent, "storageClassName:") assert.NotContains(t, savedContent, "ingress:") @@ -237,6 +243,108 @@ clusters: assert.Contains(t, savedContent, "clusterIssuer:") } +func TestConfigStore_LoadMigratesRepoHTTPSKeyForVersionedConfig(t *testing.T) { + configYAML := ` +version: v1alpha1 +clusters: + - name: migrated-cluster + dnsName: migrated.example.com + type: hub + argocd: + repo: + authMode: ssh + https: + customer: + url: "git@github.com:customer/repo.git" + managed: + url: "git@github.com:managed/repo.git" + services: + argocd: + status: enabled + cert-manager: + status: enabled + config: + clusterIssuer: + name: letsencrypt-staging + email: cert@example.com + server: https://acme-staging-v02.api.letsencrypt.org/directory + external-dns: + status: enabled + external-secrets: + status: enabled + homer-dashboard: + status: enabled + kube-prometheus-stack: + status: enabled + kyverno: + status: enabled + kyverno-policies: + status: enabled + kyverno-policy-reporter: + status: enabled + loki: + status: enabled + longhorn: + status: enabled + metallb: + status: enabled + metrics-server: + status: enabled + oauth2-proxy: + status: enabled + traefik: + status: enabled +` + + configPath := filepath.Join(t.TempDir(), "versioned-config.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(configYAML), 0644)) + + cs := NewConfigStoreWithCatalog(configPath, catalog.LoadOptions{}) + require.NoError(t, cs.Load()) + + cluster := cs.GetConfig().Clusters[0] + assert.Equal(t, "ssh", cluster.ArgoCD.Repo.AuthMode) + require.NotNil(t, cluster.ArgoCD.Repo.Git) + assert.Equal(t, "git@github.com:customer/repo.git", cluster.ArgoCD.Repo.Git.Customer.URL) + assert.Equal(t, "git@github.com:managed/repo.git", cluster.ArgoCD.Repo.Git.Managed.URL) + + savedBytes, err := os.ReadFile(configPath) + require.NoError(t, err) + savedContent := string(savedBytes) + assert.Contains(t, savedContent, "git:") + assert.NotContains(t, savedContent, "\n https:") +} + +func TestConfigStore_LoadRejectsRepoMigrationConflict(t *testing.T) { + configYAML := ` +version: v1alpha1 +clusters: + - name: conflict-cluster + dnsName: conflict.example.com + argocd: + repo: + https: + customer: + url: "https://github.com/customer/legacy.git" + managed: + url: "https://github.com/managed/legacy.git" + git: + customer: + url: "https://github.com/customer/current.git" + managed: + url: "https://github.com/managed/current.git" + services: {} +` + + configPath := filepath.Join(t.TempDir(), "repo-conflict.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(configYAML), 0644)) + + cs := NewConfigStoreWithCatalog(configPath, catalog.LoadOptions{}) + err := cs.Load() + require.Error(t, err) + assert.ErrorContains(t, err, "both legacy https and git repositories") +} + func TestConfigStore_LoadRejectsLegacyMigrationConflicts(t *testing.T) { tests := []struct { name string @@ -306,7 +414,7 @@ clusters: dnsName: legacy.example.com argocd: repo: - https: + git: customer: url: "https://github.com/customer/repo.git" managed: diff --git a/src/internal/config/types.go b/src/internal/config/types.go index 3c1ed02e..eebc2bd9 100644 --- a/src/internal/config/types.go +++ b/src/internal/config/types.go @@ -49,9 +49,10 @@ type ArgoCD struct { } type RepoProto struct { - _ struct{} `jsonschema:"minProperties=1,additionalProperties=false"` - HTTPS *RepoType `json:"https,omitempty" yaml:"https,omitempty" jsonschema:"title=Https Repository"` - OCI *RepoType `json:"oci,omitempty" yaml:"oci,omitempty" jsonschema:"title=Oci Repository"` + _ struct{} `jsonschema:"minProperties=1,additionalProperties=false"` + AuthMode string `json:"authMode,omitempty" yaml:"authMode,omitempty" jsonschema:"title=Git Auth Mode,description=Authentication mode kubara uses for the initial Argo CD Git repository secret.,enum=https,enum=ssh,enum=github-app,default=https"` + Git *RepoType `json:"git" yaml:"git" jsonschema:"required,title=Git Repository"` + OCI *RepoType `json:"oci,omitempty" yaml:"oci,omitempty" jsonschema:"title=Oci Repository"` } type RepoType struct { @@ -60,7 +61,7 @@ type RepoType struct { } type Repository struct { - URL string `json:"url" yaml:"url" jsonschema:"required,title=Repository URL,description=The HTTPS URL of the Git repository.,format=uri"` + URL string `json:"url" yaml:"url" jsonschema:"required,title=Repository URL,description=The Git repository URL used by Argo CD. Use an HTTP(S) URL for https/github-app auth modes or an SSH URL for ssh auth mode.,minLength=1"` TargetRevision string `json:"targetRevision" yaml:"targetRevision" jsonschema:"title=Target Revision,description=The Git branch or tag to track.,minLength=1,default=main"` } diff --git a/src/internal/envconfig/env.go b/src/internal/envconfig/env.go index 0a4d18a5..16fe6eb3 100644 --- a/src/internal/envconfig/env.go +++ b/src/internal/envconfig/env.go @@ -3,9 +3,11 @@ package envconfig import ( "errors" "fmt" - "github.com/kubara-io/kubara/internal/utils" "reflect" + "sort" "strings" + + "github.com/kubara-io/kubara/internal/utils" ) type ErrorEnvMap struct { @@ -15,6 +17,13 @@ type ErrorEnvMap struct { var ErrEnvsNotSet = errors.New("EnvVars have not been set") var ErrDefaultIsSet = errors.New("EnvVars are set to default value") +var ErrInvalidEnvValue = errors.New("EnvVars contain invalid value") + +const ( + GitAuthModeHTTPS = "https" + GitAuthModeSSH = "ssh" + GitAuthModeGitHubApp = "github-app" +) func (e *ErrorEnvMap) Error() string { return fmt.Sprintf("Error: %s", e.Message) @@ -26,34 +35,46 @@ func (e *ErrorEnvMap) Unwrap() error { // EnvMap holds the expected variables type EnvMap struct { - _ struct{} `doc:"# ✅ These values MUST be known BEFORE running Terraform."` - _ struct{} `doc:"# 🔁 Everything in MUST be replaced."` - _ struct{} `doc:"# 💡 Dummy values (without <>) are optional and can be left as-is if not needed"` - _ struct{} `doc:"# (e.g. no private image registry). It will still create a secret, but it will be not valid."` - _ struct{} `doc:"\n### Project related values"` - ProjectName string `default:"<...>" koanf:"PROJECT_NAME"` - ProjectStage string `default:"<...>" koanf:"PROJECT_STAGE"` - _ struct{} `doc:"\n### Container Registry Config"` - _ struct{} `doc:"# the variable must be base64 encoded - how to: https://docs.kubara.io/latest-stable/6_reference/faq/#how-do-i-create-a-dockerconfigjson-for-env-file"` - DockerconfigBase64 string `default:"<...>" koanf:"DOCKERCONFIG_BASE64"` - _ struct{} `doc:"\n### Argo CD related values"` - ArgocdWizardAccountPassword string `default:"<...>" koanf:"ARGOCD_WIZARD_ACCOUNT_PASSWORD"` - _ struct{} `doc:"\n### Git repository values"` - ArgocdGitHttpsUrl string `default:"<...>" koanf:"ARGOCD_GIT_HTTPS_URL"` - ArgocdGitPatOrPassword string `default:"<...>" koanf:"ARGOCD_GIT_PAT_OR_PASSWORD"` - ArgocdGitUsername string `default:"<...>" koanf:"ARGOCD_GIT_USERNAME"` - _ struct{} `doc:"\n### DNS Name/Zones related values"` - _ struct{} `doc:"# The Domain name under which your dns-entries will be added."` - _ struct{} `doc:"# The resulting dnsZone name will be a concatenation of -."` - _ struct{} `doc:"# the value should be looking like 'stackit.zone' eg. 'yourDomain.com'"` - DomainName string `default:"<...>" koanf:"DOMAIN_NAME"` - _ struct{} `doc:"\n### Optional values"` - _ struct{} `doc:"# Helm repository values (leave empty to disable)."` - _ struct{} `doc:"# ARGOCD_HELM_REPO_URL supports: https://... (classic Helm repo) or registry.example.com/... (OCI Helm registry)."` - _ struct{} `doc:"# Compatibility: oci://... is also accepted and normalized automatically."` - ArgocdHelmRepoUsername string `default:"" koanf:"ARGOCD_HELM_REPO_USERNAME" optional:"true"` - ArgocdHelmRepoPassword string `default:"" koanf:"ARGOCD_HELM_REPO_PASSWORD" optional:"true"` - ArgocdHelmRepoUrl string `default:"" koanf:"ARGOCD_HELM_REPO_URL" optional:"true"` + _ struct{} `doc:"# ✅ These values MUST be known BEFORE running Terraform."` + _ struct{} `doc:"# 🔁 Everything in MUST be replaced."` + _ struct{} `doc:"# 💡 Dummy values (without <>) are optional and can be left as-is if not needed"` + _ struct{} `doc:"# (e.g. no private image registry). It will still create a secret, but it will be not valid."` + _ struct{} `doc:"\n### Project related values"` + ProjectName string `default:"<...>" koanf:"PROJECT_NAME"` + ProjectStage string `default:"<...>" koanf:"PROJECT_STAGE"` + _ struct{} `doc:"\n### Container Registry Config"` + _ struct{} `doc:"# the variable must be base64 encoded - how to: https://docs.kubara.io/latest-stable/6_reference/faq/#how-do-i-create-a-dockerconfigjson-for-env-file"` + DockerconfigBase64 string `default:"<...>" koanf:"DOCKERCONFIG_BASE64"` + _ struct{} `doc:"\n### Argo CD related values"` + ArgocdWizardAccountPassword string `default:"<...>" koanf:"ARGOCD_WIZARD_ACCOUNT_PASSWORD"` + _ struct{} `doc:"\n### Git repository values"` + _ struct{} `doc:"# ARGOCD_GIT_AUTH_MODE supports: https, ssh, github-app. Empty keeps the legacy https mode."` + ArgocdGitAuthMode string `default:"https" koanf:"ARGOCD_GIT_AUTH_MODE" optional:"true"` + _ struct{} `doc:"# Prefer ARGOCD_GIT_URL for new setups. ARGOCD_GIT_HTTPS_URL is kept for backward compatibility with existing .env files."` + ArgocdGitUrl string `default:"" koanf:"ARGOCD_GIT_URL" optional:"true"` + ArgocdGitHttpsUrl string `default:"<...>" koanf:"ARGOCD_GIT_HTTPS_URL" optional:"true"` + _ struct{} `doc:"# HTTPS mode uses username + password/PAT. PAT usually means Personal Access Token; prefer a technical or machine account, not a personal user account."` + ArgocdGitPatOrPassword string `default:"<...>" koanf:"ARGOCD_GIT_PAT_OR_PASSWORD" optional:"true"` + ArgocdGitUsername string `default:"<...>" koanf:"ARGOCD_GIT_USERNAME" optional:"true"` + _ struct{} `doc:"# SSH mode uses ARGOCD_GIT_SSH_PRIVATE_KEY and requires trusted SSH host keys in Argo CD known_hosts."` + ArgocdGitSshPrivateKey string `default:"" koanf:"ARGOCD_GIT_SSH_PRIVATE_KEY" optional:"true"` + _ struct{} `doc:"# GitHub App mode uses the GitHub App IDs and private key. Enterprise base URL is optional."` + ArgocdGitGithubAppID string `default:"" koanf:"ARGOCD_GIT_GITHUB_APP_ID" optional:"true"` + ArgocdGitGithubAppInstallationID string `default:"" koanf:"ARGOCD_GIT_GITHUB_APP_INSTALLATION_ID" optional:"true"` + ArgocdGitGithubAppPrivateKey string `default:"" koanf:"ARGOCD_GIT_GITHUB_APP_PRIVATE_KEY" optional:"true"` + ArgocdGitGithubAppEnterpriseBaseUrl string `default:"" koanf:"ARGOCD_GIT_GITHUB_APP_ENTERPRISE_BASE_URL" optional:"true"` + _ struct{} `doc:"\n### DNS Name/Zones related values"` + _ struct{} `doc:"# The Domain name under which your dns-entries will be added."` + _ struct{} `doc:"# The resulting dnsZone name will be a concatenation of -."` + _ struct{} `doc:"# the value should be looking like 'stackit.zone' eg. 'yourDomain.com'"` + DomainName string `default:"<...>" koanf:"DOMAIN_NAME"` + _ struct{} `doc:"\n### Optional values"` + _ struct{} `doc:"# Helm repository values (leave empty to disable)."` + _ struct{} `doc:"# ARGOCD_HELM_REPO_URL supports: https://... (classic Helm repo) or registry.example.com/... (OCI Helm registry)."` + _ struct{} `doc:"# Compatibility: oci://... is also accepted and normalized automatically."` + ArgocdHelmRepoUsername string `default:"" koanf:"ARGOCD_HELM_REPO_USERNAME" optional:"true"` + ArgocdHelmRepoPassword string `default:"" koanf:"ARGOCD_HELM_REPO_PASSWORD" optional:"true"` + ArgocdHelmRepoUrl string `default:"" koanf:"ARGOCD_HELM_REPO_URL" optional:"true"` } // ValidateAll performs basic validation on the envMap. @@ -107,9 +128,90 @@ func (em *EnvMap) Validate() error { return defaultIsSetE } + if err := em.validateGitAuth(); err != nil { + return err + } + return nil } +func (em *EnvMap) validateGitAuth() error { + switch em.GitAuthMode() { + case GitAuthModeHTTPS: + return validateRequiredEnvValues(map[string]string{ + "ARGOCD_GIT_URL or ARGOCD_GIT_HTTPS_URL": em.GitRepositoryURL(), + "ARGOCD_GIT_USERNAME": em.ArgocdGitUsername, + "ARGOCD_GIT_PAT_OR_PASSWORD": em.ArgocdGitPatOrPassword, + }) + case GitAuthModeSSH: + if err := validateRequiredEnvValues(map[string]string{ + "ARGOCD_GIT_URL": em.ArgocdGitUrl, + "ARGOCD_GIT_SSH_PRIVATE_KEY": em.ArgocdGitSshPrivateKey, + }); err != nil { + return err + } + return validateSSHGitURL(em.ArgocdGitUrl) + case GitAuthModeGitHubApp: + if err := validateRequiredEnvValues(map[string]string{ + "ARGOCD_GIT_URL": em.ArgocdGitUrl, + "ARGOCD_GIT_GITHUB_APP_ID": em.ArgocdGitGithubAppID, + "ARGOCD_GIT_GITHUB_APP_INSTALLATION_ID": em.ArgocdGitGithubAppInstallationID, + "ARGOCD_GIT_GITHUB_APP_PRIVATE_KEY": em.ArgocdGitGithubAppPrivateKey, + }); err != nil { + return err + } + return validateHTTPGitURL(em.ArgocdGitUrl, GitAuthModeGitHubApp) + default: + return &ErrorEnvMap{ + Message: fmt.Sprintf("Invalid ARGOCD_GIT_AUTH_MODE %q. Supported values: %s, %s, %s", em.ArgocdGitAuthMode, GitAuthModeHTTPS, GitAuthModeSSH, GitAuthModeGitHubApp), + Err: ErrInvalidEnvValue, + } + } +} + +func validateRequiredEnvValues(values map[string]string) error { + var missing []string + for name, value := range values { + if !IsConfiguredEnvValue(value) { + missing = append(missing, name) + } + } + if len(missing) == 0 { + return nil + } + sort.Strings(missing) + + return &ErrorEnvMap{ + Message: fmt.Sprintf("Vars not set: %+v", missing), + Err: ErrEnvsNotSet, + } +} + +func validateSSHGitURL(value string) error { + trimmed := strings.TrimSpace(value) + lower := strings.ToLower(trimmed) + if strings.HasPrefix(lower, "ssh://") || (!strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") && strings.Contains(trimmed, "@")) { + return nil + } + + return &ErrorEnvMap{ + Message: "ARGOCD_GIT_AUTH_MODE=ssh requires ARGOCD_GIT_URL to be an SSH repository URL such as git@github.com:org/repo.git or ssh://git@example.com/org/repo.git", + Err: ErrInvalidEnvValue, + } +} + +func validateHTTPGitURL(value, mode string) error { + lower := strings.ToLower(strings.TrimSpace(value)) + if strings.HasPrefix(lower, "https://") || strings.HasPrefix(lower, "http://") { + return nil + } + + return &ErrorEnvMap{ + Message: fmt.Sprintf("ARGOCD_GIT_AUTH_MODE=%s requires ARGOCD_GIT_URL to be an HTTP(S) repository URL", mode), + Err: ErrInvalidEnvValue, + } +} + // setDefaults sets default values for empty fields based on the struct tag "default" func (em *EnvMap) setDefaults() { v := reflect.ValueOf(em).Elem() @@ -135,6 +237,25 @@ func IsConfiguredEnvValue(v string) bool { return trimmed != "" && trimmed != "<...>" } +// GitAuthMode returns the configured Argo CD Git auth mode. +// Empty values keep the legacy HTTPS username + PAT/password behavior. +func (em *EnvMap) GitAuthMode() string { + mode := strings.ToLower(strings.TrimSpace(em.ArgocdGitAuthMode)) + if mode == "" || mode == "<...>" { + return GitAuthModeHTTPS + } + return mode +} + +// GitRepositoryURL returns the preferred repository URL for Argo CD. +// ARGOCD_GIT_HTTPS_URL is a legacy fallback for existing .env files. +func (em *EnvMap) GitRepositoryURL() string { + if IsConfiguredEnvValue(em.ArgocdGitUrl) { + return strings.TrimSpace(em.ArgocdGitUrl) + } + return strings.TrimSpace(em.ArgocdGitHttpsUrl) +} + // NormalizeHelmRepoURL normalizes Helm repository inputs for ArgoCD. // If oci:// is provided, it is removed because ArgoCD helm repository // credentials expect the registry URL without the scheme. diff --git a/src/internal/envconfig/env_test.go b/src/internal/envconfig/env_test.go index ae18c9de..d1cade03 100644 --- a/src/internal/envconfig/env_test.go +++ b/src/internal/envconfig/env_test.go @@ -140,6 +140,98 @@ func TestEnvMap_Validate(t *testing.T) { }(), wantErr: false, }, + { + name: "Valid SSH git auth passes validation", + envMap: func() *EnvMap { + em := validEnvMap() + em.ArgocdGitAuthMode = GitAuthModeSSH + em.ArgocdGitUrl = "git@github.com:example/repo.git" + em.ArgocdGitHttpsUrl = "" + em.ArgocdGitPatOrPassword = "" + em.ArgocdGitUsername = "" + em.ArgocdGitSshPrivateKey = "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----" + return em + }(), + wantErr: false, + }, + { + name: "SSH git auth requires private key", + envMap: func() *EnvMap { + em := validEnvMap() + em.ArgocdGitAuthMode = GitAuthModeSSH + em.ArgocdGitUrl = "git@github.com:example/repo.git" + em.ArgocdGitSshPrivateKey = "" + return em + }(), + wantErr: true, + errType: ErrEnvsNotSet, + }, + { + name: "SSH git auth rejects HTTPS URL", + envMap: func() *EnvMap { + em := validEnvMap() + em.ArgocdGitAuthMode = GitAuthModeSSH + em.ArgocdGitUrl = "https://github.com/example/repo.git" + em.ArgocdGitSshPrivateKey = "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----" + return em + }(), + wantErr: true, + errType: ErrInvalidEnvValue, + }, + { + name: "Valid GitHub App git auth passes validation", + envMap: func() *EnvMap { + em := validEnvMap() + em.ArgocdGitAuthMode = GitAuthModeGitHubApp + em.ArgocdGitUrl = "https://github.com/example/repo.git" + em.ArgocdGitHttpsUrl = "" + em.ArgocdGitPatOrPassword = "" + em.ArgocdGitUsername = "" + em.ArgocdGitGithubAppID = "123" + em.ArgocdGitGithubAppInstallationID = "456" + em.ArgocdGitGithubAppPrivateKey = "-----BEGIN RSA PRIVATE KEY-----\nkey\n-----END RSA PRIVATE KEY-----" + return em + }(), + wantErr: false, + }, + { + name: "GitHub App git auth requires private key", + envMap: func() *EnvMap { + em := validEnvMap() + em.ArgocdGitAuthMode = GitAuthModeGitHubApp + em.ArgocdGitUrl = "https://github.com/example/repo.git" + em.ArgocdGitGithubAppID = "123" + em.ArgocdGitGithubAppInstallationID = "456" + em.ArgocdGitGithubAppPrivateKey = "" + return em + }(), + wantErr: true, + errType: ErrEnvsNotSet, + }, + { + name: "GitHub App git auth rejects SSH URL", + envMap: func() *EnvMap { + em := validEnvMap() + em.ArgocdGitAuthMode = GitAuthModeGitHubApp + em.ArgocdGitUrl = "git@github.com:example/repo.git" + em.ArgocdGitGithubAppID = "123" + em.ArgocdGitGithubAppInstallationID = "456" + em.ArgocdGitGithubAppPrivateKey = "-----BEGIN RSA PRIVATE KEY-----\nkey\n-----END RSA PRIVATE KEY-----" + return em + }(), + wantErr: true, + errType: ErrInvalidEnvValue, + }, + { + name: "Invalid git auth mode fails validation", + envMap: func() *EnvMap { + em := validEnvMap() + em.ArgocdGitAuthMode = "token" + return em + }(), + wantErr: true, + errType: ErrInvalidEnvValue, + }, { name: "Multiple missing required fields", envMap: func() *EnvMap { @@ -280,9 +372,16 @@ func TestEnvMap_setDefaults_AllFields(t *testing.T) { assert.Equal(t, "", em.ArgocdHelmRepoUsername) assert.Equal(t, "", em.ArgocdHelmRepoPassword) assert.Equal(t, "", em.ArgocdHelmRepoUrl) + assert.Equal(t, "https", em.ArgocdGitAuthMode) + assert.Equal(t, "", em.ArgocdGitUrl) assert.Equal(t, "<...>", em.ArgocdGitHttpsUrl) assert.Equal(t, "<...>", em.ArgocdGitPatOrPassword) assert.Equal(t, "<...>", em.ArgocdGitUsername) + assert.Equal(t, "", em.ArgocdGitSshPrivateKey) + assert.Equal(t, "", em.ArgocdGitGithubAppID) + assert.Equal(t, "", em.ArgocdGitGithubAppInstallationID) + assert.Equal(t, "", em.ArgocdGitGithubAppPrivateKey) + assert.Equal(t, "", em.ArgocdGitGithubAppEnterpriseBaseUrl) assert.Equal(t, "<...>", em.DomainName) }) } diff --git a/src/internal/envconfig/store_test.go b/src/internal/envconfig/store_test.go index 1f26bfda..5b0f6fbd 100644 --- a/src/internal/envconfig/store_test.go +++ b/src/internal/envconfig/store_test.go @@ -342,9 +342,18 @@ func TestEnvStore_GenerateEnvExample(t *testing.T) { assert.Contains(t, outputStr, "DOMAIN_NAME='<...>'") assert.Contains(t, outputStr, "DOCKERCONFIG_BASE64='<...>'") assert.Contains(t, outputStr, "ARGOCD_WIZARD_ACCOUNT_PASSWORD='<...>'") + assert.Contains(t, outputStr, "ARGOCD_GIT_AUTH_MODE='https'") + assert.Contains(t, outputStr, "ARGOCD_GIT_URL=''") + assert.Contains(t, outputStr, "ARGOCD_GIT_HTTPS_URL='<...>'") + assert.Contains(t, outputStr, "ARGOCD_GIT_SSH_PRIVATE_KEY=''") + assert.Contains(t, outputStr, "ARGOCD_GIT_GITHUB_APP_ID=''") + assert.Contains(t, outputStr, "ARGOCD_GIT_GITHUB_APP_INSTALLATION_ID=''") + assert.Contains(t, outputStr, "ARGOCD_GIT_GITHUB_APP_PRIVATE_KEY=''") + assert.Contains(t, outputStr, "ARGOCD_GIT_GITHUB_APP_ENTERPRISE_BASE_URL=''") assert.Contains(t, outputStr, "ARGOCD_HELM_REPO_USERNAME=''") assert.Contains(t, outputStr, "ARGOCD_HELM_REPO_PASSWORD=''") assert.Contains(t, outputStr, "ARGOCD_HELM_REPO_URL=''") + assert.Contains(t, outputStr, "PAT usually means Personal Access Token") }, }, { diff --git a/src/internal/render/render_test.go b/src/internal/render/render_test.go index 2ba8d4ee..4be63338 100644 --- a/src/internal/render/render_test.go +++ b/src/internal/render/render_test.go @@ -164,7 +164,7 @@ func TestTemplateFiles(t *testing.T) { }, "argocd": map[string]any{ "repo": map[string]any{ - "https": map[string]any{ + "git": map[string]any{ "managed": map[string]any{ "url": "https://github.com/example/repo", "path": "managed-service-catalog/helm", @@ -276,7 +276,7 @@ func TestTemplateFiles(t *testing.T) { "ssoTeam": "myteam", "argocd": map[string]any{ "repo": map[string]any{ - "https": map[string]any{ + "git": map[string]any{ "managed": map[string]any{ "url": "https://github.com/example/repo", "path": "managed-service-catalog/helm", diff --git a/src/internal/workflow/orchestrator.go b/src/internal/workflow/orchestrator.go index 7b7cf485..cf51aed1 100644 --- a/src/internal/workflow/orchestrator.go +++ b/src/internal/workflow/orchestrator.go @@ -10,6 +10,7 @@ import ( func CreateOrUpdateClusterFromEnvWithCatalog(cfg *config.Config, e *envconfig.EnvMap, catalogOptions catalog.LoadOptions) error { clusterName := e.ProjectName dnsName := e.ProjectName + "-" + e.ProjectStage + "." + e.DomainName + gitRepoURL := e.GitRepositoryURL() // Attempt to find the cluster to update for i := range cfg.Clusters { @@ -20,8 +21,9 @@ func CreateOrUpdateClusterFromEnvWithCatalog(cfg *config.Config, e *envconfig.En cfg.Clusters[i].Stage = e.ProjectStage cfg.Clusters[i].DNSName = dnsName cfg.Clusters[i].Terraform.DNS.Name = dnsName - cfg.Clusters[i].ArgoCD.Repo.HTTPS.Managed.URL = e.ArgocdGitHttpsUrl - cfg.Clusters[i].ArgoCD.Repo.HTTPS.Customer.URL = e.ArgocdGitHttpsUrl + cfg.Clusters[i].ArgoCD.Repo.AuthMode = e.GitAuthMode() + cfg.Clusters[i].ArgoCD.Repo.Git.Managed.URL = gitRepoURL + cfg.Clusters[i].ArgoCD.Repo.Git.Customer.URL = gitRepoURL if envconfig.IsConfiguredEnvValue(e.ArgocdHelmRepoUrl) { helmRepoURL := envconfig.NormalizeHelmRepoURL(e.ArgocdHelmRepoUrl) cfg.Clusters[i].ArgoCD.HelmRepo = &config.HelmRepository{ diff --git a/src/internal/workflow/orchestrator_test.go b/src/internal/workflow/orchestrator_test.go index d875e017..1f13f615 100644 --- a/src/internal/workflow/orchestrator_test.go +++ b/src/internal/workflow/orchestrator_test.go @@ -26,7 +26,7 @@ func TestCreateOrUpdateClusterFromEnv_UpdatesExistingClusterIncludingHelmRepo(t }, ArgoCD: config.ArgoCD{ Repo: config.RepoProto{ - HTTPS: &config.RepoType{ + Git: &config.RepoType{ Customer: config.Repository{ URL: "https://github.com/old/repo.git", TargetRevision: "main", @@ -57,8 +57,8 @@ func TestCreateOrUpdateClusterFromEnv_UpdatesExistingClusterIncludingHelmRepo(t assert.Equal(t, "dev", updated.Stage) assert.Equal(t, "kubara-test-dev.example.com", updated.DNSName) assert.Equal(t, "kubara-test-dev.example.com", updated.Terraform.DNS.Name) - assert.Equal(t, "https://github.com/new/repo.git", updated.ArgoCD.Repo.HTTPS.Managed.URL) - assert.Equal(t, "https://github.com/new/repo.git", updated.ArgoCD.Repo.HTTPS.Customer.URL) + assert.Equal(t, "https://github.com/new/repo.git", updated.ArgoCD.Repo.Git.Managed.URL) + assert.Equal(t, "https://github.com/new/repo.git", updated.ArgoCD.Repo.Git.Customer.URL) require.NotNil(t, updated.ArgoCD.HelmRepo) assert.Equal(t, "https://charts.example.com", updated.ArgoCD.HelmRepo.URL) } @@ -78,12 +78,52 @@ func TestCreateOrUpdateClusterFromEnv_CreatesNewClusterWithHelmRepo(t *testing.T require.Len(t, cfg.Clusters, 1) cluster := cfg.Clusters[0] - assert.Equal(t, "https://github.com/new/repo.git", cluster.ArgoCD.Repo.HTTPS.Managed.URL) - assert.Equal(t, "https://github.com/new/repo.git", cluster.ArgoCD.Repo.HTTPS.Customer.URL) + assert.Equal(t, "https://github.com/new/repo.git", cluster.ArgoCD.Repo.Git.Managed.URL) + assert.Equal(t, "https://github.com/new/repo.git", cluster.ArgoCD.Repo.Git.Customer.URL) require.NotNil(t, cluster.ArgoCD.HelmRepo) assert.Equal(t, "https://charts.example.com", cluster.ArgoCD.HelmRepo.URL) } +func TestCreateOrUpdateClusterFromEnv_UpdatesGitURLAndAuthMode(t *testing.T) { + cfg := &config.Config{ + Clusters: []config.Cluster{ + { + Name: "kubara-test", + Stage: "stage", + DNSName: "kubara-test-stage.example.com", + Terraform: &config.Terraform{ + DNS: config.DNS{Name: "kubara-test-stage.example.com"}, + }, + ArgoCD: config.ArgoCD{ + Repo: config.RepoProto{ + AuthMode: envconfig.GitAuthModeHTTPS, + Git: &config.RepoType{ + Customer: config.Repository{URL: "https://github.com/old/repo.git", TargetRevision: "main"}, + Managed: config.Repository{URL: "https://github.com/old/repo.git", TargetRevision: "main"}, + }, + }, + }, + }, + }, + } + e := &envconfig.EnvMap{ + ProjectName: "kubara-test", + ProjectStage: "dev", + DomainName: "example.com", + ArgocdGitAuthMode: envconfig.GitAuthModeSSH, + ArgocdGitUrl: "git@github.com:org/repo.git", + ArgocdGitHttpsUrl: "https://github.com/old/repo.git", + } + + err := CreateOrUpdateClusterFromEnvWithCatalog(cfg, e, catalog.LoadOptions{}) + require.NoError(t, err) + + updated := cfg.Clusters[0] + assert.Equal(t, envconfig.GitAuthModeSSH, updated.ArgoCD.Repo.AuthMode) + assert.Equal(t, "git@github.com:org/repo.git", updated.ArgoCD.Repo.Git.Managed.URL) + assert.Equal(t, "git@github.com:org/repo.git", updated.ArgoCD.Repo.Git.Customer.URL) +} + func TestCreateOrUpdateClusterFromEnv_DoesNotOverrideHelmRepoWhenEnvMissing(t *testing.T) { cfg := &config.Config{ Clusters: []config.Cluster{ @@ -98,7 +138,7 @@ func TestCreateOrUpdateClusterFromEnv_DoesNotOverrideHelmRepoWhenEnvMissing(t *t }, ArgoCD: config.ArgoCD{ Repo: config.RepoProto{ - HTTPS: &config.RepoType{ + Git: &config.RepoType{ Customer: config.Repository{ URL: "https://github.com/old/repo.git", TargetRevision: "main", From f7108149f849a5bd368c816fc6c28f1774abddef Mon Sep 17 00:00:00 2001 From: Matthiator Date: Fri, 12 Jun 2026 13:53:40 +0200 Subject: [PATCH 2/7] refactor: bump config version to v1alpha2 and drop terraform.dns Address review feedback: - introduce v1alpha2 config version and migrate v1alpha1 configs through an explicit version check instead of shape detection - move the argocd.repo.https -> argocd.repo.git migration into the v1alpha1 -> v1alpha2 migration - remove the terraform.dns block: the zone name is derived from the cluster dnsName, the contact email moves to terraform.dnsContactEmail --- .../1_getting_started/bootstrapping.md | 6 +- .../add_spoke_cluster.md | 4 +- .../3_components/network_external_dns.md | 8 +- src/cmd/generate_test.go | 9 +- .../infrastructure/env.auto.tfvars.tplt | 4 +- src/internal/config/defaults_test.go | 2 +- src/internal/config/factory.go | 5 +- src/internal/config/factory_test.go | 5 +- src/internal/config/store.go | 86 ++++++++++++++----- src/internal/config/store_test.go | 65 +++++++++++--- src/internal/config/types.go | 14 ++- src/internal/workflow/orchestrator.go | 1 - src/internal/workflow/orchestrator_test.go | 12 +-- 13 files changed, 143 insertions(+), 78 deletions(-) diff --git a/docs/content/1_getting_started/bootstrapping.md b/docs/content/1_getting_started/bootstrapping.md index 1e9e96b2..b998d83d 100644 --- a/docs/content/1_getting_started/bootstrapping.md +++ b/docs/content/1_getting_started/bootstrapping.md @@ -94,7 +94,7 @@ This command creates a `config.yaml` file based on the values from your `.env`. If you make changes to `.env` later, you can re-run the command with `--overwrite` to update the configuration. The generated Argo CD repository config records the selected Git auth mode in `argocd.repo.authMode`. Repository URLs are stored under `argocd.repo.git`. -Existing configs that still use the old `argocd.repo.https` key are migrated to `argocd.repo.git` when kubara loads and saves the config. +Existing `v1alpha1` configs are migrated to `v1alpha2` when kubara loads and saves the config: the old `argocd.repo.https` key moves to `argocd.repo.git`, and the old `terraform.dns` block is replaced by `terraform.dnsContactEmail` (the zone name is derived from the cluster `dnsName`). When using `--overwrite`, only values from `.env` are replaced. Additional settings in your existing `config.yaml` are preserved and merged. @@ -145,9 +145,7 @@ clusters: projectId: kubernetesType: kubernetesVersion: 1.34 - dns: - name: - email: + dnsContactEmail: ... ``` diff --git a/docs/content/2_managing_your_platform/add_spoke_cluster.md b/docs/content/2_managing_your_platform/add_spoke_cluster.md index 64c8ee4e..a136db03 100644 --- a/docs/content/2_managing_your_platform/add_spoke_cluster.md +++ b/docs/content/2_managing_your_platform/add_spoke_cluster.md @@ -26,9 +26,7 @@ clusters: projectId: kubernetesType: ske kubernetesVersion: 1.34 - dns: - name: workload-0.dev.example.com - email: platform@example.com + dnsContactEmail: platform@example.com argocd: repo: authMode: https diff --git a/docs/content/3_components/network_external_dns.md b/docs/content/3_components/network_external_dns.md index 00307958..049b7490 100644 --- a/docs/content/3_components/network_external_dns.md +++ b/docs/content/3_components/network_external_dns.md @@ -49,9 +49,7 @@ clusters: terraform: provider: stackit # currently supported: stackit - dns: - name: "example-zone" - email: "hostmaster@example.com" + dnsContactEmail: "hostmaster@example.com" services: external-dns: @@ -60,8 +58,8 @@ clusters: ### Explanation -- **`dnsName`** → base domain for the cluster -- **`terraform.dns`** → defines the zone for which kubara generates Terraform code (name and contact email). +- **`dnsName`** → base domain for the cluster, also used as the zone name for which kubara generates Terraform code +- **`terraform.dnsContactEmail`** → administrative contact email for the managed DNS zone. - **`services.external-dns.status`** → when set to `enabled`, ExternalDNS is templated into the Helm charts for deployment via Argo CD. - **provider-specific settings** → configure them in the chart overlay values (`values.yaml` / `additional-values.yaml`). diff --git a/src/cmd/generate_test.go b/src/cmd/generate_test.go index 9ae903fb..32330898 100644 --- a/src/cmd/generate_test.go +++ b/src/cmd/generate_test.go @@ -226,10 +226,7 @@ func TestGenerateCmd(t *testing.T) { ProjectID: "00000000-0000-0000-0000-000000000000", KubernetesType: "ske", KubernetesVersion: "1.28.0", - DNS: config.DNS{ - Name: "example.com", - Email: "admin@example.com", - }, + DNSContactEmail: "admin@example.com", }, ArgoCD: config.ArgoCD{ Repo: config.RepoProto{ @@ -314,7 +311,7 @@ func TestGenerateCmd_MissingProviderUsesDefault(t *testing.T) { ProjectID: "00000000-0000-0000-0000-000000000000", KubernetesType: "ske", KubernetesVersion: "1.28.0", - DNS: config.DNS{Name: "example.com", Email: "admin@example.com"}, + DNSContactEmail: "admin@example.com", }, ArgoCD: config.ArgoCD{ Repo: config.RepoProto{ @@ -372,7 +369,7 @@ func TestGenerateCmd_PlaceholderProviderFailsWithHint(t *testing.T) { ProjectID: "00000000-0000-0000-0000-000000000000", KubernetesType: "ske", KubernetesVersion: "1.28.0", - DNS: config.DNS{Name: "example.com", Email: "admin@example.com"}, + DNSContactEmail: "admin@example.com", }, ArgoCD: config.ArgoCD{ Repo: config.RepoProto{ diff --git a/src/internal/catalog/built-in/customer-service-catalog/terraform/providers/stackit/example/infrastructure/env.auto.tfvars.tplt b/src/internal/catalog/built-in/customer-service-catalog/terraform/providers/stackit/example/infrastructure/env.auto.tfvars.tplt index dd893e74..ade0e1c4 100644 --- a/src/internal/catalog/built-in/customer-service-catalog/terraform/providers/stackit/example/infrastructure/env.auto.tfvars.tplt +++ b/src/internal/catalog/built-in/customer-service-catalog/terraform/providers/stackit/example/infrastructure/env.auto.tfvars.tplt @@ -1,6 +1,6 @@ ### DNS -contact_email = "{{ .cluster.terraform.dns.email }}" -dns_name = "{{ .cluster.terraform.dns.name }}" +contact_email = "{{ .cluster.terraform.dnsContactEmail }}" +dns_name = "{{ .cluster.dnsName }}" ### Global project_id = "{{ .cluster.terraform.projectId }}" diff --git a/src/internal/config/defaults_test.go b/src/internal/config/defaults_test.go index 9f097bac..105b48c3 100644 --- a/src/internal/config/defaults_test.go +++ b/src/internal/config/defaults_test.go @@ -54,7 +54,7 @@ func TestApplyDefaults_NestedTerraformDefaults(t *testing.T) { Terraform: &Terraform{ ProjectID: "some-id", KubernetesVersion: "1.34", - DNS: DNS{Name: "example.com", Email: "admin@example.com"}, + DNSContactEmail: "admin@example.com", // Should get defaults for: // Provider and KubernetesType }, diff --git a/src/internal/config/factory.go b/src/internal/config/factory.go index 917ebb44..886a8ff4 100644 --- a/src/internal/config/factory.go +++ b/src/internal/config/factory.go @@ -51,10 +51,7 @@ func NewClusterFromEnvWithCatalog(e *envconfig.EnvMap, catalogOptions catalog.Lo ProjectID: "", KubernetesType: "", KubernetesVersion: "1.34", - DNS: DNS{ - Name: dnsName, - Email: "my-test@nowhere.com", - }, + DNSContactEmail: "my-test@nowhere.com", }, ArgoCD: argoCD, Services: services, diff --git a/src/internal/config/factory_test.go b/src/internal/config/factory_test.go index 471e4c67..8c88bcd6 100644 --- a/src/internal/config/factory_test.go +++ b/src/internal/config/factory_test.go @@ -58,10 +58,7 @@ func TestNewClusterFromEnv(t *testing.T) { ProjectID: "", KubernetesType: "", KubernetesVersion: "1.34", - DNS: DNS{ - Name: expectedDNSName, - Email: "my-test@nowhere.com", - }, + DNSContactEmail: "my-test@nowhere.com", }, ArgoCD: ArgoCD{ Repo: RepoProto{ diff --git a/src/internal/config/store.go b/src/internal/config/store.go index 1c7f9bee..68db171a 100644 --- a/src/internal/config/store.go +++ b/src/internal/config/store.go @@ -54,10 +54,15 @@ func (cs *ConfigStore) Load() error { if err != nil { return fmt.Errorf("migrate legacy config: %w", err) } + raw["version"] = ConfigVersionV1Alpha1 } - shapeMigrated, err := migrateConfigShape(raw) - if err != nil { - return fmt.Errorf("migrate config shape: %w", err) + versionMigrated := false + if isV1Alpha1(raw) { + if err := migrateV1Alpha1ToV1Alpha2(raw); err != nil { + return fmt.Errorf("migrate config from %s to %s: %w", ConfigVersionV1Alpha1, ConfigVersionV1Alpha2, err) + } + raw["version"] = ConfigVersionV1Alpha2 + versionMigrated = true } dc := &mapstructure.DecoderConfig{ @@ -83,7 +88,7 @@ func (cs *ConfigStore) Load() error { return fmt.Errorf("validate config: %w", err) } - if legacyConfig || shapeMigrated { + if legacyConfig || versionMigrated { if err := cs.SaveToFile(); err != nil { return fmt.Errorf("persist migrated config: %w", err) } @@ -92,64 +97,101 @@ func (cs *ConfigStore) Load() error { return nil } -func migrateConfigShape(raw map[string]any) (bool, error) { +func isV1Alpha1(raw map[string]any) bool { + version, ok := raw["version"].(string) + return ok && version == ConfigVersionV1Alpha1 +} + +func migrateV1Alpha1ToV1Alpha2(raw map[string]any) error { clustersRaw, ok := raw["clusters"] if !ok { - return false, nil + return nil } clusters, ok := clustersRaw.([]any) if !ok { - return false, nil + return nil } - migrated := false for i, clusterRaw := range clusters { cluster, ok := clusterRaw.(map[string]any) if !ok { continue } - changed, err := migrateRepoHTTPSKey(cluster, i) - if err != nil { - return false, err + if err := migrateRepoHTTPSKey(cluster, i); err != nil { + return err + } + if err := migrateTerraformDNS(cluster, i); err != nil { + return err } - migrated = migrated || changed } - return migrated, nil + return nil } -func migrateRepoHTTPSKey(cluster map[string]any, clusterIndex int) (bool, error) { +func migrateRepoHTTPSKey(cluster map[string]any, clusterIndex int) error { argocdRaw, ok := cluster["argocd"] if !ok || argocdRaw == nil { - return false, nil + return nil } argocd, ok := argocdRaw.(map[string]any) if !ok { - return false, fmt.Errorf("%s.argocd must be an object", legacyClusterLabel(cluster, clusterIndex)) + return fmt.Errorf("%s.argocd must be an object", legacyClusterLabel(cluster, clusterIndex)) } repoRaw, ok := argocd["repo"] if !ok || repoRaw == nil { - return false, nil + return nil } repo, ok := repoRaw.(map[string]any) if !ok { - return false, fmt.Errorf("%s.argocd.repo must be an object", legacyClusterLabel(cluster, clusterIndex)) + return fmt.Errorf("%s.argocd.repo must be an object", legacyClusterLabel(cluster, clusterIndex)) } httpsRepo, hasHTTPS := repo["https"] if !hasHTTPS { - return false, nil + return nil } if _, hasGit := repo["git"]; hasGit { - return false, fmt.Errorf("%s.argocd.repo has both legacy https and git repositories", legacyClusterLabel(cluster, clusterIndex)) + return fmt.Errorf("%s.argocd.repo has both legacy https and git repositories", legacyClusterLabel(cluster, clusterIndex)) } repo["git"] = httpsRepo delete(repo, "https") - return true, nil + return nil +} + +// migrateTerraformDNS removes the v1alpha1 terraform.dns object. The zone name +// duplicated the cluster dnsName, the contact email moves to terraform.dnsContactEmail. +func migrateTerraformDNS(cluster map[string]any, clusterIndex int) error { + terraformRaw, ok := cluster["terraform"] + if !ok || terraformRaw == nil { + return nil + } + terraform, ok := terraformRaw.(map[string]any) + if !ok { + return fmt.Errorf("%s.terraform must be an object", legacyClusterLabel(cluster, clusterIndex)) + } + + dnsRaw, hasDNS := terraform["dns"] + if !hasDNS { + return nil + } + dns, ok := dnsRaw.(map[string]any) + if !ok { + return fmt.Errorf("%s.terraform.dns must be an object", legacyClusterLabel(cluster, clusterIndex)) + } + + if email, ok := dns["email"]; ok { + if _, exists := terraform["dnsContactEmail"]; exists { + return fmt.Errorf("%s.terraform has both legacy dns.email and dnsContactEmail", legacyClusterLabel(cluster, clusterIndex)) + } + terraform["dnsContactEmail"] = email + } + + delete(terraform, "dns") + return nil } func isLegacyConfig(raw map[string]any) bool { @@ -487,7 +529,7 @@ func (cs *ConfigStore) GetFilepath() string { // SaveToFile saves the configuration to a YAML file func (cs *ConfigStore) SaveToFile() error { if strings.TrimSpace(cs.config.Version) == "" { - cs.config.Version = ConfigVersionV1Alpha1 + cs.config.Version = ConfigVersionV1Alpha2 } // Ensure directory exists diff --git a/src/internal/config/store_test.go b/src/internal/config/store_test.go index 6a4021f8..9bee9680 100644 --- a/src/internal/config/store_test.go +++ b/src/internal/config/store_test.go @@ -19,7 +19,7 @@ import ( // Helper function to create a valid test config func newValidTestConfig() *Config { return &Config{ - Version: ConfigVersionV1Alpha1, + Version: ConfigVersionV1Alpha2, Clusters: []Cluster{ { Name: "test-cluster", @@ -32,10 +32,7 @@ func newValidTestConfig() *Config { ProjectID: "00000000-0000-0000-0000-000000000000", KubernetesType: "ske", KubernetesVersion: "1.34", - DNS: DNS{ - Name: "example.com", - Email: "admin@example.com", - }, + DNSContactEmail: "admin@example.com", }, ArgoCD: ArgoCD{ Repo: RepoProto{ @@ -201,7 +198,7 @@ clusters: require.NoError(t, cs.Load()) loaded := cs.GetConfig() - require.Equal(t, ConfigVersionV1Alpha1, loaded.Version) + require.Equal(t, ConfigVersionV1Alpha2, loaded.Version) require.Len(t, loaded.Clusters, 1) cluster := loaded.Clusters[0] @@ -229,7 +226,7 @@ clusters: savedBytes, err := os.ReadFile(configPath) require.NoError(t, err) savedContent := string(savedBytes) - assert.Contains(t, savedContent, "version: v1alpha1") + assert.Contains(t, savedContent, "version: v1alpha2") assert.Contains(t, savedContent, "cert-manager:") assert.Contains(t, savedContent, "argocd:") assert.Contains(t, savedContent, "git:") @@ -243,13 +240,21 @@ clusters: assert.Contains(t, savedContent, "clusterIssuer:") } -func TestConfigStore_LoadMigratesRepoHTTPSKeyForVersionedConfig(t *testing.T) { +func TestConfigStore_LoadMigratesV1Alpha1ToV1Alpha2(t *testing.T) { configYAML := ` version: v1alpha1 clusters: - name: migrated-cluster dnsName: migrated.example.com type: hub + terraform: + provider: stackit + projectId: "00000000-0000-0000-0000-000000000000" + kubernetesType: ske + kubernetesVersion: "1.34" + dns: + name: migrated.example.com + email: admin@example.com argocd: repo: authMode: ssh @@ -302,17 +307,57 @@ clusters: cs := NewConfigStoreWithCatalog(configPath, catalog.LoadOptions{}) require.NoError(t, cs.Load()) - cluster := cs.GetConfig().Clusters[0] + loaded := cs.GetConfig() + assert.Equal(t, ConfigVersionV1Alpha2, loaded.Version) + + cluster := loaded.Clusters[0] assert.Equal(t, "ssh", cluster.ArgoCD.Repo.AuthMode) require.NotNil(t, cluster.ArgoCD.Repo.Git) assert.Equal(t, "git@github.com:customer/repo.git", cluster.ArgoCD.Repo.Git.Customer.URL) assert.Equal(t, "git@github.com:managed/repo.git", cluster.ArgoCD.Repo.Git.Managed.URL) + require.NotNil(t, cluster.Terraform) + assert.Equal(t, "admin@example.com", cluster.Terraform.DNSContactEmail) savedBytes, err := os.ReadFile(configPath) require.NoError(t, err) savedContent := string(savedBytes) + assert.Contains(t, savedContent, "version: v1alpha2") assert.Contains(t, savedContent, "git:") assert.NotContains(t, savedContent, "\n https:") + assert.Contains(t, savedContent, "dnsContactEmail: admin@example.com") + assert.NotContains(t, savedContent, "\n dns:") +} + +func TestConfigStore_LoadRejectsTerraformDNSMigrationConflict(t *testing.T) { + configYAML := ` +version: v1alpha1 +clusters: + - name: conflict-cluster + dnsName: conflict.example.com + terraform: + projectId: "00000000-0000-0000-0000-000000000000" + kubernetesVersion: "1.34" + dnsContactEmail: new@example.com + dns: + name: conflict.example.com + email: old@example.com + argocd: + repo: + git: + customer: + url: "https://github.com/customer/repo.git" + managed: + url: "https://github.com/managed/repo.git" + services: {} +` + + configPath := filepath.Join(t.TempDir(), "dns-conflict.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(configYAML), 0644)) + + cs := NewConfigStoreWithCatalog(configPath, catalog.LoadOptions{}) + err := cs.Load() + require.Error(t, err) + assert.ErrorContains(t, err, "both legacy dns.email and dnsContactEmail") } func TestConfigStore_LoadRejectsRepoMigrationConflict(t *testing.T) { @@ -454,7 +499,7 @@ func TestConfigStore_Validate(t *testing.T) { // Test format validation (email) invalidConfigFormatMismatch := deepCopyConfig(validConfig) clonedTerraform := *invalidConfigFormatMismatch.Clusters[0].Terraform - clonedTerraform.DNS.Email = "not-an-email" + clonedTerraform.DNSContactEmail = "not-an-email" invalidConfigFormatMismatch.Clusters[0].Terraform = &clonedTerraform // Terraform is optional at the cluster level diff --git a/src/internal/config/types.go b/src/internal/config/types.go index eebc2bd9..98eeddb3 100644 --- a/src/internal/config/types.go +++ b/src/internal/config/types.go @@ -2,11 +2,14 @@ package config import "github.com/kubara-io/kubara/internal/service" -const ConfigVersionV1Alpha1 = "v1alpha1" +const ( + ConfigVersionV1Alpha1 = "v1alpha1" + ConfigVersionV1Alpha2 = "v1alpha2" +) // Config is the root of the configuration structure. type Config struct { - Version string `json:"version,omitempty" yaml:"version,omitempty" jsonschema:"title=Config Version,description=The schema version of this config file.,enum=v1alpha1,default=v1alpha1"` + Version string `json:"version,omitempty" yaml:"version,omitempty" jsonschema:"title=Config Version,description=The schema version of this config file.,enum=v1alpha2,default=v1alpha2"` Clusters []Cluster `json:"clusters" yaml:"clusters" jsonschema:"title=Clusters,description=A list of cluster configurations."` } @@ -35,12 +38,7 @@ type Terraform struct { ProjectID string `json:"projectId" yaml:"projectId" jsonschema:"required,title=Cloud Project ID,description=The cloud provider project or subscription identifier. Accepts various formats depending on the provider.,minLength=1"` KubernetesType string `json:"kubernetesType" yaml:"kubernetesType" jsonschema:"title=Kubernetes Type,description=The type of Kubernetes cluster.,enum=edge,enum=ske,default=ske"` KubernetesVersion string `json:"kubernetesVersion" yaml:"kubernetesVersion" jsonschema:"required,title=Kubernetes Version,description=The Kubernetes version for the cluster.,example=1.34,pattern=^[0-9]\\.[0-9]+(\\.[0-9]+)?$"` - DNS DNS `json:"dns" yaml:"dns" jsonschema:"required,title=DNS Config,description=DNS Zone configuration"` -} - -type DNS struct { - Name string `json:"name" yaml:"name" jsonschema:"required,title=DNS Zone Name,description=The managed DNS zone name.,format=hostname"` - Email string `json:"email" yaml:"email" jsonschema:"required,title=Admin Email,description=Administrative email for the DNS zone.,format=email"` + DNSContactEmail string `json:"dnsContactEmail" yaml:"dnsContactEmail" jsonschema:"required,title=DNS Zone Contact Email,description=Administrative contact email for the managed DNS zone. The zone name itself is derived from the cluster dnsName.,format=email"` } type ArgoCD struct { diff --git a/src/internal/workflow/orchestrator.go b/src/internal/workflow/orchestrator.go index cf51aed1..828e7a7d 100644 --- a/src/internal/workflow/orchestrator.go +++ b/src/internal/workflow/orchestrator.go @@ -20,7 +20,6 @@ func CreateOrUpdateClusterFromEnvWithCatalog(cfg *config.Config, e *envconfig.En // Apply the new values from the environment to the found cluster. cfg.Clusters[i].Stage = e.ProjectStage cfg.Clusters[i].DNSName = dnsName - cfg.Clusters[i].Terraform.DNS.Name = dnsName cfg.Clusters[i].ArgoCD.Repo.AuthMode = e.GitAuthMode() cfg.Clusters[i].ArgoCD.Repo.Git.Managed.URL = gitRepoURL cfg.Clusters[i].ArgoCD.Repo.Git.Customer.URL = gitRepoURL diff --git a/src/internal/workflow/orchestrator_test.go b/src/internal/workflow/orchestrator_test.go index 1f13f615..c6a0f270 100644 --- a/src/internal/workflow/orchestrator_test.go +++ b/src/internal/workflow/orchestrator_test.go @@ -20,9 +20,7 @@ func TestCreateOrUpdateClusterFromEnv_UpdatesExistingClusterIncludingHelmRepo(t Stage: "stage", DNSName: "kubara-test-stage.example.com", Terraform: &config.Terraform{ - DNS: config.DNS{ - Name: "kubara-test-stage.example.com", - }, + DNSContactEmail: "admin@example.com", }, ArgoCD: config.ArgoCD{ Repo: config.RepoProto{ @@ -56,7 +54,7 @@ func TestCreateOrUpdateClusterFromEnv_UpdatesExistingClusterIncludingHelmRepo(t updated := cfg.Clusters[0] assert.Equal(t, "dev", updated.Stage) assert.Equal(t, "kubara-test-dev.example.com", updated.DNSName) - assert.Equal(t, "kubara-test-dev.example.com", updated.Terraform.DNS.Name) + assert.Equal(t, "admin@example.com", updated.Terraform.DNSContactEmail) assert.Equal(t, "https://github.com/new/repo.git", updated.ArgoCD.Repo.Git.Managed.URL) assert.Equal(t, "https://github.com/new/repo.git", updated.ArgoCD.Repo.Git.Customer.URL) require.NotNil(t, updated.ArgoCD.HelmRepo) @@ -92,7 +90,7 @@ func TestCreateOrUpdateClusterFromEnv_UpdatesGitURLAndAuthMode(t *testing.T) { Stage: "stage", DNSName: "kubara-test-stage.example.com", Terraform: &config.Terraform{ - DNS: config.DNS{Name: "kubara-test-stage.example.com"}, + DNSContactEmail: "admin@example.com", }, ArgoCD: config.ArgoCD{ Repo: config.RepoProto{ @@ -132,9 +130,7 @@ func TestCreateOrUpdateClusterFromEnv_DoesNotOverrideHelmRepoWhenEnvMissing(t *t Stage: "stage", DNSName: "kubara-test-stage.example.com", Terraform: &config.Terraform{ - DNS: config.DNS{ - Name: "kubara-test-stage.example.com", - }, + DNSContactEmail: "admin@example.com", }, ArgoCD: config.ArgoCD{ Repo: config.RepoProto{ From 0344c394118a9a3ae377c3e1e932cb6ef1ddf94f Mon Sep 17 00:00:00 2001 From: Matthiator Date: Tue, 21 Jul 2026 10:59:02 +0200 Subject: [PATCH 3/7] fix(ci): remove stale terraform DNS update --- .scripts/kubara-config-update.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/.scripts/kubara-config-update.sh b/.scripts/kubara-config-update.sh index 90fddaa5..75162fd3 100755 --- a/.scripts/kubara-config-update.sh +++ b/.scripts/kubara-config-update.sh @@ -47,6 +47,5 @@ apply_yaml_if_set KUBARA_STACKIT_PROJECT_ID ".clusters[0].terraform.projectId" apply_yaml_if_set KUBARA_KUBERNETES_TYPE ".clusters[0].terraform.kubernetesType" apply_yaml_if_set KUBARA_KUBERNETES_VERSION ".clusters[0].terraform.kubernetesVersion" apply_yaml_if_set KUBARA_DNS_NAME ".clusters[0].dnsName" -apply_yaml_if_set KUBARA_DNS_NAME ".clusters[0].terraform.dns.name" log "✅ config.yaml updated" From 7bdc6b83850b9512b6a623e4dc8011464946a97c Mon Sep 17 00:00:00 2001 From: Matthiator Date: Tue, 21 Jul 2026 11:07:50 +0200 Subject: [PATCH 4/7] fix: validate HTTPS Git credential pairs --- src/internal/envconfig/env.go | 10 ++++++++-- src/internal/envconfig/env_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/internal/envconfig/env.go b/src/internal/envconfig/env.go index dc7ac992..94d06f7a 100644 --- a/src/internal/envconfig/env.go +++ b/src/internal/envconfig/env.go @@ -126,13 +126,19 @@ func (em *EnvMap) Validate() error { func (em *EnvMap) validateGitAuth() error { switch em.GitAuthMode() { case GitAuthModeHTTPS: - // Username/PAT are optional so public repositories keep working; - // createGitRepositorySecret only adds basic-auth when both are set. if err := validateRequiredEnvValues(map[string]string{ "ARGOCD_GIT_URL or ARGOCD_GIT_HTTPS_URL": em.GitRepositoryURL(), }); err != nil { return err } + usernameConfigured := IsConfiguredEnvValue(em.ArgocdGitUsername) + passwordConfigured := IsConfiguredEnvValue(em.ArgocdGitPatOrPassword) + if usernameConfigured != passwordConfigured { + return &ErrorEnvMap{ + Message: "ARGOCD_GIT_USERNAME and ARGOCD_GIT_PAT_OR_PASSWORD must either both be set for a private repository or both be omitted for a public repository", + Err: ErrInvalidEnvValue, + } + } return validateHTTPGitURL(em.GitRepositoryURL(), GitAuthModeHTTPS) case GitAuthModeSSH: if err := validateRequiredEnvValues(map[string]string{ diff --git a/src/internal/envconfig/env_test.go b/src/internal/envconfig/env_test.go index 683e262b..b7aba3e8 100644 --- a/src/internal/envconfig/env_test.go +++ b/src/internal/envconfig/env_test.go @@ -337,6 +337,33 @@ func TestEnvMap_ValidateGitAuth(t *testing.T) { em.ArgocdGitHttpsUrl = "https://github.com/example/repo.git" }, }, + { + name: "https with username and PAT is allowed (private repo)", + mutate: func(em *EnvMap) { + em.ArgocdGitAuthMode = "https" + em.ArgocdGitHttpsUrl = "https://github.com/example/repo.git" + em.ArgocdGitUsername = "git" + em.ArgocdGitPatOrPassword = "token" + }, + }, + { + name: "https with only username fails", + mutate: func(em *EnvMap) { + em.ArgocdGitAuthMode = "https" + em.ArgocdGitHttpsUrl = "https://github.com/example/repo.git" + em.ArgocdGitUsername = "git" + }, + wantErr: ErrInvalidEnvValue, + }, + { + name: "https with only PAT fails", + mutate: func(em *EnvMap) { + em.ArgocdGitAuthMode = "https" + em.ArgocdGitHttpsUrl = "https://github.com/example/repo.git" + em.ArgocdGitPatOrPassword = "token" + }, + wantErr: ErrInvalidEnvValue, + }, { name: "https without any URL fails", mutate: func(em *EnvMap) { em.ArgocdGitAuthMode = "https" }, From 1d77980260ec5704189a97eeb09b95e34635a778 Mon Sep 17 00:00:00 2001 From: Matthiator Date: Tue, 21 Jul 2026 11:18:53 +0200 Subject: [PATCH 5/7] fix(kyverno): use configured Git repository URL --- .../helm/kyverno-policies/values.generated.yaml.tplt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/catalog/built-in/platform-configs/helm/kyverno-policies/values.generated.yaml.tplt b/src/internal/catalog/built-in/platform-configs/helm/kyverno-policies/values.generated.yaml.tplt index 48c14104..e264f300 100644 --- a/src/internal/catalog/built-in/platform-configs/helm/kyverno-policies/values.generated.yaml.tplt +++ b/src/internal/catalog/built-in/platform-configs/helm/kyverno-policies/values.generated.yaml.tplt @@ -5,4 +5,4 @@ global: allowedIssuerDomains: "{{ (index .cluster.services "cert-manager" "config" "clusterIssuer" "server") }}" allowedImageRegistries: | - "${ARGOCD_GIT_HTTPS_URL}/*|${ARGOCD_HELM_REPO_URL}/*" + "{{ .cluster.argocd.repo.git.configs.url }}/*|${ARGOCD_HELM_REPO_URL}/*" From f214466649625faab65f55900de33acc9c3a0fcd Mon Sep 17 00:00:00 2001 From: Matthiator Date: Wed, 22 Jul 2026 18:26:44 +0200 Subject: [PATCH 6/7] ci: test matching catalog changes --- .github/workflows/pr-checks.yaml | 39 +++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-checks.yaml b/.github/workflows/pr-checks.yaml index e008c0ec..4c18eae9 100644 --- a/.github/workflows/pr-checks.yaml +++ b/.github/workflows/pr-checks.yaml @@ -278,6 +278,15 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + # TODO: Remove this checkout and the local packaging step once catalog + # version 1.1.0 has been published by kubara-io/catalogs#6. + - name: Checkout matching catalogs + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: kubara-io/catalogs + ref: feat/catalog-repository-auth + path: .ci/catalogs + - name: Setup Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: @@ -285,6 +294,21 @@ jobs: cache: true cache-dependency-path: src/go.sum + - name: Build kubara CLI + working-directory: src + run: go build -o "${RUNNER_TEMP}/kubara" . + + - name: Package matching catalogs locally + run: | + set -euo pipefail + for catalog in bootstrap general; do + ( + cd ".ci/catalogs/${catalog}" + "${RUNNER_TEMP}/kubara" catalog package \ + oci://ghcr.io/kubara-io/catalogs/ + ) + done + - name: Create output directory run: | echo "output dir: ${{ env.OUTPUT_GENERATED_DIR }}" @@ -293,8 +317,9 @@ jobs: - name: kubara init --prep run: | set -euo pipefail - cd src - go run main.go --work-dir "${{ env.OUTPUT_GENERATED_DIR }}" init --prep + "${RUNNER_TEMP}/kubara" \ + --work-dir "${{ env.OUTPUT_GENERATED_DIR }}" \ + init --prep - name: Update .env (strict template mode) run: | @@ -304,8 +329,9 @@ jobs: - name: kubara init run: | set -euo pipefail - cd src - go run main.go --work-dir "${{ env.OUTPUT_GENERATED_DIR }}" init + "${RUNNER_TEMP}/kubara" \ + --work-dir "${{ env.OUTPUT_GENERATED_DIR }}" \ + init - name: Update config.yaml (strict mode) run: | @@ -314,8 +340,9 @@ jobs: - name: Generate kubara artifacts run: | - cd src - go run main.go --work-dir "${{ env.OUTPUT_GENERATED_DIR }}" generate + "${RUNNER_TEMP}/kubara" \ + --work-dir "${{ env.OUTPUT_GENERATED_DIR }}" \ + generate - name: Upload generated helm and terraform files uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 From e7b2e8395f92ccd2980e923c136fba692c5fc2a7 Mon Sep 17 00:00:00 2001 From: Matthiator Date: Wed, 22 Jul 2026 18:37:19 +0200 Subject: [PATCH 7/7] docs: document repository auth modes --- .../add_app_repository.md | 104 +++++++++++++++--- 1 file changed, 87 insertions(+), 17 deletions(-) diff --git a/docs/content/5_workload_onboarding/add_app_repository.md b/docs/content/5_workload_onboarding/add_app_repository.md index b8c362ba..81e979b1 100644 --- a/docs/content/5_workload_onboarding/add_app_repository.md +++ b/docs/content/5_workload_onboarding/add_app_repository.md @@ -7,34 +7,104 @@ For more information check: https://argo-cd.readthedocs.io/en/stable/user-guide/private-repositories/ ## **Add credentials to vault** -Add the repository credentials to your vault at -`//repo_pat`. This can be a `password` or a `PAT`. +Add the repository credentials to your vault below `/`. +The examples below use one secret value per repository credential. + +For HTTPS username + password/PAT authentication, `PAT` usually means Personal Access Token and is often tied to a user account. +For platform automation, prefer a technical or machine account instead of a personal user account. +Set `username` to the account name expected by your Git provider; the exact value is provider-dependent. + ```json { "repo_pat": { - "pat": "" + "pat": "" + } +} +``` + +For SSH deploy key authentication: + +```json +{ + "repo_ssh": { + "privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----" + } +} +``` + +For GitHub App authentication: + +```json +{ + "repo_github_app": { + "privateKey": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" } } ``` + ## **Modify Argo CD overlays** -Add the following to your Argo CD overlay, typically `platform-configs//helm/argo-cd/values-additional.yaml`. +Add one of the following repository definitions to your Argo CD overlay, typically +`platform-configs//helm/argo-cd/values-additional.yaml`. + +HTTPS username + password/PAT: + +```yaml +repositories: + - name: user-repo-mock + authMode: https + projectScope: k8s-spoke-0 + remoteRef: + remoteKey: //repo_pat + remoteKeyProperty: pat + repoType: git + secretStoreRef: + kind: ClusterSecretStore + name: hub-0-production + url: https://git.example.com/org/repo.git + username: +``` + +SSH deploy key: + ```yaml repositories: - - name: user-repo-mock - projectScope: k8s-spoke-0 - # # This points to the secret in vault - remoteRef: - remoteKey: //repo_pat - remoteKeyProperty: pat - repoType: git - secretStoreRef: - kind: ClusterSecretStore - name: hub-0-production - url: - username: + - name: user-repo-ssh + authMode: ssh + projectScope: k8s-spoke-0 + sshPrivateKeyRemoteRef: + remoteKey: //repo_ssh + remoteKeyProperty: privateKey + repoType: git + secretStoreRef: + kind: ClusterSecretStore + name: hub-0-production + url: git@git.example.com:org/repo.git ``` -That whats happening behind the scenes: +For SSH repositories, make sure Argo CD already trusts the SSH host key. See the bootstrap documentation for `configs.ssh.extraHosts`. + +GitHub App: + +```yaml +repositories: + - name: user-repo-github-app + authMode: github-app + projectScope: k8s-spoke-0 + githubAppID: "123456" + githubAppInstallationID: "987654" + githubAppPrivateKeyRemoteRef: + remoteKey: //repo_github_app + remoteKeyProperty: privateKey + repoType: git + secretStoreRef: + kind: ClusterSecretStore + name: hub-0-production + url: https://github.com/org/repo.git +``` + +For GitHub Enterprise, also set `githubAppEnterpriseBaseUrl`. + +That's what's happening behind the scenes: ![Add Repository](../images/add-repository.png)