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/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..6c38e4a178 100644 --- a/helmfile.d/snippets/defaults.yaml +++ b/helmfile.d/snippets/defaults.yaml @@ -146,7 +146,6 @@ environments: memory: 64Mi cpu: 10m gitea: - adminUsername: otomi-admin _rawValues: {} networkPolicies: enabled: true @@ -1143,6 +1142,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 diff --git a/package-lock.json b/package-lock.json index 0e397f1a02..741c56efb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -200,6 +200,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2465,6 +2466,7 @@ "integrity": "sha512-RsUFrSB0oQHEBnR8yarKIReUPwSu2ROpbjhdVKi4T/nQhMaS+TnIQPBwkMtb2r8A1KS2Hijw4D/4bV/XHoFQWw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=20" } @@ -2546,7 +2548,8 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.0.19.tgz", "integrity": "sha512-VYHtPnZt/Zd/ATbW3rtexWpBnHUohUrQOHff/2JBhsVgxOrksAxJnLAO43Q1ayLJBJUUwNVo+RU0sx0aaysZfg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-dart": { "version": "2.3.2", @@ -2686,14 +2689,16 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.14.tgz", "integrity": "sha512-2bf7n+kS92g+cMKV0wr9o/Oq9n8JzU7CcrB96gIh2GHgnF+0xDOqO2W/1KeFAqOfqosoOVE48t+4dnEMkkoJ2Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-html-symbol-entities": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-4.0.5.tgz", "integrity": "sha512-429alTD4cE0FIwpMucvSN35Ld87HCyuM8mF731KU5Rm4Je2SG6hmVx7nkBsLyrmH3sQukTcr1GaiZsiEg8svPA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-java": { "version": "5.0.12", @@ -2891,7 +2896,8 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz", "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-vue": { "version": "3.0.5", @@ -4994,6 +5000,7 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -6583,7 +6590,8 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/json5": { "version": "0.0.29", @@ -6649,6 +6657,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -6848,6 +6857,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -7388,6 +7398,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8269,6 +8280,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -10666,6 +10678,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -12034,6 +12047,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -12094,6 +12108,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -12228,6 +12243,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -15729,6 +15745,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -17496,6 +17513,7 @@ "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 10.16.0" } @@ -18539,6 +18557,7 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -21619,6 +21638,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -22674,6 +22694,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -23675,6 +23696,7 @@ "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -25607,6 +25629,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -25810,6 +25833,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -26107,6 +26131,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -26286,6 +26311,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.2.4" }, @@ -26787,6 +26813,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, 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..775af1385a 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,9 @@ 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}`) + await deps.createUpdateGenericSecret(k8s.core(), 'apl-sops-secrets', 'apl-operator', { + SOPS_AGE_KEY: privateKey, + }) } } diff --git a/src/cmd/commit.ts b/src/cmd/commit.ts index 43db20e0a2..e2960d6a93 100644 --- a/src/cmd/commit.ts +++ b/src/cmd/commit.ts @@ -7,7 +7,7 @@ import { env } from 'src/common/envalid' import { hfValues } from 'src/common/hf' import { createUpdateConfigMap, createUpdateGenericSecret, k8s, waitTillGitRepoAvailable } 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' @@ -53,11 +53,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 { @@ -127,12 +132,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/crypt.ts b/src/common/crypt.ts index 034aa09734..e65d668887 100644 --- a/src/common/crypt.ts +++ b/src/common/crypt.ts @@ -5,7 +5,7 @@ import { chunk } from 'lodash' import { $, cd, ProcessOutput } from 'zx' import { terminal } from './debug' import { cleanEnv, cliEnvSpec, env, isCli } from './envalid' -import { hasFileDifference, readdirRecurse, rootDir } from './utils' +import { readdirRecurse, rootDir } from './utils' import { BasicArguments } from './yargs' export interface Arguments extends BasicArguments { @@ -22,12 +22,12 @@ enum CryptType { const preCrypt = async (path): Promise => { const d = terminal(`common:crypt:preCrypt`) - d.debug('Checking prerequisites for the (de,en)crypt action') + d.info('Checking prerequisites for the (de,en)crypt action') // we might have set GCLOUD_SERVICE_KEY in bootstrap so reparse env // just this time (not desired as should be considered read only): const lateEnv = cleanEnv(cliEnvSpec) if (lateEnv.GCLOUD_SERVICE_KEY) { - d.debug('Writing GOOGLE_APPLICATION_CREDENTIAL') + d.info('Writing GOOGLE_APPLICATION_CREDENTIAL') // and set the location to the file holding the credentials for zx running sops process.env.GOOGLE_APPLICATION_CREDENTIALS = '/tmp/key.json' await writeFile(process.env.GOOGLE_APPLICATION_CREDENTIALS, JSON.stringify(lateEnv.GCLOUD_SERVICE_KEY)) @@ -46,7 +46,7 @@ const getAllSecretFiles = async (path) => { (file) => file.endsWith('.yaml') && file.includes('/secrets.'), ) - d.debug('getAllSecretFiles: ', files) + d.info('getAllSecretFiles: ', files) return files } @@ -60,7 +60,7 @@ const processFileChunk = async (crypt: CR, files: string[]): Promise<(ProcessOut const d = terminal(`common:crypt:processFileChunk`) const commands = files.map(async (file) => { if (!crypt.condition || (await crypt.condition(file))) { - d.debug(`${crypt.cmd} ${file}`) + d.info(`${crypt.cmd} ${file}`) try { const result = await $`${[...crypt.cmd.split(' '), file]}`.quiet() @@ -112,7 +112,7 @@ const runOnSecretFiles = async (path: string, crypt: CR, filesArgs: string[] = [ const eventEmitterDefaultListeners = EventEmitter.defaultMaxListeners // EventEmitter.defaultMaxListeners is 10, if we increase chunkSize in the future then this line will prevent it from crashing if (chunkSize + 2 > EventEmitter.defaultMaxListeners) EventEmitter.defaultMaxListeners = chunkSize + 2 - d.debug(`runOnSecretFiles: ${crypt.cmd}`) + d.info(`runOnSecretFiles: ${crypt.cmd}`) try { for (const fileChunk of filesChunked) { await processFileChunk(crypt, fileChunk) @@ -127,20 +127,19 @@ const runOnSecretFiles = async (path: string, crypt: CR, filesArgs: string[] = [ } } -const matchTimestamps = async (path, file: string) => { +const matchTimestamps = async (file: string) => { const d = terminal(`common:crypt:matchTimeStamps`) - const absFilePath = `${path}/${file}` - if (!existsSync(`${absFilePath}.dec`)) { - d.debug(`Missing ${file}.dec, skipping...`) + if (!existsSync(`${file}.dec`)) { + d.info(`Missing ${file}.dec, skipping...`) return } - const encTS = await stat(absFilePath) - const decTS = await stat(`${absFilePath}.dec`) - await utimes(`${absFilePath}.dec`, decTS.mtime, encTS.mtime) + const encTS = await stat(file) + const decTS = await stat(`${file}.dec`) + await utimes(`${file}.dec`, decTS.mtime, encTS.mtime) const encSec = Math.round(encTS.mtimeMs / 1000) const decSec = Math.round(decTS.mtimeMs / 1000) - d.debug(`Updated timestamp for ${file}.dec from ${decSec} to ${encSec}`) + d.info(`Updated timestamp for ${file}.dec from ${decSec} to ${encSec}`) } export const decrypt = async (path = env.ENV_DIR, ...files: string[]): Promise => { @@ -155,7 +154,7 @@ export const decrypt = async (path = env.ENV_DIR, ...files: string[]): Promise matchTimestamps(path, f), + post: async (f) => matchTimestamps(f), }, files, ) @@ -179,33 +178,37 @@ export const encrypt = async (path = env.ENV_DIR, ...files: string[]): Promise encSec) { + d.info(`Encrypting ${file}, .dec file is newer (modified since last encryption)`) + return true + } + d.info(`Skipping encryption for ${file} as it has not changed`) + return false } - // Compare files - const specsAreDifferent = await hasFileDifference(file, `${file}.dec`) - if (specsAreDifferent) { - d.info(`Encrypting ${file}, difference found between encrypted and .dec file`) + // No .dec file - check if file is already encrypted + try { + // Same logic is used in helm-secrets + await $`grep -q 'mac.*,type:str]' ${file}` + d.debug(`Skipping encryption for ${file} (already encrypted, no .dec file)`) + return false + } catch { + d.debug(`${file} is not yet encrypted, will encrypt`) return true } - - d.info(`Skipping encryption for ${file} as it has not changed`) - return false }, cmd: CryptType.ENCRYPT, - post: async (f: string) => matchTimestamps(path, f), + post: async (f: string) => matchTimestamps(f), }, files, ) 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/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 5e96b52166..cfcb109b8b 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, resetGiteaPasswordValidity } from './v4.13.0' import { removeHttpBinApplication } from './remove-httpbin-application' +import { migrateGitConfig } from './migrate-git-config' export interface RuntimeUpgradeContext { debug: OtomiDebugger @@ -161,4 +162,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 beb92a7d16..4542d2703d 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/k8s' 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 cefc628508..518e5d42d1 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/k8s' 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..8a226c1f75 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) } @@ -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..389d09a1b2 100644 --- a/src/operator/installer.test.ts +++ b/src/operator/installer.test.ts @@ -1,4 +1,3 @@ -import { hfValues } from '../common/hf' import * as k8s from '../common/k8s' import { AplOperations } from './apl-operations' import { Installer } from './installer' @@ -27,6 +26,14 @@ jest.mock('../common/hf', () => ({ hfValues: jest.fn(), })) +jest.mock('../common/git-config', () => ({ + getGitCredentials: 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 +282,19 @@ 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', - }, - }, - }, - } - - ;(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') - }) - - test('should use default username when not provided', async () => { - const mockValues = { - apps: { - gitea: { - adminPassword: 'test-password', - }, - }, - kms: { - sops: { - age: { - privateKey: 'AGE-SECRET-KEY-1234567890', - }, - }, - }, - } - - ;(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(result).toEqual({ - username: 'otomi-admin', - password: 'test-password', - }) - }) - - test('should throw error when password is missing', async () => { - const mockValues = { - apps: { - gitea: { - adminUsername: 'test-admin', - }, - }, - } - - ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue(null) - ;(hfValues as jest.Mock).mockResolvedValue(mockValues) - - await expect(installer.setEnvAndCreateSecrets()).rejects.toThrow('Git credentials not found in values') - }) - - 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) - - const result = await installer.setEnvAndCreateSecrets() - - // Empty string falls back to default due to || operator - expect(result).toEqual({ - username: 'otomi-admin', - password: 'test-password', - }) - }) - - test('should throw error when password is empty string', async () => { - const mockValues = { - apps: { - gitea: { - adminUsername: 'test-admin', - adminPassword: '', - }, - }, - } - - ;(k8s.getK8sSecret as jest.Mock).mockResolvedValue(null) - ;(hfValues as jest.Mock).mockResolvedValue(mockValues) - - await expect(installer.setEnvAndCreateSecrets()).rejects.toThrow('Git credentials not found in values') }) - 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 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) - await installer.setEnvAndCreateSecrets() - - expect(process.env.SOPS_AGE_KEY).toBe('') - }) - - 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 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 expect(installer.setEnvAndCreateSecrets()).rejects.toThrow('Failed to get values') - }) - - test('should handle secret creation failure', 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).mockRejectedValue(new Error('Secret creation failed')) - - await expect(installer.setEnvAndCreateSecrets()).rejects.toThrow('Secret creation failed') - }) - - 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) - - const result = await installer.setEnvAndCreateSecrets() - - expect(result).toEqual({ - username: 'nested-admin', - password: 'nested-password', - }) - }) - - 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) - - const result = await installer.setEnvAndCreateSecrets() - - 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(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', - }, - }, - }, - } - - ;(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) - - 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-sops-secrets', 'apl-operator', { - SOPS_AGE_KEY: 'AGE-SECRET-KEY-NEW', - }) - expect(k8s.createUpdateGenericSecret).toHaveBeenCalledTimes(1) + await expect(installer.setEnvAndCreateSecrets()).rejects.toThrow('SOPS_AGE_KEY not found in secret') }) }) }) diff --git a/src/operator/installer.ts b/src/operator/installer.ts index 63c2eb6cdd..b4880625c5 100644 --- a/src/operator/installer.ts +++ b/src/operator/installer.ts @@ -1,7 +1,6 @@ import * as process from 'node:process' import { terminal } from '../common/debug' -import { hfValues } from '../common/hf' -import { createUpdateConfigMap, createUpdateGenericSecret, getK8sConfigMap, getK8sSecret, k8s } from '../common/k8s' +import { createUpdateConfigMap, getK8sConfigMap, getK8sSecret, k8s } from '../common/k8s' import { AplOperations } from './apl-operations' import { getErrorMessage } from './utils' @@ -94,70 +93,17 @@ 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 } - } - - this.d.debug('Extracting credentials from installation values') - const values = (await hfValues()) as Record - - const gitUsername: string = values.apps.gitea?.adminUsername || 'otomi-admin' - const gitPassword: string = values.apps.gitea?.adminPassword - - if (!gitUsername || !gitPassword) { - throw new Error('Git credentials not found in values') - } - - await createUpdateGenericSecret(k8s.core(), 'gitea-credentials', 'apl-operator', { - GIT_USERNAME: gitUsername, - GIT_PASSWORD: gitPassword, - }) - - 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 - } } 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 + const aplSopsSecret = await getK8sSecret('apl-sops-secrets', 'apl-operator') + + if (!aplSopsSecret?.SOPS_AGE_KEY) { + throw new Error('SOPS_AGE_KEY not found in secret') } + process.env.SOPS_AGE_KEY = aplSopsSecret.SOPS_AGE_KEY } } 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..93fb9fb48b 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 @@ -20,8 +19,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 +31,5 @@ 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 }} 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..7a31d25ae4 100644 --- a/values/otomi-api/otomi-api.gotmpl +++ b/values/otomi-api/otomi-api.gotmpl @@ -1,11 +1,9 @@ {{- $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 }} +{{- $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 +36,17 @@ 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 }} + GIT_BRANCH: {{ $git.branch }} VERSIONS: | {{ $v.versions | toJson | nindent 4}} {{- if $v._derived.untrustedCA }}