diff --git a/chart/apl/templates/NOTES.txt b/chart/apl/templates/NOTES.txt index ea17bb03a0..ce0e6e118e 100644 --- a/chart/apl/templates/NOTES.txt +++ b/chart/apl/templates/NOTES.txt @@ -1,5 +1,5 @@ The APL installer was successfully deployed on the cluster. -Please inspect the output of the installer job ({{ .Release.Namespace }}/{{ include "apl-operator.fullname" . }}) for any feedback or errors. +Please inspect the output of the apl-operator deployment (apl-operator/{{ include "apl-operator.fullname" . }}) for any feedback or errors. -Also visit https://apl-docs.net for further instructions and reference documentation. \ No newline at end of file +Also visit https://apl-docs.net for further instructions and reference documentation. diff --git a/chart/apl/templates/deployment.yaml b/chart/apl/templates/deployment.yaml index 2b34ecc718..7f6bff1de9 100644 --- a/chart/apl/templates/deployment.yaml +++ b/chart/apl/templates/deployment.yaml @@ -80,7 +80,7 @@ spec: - secretRef: name: apl-sops-secrets - secretRef: - name: gitea-credentials + name: apl-git-credentials {{- end }} volumeMounts: - name: otomi-values diff --git a/chart/apl/templates/git-config.yaml b/chart/apl/templates/git-config.yaml new file mode 100644 index 0000000000..ba29121125 --- /dev/null +++ b/chart/apl/templates/git-config.yaml @@ -0,0 +1,14 @@ +{{- $git := .Values.otomi.git | default dict }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: apl-git-config + namespace: apl-operator +data: + {{- if $git.repoUrl }} + repoUrl: {{ $git.repoUrl | quote }} + {{- end }} + branch: {{ $git.branch | quote }} + {{- if $git.email }} + email: {{ $git.email | quote }} + {{- end }} diff --git a/chart/apl/templates/git-secret.yaml b/chart/apl/templates/git-secret.yaml index 5d40880c5c..d8262fc2ca 100644 --- a/chart/apl/templates/git-secret.yaml +++ b/chart/apl/templates/git-secret.yaml @@ -1,13 +1,14 @@ +{{- $git := .Values.otomi.git | default dict }} apiVersion: v1 kind: Secret metadata: - name: gitea-credentials + name: apl-git-credentials namespace: apl-operator type: Opaque stringData: -{{- if .Values.gitUsername }} - GIT_USERNAME: {{ .Values.gitUsername | quote }} -{{- end }} -{{- if .Values.gitPassword }} - GIT_PASSWORD: {{ .Values.gitPassword | quote }} -{{- end }} + {{- if $git.username }} + username: {{ $git.username | quote }} + {{- end }} + {{- if $git.password }} + password: {{ $git.password | quote }} + {{- end }} diff --git a/chart/apl/values.yaml b/chart/apl/values.yaml index 8b7b00412b..edd983241d 100644 --- a/chart/apl/values.yaml +++ b/chart/apl/values.yaml @@ -43,6 +43,15 @@ otomi: ## By default the image tag is set to .Chart.AppVersion # version: main + ## Git repository configuration + ## By default, APL uses the built-in Gitea instance. + git: + # repoUrl: '' # Repository url (e.g., https://github.com/org/repo) + # user: '' # Git username (defaults to 'otomi-admin') + # password: '' # Git password or personal access token + # email: '' # Email for git commits (defaults to 'pipeline@cluster.local') + branch: main + ## Optional configuration # apps: # cert-manager: diff --git a/charts/apl-operator/templates/deployment.yaml b/charts/apl-operator/templates/deployment.yaml index 5691159a8c..c4f5011207 100644 --- a/charts/apl-operator/templates/deployment.yaml +++ b/charts/apl-operator/templates/deployment.yaml @@ -11,10 +11,13 @@ spec: {{- include "apl-operator.selectorLabels" . | nindent 6 }} template: metadata: - {{- with .Values.podAnnotations }} annotations: + # Restart pod when git credentials or config changes (important for migration) + checksum/git-credentials: {{ include (print $.Template.BasePath "/secrets.yaml") . | sha256sum }} + checksum/git-config: {{ include (print $.Template.BasePath "/git-config.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} {{- toYaml . | nindent 8 }} - {{- end }} + {{- end }} labels: {{- include "apl-operator.selectorLabels" . | nindent 8 }} spec: @@ -38,6 +41,11 @@ spec: env: - name: CI value: "true" + envFrom: + - secretRef: + name: apl-sops-secrets + - secretRef: + name: apl-git-credentials resources: {{- toYaml .Values.resources | nindent 12 }} volumeMounts: diff --git a/charts/apl-operator/templates/git-config.yaml b/charts/apl-operator/templates/git-config.yaml new file mode 100644 index 0000000000..e16f65f8f5 --- /dev/null +++ b/charts/apl-operator/templates/git-config.yaml @@ -0,0 +1,16 @@ +{{- $git := .Values.git | default dict }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: apl-git-config + namespace: apl-operator +data: + {{- if $git.repoUrl }} + repoUrl: {{ $git.repoUrl | quote }} + {{- end }} + {{- if $git.branch }} + branch: {{ $git.branch | quote }} + {{- end }} + {{- if $git.email }} + email: {{ $git.email | quote }} + {{- end }} diff --git a/charts/apl-operator/templates/secrets.yaml b/charts/apl-operator/templates/secrets.yaml index e12e395e89..705a61b3d7 100644 --- a/charts/apl-operator/templates/secrets.yaml +++ b/charts/apl-operator/templates/secrets.yaml @@ -1,4 +1,5 @@ {{- $kms := .Values.kms | default dict }} +{{- $git := .Values.git | default dict }} {{- if hasKey $kms "sops" }} {{- $v := $kms.sops }} apiVersion: v1 @@ -34,12 +35,30 @@ data: {{- end }} {{- end }} --- +# Keep old secret for migration. Remove in future release. apiVersion: v1 kind: Secret metadata: name: gitea-credentials - namespace: {{ .Release.Namespace }} + namespace: apl-operator type: Opaque stringData: +{{- if .Values.gitUsername }} GIT_USERNAME: {{ .Values.gitUsername | quote }} +{{- end }} +{{- if .Values.gitPassword }} GIT_PASSWORD: {{ .Values.gitPassword | quote }} +{{- end }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: apl-git-credentials +type: Opaque +stringData: + {{- if $git.username }} + username: {{ $git.username | quote }} + {{- end }} + {{- if $git.password }} + password: {{ $git.password | quote }} + {{- end }} diff --git a/charts/apl-operator/values.yaml b/charts/apl-operator/values.yaml index c476b29f43..7865491c88 100644 --- a/charts/apl-operator/values.yaml +++ b/charts/apl-operator/values.yaml @@ -63,6 +63,3 @@ kms: {} # sops: # age: # privateKey: "AGE-SECRET-KEY-EXAMPLExxxxxxxxxxxxxxxxxxxxxxxx" - -gitPassword: "" -gitUsername: "otomi-admin" diff --git a/charts/team-ns/templates/argocd/argocd-application-gitops.yaml b/charts/team-ns/templates/argocd/argocd-application-gitops.yaml index 646113bcf8..adefe2d57c 100644 --- a/charts/team-ns/templates/argocd/argocd-application-gitops.yaml +++ b/charts/team-ns/templates/argocd/argocd-application-gitops.yaml @@ -1,6 +1,6 @@ {{- $v := .Values }} {{- $a := $v.apps.argocd }} -{{- if $a.enabled }} +{{- if and $a.enabled $v.gitOps.teamRepoUrl }} apiVersion: argoproj.io/v1alpha1 kind: Application metadata: diff --git a/charts/team-ns/templates/argocd/argocd-project.yaml b/charts/team-ns/templates/argocd/argocd-project.yaml index cd1c8c4c69..866e6be883 100644 --- a/charts/team-ns/templates/argocd/argocd-project.yaml +++ b/charts/team-ns/templates/argocd/argocd-project.yaml @@ -5,7 +5,9 @@ {{- range $i, $item := $v.workloads }} {{- $urls = append $urls $item.url }} {{- end }} +{{- if $v.gitOps.teamRepoUrl }} {{- $urls = append $urls $v.gitOps.teamRepoUrl }} +{{- end }} {{- $urls = append $urls $v.gitOps.valuesRepoUrl }} {{- $urls = sortAlpha (uniq $urls) }} diff --git a/charts/team-ns/templates/argocd/argocd-repo.yaml b/charts/team-ns/templates/argocd/argocd-repo.yaml index b27b0c2688..1d13fa8d0a 100644 --- a/charts/team-ns/templates/argocd/argocd-repo.yaml +++ b/charts/team-ns/templates/argocd/argocd-repo.yaml @@ -1,3 +1,4 @@ +{{- if .Values.gitOps.teamRepoUrl }} apiVersion: v1 kind: Secret metadata: @@ -8,3 +9,4 @@ metadata: stringData: type: git url: {{ .Values.gitOps.teamRepoUrl }} +{{- end }} diff --git a/charts/team-ns/templates/builds/docker.yaml b/charts/team-ns/templates/builds/docker.yaml index 9893a4c731..13bb3d903d 100644 --- a/charts/team-ns/templates/builds/docker.yaml +++ b/charts/team-ns/templates/builds/docker.yaml @@ -10,7 +10,7 @@ metadata: annotations: sidecar.istio.io/inject: "false" # ArgoCD sync wave annotation to ensure it's applied first - argocd.argoproj.io/sync-wave: "-2" + argocd.argoproj.io/sync-wave: "-2" labels: {{- include "team-ns.chart-labels" $ | nindent 4 }} spec: workspaces: @@ -76,7 +76,7 @@ spec: value: {{ $v.harborDomain }}/team-{{ $v.teamId }}/{{ .imageName }}:{{ .tag }} {{- with (dig "mode" "docker" "envVars" nil . ) }} - name: EXTRA_ARGS - value: + value: {{- range . }} - --build-arg={{ .name }}={{ .value }} {{- end }} @@ -116,7 +116,7 @@ spec: metadata: creationTimestamp: null spec: - {{- if $v.buildStorageClassName }} + {{- if $v.buildStorageClassName }} storageClassName: {{ $v.buildStorageClassName }} {{- end }} accessModes: @@ -134,7 +134,7 @@ spec: {{- else }} - name: git-credentials secret: - secretName: gitea-credentials + secretName: apl-git-credentials {{- end }} - name: docker-credentials secret: @@ -166,7 +166,7 @@ spec: metadata: creationTimestamp: null spec: - {{- if $v.buildStorageClassName }} + {{- if $v.buildStorageClassName }} storageClassName: {{ $v.buildStorageClassName }} {{- end }} accessModes: @@ -184,7 +184,7 @@ spec: {{- else }} - name: git-credentials secret: - secretName: gitea-credentials + secretName: apl-git-credentials {{- end }} - name: docker-credentials secret: @@ -211,7 +211,7 @@ kind: EventListener metadata: name: gitea-webhook-{{ .name }} annotations: - argocd.argoproj.io/sync-wave: "-1" + argocd.argoproj.io/sync-wave: "-1" labels: tekton.dev/pipeline: docker-build-{{ .name }} {{- include "team-ns.chart-labels" $ | nindent 4 }} diff --git a/helmfile.d/helmfile-03.databases.yaml.gotmpl b/helmfile.d/helmfile-03.databases.yaml.gotmpl index 3c718d9283..bb0882cc3a 100644 --- a/helmfile.d/helmfile-03.databases.yaml.gotmpl +++ b/helmfile.d/helmfile-03.databases.yaml.gotmpl @@ -15,14 +15,14 @@ bases: releases: - name: gitea-db-secret-artifacts - installed: true + installed: {{ $a | get "gitea.enabled" }} namespace: gitea labels: pkg: gitea app: core <<: *raw - name: gitea-otomi-db - installed: true + installed: {{ $a | get "gitea.enabled" }} namespace: gitea labels: pkg: gitea diff --git a/helmfile.d/snippets/defaults.gotmpl b/helmfile.d/snippets/defaults.gotmpl index 70957e50fc..df5cca08c3 100644 --- a/helmfile.d/snippets/defaults.gotmpl +++ b/helmfile.d/snippets/defaults.gotmpl @@ -24,8 +24,6 @@ environments: - apps: kubeflow-pipelines: rootPassword: {{ randAlphaNum 32 }} - gitea: - adminPassword: {{ randAlphaNum 20 }} {{- range $index,$ingressClassName := $ingressClassNames }} ingress-nginx-{{ $ingressClassName}}: autoscaling: @@ -274,6 +272,8 @@ environments: {{- end }} otomi: adminPassword: {{ randAlphaNum 32 }} + git: + password: {{ randAlphaNum 20 }} cluster: owner: customer name: apl diff --git a/helmfile.d/snippets/defaults.yaml b/helmfile.d/snippets/defaults.yaml index 763f2c1d27..a2b1d51d5a 100644 --- a/helmfile.d/snippets/defaults.yaml +++ b/helmfile.d/snippets/defaults.yaml @@ -146,8 +146,8 @@ environments: memory: 64Mi cpu: 10m gitea: - adminUsername: otomi-admin _rawValues: {} + enabled: true networkPolicies: enabled: true databaseMaxConnections: 28 @@ -1143,6 +1143,11 @@ environments: receivers: - none otomi: + git: + branch: main + repoUrl: http://gitea-http.gitea.svc.cluster.local:3000/otomi/values.git + username: otomi-admin + email: pipeline@cluster.local hasExternalDNS: false hasExternalIDP: false isMultitenant: true @@ -1159,4 +1164,4 @@ environments: branch: main enabled: true versions: - specVersion: 54 + specVersion: 55 diff --git a/helmfile.d/snippets/derived.gotmpl b/helmfile.d/snippets/derived.gotmpl index ae1cf08b91..569f30c8b6 100644 --- a/helmfile.d/snippets/derived.gotmpl +++ b/helmfile.d/snippets/derived.gotmpl @@ -225,8 +225,6 @@ environments: registry: credentials: password: {{ $a | get "harbor.registry.credentials.password" $v.otomi.adminPassword | quote }} - gitea: - enabled: true keycloak: enabled: true address: {{ $keycloakBaseUrl }} diff --git a/src/cmd/bootstrap.test.ts b/src/cmd/bootstrap.test.ts index 4d61225a86..1417769ca7 100644 --- a/src/cmd/bootstrap.test.ts +++ b/src/cmd/bootstrap.test.ts @@ -207,6 +207,7 @@ describe('Bootstrapping values', () => { getKmsSettings: jest.fn(), terminal, writeFile: jest.fn(), + createUpdateGenericSecret: jest.fn(), } it('should create files on first run and en/de-crypt', async () => { deps.pathExists.mockReturnValue(false) diff --git a/src/cmd/bootstrap.ts b/src/cmd/bootstrap.ts index 9c30aea8f2..96e30c60c7 100644 --- a/src/cmd/bootstrap.ts +++ b/src/cmd/bootstrap.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'crypto' import { existsSync } from 'fs' import { copyFile, cp, mkdir, readFile, writeFile } from 'fs/promises' import { generate as generatePassword } from 'generate-password' -import { cloneDeep, get, isEmpty, merge, set } from 'lodash' +import { cloneDeep, get, merge, set } from 'lodash' import { pki } from 'node-forge' import path from 'path' import { bootstrapGit } from 'src/common/bootstrap' @@ -12,7 +12,14 @@ import { decrypt, encrypt } from 'src/common/crypt' import { terminal } from 'src/common/debug' import { env, isCli } from 'src/common/envalid' import { hfValues } from 'src/common/hf' -import { createK8sSecret, getDeploymentState, getK8sSecret, secretId } from 'src/common/k8s' +import { + createK8sSecret, + createUpdateGenericSecret, + getDeploymentState, + getK8sSecret, + k8s, + secretId, +} from 'src/common/k8s' import { getKmsSettings } from 'src/common/repo' import { ensureTeamGitOpsDirectories, getFilename, gucci, isCore, loadYaml, rootDir } from 'src/common/utils' import { generateSecrets, writeValues } from 'src/common/values' @@ -44,6 +51,7 @@ export const bootstrapSops = async ( readFile, terminal, writeFile, + createUpdateGenericSecret, }, ): Promise => { const d = deps.terminal(`cmd:${cmdName}:genSops`) @@ -75,6 +83,13 @@ export const bootstrapSops = async ( if (privateKey && !process.env.SOPS_AGE_KEY) { process.env.SOPS_AGE_KEY = privateKey await deps.writeFile(`${envDir}/.secrets`, `SOPS_AGE_KEY=${privateKey}`) + try { + await deps.createUpdateGenericSecret(k8s.core(), 'apl-sops-secrets', 'apl-operator', { + SOPS_AGE_KEY: privateKey, + }) + } catch (e) { + d.warn('Failed to create or update apl-sops-secrets secret with SOPS_AGE_KEY, this might come later') + } } } diff --git a/src/cmd/commit.ts b/src/cmd/commit.ts index 4376499db2..1605e9a3a8 100644 --- a/src/cmd/commit.ts +++ b/src/cmd/commit.ts @@ -8,7 +8,7 @@ import { hfValues } from 'src/common/hf' import { waitTillGitRepoAvailable } from 'src/common/gitea' import { createUpdateConfigMap, createUpdateGenericSecret, k8s } from 'src/common/k8s' import { getFilename } from 'src/common/utils' -import { getRepo } from 'src/common/values' +import { getRepo, GitRepoConfig } from 'src/common/git-config' import { HelmArguments, setParsedArgs } from 'src/common/yargs' import { Argv } from 'yargs' import { $, cd } from 'zx' @@ -54,11 +54,16 @@ const cleanupGitState = async (d: any): Promise => { } } -const commitAndPush = async (values: Record, branch: string, initialInstall = false): Promise => { +const commitAndPush = async ( + values: Record, + branch: string, + initialInstall = false, + gitConfig?: GitRepoConfig, +): Promise => { const d = terminal(`cmd:${cmdName}:commitAndPush`) d.info('Committing values') const message = initialInstall ? 'otomi commit' : 'updated values [ci skip]' - const { password } = getRepo(values) + const { password } = gitConfig ?? getRepo(values) cd(env.ENV_DIR) try { try { @@ -128,12 +133,17 @@ const commitAndPush = async (values: Record, branch: string, initia d.log('Successfully pushed the updated values') } -export const commit = async (initialInstall: boolean, overrideArgs?: HelmArguments): Promise => { +export const commit = async ( + initialInstall: boolean, + overrideArgs?: HelmArguments, + gitConfig?: GitRepoConfig, +): Promise => { const d = terminal(`cmd:${cmdName}:commit`) await validateValues(overrideArgs) d.info('Preparing values') const values = (await hfValues()) as Record - const { branch, remote, username, email } = getRepo(values) + // Use provided gitConfig if available (operator mode), otherwise read from values (bootstrap/install mode) + const { branch, authenticatedUrl: remote, username, email } = gitConfig ?? getRepo(values) if (initialInstall) { // we call this here again, as we might not have completed (happens upon first install): await bootstrapGit(values) diff --git a/src/cmd/install.test.ts b/src/cmd/install.test.ts index 2645cb2c9b..86d69a4a5a 100644 --- a/src/cmd/install.test.ts +++ b/src/cmd/install.test.ts @@ -30,10 +30,15 @@ jest.mock('src/common/values', () => ({ jest.mock('src/common/hf', () => ({ hf: jest.fn(), + hfValues: jest.fn(), deployEssential: jest.fn(), HF_DEFAULT_SYNC_ARGS: ['apply', '--sync-args', '--include-needs'], })) +jest.mock('src/common/git-config', () => ({ + setGitConfig: jest.fn(), +})) + jest.mock('zx', () => ({ $: jest.fn(), cd: jest.fn(), diff --git a/src/cmd/install.ts b/src/cmd/install.ts index 2de0cbd1c5..b91de56f4e 100644 --- a/src/cmd/install.ts +++ b/src/cmd/install.ts @@ -3,7 +3,8 @@ import { mkdirSync, rmSync } from 'fs' import { cleanupHandler, prepareEnvironment } from 'src/common/cli' import { logLevelString, terminal } from 'src/common/debug' import { env } from 'src/common/envalid' -import { deployEssential, hf, HF_DEFAULT_SYNC_ARGS } from 'src/common/hf' +import { setGitConfig } from 'src/common/git-config' +import { deployEssential, hf, HF_DEFAULT_SYNC_ARGS, hfValues } from 'src/common/hf' import { applyServerSide, getDeploymentState, getHelmReleases, setDeploymentState, waitForCRD } from 'src/common/k8s' import { getFilename, rootDir } from 'src/common/utils' import { getImageTagFromValues, getPackageVersion, writeValuesToFile } from 'src/common/values' @@ -96,7 +97,17 @@ export const installAll = async () => { ) if (!(env.isDev && env.DISABLE_SYNC)) { + // Get the git configuration from values + const values = (await hfValues()) as Record + // Commit to Git repository await commit(true) + + await setGitConfig({ + repoUrl: values?.otomi?.git?.repoUrl, + branch: values?.otomi?.git?.branch ?? 'main', + email: values?.otomi?.git?.email, + }) + const initialData = await initialSetupData() await retryInstallStep(createCredentialsSecret, initialData.secretName, initialData.username, initialData.password) await retryInstallStep(createWelcomeConfigMap, initialData.secretName, initialData.domainSuffix) diff --git a/src/cmd/migrate.ts b/src/cmd/migrate.ts index 898d6c235c..d862f6a472 100644 --- a/src/cmd/migrate.ts +++ b/src/cmd/migrate.ts @@ -3,7 +3,7 @@ import { encryptSecretItem } from '@linode/kubeseal-encrypt' import { randomUUID } from 'crypto' import { diff } from 'deep-diff' import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'fs' -import { cp, rename as fsRename, mkdir, readFile, writeFile } from 'fs/promises' +import { cp, mkdir, readFile, rename as fsRename, writeFile } from 'fs/promises' import { glob, globSync } from 'glob' import { cloneDeep, each, get, isObject, isUndefined, mapKeys, mapValues, omit, pick, pull, set, unset } from 'lodash' import { basename, dirname, join } from 'path' diff --git a/src/common/bootstrap.ts b/src/common/bootstrap.ts index 2773d3ea3f..7fc12aa385 100644 --- a/src/common/bootstrap.ts +++ b/src/common/bootstrap.ts @@ -4,7 +4,8 @@ import { terminal } from 'src/common/debug' import { env, isCli } from 'src/common/envalid' import { hfValues } from 'src/common/hf' import { getFilename } from 'src/common/utils' -import { getRepo, writeValues } from 'src/common/values' +import { getRepo } from 'src/common/git-config' +import { writeValues } from 'src/common/values' import { $, cd } from 'zx' const cmdName = getFilename(__filename) @@ -22,7 +23,7 @@ export const bootstrapGit = async (inValues?: Record): Promise) - const { remote, branch, email, username, password } = getRepo(values) + const { authenticatedUrl: remote, branch, email, username, password } = getRepo(values) cd(env.ENV_DIR) if (existsSync(`${env.ENV_DIR}/.git`)) { d.info(`Git repo was already bootstrapped, setting identity just in case`) @@ -58,7 +59,7 @@ export const bootstrapGit = async (inValues?: Record): Promise // finally write back the new values without overwriting existing values diff --git a/src/common/git-config.test.ts b/src/common/git-config.test.ts new file mode 100644 index 0000000000..6d2e23888f --- /dev/null +++ b/src/common/git-config.test.ts @@ -0,0 +1,349 @@ +import { + getGitConfigData, + getGitCredentials, + getOldGitCredentials, + getRepo, + getStoredGitRepoConfig, + setGitConfig, +} from './git-config' + +const mockGetK8sSecret = jest.fn() +const mockGetK8sConfigMap = jest.fn() +const mockCreateUpdateConfigMap = jest.fn() +const mockCoreApi = {} + +jest.mock('./k8s', () => ({ + getK8sSecret: (...args: any[]) => mockGetK8sSecret(...args), + getK8sConfigMap: (...args: any[]) => mockGetK8sConfigMap(...args), + createUpdateConfigMap: (...args: any[]) => mockCreateUpdateConfigMap(...args), + k8s: { core: () => mockCoreApi }, +})) + +jest.mock('./debug', () => ({ + terminal: jest.fn().mockImplementation(() => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + })), +})) + +describe('git-config', () => { + const originalEnv = process.env + + beforeEach(() => { + jest.clearAllMocks() + process.env = { ...originalEnv } + delete process.env.NODE_ENV + delete process.env.GIT_REPO_URL + }) + + afterAll(() => { + process.env = originalEnv + }) + + describe('getGitCredentials', () => { + it('should return credentials when secret exists', async () => { + mockGetK8sSecret.mockResolvedValue({ username: 'admin', password: 's3cret' }) + const result = await getGitCredentials() + expect(result).toEqual({ username: 'admin', password: 's3cret' }) + expect(mockGetK8sSecret).toHaveBeenCalledWith('apl-git-credentials', 'apl-operator') + }) + + it('should return undefined when secret does not exist', async () => { + mockGetK8sSecret.mockResolvedValue(undefined) + const result = await getGitCredentials() + expect(result).toBeUndefined() + }) + + it('should return undefined when username is missing', async () => { + mockGetK8sSecret.mockResolvedValue({ password: 's3cret' }) + const result = await getGitCredentials() + expect(result).toBeUndefined() + }) + + it('should return undefined when password is missing', async () => { + mockGetK8sSecret.mockResolvedValue({ username: 'admin' }) + const result = await getGitCredentials() + expect(result).toBeUndefined() + }) + }) + + describe('getOldGitCredentials', () => { + it('should return credentials from old gitea-credentials secret', async () => { + mockGetK8sSecret.mockResolvedValue({ GIT_USERNAME: 'otomi-admin', GIT_PASSWORD: 'oldpass' }) + const result = await getOldGitCredentials() + expect(result).toEqual({ username: 'otomi-admin', password: 'oldpass' }) + expect(mockGetK8sSecret).toHaveBeenCalledWith('gitea-credentials', 'apl-operator') + }) + + it('should return undefined fields when secret does not exist', async () => { + mockGetK8sSecret.mockResolvedValue(undefined) + const result = await getOldGitCredentials() + expect(result).toEqual({ username: undefined, password: undefined }) + }) + }) + + describe('getGitConfigData', () => { + it('should return config data from configmap', async () => { + mockGetK8sConfigMap.mockResolvedValue({ + data: { + repoUrl: 'https://github.com/org/repo.git', + branch: 'main', + email: 'pipeline@cluster.local', + }, + }) + const result = await getGitConfigData() + expect(result).toEqual({ + repoUrl: 'https://github.com/org/repo.git', + branch: 'main', + email: 'pipeline@cluster.local', + }) + expect(mockGetK8sConfigMap).toHaveBeenCalledWith('apl-operator', 'apl-git-config', mockCoreApi) + }) + + it('should return undefined when configmap does not exist', async () => { + mockGetK8sConfigMap.mockResolvedValue(undefined) + const result = await getGitConfigData() + expect(result).toBeUndefined() + }) + + it('should return undefined when configmap has no data', async () => { + mockGetK8sConfigMap.mockResolvedValue({ data: undefined }) + const result = await getGitConfigData() + expect(result).toBeUndefined() + }) + }) + + describe('getStoredGitRepoConfig', () => { + it('should return full config with authenticated URL', async () => { + mockGetK8sConfigMap.mockResolvedValue({ + data: { + repoUrl: 'https://github.com/org/repo.git', + branch: 'main', + email: 'pipeline@cluster.local', + }, + }) + mockGetK8sSecret.mockResolvedValue({ username: 'admin', password: 's3cret' }) + + const result = await getStoredGitRepoConfig() + expect(result).toEqual({ + repoUrl: 'https://github.com/org/repo.git', + authenticatedUrl: 'https://admin:s3cret@github.com/org/repo.git', + branch: 'main', + email: 'pipeline@cluster.local', + username: 'admin', + password: 's3cret', + }) + }) + + it('should fall back to old credentials when new secret is missing', async () => { + mockGetK8sConfigMap.mockResolvedValue({ + data: { + repoUrl: 'https://github.com/org/repo.git', + branch: 'main', + email: 'pipeline@cluster.local', + }, + }) + mockGetK8sSecret + .mockResolvedValueOnce(undefined) // getGitCredentials + .mockResolvedValueOnce({ GIT_USERNAME: 'otomi-admin', GIT_PASSWORD: 'oldpass' }) // getOldGitCredentials + + const result = await getStoredGitRepoConfig() + expect(result).toEqual({ + repoUrl: 'https://github.com/org/repo.git', + authenticatedUrl: 'https://otomi-admin:oldpass@github.com/org/repo.git', + branch: 'main', + email: 'pipeline@cluster.local', + username: 'otomi-admin', + password: 'oldpass', + }) + }) + + it('should fall back to default gitea config when configmap is missing', async () => { + mockGetK8sConfigMap.mockResolvedValue(undefined) + mockGetK8sSecret.mockResolvedValue({ username: 'admin', password: 's3cret' }) + + const result = await getStoredGitRepoConfig() + expect(result).toEqual({ + repoUrl: 'http://gitea-http.gitea.svc.cluster.local:3000/otomi/values.git', + authenticatedUrl: 'http://admin:s3cret@gitea-http.gitea.svc.cluster.local:3000/otomi/values.git', + branch: 'main', + email: 'pipeline@cluster.local', + username: 'admin', + password: 's3cret', + }) + }) + + it('should throw when no credentials are found at all', async () => { + mockGetK8sConfigMap.mockResolvedValue({ + data: { repoUrl: 'https://github.com/org/repo.git', branch: 'main', email: 'test@test.com' }, + }) + mockGetK8sSecret.mockResolvedValue(undefined) // both calls return undefined + + // getOldGitCredentials returns { username: undefined, password: undefined } + // which passes the !credentials check but fails the !username || !password check + await expect(getStoredGitRepoConfig()).rejects.toThrow( + 'Git credentials are incomplete in apl-git-credentials secret', + ) + }) + + it('should throw when repoUrl is missing', async () => { + mockGetK8sConfigMap.mockResolvedValue({ + data: { branch: 'main', email: 'test@test.com' }, + }) + mockGetK8sSecret.mockResolvedValue({ username: 'admin', password: 's3cret' }) + + await expect(getStoredGitRepoConfig()).rejects.toThrow('Git repository URL is missing') + }) + + it('should throw when branch is missing', async () => { + mockGetK8sConfigMap.mockResolvedValue({ + data: { repoUrl: 'https://github.com/org/repo.git', email: 'test@test.com' }, + }) + mockGetK8sSecret.mockResolvedValue({ username: 'admin', password: 's3cret' }) + + await expect(getStoredGitRepoConfig()).rejects.toThrow('Git branch or email is missing') + }) + + it('should throw when email is missing', async () => { + mockGetK8sConfigMap.mockResolvedValue({ + data: { repoUrl: 'https://github.com/org/repo.git', branch: 'main' }, + }) + mockGetK8sSecret.mockResolvedValue({ username: 'admin', password: 's3cret' }) + + await expect(getStoredGitRepoConfig()).rejects.toThrow('Git branch or email is missing') + }) + + it('should use GIT_REPO_URL env var in development mode', async () => { + process.env.NODE_ENV = 'development' + process.env.GIT_REPO_URL = 'http://localhost:3000/dev/repo.git' + + mockGetK8sConfigMap.mockResolvedValue({ + data: { + repoUrl: 'https://github.com/org/repo.git', + branch: 'main', + email: 'pipeline@cluster.local', + }, + }) + mockGetK8sSecret.mockResolvedValue({ username: 'admin', password: 's3cret' }) + + const result = await getStoredGitRepoConfig() + expect(result!.repoUrl).toBe('http://localhost:3000/dev/repo.git') + expect(result!.authenticatedUrl).toBe('http://admin:s3cret@localhost:3000/dev/repo.git') + }) + + it('should URL-encode special characters in credentials', async () => { + mockGetK8sConfigMap.mockResolvedValue({ + data: { + repoUrl: 'https://github.com/org/repo.git', + branch: 'main', + email: 'pipeline@cluster.local', + }, + }) + mockGetK8sSecret.mockResolvedValue({ username: 'user@org', password: 'p@ss:word/123' }) + + const result = await getStoredGitRepoConfig() + expect(result!.authenticatedUrl).toContain('user%40org') + expect(result!.authenticatedUrl).toContain('p%40ss%3Aword%2F123') + }) + }) + + describe('setGitConfig', () => { + it('should create configmap with all fields', async () => { + await setGitConfig({ + repoUrl: 'https://github.com/org/repo.git', + branch: 'main', + email: 'test@test.com', + }) + + expect(mockCreateUpdateConfigMap).toHaveBeenCalledWith(mockCoreApi, 'apl-git-config', 'apl-operator', { + repoUrl: 'https://github.com/org/repo.git', + branch: 'main', + email: 'test@test.com', + }) + }) + + it('should only include defined fields', async () => { + await setGitConfig({ repoUrl: 'https://github.com/org/repo.git' }) + + expect(mockCreateUpdateConfigMap).toHaveBeenCalledWith(mockCoreApi, 'apl-git-config', 'apl-operator', { + repoUrl: 'https://github.com/org/repo.git', + }) + }) + + it('should use provided coreV1Api', async () => { + const customApi = { custom: true } as any + await setGitConfig({ branch: 'develop' }, customApi) + + expect(mockCreateUpdateConfigMap).toHaveBeenCalledWith(customApi, 'apl-git-config', 'apl-operator', { + branch: 'develop', + }) + }) + + it('should pass empty data when no fields provided', async () => { + await setGitConfig({}) + + expect(mockCreateUpdateConfigMap).toHaveBeenCalledWith(mockCoreApi, 'apl-git-config', 'apl-operator', {}) + }) + }) + + describe('getRepo', () => { + it('should return full config from values', () => { + const values = { + otomi: { + git: { + repoUrl: 'https://github.com/org/repo.git', + username: 'admin', + password: 's3cret', + branch: 'main', + email: 'pipeline@cluster.local', + }, + }, + } + + const result = getRepo(values) + expect(result).toEqual({ + repoUrl: 'https://github.com/org/repo.git', + authenticatedUrl: 'https://admin:s3cret@github.com/org/repo.git', + branch: 'main', + email: 'pipeline@cluster.local', + username: 'admin', + password: 's3cret', + }) + }) + + it('should throw when repoUrl is missing', () => { + expect(() => getRepo({ otomi: { git: {} } })).toThrow('No otomi.git.repoUrl config was given.') + }) + + it('should throw when otomi.git is missing', () => { + expect(() => getRepo({ otomi: {} })).toThrow('No otomi.git.repoUrl config was given.') + }) + + it('should throw when values is empty', () => { + expect(() => getRepo({})).toThrow('No otomi.git.repoUrl config was given.') + }) + + it('should use GIT_REPO_URL env var in development mode', () => { + process.env.NODE_ENV = 'development' + process.env.GIT_REPO_URL = 'http://localhost:3000/dev/repo.git' + + const values = { + otomi: { + git: { + repoUrl: 'https://github.com/org/repo.git', + username: 'admin', + password: 's3cret', + branch: 'main', + email: 'pipeline@cluster.local', + }, + }, + } + + const result = getRepo(values) + expect(result.repoUrl).toBe('http://localhost:3000/dev/repo.git') + expect(result.authenticatedUrl).toBe('http://admin:s3cret@localhost:3000/dev/repo.git') + }) + }) +}) diff --git a/src/common/git-config.ts b/src/common/git-config.ts new file mode 100644 index 0000000000..b92d318b85 --- /dev/null +++ b/src/common/git-config.ts @@ -0,0 +1,156 @@ +import { terminal } from './debug' +import { createUpdateConfigMap, getK8sConfigMap, getK8sSecret, k8s } from './k8s' +import type { CoreV1Api } from '@kubernetes/client-node' + +const d = terminal('common:git-config') + +// Constants +export const GIT_CONFIG_CONFIGMAP_NAME = 'apl-git-config' +export const GIT_CONFIG_SECRET_NAME = 'apl-git-credentials' +export const GIT_CONFIG_NAMESPACE = 'apl-operator' + +/** + * Unified Git repository configuration with credentials. + * Contains both the base URL (without credentials) and the authenticated URL (with embedded credentials). + */ +export interface GitRepoConfig { + repoUrl: string // URL without credentials (e.g., https://github.com/org/repo.git) + authenticatedUrl: string // URL with embedded credentials for git operations + branch: string + email: string + username: string + password: string +} + +export interface GitConfigData { + repoUrl?: string + branch?: string + email?: string +} + +export interface GitCredentials { + username: string + password: string +} + +export async function getGitCredentials(): Promise { + const secretData = await getK8sSecret(GIT_CONFIG_SECRET_NAME, GIT_CONFIG_NAMESPACE) + + if (!secretData?.username || !secretData?.password) { + return undefined + } + + return { + username: secretData.username, + password: secretData.password, + } +} +export async function getOldGitCredentials(): Promise { + const secretData = await getK8sSecret('gitea-credentials', GIT_CONFIG_NAMESPACE) + + return { + username: secretData?.GIT_USERNAME, + password: secretData?.GIT_PASSWORD, + } +} + +export async function getGitConfigData(): Promise { + const configMap = await getK8sConfigMap(GIT_CONFIG_NAMESPACE, GIT_CONFIG_CONFIGMAP_NAME, k8s.core()) + if (!configMap?.data) return undefined + + const { data } = configMap + return { + repoUrl: data.repoUrl, + branch: data.branch, + email: data.email, + } +} + +/** + * Reconstructs GitRepoConfig from stored ConfigMap + Secret. + * This avoids calling hfValues() in operator startup path. + */ +export async function getStoredGitRepoConfig(): Promise { + let [configData, credentials] = await Promise.all([getGitConfigData(), getGitCredentials()]) + + //TODO This can be removed after BYO Git has been released + if (!credentials) { + credentials = await getOldGitCredentials() + } + + if (!credentials) { + throw new Error(`Git credentials not found in ${GIT_CONFIG_SECRET_NAME} & gitea-credentials secret`) + } + + // We cannot do hfValues because the env dir does not exist yet. + //TODO This should be removed after BYO Git has been released. + if (!configData) { + configData = { + repoUrl: 'http://gitea-http.gitea.svc.cluster.local:3000/otomi/values.git', + branch: 'main', + email: 'pipeline@cluster.local', + } + } + if (process.env.NODE_ENV === 'development') { + configData.repoUrl = process.env.GIT_REPO_URL + } + const { username, password } = credentials + const { branch, email, repoUrl } = configData + + if (!repoUrl) { + throw new Error(`Git repository URL is missing in ${GIT_CONFIG_CONFIGMAP_NAME} config`) + } + if (!username || !password) { + throw new Error(`Git credentials are incomplete in ${GIT_CONFIG_SECRET_NAME} secret`) + } + if (!branch || !email) { + throw new Error(`Git branch or email is missing in ${GIT_CONFIG_CONFIGMAP_NAME} config`) + } + const url = new URL(repoUrl) + url.username = username + url.password = password + const authenticatedUrl = url.toString() + + return { repoUrl, authenticatedUrl, branch, email, username, password } +} + +/** + * Creates or updates the Git configuration ConfigMap + */ +export async function setGitConfig(config: Partial, coreV1Api?: CoreV1Api): Promise { + const api = coreV1Api ?? k8s.core() + + const data: Record = {} + + if (config.repoUrl !== undefined) data.repoUrl = config.repoUrl + if (config.branch !== undefined) data.branch = config.branch + if (config.email !== undefined) data.email = config.email + + await createUpdateConfigMap(api, GIT_CONFIG_CONFIGMAP_NAME, GIT_CONFIG_NAMESPACE, data) +} + +/** + * Gets repository configuration from values, constructing the authenticated URL with embedded credentials. + */ +export const getRepo = (values: Record): GitRepoConfig => { + const otomiGit = values?.otomi?.git + + if (!otomiGit?.repoUrl) { + throw new Error('No otomi.git.repoUrl config was given.') + } + if (process.env.NODE_ENV === 'development') { + otomiGit.repoUrl = process.env.GIT_REPO_URL + } + const username = otomiGit?.username + const password = otomiGit?.password + const email = otomiGit?.email + const branch = otomiGit?.branch + + const repoUrl = otomiGit?.repoUrl as string + const url = new URL(repoUrl) + url.username = username + url.password = password + const authenticatedUrl = url.toString() + + return { repoUrl, authenticatedUrl, branch, email, username, password } +} diff --git a/src/common/gitea.ts b/src/common/gitea.ts index 55e983fd19..c1f6db3996 100644 --- a/src/common/gitea.ts +++ b/src/common/gitea.ts @@ -11,8 +11,8 @@ export async function resetGiteaPasswordValidity() { const [firstPod] = giteaPods.items // In case Gitea pods happened to be restarting in the meantime, it will likely fix the issue by itself if (firstPod) { - const giteaCredentialsSecret = await getK8sSecret('gitea-credentials', 'apl-operator') - const userName = giteaCredentialsSecret?.GITEA_USERNAME ?? 'otomi-admin' + const giteaCredentialsSecret = await getK8sSecret('apl-git-credentials', 'apl-operator') + const userName = giteaCredentialsSecret?.username ?? 'otomi-admin' const resetCmd = ['gitea', 'admin', 'user', 'must-change-password', '--unset', userName as string] const { stdout, stderr } = await exec( firstPod.metadata!.namespace as string, diff --git a/src/common/runtime-upgrades/migrate-git-config.ts b/src/common/runtime-upgrades/migrate-git-config.ts new file mode 100644 index 0000000000..90922a2dcd --- /dev/null +++ b/src/common/runtime-upgrades/migrate-git-config.ts @@ -0,0 +1,22 @@ +import { RuntimeUpgradeContext } from './runtime-upgrades' +import { createUpdateGenericSecret, getK8sSecret, k8s } from '../k8s' +import { GIT_CONFIG_NAMESPACE, GIT_CONFIG_SECRET_NAME, setGitConfig } from '../git-config' +import { hfValues } from '../hf' + +export async function migrateGitConfig(context: RuntimeUpgradeContext) { + context.debug.info('Create apl-git-config ConfigMap and apl-git-credentials Secret if not present') + const secretData = await getK8sSecret('gitea-credentials', GIT_CONFIG_NAMESPACE) + + const defaultValues = (await hfValues({ defaultValues: true })) as Record + const otomiGit = defaultValues?.otomi?.git + + await createUpdateGenericSecret(k8s.core(), GIT_CONFIG_SECRET_NAME, GIT_CONFIG_NAMESPACE, { + username: secretData?.GIT_USERNAME, + password: secretData?.GIT_PASSWORD, + }) + await setGitConfig({ + repoUrl: otomiGit?.repoUrl, + branch: otomiGit?.branch, + email: otomiGit?.email, + }) +} diff --git a/src/common/runtime-upgrades/runtime-upgrades.ts b/src/common/runtime-upgrades/runtime-upgrades.ts index 269a5b3889..9e9d41d088 100644 --- a/src/common/runtime-upgrades/runtime-upgrades.ts +++ b/src/common/runtime-upgrades/runtime-upgrades.ts @@ -8,6 +8,7 @@ import { detectAndRestartOutdatedIstioSidecars } from './restart-istio-sidecars' import { upgradeKnativeServing } from './upgrade-knative-serving-cr' import { detachApplicationFromApplicationSet, pruneArgoCDImageUpdater } from './v4.13.0' import { removeHttpBinApplication } from './remove-httpbin-application' +import { migrateGitConfig } from './migrate-git-config' export interface RuntimeUpgradeContext { debug: OtomiDebugger @@ -156,4 +157,14 @@ export const runtimeUpgrades: RuntimeUpgrades = [ }, }, }, + { + version: '4.15.0', + applications: { + 'apl-operator-apl-operator': { + pre: async (context: RuntimeUpgradeContext) => { + await migrateGitConfig(context) + }, + }, + }, + }, ] diff --git a/src/common/values.ts b/src/common/values.ts index 5e2a0d7fc5..157ee2fc2c 100644 --- a/src/common/values.ts +++ b/src/common/values.ts @@ -55,46 +55,6 @@ export const getPackageVersion = (): string => { return pkg.version } -export interface Repo { - email: string - username: string - password: string - remote: string - branch: string -} - -export const getRepo = (values: Record): Repo => { - const giteaEnabled = values?.apps?.gitea?.enabled ?? true - const byor = !!values?.apps?.['otomi-api']?.git - if (!giteaEnabled && !byor) { - throw new Error('Gitea is disabled but no apps.otomi-api.git config was given.') - } - let username = 'Otomi Admin' - let email: string - let password: string - let branch = 'main' - let remote - if (!giteaEnabled) { - const otomiApiGit = values?.apps?.['otomi-api']?.git - username = otomiApiGit?.user - password = otomiApiGit?.password - remote = otomiApiGit?.repoUrl - email = otomiApiGit?.email - branch = otomiApiGit?.branch ?? branch - } else { - username = 'otomi-admin' - password = values?.apps?.gitea?.adminPassword - email = `pipeline@cluster.local` - const gitUrl = env.GIT_URL - const gitPort = env.GIT_PORT - const gitOrg = 'otomi' - const gitRepo = 'values' - const protocol = env.GIT_PROTOCOL - remote = `${protocol}://${username}:${encodeURIComponent(password)}@${gitUrl}:${gitPort}/${gitOrg}/${gitRepo}.git` - } - return { remote, branch, email, username, password } -} - function mergeCustomizer(prev, next) { return next } diff --git a/src/operator/apl-operator.test.ts b/src/operator/apl-operator.test.ts index 8333727782..671fc88d87 100644 --- a/src/operator/apl-operator.test.ts +++ b/src/operator/apl-operator.test.ts @@ -1,3 +1,4 @@ +import { GitRepoConfig } from '../common/git-config' import { waitTillGitRepoAvailable } from '../common/gitea' import { AplOperations } from './apl-operations' import { AplOperator, AplOperatorConfig, ApplyTrigger } from './apl-operator' @@ -12,7 +13,7 @@ const mockDebugFn = jest.fn() const mockGitRepo = { clone: jest.fn().mockResolvedValue(undefined), syncAndAnalyzeChanges: jest.fn().mockResolvedValue({ hasChangesToApply: false, applyTeamsOnly: false }), - repoUrl: 'https://username:password@example.com:443/org/repo.git', + authenticatedUrl: 'https://username:password@example.com:443/org/repo.git', lastRevision: 'abc123', } @@ -74,6 +75,7 @@ describe('AplOperator', () => { jest.clearAllMocks() defaultConfig = { + gitConfig: {} as GitRepoConfig, gitRepo: mockGitRepo as unknown as GitRepository, aplOps: mockAplOps as unknown as AplOperations, pollIntervalMs: 1, @@ -108,7 +110,7 @@ describe('AplOperator', () => { await startPromise - expect(waitTillGitRepoAvailable).toHaveBeenCalledWith(mockGitRepo.repoUrl) + expect(waitTillGitRepoAvailable).toHaveBeenCalledWith(mockGitRepo.authenticatedUrl) expect(mockGitRepo.clone).toHaveBeenCalled() expect(mockInfoFn).toHaveBeenCalledWith('APL operator started successfully') @@ -120,7 +122,7 @@ describe('AplOperator', () => { await expect(aplOperator.start()).rejects.toThrow('Start failed') - expect(waitTillGitRepoAvailable).toHaveBeenCalledWith(mockGitRepo.repoUrl) + expect(waitTillGitRepoAvailable).toHaveBeenCalledWith(mockGitRepo.authenticatedUrl) expect(mockGitRepo.clone).toHaveBeenCalled() expect(mockErrorFn).toHaveBeenCalledWith('Failed to start APL operator:', 'Start failed') diff --git a/src/operator/apl-operator.ts b/src/operator/apl-operator.ts index 94b07424d6..8618955f3b 100644 --- a/src/operator/apl-operator.ts +++ b/src/operator/apl-operator.ts @@ -2,6 +2,7 @@ import { decrypt } from 'src/common/crypt' import { commit } from '../cmd/commit' import { terminal } from '../common/debug' import { env } from '../common/envalid' +import { GitRepoConfig } from '../common/git-config' import { hfValues } from '../common/hf' import { waitTillGitRepoAvailable } from '../common/gitea' import { ensureTeamGitOpsDirectories } from '../common/utils' @@ -14,6 +15,7 @@ import { getErrorMessage } from './utils' export interface AplOperatorConfig { gitRepo: GitRepository + gitConfig: GitRepoConfig aplOps: AplOperations pollIntervalMs: number reconcileIntervalMs: number @@ -35,21 +37,23 @@ export class AplOperator { private gitRepo: GitRepository private aplOps: AplOperations - readonly repoUrl: string + readonly authenticatedUrl: string readonly pollInterval: number readonly reconcileInterval: number + readonly startupGitConfig: GitRepoConfig constructor(config: AplOperatorConfig) { - const { gitRepo, aplOps, pollIntervalMs, reconcileIntervalMs } = config + const { gitRepo, gitConfig, aplOps, pollIntervalMs, reconcileIntervalMs } = config this.pollInterval = pollIntervalMs this.reconcileInterval = reconcileIntervalMs this.gitRepo = gitRepo this.aplOps = aplOps - this.repoUrl = gitRepo.repoUrl + this.authenticatedUrl = gitRepo.authenticatedUrl + this.startupGitConfig = gitConfig - this.d.info(`Initializing APL Operator with repo URL: ${maskRepoUrl(gitRepo.repoUrl)}`) - this.d.debug(`Initializing APL Operator with repo URL: ${gitRepo.repoUrl}`) + this.d.info(`Initializing APL Operator with repo URL: ${maskRepoUrl(gitRepo.authenticatedUrl)}`) + this.d.debug(`Initializing APL Operator with repo URL: ${gitRepo.authenticatedUrl}`) } // public for testing @@ -87,7 +91,7 @@ export class AplOperator { const values = await hfValues({}, env.ENV_DIR) await ensureTeamGitOpsDirectories(env.ENV_DIR, values ?? {}) - await commit(false, {} as HelmArguments) // Pass an empty object to clear any stale parsed args + await commit(false, {} as HelmArguments, this.startupGitConfig) // Pass startup config to use frozen git credentials if (applyTeamsOnly) { await this.aplOps.applyTeams() @@ -178,7 +182,7 @@ export class AplOperator { this.d.info('Starting APL operator') try { - await waitTillGitRepoAvailable(this.repoUrl) + await waitTillGitRepoAvailable(this.authenticatedUrl) await this.gitRepo.clone() this.d.info('APL operator started successfully') } catch (error) { diff --git a/src/operator/git-repository.test.ts b/src/operator/git-repository.test.ts index cc5453d699..bfdd95e30d 100644 --- a/src/operator/git-repository.test.ts +++ b/src/operator/git-repository.test.ts @@ -36,14 +36,8 @@ describe('GitRepository', () => { jest.clearAllMocks() defaultConfig = { - username: 'testuser', - password: 'testpass', - gitHost: 'github.com', - gitPort: '443', - gitProtocol: 'https', + authenticatedUrl: 'https://testuser:testpass@github.com:443/testorg/testrepo.git', repoPath: '/tmp/repo', - gitOrg: 'testorg', - gitRepo: 'testrepo', } const simpleGit = require('simple-git') @@ -54,17 +48,8 @@ describe('GitRepository', () => { }) describe('constructor', () => { - test('should create repository URL correctly', () => { - expect(gitRepository.repoUrl).toBe('https://testuser:testpass@github.com:443/testorg/testrepo.git') - }) - - test('should URL encode password with special characters', () => { - const configWithSpecialChars = { - ...defaultConfig, - password: 'test@pass#123', - } - const repo = new GitRepository(configWithSpecialChars) - expect(repo.repoUrl).toBe('https://testuser:test%40pass%23123@github.com:443/testorg/testrepo.git') + test('should store repository URL from config', () => { + expect(gitRepository.authenticatedUrl).toBe('https://testuser:testpass@github.com:443/testorg/testrepo.git') }) }) diff --git a/src/operator/git-repository.ts b/src/operator/git-repository.ts index 56b195afa1..579d37ae07 100644 --- a/src/operator/git-repository.ts +++ b/src/operator/git-repository.ts @@ -6,29 +6,22 @@ import * as fs from 'fs' import * as path from 'path' export interface GitRepositoryConfig { - username: string - password: string - gitHost: string - gitPort: string - gitProtocol: string + authenticatedUrl: string // Full URL with credentials already embedded repoPath: string - gitOrg: string - gitRepo: string } export class GitRepository { private git: SimpleGit private _lastRevision = '' private d: OtomiDebugger - readonly repoUrl: string + readonly authenticatedUrl: string private readonly repoPath: string private readonly skipMarker = '[ci skip]' constructor(config: GitRepositoryConfig) { - const { username, password, gitHost, gitPort, gitProtocol, repoPath, gitOrg, gitRepo } = config this.d = terminal('operator:git-repository') - this.repoUrl = `${gitProtocol}://${username}:${encodeURIComponent(password)}@${gitHost}:${gitPort}/${gitOrg}/${gitRepo}.git` - this.repoPath = repoPath + this.authenticatedUrl = config.authenticatedUrl + this.repoPath = config.repoPath this.git = simpleGit(this.repoPath) } @@ -40,7 +33,7 @@ export class GitRepository { this._lastRevision = logs.latest?.hash || '' } } catch (error) { - this.d.warn('Gitea has no commits yet:', getErrorMessage(error)) + this.d.warn('Git has no commits yet:', getErrorMessage(error)) throw error } } @@ -57,7 +50,7 @@ export class GitRepository { this.d.info(`Cloning repository to ${this.repoPath}`) try { - await this.git.clone(this.repoUrl, this.repoPath) + await this.git.clone(this.authenticatedUrl, this.repoPath) this.d.info(`Repository cloned successfully`) } catch (error) { this.d.error('Failed to clone repository:', getErrorMessage(error)) @@ -72,14 +65,14 @@ export class GitRepository { if (!origin) { this.d.warn('Origin remote not found, adding it') - await this.git.remote(['add', 'origin', this.repoUrl]) + await this.git.remote(['add', 'origin', this.authenticatedUrl]) this.d.info('Origin remote added successfully') return } - if (origin.refs.fetch !== this.repoUrl) { + if (origin.refs.fetch !== this.authenticatedUrl) { this.d.warn('Origin remote URL mismatch detected, resetting to correct URL') - await this.git.remote(['set-url', 'origin', this.repoUrl]) + await this.git.remote(['set-url', 'origin', this.authenticatedUrl]) this.d.info('Origin remote URL reset successfully') } else { this.d.debug('Origin remote URL is correct') diff --git a/src/operator/installer.test.ts b/src/operator/installer.test.ts index 59bdd81a08..560245d8ea 100644 --- a/src/operator/installer.test.ts +++ b/src/operator/installer.test.ts @@ -1,4 +1,5 @@ -import { hfValues } from '../common/hf' +import * as gitConfig from '../common/git-config' +import * as hf from '../common/hf' import * as k8s from '../common/k8s' import { AplOperations } from './apl-operations' import { Installer } from './installer' @@ -27,6 +28,15 @@ jest.mock('../common/hf', () => ({ hfValues: jest.fn(), })) +jest.mock('../common/git-config', () => ({ + getGitCredentials: jest.fn().mockResolvedValue(undefined), + getGitConfigData: jest.fn().mockResolvedValue(undefined), + getStoredGitRepoConfig: jest.fn().mockResolvedValue(undefined), + setGitConfig: jest.fn().mockResolvedValue(undefined), + GIT_CONFIG_SECRET_NAME: 'apl-git-credentials', + GIT_CONFIG_NAMESPACE: 'apl-operator', +})) + jest.mock('./utils', () => ({ getErrorMessage: jest.fn((error) => (error instanceof Error ? error.message : String(error))), })) @@ -275,300 +285,211 @@ describe('Installer', () => { }) describe('setEnvAndCreateSecrets', () => { - test('should use existing credentials from secrets when available', async () => { - ;(k8s.getK8sSecret as jest.Mock) - .mockResolvedValueOnce({ SOPS_AGE_KEY: 'existing-sops-key' }) // apl-sops-secrets - .mockResolvedValueOnce({ GIT_USERNAME: 'existing-admin', GIT_PASSWORD: 'existing-password' }) // gitea-credentials + test('should use existing credentials from apl-git-credentials secret when available', async () => { + ;(k8s.getK8sSecret as jest.Mock).mockResolvedValueOnce({ SOPS_AGE_KEY: 'existing-sops-key' }) // apl-sops-secrets const result = await installer.setEnvAndCreateSecrets() expect(k8s.getK8sSecret).toHaveBeenCalledWith('apl-sops-secrets', 'apl-operator') - expect(k8s.getK8sSecret).toHaveBeenCalledWith('gitea-credentials', 'apl-operator') expect(process.env.SOPS_AGE_KEY).toBe('existing-sops-key') - expect(result).toEqual({ - username: 'existing-admin', - password: 'existing-password', - }) - expect(hfValues).not.toHaveBeenCalled() - expect(k8s.createUpdateGenericSecret).not.toHaveBeenCalled() }) - test('should extract credentials and create secrets when secrets do not exist', async () => { - const mockValues = { - apps: { - gitea: { - adminUsername: 'test-admin', - adminPassword: 'test-password', - }, - }, - kms: { - sops: { - age: { - privateKey: 'AGE-SECRET-KEY-1234567890', - }, - }, - }, - } - + test('should handle failure when SOPS key not found in secret', async () => { ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue(null) - ;(hfValues as jest.Mock).mockResolvedValue(mockValues) - ;(k8s.createUpdateGenericSecret as jest.Mock).mockResolvedValue(undefined) - - const result = await installer.setEnvAndCreateSecrets() - expect(hfValues).toHaveBeenCalled() - expect(k8s.createUpdateGenericSecret).toHaveBeenCalledWith(mockCoreApi, 'apl-sops-secrets', 'apl-operator', { - SOPS_AGE_KEY: 'AGE-SECRET-KEY-1234567890', - }) - expect(k8s.createUpdateGenericSecret).toHaveBeenCalledWith(mockCoreApi, 'gitea-credentials', 'apl-operator', { - GIT_USERNAME: 'test-admin', - GIT_PASSWORD: 'test-password', - }) - expect(result).toEqual({ - username: 'test-admin', - password: 'test-password', - }) - expect(process.env.SOPS_AGE_KEY).toBe('AGE-SECRET-KEY-1234567890') + await expect(installer.setEnvAndCreateSecrets()).rejects.toThrow('SOPS_AGE_KEY not found in secret') }) + }) - test('should use default username when not provided', async () => { - const mockValues = { - apps: { - gitea: { - adminPassword: 'test-password', - }, + describe('ensureSecretsAndConfig', () => { + const mockValues = { + otomi: { + git: { + repoUrl: 'https://github.com/org/repo.git', + branch: 'main', + email: 'pipeline@cluster.local', + username: 'admin', + password: 's3cret', }, - kms: { - sops: { - age: { - privateKey: 'AGE-SECRET-KEY-1234567890', - }, + }, + kms: { + sops: { + age: { + privateKey: 'AGE-SECRET-KEY-1234', }, }, - } + }, + } - ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue(null) - ;(hfValues as jest.Mock).mockResolvedValue(mockValues) - ;(k8s.createUpdateGenericSecret as jest.Mock).mockResolvedValue(undefined) + test('should skip when hfValues returns undefined', async () => { + ;(hf.hfValues as jest.Mock).mockResolvedValue(undefined) - const result = await installer.setEnvAndCreateSecrets() + await installer.ensureSecretsAndConfig() - expect(result).toEqual({ - username: 'otomi-admin', - password: 'test-password', - }) + expect(gitConfig.getGitCredentials).not.toHaveBeenCalled() + expect(k8s.getK8sSecret).not.toHaveBeenCalled() + expect(gitConfig.getGitConfigData).not.toHaveBeenCalled() }) - test('should throw error when password is missing', async () => { - const mockValues = { - apps: { - gitea: { - adminUsername: 'test-admin', - }, - }, - } + test('should not recreate resources when all exist', async () => { + ;(hf.hfValues as jest.Mock).mockResolvedValue(mockValues) + ;(gitConfig.getGitCredentials as jest.Mock).mockResolvedValue({ username: 'admin', password: 's3cret' }) + ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue({ SOPS_AGE_KEY: 'existing-key' }) + ;(gitConfig.getGitConfigData as jest.Mock).mockResolvedValue({ + repoUrl: 'https://github.com/org/repo.git', + branch: 'main', + email: 'pipeline@cluster.local', + }) - ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue(null) - ;(hfValues as jest.Mock).mockResolvedValue(mockValues) + await installer.ensureSecretsAndConfig() - await expect(installer.setEnvAndCreateSecrets()).rejects.toThrow('Git credentials not found in values') + expect(k8s.createUpdateGenericSecret).not.toHaveBeenCalled() + expect(gitConfig.setGitConfig).not.toHaveBeenCalled() }) - test('should use default username when username is empty string', async () => { - const mockValues = { - apps: { - gitea: { - adminUsername: '', - adminPassword: 'test-password', - }, - }, - } - - ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue(null) - ;(hfValues as jest.Mock).mockResolvedValue(mockValues) - ;(k8s.createUpdateGenericSecret as jest.Mock).mockResolvedValue(undefined) + test('should recreate git credentials when missing', async () => { + ;(hf.hfValues as jest.Mock).mockResolvedValue(mockValues) + ;(gitConfig.getGitCredentials as jest.Mock).mockResolvedValue(undefined) + ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue({ SOPS_AGE_KEY: 'existing-key' }) + ;(gitConfig.getGitConfigData as jest.Mock).mockResolvedValue({ + repoUrl: 'https://github.com/org/repo.git', + branch: 'main', + email: 'pipeline@cluster.local', + }) - const result = await installer.setEnvAndCreateSecrets() + await installer.ensureSecretsAndConfig() - // Empty string falls back to default due to || operator - expect(result).toEqual({ - username: 'otomi-admin', - password: 'test-password', + expect(k8s.createUpdateGenericSecret).toHaveBeenCalledWith(mockCoreApi, 'apl-git-credentials', 'apl-operator', { + username: 'admin', + password: 's3cret', }) }) - test('should throw error when password is empty string', async () => { - const mockValues = { - apps: { - gitea: { - adminUsername: 'test-admin', - adminPassword: '', - }, - }, - } + test('should recreate sops secret when missing and age key exists in values', async () => { + ;(hf.hfValues as jest.Mock).mockResolvedValue(mockValues) + ;(gitConfig.getGitCredentials as jest.Mock).mockResolvedValue({ username: 'admin', password: 's3cret' }) + ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue(undefined) + ;(gitConfig.getGitConfigData as jest.Mock).mockResolvedValue({ + repoUrl: 'https://github.com/org/repo.git', + branch: 'main', + email: 'pipeline@cluster.local', + }) - ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue(null) - ;(hfValues as jest.Mock).mockResolvedValue(mockValues) + await installer.ensureSecretsAndConfig() - await expect(installer.setEnvAndCreateSecrets()).rejects.toThrow('Git credentials not found in values') + expect(k8s.createUpdateGenericSecret).toHaveBeenCalledWith(mockCoreApi, 'apl-sops-secrets', 'apl-operator', { + SOPS_AGE_KEY: 'AGE-SECRET-KEY-1234', + }) }) - test('should skip SOPS key when encrypted', async () => { - const mockValues = { - apps: { - gitea: { - adminUsername: 'test-admin', - adminPassword: 'test-password', - }, - }, - kms: { - sops: { - age: { - privateKey: 'ENC[AES256_GCM,data:encrypted]', - }, - }, - }, + test('should not recreate sops secret when missing but no age key in values', async () => { + const valuesWithoutSops = { + ...mockValues, + kms: { sops: { age: {} } }, } + ;(hf.hfValues as jest.Mock).mockResolvedValue(valuesWithoutSops) + ;(gitConfig.getGitCredentials as jest.Mock).mockResolvedValue({ username: 'admin', password: 's3cret' }) + ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue(undefined) + ;(gitConfig.getGitConfigData as jest.Mock).mockResolvedValue({ + repoUrl: 'https://github.com/org/repo.git', + branch: 'main', + email: 'pipeline@cluster.local', + }) - ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue(null) - ;(hfValues as jest.Mock).mockResolvedValue(mockValues) - ;(k8s.createUpdateGenericSecret as jest.Mock).mockResolvedValue(undefined) - - await installer.setEnvAndCreateSecrets() + await installer.ensureSecretsAndConfig() - expect(process.env.SOPS_AGE_KEY).toBe('') + expect(k8s.createUpdateGenericSecret).not.toHaveBeenCalled() }) - test('should skip SOPS key when not provided', async () => { - const mockValues = { - apps: { - gitea: { - adminUsername: 'test-admin', - adminPassword: 'test-password', - }, - }, - } - - ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue(null) - ;(hfValues as jest.Mock).mockResolvedValue(mockValues) - ;(k8s.createUpdateGenericSecret as jest.Mock).mockResolvedValue(undefined) - - await installer.setEnvAndCreateSecrets() - - expect(process.env.SOPS_AGE_KEY).toBe('') - }) + test('should recreate git config when repoUrl is missing', async () => { + ;(hf.hfValues as jest.Mock).mockResolvedValue(mockValues) + ;(gitConfig.getGitCredentials as jest.Mock).mockResolvedValue({ username: 'admin', password: 's3cret' }) + ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue({ SOPS_AGE_KEY: 'existing-key' }) + ;(gitConfig.getGitConfigData as jest.Mock).mockResolvedValue({ + branch: 'main', + email: 'pipeline@cluster.local', + }) - test('should handle hfValues failure when secrets do not exist', async () => { - ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue(null) - ;(hfValues as jest.Mock).mockRejectedValue(new Error('Failed to get values')) + await installer.ensureSecretsAndConfig() - await expect(installer.setEnvAndCreateSecrets()).rejects.toThrow('Failed to get values') + expect(gitConfig.setGitConfig).toHaveBeenCalledWith({ + repoUrl: 'https://github.com/org/repo.git', + branch: 'main', + email: 'pipeline@cluster.local', + }) }) - test('should handle secret creation failure', async () => { - const mockValues = { - apps: { - gitea: { - adminUsername: 'test-admin', - adminPassword: 'test-password', - }, - }, - } + test('should recreate git config when branch is missing', async () => { + ;(hf.hfValues as jest.Mock).mockResolvedValue(mockValues) + ;(gitConfig.getGitCredentials as jest.Mock).mockResolvedValue({ username: 'admin', password: 's3cret' }) + ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue({ SOPS_AGE_KEY: 'existing-key' }) + ;(gitConfig.getGitConfigData as jest.Mock).mockResolvedValue({ + repoUrl: 'https://github.com/org/repo.git', + email: 'pipeline@cluster.local', + }) - ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue(null) - ;(hfValues as jest.Mock).mockResolvedValue(mockValues) - ;(k8s.createUpdateGenericSecret as jest.Mock).mockRejectedValue(new Error('Secret creation failed')) + await installer.ensureSecretsAndConfig() - await expect(installer.setEnvAndCreateSecrets()).rejects.toThrow('Secret creation failed') + expect(gitConfig.setGitConfig).toHaveBeenCalledWith({ + repoUrl: 'https://github.com/org/repo.git', + branch: 'main', + email: 'pipeline@cluster.local', + }) }) - test('should handle nested gitea structure', async () => { - const mockValues = { - apps: { - gitea: { - adminUsername: 'nested-admin', - adminPassword: 'nested-password', - }, - }, - kms: { - sops: { - age: { - privateKey: 'AGE-SECRET-KEY-NESTED', - }, - }, - }, - } - - ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue(null) - ;(hfValues as jest.Mock).mockResolvedValue(mockValues) - ;(k8s.createUpdateGenericSecret as jest.Mock).mockResolvedValue(undefined) + test('should recreate git config when email is missing', async () => { + ;(hf.hfValues as jest.Mock).mockResolvedValue(mockValues) + ;(gitConfig.getGitCredentials as jest.Mock).mockResolvedValue({ username: 'admin', password: 's3cret' }) + ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue({ SOPS_AGE_KEY: 'existing-key' }) + ;(gitConfig.getGitConfigData as jest.Mock).mockResolvedValue({ + repoUrl: 'https://github.com/org/repo.git', + branch: 'main', + }) - const result = await installer.setEnvAndCreateSecrets() + await installer.ensureSecretsAndConfig() - expect(result).toEqual({ - username: 'nested-admin', - password: 'nested-password', + expect(gitConfig.setGitConfig).toHaveBeenCalledWith({ + repoUrl: 'https://github.com/org/repo.git', + branch: 'main', + email: 'pipeline@cluster.local', }) }) - test('should use only existing SOPS secret when gitea credentials need creation', async () => { - const mockValues = { - apps: { - gitea: { - adminUsername: 'test-admin', - adminPassword: 'test-password', - }, - }, - } - - ;(k8s.getK8sSecret as jest.Mock) - .mockResolvedValueOnce({ SOPS_AGE_KEY: 'existing-sops-key' }) // apl-sops-secrets exists - .mockResolvedValueOnce(null) // gitea-credentials does not exist - ;(hfValues as jest.Mock).mockResolvedValue(mockValues) - ;(k8s.createUpdateGenericSecret as jest.Mock).mockResolvedValue(undefined) + test('should recreate git config when configData is undefined', async () => { + ;(hf.hfValues as jest.Mock).mockResolvedValue(mockValues) + ;(gitConfig.getGitCredentials as jest.Mock).mockResolvedValue({ username: 'admin', password: 's3cret' }) + ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue({ SOPS_AGE_KEY: 'existing-key' }) + ;(gitConfig.getGitConfigData as jest.Mock).mockResolvedValue(undefined) - const result = await installer.setEnvAndCreateSecrets() + await installer.ensureSecretsAndConfig() - expect(process.env.SOPS_AGE_KEY).toBe('existing-sops-key') - expect(result).toEqual({ - username: 'test-admin', - password: 'test-password', - }) - expect(k8s.createUpdateGenericSecret).toHaveBeenCalledWith(mockCoreApi, 'gitea-credentials', 'apl-operator', { - GIT_USERNAME: 'test-admin', - GIT_PASSWORD: 'test-password', + expect(gitConfig.setGitConfig).toHaveBeenCalledWith({ + repoUrl: 'https://github.com/org/repo.git', + branch: 'main', + email: 'pipeline@cluster.local', }) - expect(k8s.createUpdateGenericSecret).toHaveBeenCalledTimes(1) }) - test('should use only existing gitea credentials when SOPS secret needs creation', async () => { - const mockValues = { - kms: { - sops: { - age: { - privateKey: 'AGE-SECRET-KEY-NEW', - }, - }, - }, - } + test('should recreate all resources when all are missing', async () => { + ;(hf.hfValues as jest.Mock).mockResolvedValue(mockValues) + ;(gitConfig.getGitCredentials as jest.Mock).mockResolvedValue(undefined) + ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue(undefined) + ;(gitConfig.getGitConfigData as jest.Mock).mockResolvedValue(undefined) - ;(k8s.getK8sSecret as jest.Mock) - .mockResolvedValueOnce(null) // apl-sops-secrets does not exist - .mockResolvedValueOnce({ GIT_USERNAME: 'existing-admin', GIT_PASSWORD: 'existing-password' }) // gitea-credentials exists - ;(hfValues as jest.Mock).mockResolvedValue(mockValues) - ;(k8s.createUpdateGenericSecret as jest.Mock).mockResolvedValue(undefined) + await installer.ensureSecretsAndConfig() - const result = await installer.setEnvAndCreateSecrets() - - expect(process.env.SOPS_AGE_KEY).toBe('AGE-SECRET-KEY-NEW') - expect(result).toEqual({ - username: 'existing-admin', - password: 'existing-password', + expect(k8s.createUpdateGenericSecret).toHaveBeenCalledWith(mockCoreApi, 'apl-git-credentials', 'apl-operator', { + username: 'admin', + password: 's3cret', }) expect(k8s.createUpdateGenericSecret).toHaveBeenCalledWith(mockCoreApi, 'apl-sops-secrets', 'apl-operator', { - SOPS_AGE_KEY: 'AGE-SECRET-KEY-NEW', + SOPS_AGE_KEY: 'AGE-SECRET-KEY-1234', + }) + expect(gitConfig.setGitConfig).toHaveBeenCalledWith({ + repoUrl: 'https://github.com/org/repo.git', + branch: 'main', + email: 'pipeline@cluster.local', }) - expect(k8s.createUpdateGenericSecret).toHaveBeenCalledTimes(1) }) }) }) diff --git a/src/operator/installer.ts b/src/operator/installer.ts index 63c2eb6cdd..3485460867 100644 --- a/src/operator/installer.ts +++ b/src/operator/installer.ts @@ -1,15 +1,17 @@ import * as process from 'node:process' import { terminal } from '../common/debug' +import { + getGitConfigData, + getGitCredentials, + GIT_CONFIG_NAMESPACE, + GIT_CONFIG_SECRET_NAME, + setGitConfig, +} from '../common/git-config' import { hfValues } from '../common/hf' import { createUpdateConfigMap, createUpdateGenericSecret, getK8sConfigMap, getK8sSecret, k8s } from '../common/k8s' import { AplOperations } from './apl-operations' import { getErrorMessage } from './utils' -export interface GitCredentials { - username: string - password: string -} - export class Installer { private d = terminal('operator:installer') private aplOps: AplOperations @@ -56,6 +58,7 @@ export class Installer { // Run the installation sequence await this.updateInstallationStatus('in-progress', attemptNumber) await this.aplOps.install() + await this.ensureSecretsAndConfig() await this.updateInstallationStatus('completed', attemptNumber) return @@ -94,70 +97,60 @@ export class Installer { } } - public async setEnvAndCreateSecrets(): Promise { + public async setEnvAndCreateSecrets(): Promise { this.d.debug('Retrieving or creating git credentials') await this.setupSopsEnvironment() - return await this.setupGiteaCredentials() } - private async setupGiteaCredentials() { - try { - const giteaCredentialsSecret = await getK8sSecret('gitea-credentials', 'apl-operator') - - if (giteaCredentialsSecret?.GIT_USERNAME && giteaCredentialsSecret?.GIT_PASSWORD) { - const gitUsername = giteaCredentialsSecret.GIT_USERNAME - const gitPassword = giteaCredentialsSecret.GIT_PASSWORD - - this.d.debug('Using existing git credentials from secret') - return { username: gitUsername, password: gitPassword } - } + private async setupSopsEnvironment() { + const aplSopsSecret = await getK8sSecret('apl-sops-secrets', 'apl-operator') - this.d.debug('Extracting credentials from installation values') - const values = (await hfValues()) as Record + if (!aplSopsSecret?.SOPS_AGE_KEY) { + throw new Error('SOPS_AGE_KEY not found in secret') + } + process.env.SOPS_AGE_KEY = aplSopsSecret.SOPS_AGE_KEY + } - const gitUsername: string = values.apps.gitea?.adminUsername || 'otomi-admin' - const gitPassword: string = values.apps.gitea?.adminPassword + // public for testing. This method should only be used if you are certain there are values locally. + async ensureSecretsAndConfig(): Promise { + this.d.info('Verifying secrets and config after installation') + const values = (await hfValues()) as Record + if (!values) { + this.d.warn('Could not retrieve hfValues, skipping secrets/config verification') + return + } - if (!gitUsername || !gitPassword) { - throw new Error('Git credentials not found in values') - } + const otomiGit = values?.otomi?.git + const agePrivateKey = values?.kms?.sops?.age?.privateKey - await createUpdateGenericSecret(k8s.core(), 'gitea-credentials', 'apl-operator', { - GIT_USERNAME: gitUsername, - GIT_PASSWORD: gitPassword, + // Ensure apl-git-credentials secret + const credentials = await getGitCredentials() + if (!credentials) { + this.d.info('Recreating apl-git-credentials secret') + await createUpdateGenericSecret(k8s.core(), GIT_CONFIG_SECRET_NAME, GIT_CONFIG_NAMESPACE, { + username: otomiGit?.username, + password: otomiGit?.password, }) + } - this.d.debug('Created git credentials secret') - return { username: gitUsername, password: gitPassword } - } catch (error) { - this.d.error('Failed to retrieve or create gitea credentials:', getErrorMessage(error)) - throw error + // Ensure apl-sops-secrets secret + const sopsSecret = await getK8sSecret('apl-sops-secrets', GIT_CONFIG_NAMESPACE) + if (!sopsSecret?.SOPS_AGE_KEY && agePrivateKey) { + this.d.info('Recreating apl-sops-secrets secret') + await createUpdateGenericSecret(k8s.core(), 'apl-sops-secrets', GIT_CONFIG_NAMESPACE, { + SOPS_AGE_KEY: agePrivateKey, + }) } - } - private async setupSopsEnvironment() { - try { - const aplSopsSecret = await getK8sSecret('apl-sops-secrets', 'apl-operator') - - if (aplSopsSecret?.SOPS_AGE_KEY) { - process.env.SOPS_AGE_KEY = aplSopsSecret.SOPS_AGE_KEY - this.d.debug('Using existing sops credentials from secret') - } else { - const values = (await hfValues()) as Record - const sopsAgePrivateKey = values.kms?.sops?.age?.privateKey - if (sopsAgePrivateKey && !sopsAgePrivateKey.startsWith('ENC')) { - process.env.SOPS_AGE_KEY = sopsAgePrivateKey - this.d.debug('Set SOPS_AGE_KEY in environment variables') - await createUpdateGenericSecret(k8s.core(), 'apl-sops-secrets', 'apl-operator', { - SOPS_AGE_KEY: sopsAgePrivateKey, - }) - } else { - this.d.debug('SOPS Age private key not found or encrypted, skipping') - } - } - } catch (error) { - this.d.error('Failed to retrieve or create sops credentials:', getErrorMessage(error)) - throw error + // Ensure apl-git-config configmap + const configData = await getGitConfigData() + if (!configData?.repoUrl || !configData?.branch || !configData?.email) { + this.d.info('Recreating apl-git-config configmap') + await setGitConfig({ + repoUrl: otomiGit?.repoUrl, + branch: otomiGit?.branch, + email: otomiGit?.email, + }) } } } diff --git a/src/operator/main.ts b/src/operator/main.ts index dc0643b5e7..bc8e897e60 100644 --- a/src/operator/main.ts +++ b/src/operator/main.ts @@ -1,7 +1,7 @@ import * as dotenv from 'dotenv' import { terminal } from '../common/debug' import { AplOperator, AplOperatorConfig } from './apl-operator' -import { GitCredentials, Installer } from './installer' +import { Installer } from './installer' import { operatorEnv } from './validators' import { env } from '../common/envalid' import fs from 'fs' @@ -9,6 +9,7 @@ import path from 'path' import { AplOperations } from './apl-operations' import { getErrorMessage } from './utils' import { GitRepository } from './git-repository' +import { getStoredGitRepoConfig } from '../common/git-config' import process from 'node:process' import { runTraceCollectionLoop } from '../cmd/traces' @@ -16,34 +17,24 @@ dotenv.config() const d = terminal('operator:main') -function loadConfig(aplOps: AplOperations, gitCredentials: GitCredentials): AplOperatorConfig { - // Get credentials from process.env directly since they may have been set after operatorEnv was parsed - const { username } = gitCredentials - const { password } = gitCredentials - const gitHost = env.GIT_URL - const gitPort = env.GIT_PORT - const gitProtocol = env.GIT_PROTOCOL - const repoPath = env.ENV_DIR - const gitOrg = operatorEnv.GIT_ORG - const gitRepo = operatorEnv.GIT_REPO - const pollIntervalMs = operatorEnv.POLL_INTERVAL_MS - const reconcileIntervalMs = operatorEnv.RECONCILE_INTERVAL_MS +async function loadConfig(aplOps: AplOperations): Promise { + const gitConfig = await getStoredGitRepoConfig() + + if (!gitConfig) { + throw new Error('Git config not found in stored secrets/configmap. Run installation first.') + } + const gitRepository = new GitRepository({ - username, - password, - gitHost, - gitPort, - gitProtocol, - repoPath, - gitOrg, - gitRepo, + authenticatedUrl: gitConfig.authenticatedUrl, + repoPath: env.ENV_DIR, }) return { gitRepo: gitRepository, + gitConfig, aplOps, - pollIntervalMs, - reconcileIntervalMs, + pollIntervalMs: operatorEnv.POLL_INTERVAL_MS, + reconcileIntervalMs: operatorEnv.RECONCILE_INTERVAL_MS, } } @@ -86,15 +77,15 @@ async function main(): Promise { await installer.initialize() await installer.reconcileInstall() } - const gitCredentials = await installer.setEnvAndCreateSecrets() // Start trace collection in background (runs for 30 minutes from ConfigMap creation) runTraceCollectionLoop().catch((error) => { d.warn('Trace collection loop failed:', getErrorMessage(error)) }) - // Phase 2: Start operator for GitOps operations - const config = loadConfig(aplOps, gitCredentials) + // Phase 2: Set environment variables and start operator for GitOps operations + // await installer.setEnvAndCreateSecrets() + const config = await loadConfig(aplOps) const operator = new AplOperator(config) handleTerminationSignals(operator) d.info('=== Starting Operator Process ===') diff --git a/src/playground.ts b/src/playground.ts index ecda0e39ea..6a277ff755 100755 --- a/src/playground.ts +++ b/src/playground.ts @@ -1,33 +1,37 @@ #!/usr/bin/env node --nolazy --import tsx -import { terminal } from './common/debug' -import { RuntimeUpgradeContext } from './common/runtime-upgrades/runtime-upgrades' -import { scaleDeployment } from './common/runtime-upgrades/v4.13.0' +import { createUpdateConfigMap, k8s } from './common/k8s' +import { PatchStrategy, setHeaderOptions } from '@kubernetes/client-node' async function play() { - // const version = getPackageVersion() - // const prevVersion: string = (await getDeploymentState()).version ?? version - // console.log(version) - // const state = await getDeploymentState() - // const releases = await getHelmReleases() - // const data = await hfValues( - // { withWorkloadValues: true }, - // '/Users/jehoszafatzimnowoda/workspace/linode/apl-core/tests/fixtures', - // ) - // await writeValuesToFile(`/tmp/status.yaml`, { status: { otomi: state, helm: releases } }, true) - // '/tmp/otomi-bootstrap-dev/**/teams/*/builds/*.yaml' - const d = terminal('cmd:upgrade:runtimeUpgrade') - const context: RuntimeUpgradeContext = { - debug: d, - } - try { - await scaleDeployment(context, 'argocd', 'argocd-applicationset-controller', 0) - } catch (error) { - d.error('Error during playground execution', error) + const body = { + key1: 'value100', + key2: 'value200', } + await createUpdateConfigMap(k8s.core(), 'create-update', 'default', body) - // const spec = await load('/tmp/otomi-bootstrap-dev') - // console.log(JSON.stringify(spec)) + const bodyPatch = { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: 'patch', + namespace: 'default', + }, + data: { + key1: 'value100', + key2: 'value200', + }, + } + await k8s.core().patchNamespacedConfigMap( + { + name: 'patch', + namespace: 'default', + body: bodyPatch, + fieldManager: 'apl-operator', + force: true, + }, + setHeaderOptions('Content-Type', PatchStrategy.ServerSideApply), + ) } play() diff --git a/tests/fixtures/env/apps/gitea.yaml b/tests/fixtures/env/apps/gitea.yaml index c3adf6e541..cb456d1ab7 100644 --- a/tests/fixtures/env/apps/gitea.yaml +++ b/tests/fixtures/env/apps/gitea.yaml @@ -4,7 +4,6 @@ metadata: labels: {} spec: _rawValues: {} - adminUsername: otomi-admin enabled: true resources: gitea: diff --git a/tests/fixtures/env/apps/otomi-api.yaml b/tests/fixtures/env/apps/otomi-api.yaml index e0f1cec617..0f9e63a58f 100644 --- a/tests/fixtures/env/apps/otomi-api.yaml +++ b/tests/fixtures/env/apps/otomi-api.yaml @@ -4,9 +4,6 @@ metadata: labels: {} spec: editorInactivityTimeout: 5 - git: - email: some@secret.value - user: someuser _rawValues: {} resources: api: diff --git a/tests/fixtures/env/apps/secrets.gitea.yaml b/tests/fixtures/env/apps/secrets.gitea.yaml index bbf17fa907..25e72d933c 100644 --- a/tests/fixtures/env/apps/secrets.gitea.yaml +++ b/tests/fixtures/env/apps/secrets.gitea.yaml @@ -1,6 +1,5 @@ kind: AplApp spec: - adminPassword: giteaAdminPassword postgresqlPassword: postgresqlPassword name: gitea metadata: diff --git a/tests/fixtures/env/apps/secrets.otomi-api.yaml b/tests/fixtures/env/apps/secrets.otomi-api.yaml deleted file mode 100644 index ae0f7adc9f..0000000000 --- a/tests/fixtures/env/apps/secrets.otomi-api.yaml +++ /dev/null @@ -1,7 +0,0 @@ -kind: AplApp -spec: - git: - password: somesecretvalue -name: otomi-api -metadata: - name: otomi-api diff --git a/tests/fixtures/env/settings/otomi.yaml b/tests/fixtures/env/settings/otomi.yaml index 3e0d6e6f28..18e1ecc81f 100644 --- a/tests/fixtures/env/settings/otomi.yaml +++ b/tests/fixtures/env/settings/otomi.yaml @@ -14,3 +14,8 @@ spec: isPreInstalled: false useORCS: false aiEnabled: true + git: + branch: main + repoUrl: http://gitea-http.gitea.svc.cluster.local:3000/otomi/values.git + username: otomi-admin + email: pipeline@cluster.local diff --git a/tests/fixtures/env/settings/secrets.otomi.yaml b/tests/fixtures/env/settings/secrets.otomi.yaml index 12eda4e881..1819165b7c 100644 --- a/tests/fixtures/env/settings/secrets.otomi.yaml +++ b/tests/fixtures/env/settings/secrets.otomi.yaml @@ -1,6 +1,8 @@ kind: AplCapabilitySet spec: adminPassword: bladibla + git: + password: gitPasswordForTesting globalPullSecret: password: blablabla name: otomi diff --git a/values-changes.yaml b/values-changes.yaml index d290f309ba..52ee7013e5 100644 --- a/values-changes.yaml +++ b/values-changes.yaml @@ -440,3 +440,9 @@ changes: - databases.keycloak.backupSidecarResources.limits.memory: 256Mi - databases.gitea.backupSidecarResources.limits.memory: 256Mi - databases.harbor.backupSidecarResources.limits.memory: 256Mi + - version: 55 + deletions: + - 'apps.otomi-api.git' + relocations: + - apps.gitea.adminPassword: otomi.git.password + - apps.gitea.adminUsername: otomi.git.username diff --git a/values-schema.yaml b/values-schema.yaml index 35e877a9a7..f7ceea7a84 100644 --- a/values-schema.yaml +++ b/values-schema.yaml @@ -1702,9 +1702,8 @@ properties: properties: _rawValues: $ref: '#/definitions/rawValues' - adminPassword: - type: string - x-secret: '{{ randAlphaNum 20 }}' + enabled: + type: boolean postgresqlPassword: type: string description: This password was generated and cannot be changed without manual intervention. @@ -2214,20 +2213,6 @@ properties: editorInactivityTimeout: type: integer default: 10 - git: - additionalProperties: false - properties: - branch: - type: string - email: - $ref: '#/definitions/email' - password: - type: string - x-secret: '' - repoUrl: - $ref: '#/definitions/repoUrl' - user: - type: string resources: additionalProperties: false properties: @@ -2800,6 +2785,42 @@ properties: required: - username - password + git: + type: object + title: Git Configuration + description: | + Git configuration for APL values repository. + additionalProperties: false + properties: + repoUrl: + type: string + description: | + The base URL of the Git repository (without credentials). Auto-derived for internal Gitea. + pattern: '^https?://.+' + username: + type: string + description: | + Username for authenticating with the Git repository. + Defaults to 'otomi-admin' for internal Gitea. + password: + type: string + description: Password or token for authenticating with the Git repository + x-secret: '{{ randAlphaNum 20 }}' + email: + type: string + description: | + Email address to use for Git commits. + Defaults to 'pipeline@cluster.local' for internal Gitea. + format: email + branch: + type: string + description: The branch to use in the Git repository + required: + - repoUrl + - username + - password + - email + - branch hasExternalDNS: description: Set this to true when an external dns zone is available to manage dns records. (Expects required `dns:` fields to be set.) default: false @@ -2822,6 +2843,8 @@ properties: useORCS: type: boolean description: Defines if the OCI Registry Cache Service (ORCS) is used to cache images from the public registry. + required: + - git ingress: properties: platformClass: diff --git a/values/apl-gitea-operator/apl-gitea-operator-raw.gotmpl b/values/apl-gitea-operator/apl-gitea-operator-raw.gotmpl index 99dc585e57..36fa0f144a 100644 --- a/values/apl-gitea-operator/apl-gitea-operator-raw.gotmpl +++ b/values/apl-gitea-operator/apl-gitea-operator-raw.gotmpl @@ -25,7 +25,7 @@ resources: name: apl-gitea-operator-secret namespace: apl-gitea-operator data: - giteaPassword: {{ $g.adminPassword | b64enc }} + giteaPassword: {{ $v.otomi.git.password | b64enc }} oidcClientId: {{ $k.idp.clientID | b64enc }} oidcClientSecret: {{ $k.idp.clientSecret | b64enc }} - oidcEndpoint: {{ $v._derived.oidcBaseUrl | b64enc }} \ No newline at end of file + oidcEndpoint: {{ $v._derived.oidcBaseUrl | b64enc }} diff --git a/values/apl-operator/apl-operator.gotmpl b/values/apl-operator/apl-operator.gotmpl index b3aea0397d..d3bc151b2a 100644 --- a/values/apl-operator/apl-operator.gotmpl +++ b/values/apl-operator/apl-operator.gotmpl @@ -20,5 +20,10 @@ resources: {{- toYaml $o.resources.operator | nindent 2 }} kms: {{- $kms | toYaml | nindent 2 }} -gitPassword: {{ $g.adminPassword | quote }} -gitUsername: {{ $g.adminUsername | quote }} +git: + password: {{ $v.otomi.git.password | quote }} + username: {{ $v.otomi.git.username | quote }} + email: {{ $v.otomi.git.email | quote }} + repoUrl: {{ $v.otomi.git.repoUrl | quote }} + branch: {{ $v.otomi.git.branch | quote }} + diff --git a/values/argocd/argocd-raw.gotmpl b/values/argocd/argocd-raw.gotmpl index fad597fd26..8fdc599015 100644 --- a/values/argocd/argocd-raw.gotmpl +++ b/values/argocd/argocd-raw.gotmpl @@ -1,6 +1,5 @@ {{- $v := .Values }} {{- $a := $v.apps.argocd }} -{{- $g := $v.apps.gitea }} resources: {{- if $v._derived.untrustedCA }} - apiVersion: v1 @@ -10,6 +9,7 @@ resources: data: custom-ca-certificates.crt: {{ .Values._derived.caCert | b64enc }} {{- end }} +{{- if contains "gitea-http.gitea.svc.cluster.local" $v.otomi.git.repoUrl }} - apiVersion: v1 kind: Secret metadata: @@ -20,8 +20,8 @@ resources: data: type: {{ print "git" | b64enc | quote }} url: {{ printf "https://%s" $v._derived.giteaDomain | b64enc }} - username: {{ $g.adminUsername | b64enc }} - password: {{ $g.adminPassword | b64enc }} + username: {{ $v.otomi.git.username | b64enc }} + password: {{ $v.otomi.git.password| b64enc }} - apiVersion: v1 kind: Secret metadata: @@ -32,5 +32,19 @@ resources: data: type: {{ print "git" | b64enc | quote }} url: {{ "http://gitea-http.gitea.svc.cluster.local:3000/otomi/values.git" | b64enc | quote }} - username: {{ $g.adminUsername | b64enc }} - password: {{ $g.adminPassword | b64enc }} + username: {{ $v.otomi.git.username | b64enc }} + password: {{ $v.otomi.git.password| b64enc }} +{{- else }} + - apiVersion: v1 + kind: Secret + metadata: + name: argocd-repo-creds-git + namespace: argocd + labels: + argocd.argoproj.io/secret-type: repo-creds + data: + type: {{ print "git" | b64enc | quote }} + url: {{ $v.otomi.git.repoUrl | b64enc }} + username: {{ $v.otomi.git.username | b64enc }} + password: {{ $v.otomi.git.password | b64enc }} +{{- end }} diff --git a/values/gitea/gitea-raw.gotmpl b/values/gitea/gitea-raw.gotmpl index 0b766b4a9c..02b2cc15e8 100644 --- a/values/gitea/gitea-raw.gotmpl +++ b/values/gitea/gitea-raw.gotmpl @@ -26,8 +26,8 @@ resources: metadata: name: gitea-admin-secret data: - username: "{{ $g.adminUsername | b64enc }}" - password: "{{ $g.adminPassword | b64enc }}" + username: "{{ $v.otomi.git.username | b64enc }}" + password: "{{ $v.otomi.git.password | b64enc }}" # DB / app backup resources {{- if eq $obj.type "linode" }} - apiVersion: v1 diff --git a/values/otomi-api/otomi-api.gotmpl b/values/otomi-api/otomi-api.gotmpl index e8681d450a..6a21424911 100644 --- a/values/otomi-api/otomi-api.gotmpl +++ b/values/otomi-api/otomi-api.gotmpl @@ -1,11 +1,10 @@ {{- $v := .Values }} {{- $c := $v.cluster }} {{- $o := $v.apps | get "otomi-api" }} -{{- $g := $v.apps.gitea }} {{- $cm := $v.apps | get "cert-manager" }} {{- $sops := $v | get "kms.sops" dict }} -{{- $giteaValuesUrl := "http://gitea-http.gitea.svc.cluster.local:3000/otomi/values" }} -{{- $giteaValuesPublilcUrl := printf "https://gitea.%s/otomi/values" $v.cluster.domainSuffix }} +{{- $giteaValuesPublicUrl := printf "https://gitea.%s/otomi/values" $v.cluster.domainSuffix }} +{{- $git := $v.otomi.git }} {{- $defaultPlatformAdminEmail := printf "platform-admin@%s" $v.cluster.domainSuffix }} {{- $sopsEnv := tpl (readFile "../../helmfile.d/snippets/sops-env.gotmpl") $sops }} {{- $version := $v.versions | get "api" }} @@ -38,18 +37,22 @@ tools: VERBOSITY: '1' secrets: - GIT_USER: otomi-admin - GIT_EMAIL: not@us.ed - GIT_PASSWORD: {{ $g.adminPassword | quote}} + GIT_USER: {{ $git.username | quote }} + GIT_EMAIL: {{ $git.email | quote }} + GIT_PASSWORD: {{ $git.password | quote }} {{- $sopsEnv | nindent 2 }} env: DEFAULT_PLATFORM_ADMIN_EMAIL: {{ $defaultPlatformAdminEmail }} DEBUG: 'otomi:*,-otomi:authz,-otomi:repo' VERBOSITY: '1' - GIT_REPO_URL: {{ $o | get "git.repoUrl" $giteaValuesUrl }} - GIT_REPO_PUBLIC_URL: {{ $giteaValuesPublilcUrl }} - GIT_BRANCH: {{ $o | get "git.branch" "main" }} + GIT_REPO_URL: {{ $git.repoUrl }} + {{- if contains "gitea-http.gitea.svc.cluster.local" $git.repoUrl }} + GIT_REPO_PUBLIC_URL: {{ $giteaValuesPublicUrl }} + {{- else }} + GIT_REPO_PUBLIC_URL: {{ $git.repoUrl }} + {{- end }} + GIT_BRANCH: {{ $git.branch }} VERSIONS: | {{ $v.versions | toJson | nindent 4}} {{- if $v._derived.untrustedCA }} diff --git a/values/team-ns/team-ns.gotmpl b/values/team-ns/team-ns.gotmpl index a971f15190..7d1dfd36b5 100644 --- a/values/team-ns/team-ns.gotmpl +++ b/values/team-ns/team-ns.gotmpl @@ -66,9 +66,14 @@ defaultIngressGatewayLabel: {{ $v._derived.defaultIngressGatewayLabel }} canaryIngressGatewayLabel: {{ $v._derived.canaryIngressGatewayLabel | quote }} gitOps: +{{- if contains "gitea-http.gitea.svc.cluster.local" $v.otomi.git.repoUrl }} teamRepoUrl: "https://{{ $v._derived.giteaDomain }}/otomi/team-{{ $teamId }}-argocd.git" valuesRepoUrl: "https://{{ $v._derived.giteaDomain }}/otomi/values.git" workloadValuesRepoUrl: "https://{{ $v._derived.giteaDomain }}/otomi/values.git" +{{- else }} + valuesRepoUrl: "{{ $v.otomi.git.repoUrl }}" + workloadValuesRepoUrl: "{{ $v.otomi.git.repoUrl }}" +{{- end }} tlsSecretName: {{ $v._derived.tlsSecretName }} giteaDomain: {{ $v._derived.giteaDomain }}