From 6d73217a51f77cd925b5f55fd1e806ec163f8896 Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Thu, 18 Jun 2026 14:49:26 +0200 Subject: [PATCH 1/9] fix: remove git settings from schemas --- src/openapi/settings.yaml | 34 ---------------------------------- src/openapi/settingsinfo.yaml | 9 --------- 2 files changed, 43 deletions(-) diff --git a/src/openapi/settings.yaml b/src/openapi/settings.yaml index 99ccff0ab..d44804ba8 100644 --- a/src/openapi/settings.yaml +++ b/src/openapi/settings.yaml @@ -228,40 +228,6 @@ Settings: type: object additionalProperties: false properties: - 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). - 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 - - email - - branch adminPassword: description: Master admin password that will be used for all apps that are not configured to use their own password. $ref: 'definitions.yaml#/adminPassword' diff --git a/src/openapi/settingsinfo.yaml b/src/openapi/settingsinfo.yaml index 43479813b..0dadc2d09 100644 --- a/src/openapi/settingsinfo.yaml +++ b/src/openapi/settingsinfo.yaml @@ -50,15 +50,6 @@ SettingsInfo: hasExternalIDP: type: boolean default: false - git: - properties: - repoUrl: - type: string - description: The base URL of the Git repository (without credentials). - pattern: '^https?://.+' - branch: - type: string - description: The branch to use in the Git repository. ingressClassNames: description: Ingress class names that are used by the cluster. items: From 03d61e8e6f1e70b1c3c565b6209e0a4c470a265b Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Fri, 19 Jun 2026 16:44:18 +0200 Subject: [PATCH 2/9] fix: reject non-empty repository --- src/api/v2/git.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/api/v2/git.ts b/src/api/v2/git.ts index 593c7a13b..35391055d 100644 --- a/src/api/v2/git.ts +++ b/src/api/v2/git.ts @@ -39,6 +39,9 @@ export const migrateGit = async (req: OpenApiRequestExt, res: Response): Promise return } } + if (remoteHasContent) { + res.json({ message: 'New repository is not empty', statusCode: 400 }) + } // Write config + commit locally → push to new remote (if empty) → push to current remote await req.otomi.migrateGitSettings({ repoUrl, username, password, email, branch, remoteHasContent }) From 720c06510b56e0f915e3851cd47a6cb351425a22 Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Fri, 19 Jun 2026 16:45:49 +0200 Subject: [PATCH 3/9] refactor: use common git config --- src/api/v2/git.ts | 14 +-- src/app.ts | 2 +- src/constants.ts | 9 ++ src/git.test.ts | 90 ++++++++++++++-- src/git.ts | 97 +++++------------ src/git/connect.ts | 40 ++++--- src/middleware/session.ts | 2 +- src/otomi-models.ts | 36 ++----- src/otomi-stack.test.ts | 39 ++----- src/otomi-stack.ts | 222 ++++++++++++++++++++++---------------- src/utils.test.ts | 23 ++-- src/utils.ts | 13 ++- 12 files changed, 309 insertions(+), 278 deletions(-) diff --git a/src/api/v2/git.ts b/src/api/v2/git.ts index 35391055d..245e63a40 100644 --- a/src/api/v2/git.ts +++ b/src/api/v2/git.ts @@ -1,7 +1,7 @@ import Debug from 'debug' import { Response } from 'express' import { lockApi } from 'src/middleware' -import { OpenApiRequestExt } from 'src/otomi-models' +import { GitConfig, OpenApiRequestExt } from 'src/otomi-models' const debug = Debug('otomi:api:v2:git') @@ -16,18 +16,12 @@ const debug = Debug('otomi:api:v2:git') */ export const migrateGit = async (req: OpenApiRequestExt, res: Response): Promise => { debug('migrateGit') - const { repoUrl, username, password, email, branch } = req.body as { - repoUrl: string - username?: string - password: string - email: string - branch: string - } + const newGitConfig = req.body as GitConfig // Validate new remote connectivity; returns true if remote already has content let remoteHasContent: boolean try { - remoteHasContent = await req.otomi.git.testRemoteConnection(repoUrl, password, branch, username) + remoteHasContent = await req.otomi.git.testRemoteConnection(newGitConfig) } catch (e: any) { if (e.message.includes('not found')) { const error = { message: `Cannot connect to new git remote`, statusCode: 404 } @@ -44,7 +38,7 @@ export const migrateGit = async (req: OpenApiRequestExt, res: Response): Promise } // Write config + commit locally → push to new remote (if empty) → push to current remote - await req.otomi.migrateGitSettings({ repoUrl, username, password, email, branch, remoteHasContent }) + await req.otomi.migrateGitSettings(newGitConfig) await lockApi() diff --git a/src/app.ts b/src/app.ts index 9ce136b67..0cb33c29f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -54,7 +54,7 @@ type OtomiSpec = { // get the latest commit from Git and checks it against the local values const checkAgainstGit = async () => { const otomiStack = await getSessionStack() - const latestOtomiVersion = await getLatestRemoteCommitSha() + const latestOtomiVersion = await getLatestRemoteCommitSha(otomiStack.git) // check the local version against the latest online version // if the latest online is newer it will be pulled locally if (latestOtomiVersion && latestOtomiVersion !== otomiStack.git.commitSha) { diff --git a/src/constants.ts b/src/constants.ts index 44c828240..d9373107b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,3 +2,12 @@ export const APL_SECRETS_NAMESPACE = 'apl-secrets' export const APL_USERS_NAMESPACE = 'apl-users' export const PLATFORM_SECRETS_NAME = 'otomi-secrets' export const GITEA_SECRETS_NAME = 'gitea-secrets' +export const GIT_CONFIG_SECRET_NAME = 'apl-git-config' +export const GIT_DEFAULT_CONFIG = { + repoUrl: 'http://git-server.git-server.svc.cluster.local/otomi/values.git', + branch: 'main', + email: 'pipeline@cluster.local', +} +export const GIT_LEGACY_CONFIG = { + repoUrl: 'http://gitea-http.gitea.svc.cluster.local:3000/otomi/values.git', +} diff --git a/src/git.test.ts b/src/git.test.ts index f114bbce2..0879bf739 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -17,7 +17,7 @@ const mockGitInstance = { ;(simpleGit as jest.Mock).mockReturnValue(mockGitInstance) function makeRepo(): Git { - return new Git('/tmp/test', 'https://origin.example.com/repo.git', 'user', 'user@example.com', undefined, 'main') + return new Git('/tmp/test', 'https://origin.example.com/repo.git', 'user', 'user@example.com', '', 'main') } describe('Git.testRemoteConnection', () => { @@ -26,7 +26,14 @@ describe('Git.testRemoteConnection', () => { it('returns false when remote is empty (no refs)', async () => { mockRaw.mockResolvedValue('') const repo = makeRepo() - const result = await repo.testRemoteConnection('https://example.com/repo.git', 'mypass', 'main', 'myuser') + const gitConfig = { + repoUrl: 'https://example.com/repo.git', + password: 'mypass', + branch: 'main', + username: 'myuser', + email: '', + } + const result = await repo.testRemoteConnection(gitConfig) expect(result).toBe(false) expect(mockRaw).toHaveBeenCalledWith(['ls-remote', expect.stringContaining('myuser'), 'refs/heads/main']) }) @@ -34,14 +41,27 @@ describe('Git.testRemoteConnection', () => { it('returns true when remote has existing refs', async () => { mockRaw.mockResolvedValue('abc123\trefs/heads/main\n') const repo = makeRepo() - const result = await repo.testRemoteConnection('https://example.com/repo.git', 'mypass', 'main', 'myuser') + const gitConfig = { + repoUrl: 'https://example.com/repo.git', + password: 'mypass', + branch: 'main', + username: 'myuser', + email: '', + } + const result = await repo.testRemoteConnection(gitConfig) expect(result).toBe(true) }) it('calls ls-remote with PAT only (no username) in url', async () => { mockRaw.mockResolvedValue('abc123\trefs/heads/main\n') const repo = makeRepo() - const result = await repo.testRemoteConnection('https://example.com/repo.git', 'mytoken', 'main') + const gitConfig = { + repoUrl: 'https://example.com/repo.git', + password: 'mytoken', + branch: 'main', + email: '', + } + const result = await repo.testRemoteConnection(gitConfig) expect(result).toBe(true) expect(mockRaw).toHaveBeenCalledWith(['ls-remote', 'https://mytoken@example.com/repo.git', 'refs/heads/main']) }) @@ -49,7 +69,14 @@ describe('Git.testRemoteConnection', () => { it('returns false when branch does not exist but remote has other refs', async () => { mockRaw.mockResolvedValue('') const repo = makeRepo() - const result = await repo.testRemoteConnection('https://example.com/repo.git', 'mypass', 'feature-branch', 'myuser') + const gitConfig = { + repoUrl: 'https://example.com/repo.git', + password: 'mytoken', + username: 'myuser', + branch: 'feature-branch', + email: '', + } + const result = await repo.testRemoteConnection(gitConfig) expect(result).toBe(false) expect(mockRaw).toHaveBeenCalledWith(['ls-remote', expect.stringContaining('myuser'), 'refs/heads/feature-branch']) }) @@ -57,7 +84,14 @@ describe('Git.testRemoteConnection', () => { it('throws when ls-remote fails (unreachable remote)', async () => { mockRaw.mockRejectedValue(new Error('exit code 128')) const repo = makeRepo() - await expect(repo.testRemoteConnection('https://bad.example.com/repo.git', 'p', 'main', 'u')).rejects.toThrow() + const gitConfig = { + repoUrl: 'https://bad.example.com/repo.git', + password: 'p', + username: 'u', + branch: 'main', + email: '', + } + await expect(repo.testRemoteConnection(gitConfig)).rejects.toThrow() }) }) @@ -69,7 +103,14 @@ describe('Git.pushToNewRemote', () => { mockRemote.mockResolvedValue('') mockPush.mockResolvedValue({}) const repo = makeRepo() - await repo.pushToNewRemote('https://example.com/repo.git', 'main', 'p', 'u') + const gitConfig = { + repoUrl: 'https://example.com/repo.git', + branch: 'main', + password: 'p', + username: 'u', + email: '', + } + await repo.pushToNewRemote(gitConfig) expect(mockFetch).toHaveBeenCalledWith(['origin', '--unshallow']) expect(mockRemote).toHaveBeenCalledWith( @@ -84,7 +125,14 @@ describe('Git.pushToNewRemote', () => { mockRemote.mockResolvedValue('') mockPush.mockResolvedValue({}) const repo = makeRepo() - await repo.pushToNewRemote('https://example.com/repo.git', 'main', 'p', 'u') + const gitConfig = { + repoUrl: 'https://example.com/repo.git', + branch: 'main', + password: 'p', + username: 'u', + email: '', + } + await repo.pushToNewRemote(gitConfig) expect(mockPush).toHaveBeenCalledWith('migration-remote', 'HEAD:refs/heads/main') }) @@ -94,7 +142,13 @@ describe('Git.pushToNewRemote', () => { mockRemote.mockResolvedValue('') mockPush.mockResolvedValue({}) const repo = makeRepo() - await repo.pushToNewRemote('https://example.com/repo.git', 'main', 'mytoken') + const gitConfig = { + repoUrl: 'https://example.com/repo.git', + branch: 'main', + password: 'mytoken', + email: '', + } + await repo.pushToNewRemote(gitConfig) expect(mockRemote).toHaveBeenCalledWith( expect.arrayContaining(['add', 'migration-remote', 'https://mytoken@example.com/repo.git']), @@ -106,7 +160,14 @@ describe('Git.pushToNewRemote', () => { mockRemote.mockResolvedValue('') mockPush.mockResolvedValue({}) const repo = makeRepo() // local branch is 'main' - await repo.pushToNewRemote('https://example.com/repo.git', 'feature-branch', 'p', 'u') + const gitConfig = { + repoUrl: 'https://example.com/repo.git', + branch: 'feature-branch', + username: 'u', + password: 'p', + email: '', + } + await repo.pushToNewRemote(gitConfig) expect(mockPush).toHaveBeenCalledWith('migration-remote', 'HEAD:refs/heads/feature-branch') }) @@ -116,7 +177,14 @@ describe('Git.pushToNewRemote', () => { mockRemote.mockResolvedValue('') mockPush.mockRejectedValue(new Error('push failed')) const repo = makeRepo() - await expect(repo.pushToNewRemote('https://example.com/repo.git', 'main', 'p', 'u')).rejects.toThrow('push failed') + const gitConfig = { + repoUrl: 'https://example.com/repo.git', + branch: 'main', + username: 'u', + password: 'p', + email: '', + } + await expect(repo.pushToNewRemote(gitConfig)).rejects.toThrow('push failed') expect(mockRemote).toHaveBeenCalledWith(['remove', 'migration-remote']) }) }) diff --git a/src/git.ts b/src/git.ts index 0f8b0d2ba..702b7fbae 100644 --- a/src/git.ts +++ b/src/git.ts @@ -7,45 +7,23 @@ import { glob } from 'glob' import { merge } from 'lodash' import { basename, dirname, join } from 'path' import simpleGit, { CheckRepoActions, CleanOptions, CommitResult, ResetMode, SimpleGit } from 'simple-git' -import { - cleanEnv, - GIT_BRANCH, - GIT_LOCAL_PATH, - GIT_PASSWORD, - GIT_PUSH_RETRIES, - GIT_REPO_URL, - GIT_USER, -} from 'src/validators' +import { cleanEnv, GIT_LOCAL_PATH, GIT_PASSWORD, GIT_PUSH_RETRIES } from 'src/validators' import { parse as parseYaml, stringify as stringifyYaml } from 'yaml' import { GitPullError } from './error' -import { Core } from './otomi-models' +import { Core, GitConfig } from './otomi-models' import { getSanitizedErrorMessage, removeBlankAttributes, sanitizeGitPassword } from './utils' +import { getAuthenticatedUrl, getProtocol } from './git/connect' const debug = Debug('otomi:repo') const env = cleanEnv({ - GIT_BRANCH, GIT_LOCAL_PATH, GIT_PASSWORD, - GIT_REPO_URL, - GIT_USER, GIT_PUSH_RETRIES, }) -const getProtocol = (url): string => (url && url.includes('://') ? url.split('://')[0] : 'http') - const getUrl = (url): string => (!url || url.includes('://') ? url : `${getProtocol(url)}://${url}`) -function getUrlAuth(url, user: string | undefined, password): string | undefined { - if (!url) return - const protocol = getProtocol(url) - const [_, bareUrl] = url.split('://') - const credentials = user - ? `${encodeURIComponent(user)}:${encodeURIComponent(password)}` - : encodeURIComponent(password) - return protocol === 'file' ? `${protocol}://${bareUrl}` : `${protocol}://${credentials}@${bareUrl}` -} - export class Git { branch: string commitSha: string @@ -57,18 +35,11 @@ export class Git { remote: string remoteBranch: string url: string | undefined - urlAuth: string | undefined + urlAuth: string user: string - constructor( - path: string, - url: string | undefined, - user: string, - email: string, - urlAuth: string | undefined, - branch: string | undefined, - ) { - this.branch = branch || 'main' + constructor(path: string, url: string | undefined, user: string, email: string, urlAuth: string, branch: string) { + this.branch = branch this.email = email this.path = path this.remote = 'origin' @@ -81,16 +52,12 @@ export class Git { this.git = simpleGit(this.path).env('GIT_SSL_NO_VERIFY', String(gitSSLNoVerify)) } - getProtocol() { - return getProtocol(this.url) - } - async addConfig(): Promise { debug(`Adding git config`) await this.git.addConfig('user.name', this.user) await this.git.addConfig('user.email', this.email) if (this.isRootClone()) { - if (this.getProtocol() === 'file') { + if (getProtocol(this.url) === 'file') { // tell the the git repo there to accept updates even when it is checked out const _git = simpleGit(this.url!.replace('file://', '')) await _git.addConfig('receive.denyCurrentBranch', 'updateInstead') @@ -189,7 +156,7 @@ export class Git { } hasRemote(): boolean { - return !!env.GIT_REPO_URL + return !!this.url } async initFromTestFolder(): Promise { @@ -208,18 +175,13 @@ export class Git { async clone(): Promise { debug(`Checking if local git repository exists at: ${this.path}`) const isRepo = await this.git.checkIsRepo(CheckRepoActions.IS_REPO_ROOT) - // remote root url - this.url = getUrl(`${env.GIT_REPO_URL}`) if (!isRepo) { debug(`Initializing repo...`) if (!this.hasRemote() && this.isRootClone()) { return await this.initFromTestFolder() - } else if (!this.isRootClone()) { - // child clone, point to remote root - this.urlAuth = getUrlAuth(this.url, env.GIT_USER, env.GIT_PASSWORD) } debug(`Cloning from '${this.url}' to '${this.path}'`) - await this.git.clone(this.urlAuth!, this.path) + await this.git.clone(this.urlAuth, this.path) await this.addConfig() await this.git.checkout(this.branch) } else if (this.url) { @@ -254,7 +216,7 @@ export class Git { debug(`Pull summary: ${summJson}`) this.commitSha = await this.getCommitSha() } catch (e) { - const eMessage = getSanitizedErrorMessage(e) + const eMessage = getSanitizedErrorMessage(e, this.password) debug('Could not pull from remote. Upstream commits? Marked db as corrupt.', eMessage) this.corrupt = true try { @@ -282,7 +244,7 @@ export class Git { await this.git.raw(['rebase', `${this.remote}/${this.branch}`, '--strategy-option=ours']) } } catch (error) { - const errorMessage = getSanitizedErrorMessage(error) + const errorMessage = getSanitizedErrorMessage(error, this.password) debug('Failed to remove upstream commits: ', errorMessage) throw new GitPullError('Failed to remove upstream commits!') } @@ -309,15 +271,15 @@ export class Git { return } - async testRemoteConnection(url: string, password: string, branch: string, user?: string): Promise { - const authUrl = password ? getUrlAuth(url, user, password) : url + async testRemoteConnection(gitConfig: GitConfig): Promise { + const authUrl = getAuthenticatedUrl(gitConfig) // returns true only if the configured branch exists on the remote - const result = await this.git.raw(['ls-remote', authUrl!, `refs/heads/${branch}`]) + const result = await this.git.raw(['ls-remote', authUrl, `refs/heads/${gitConfig.branch}`]) return result.trim().length > 0 } - async pushToNewRemote(url: string, branch: string, password: string, user?: string): Promise { - const authUrl = password ? getUrlAuth(url, user, password) : url + async pushToNewRemote(newGitConfig: GitConfig): Promise { + const authUrl = getAuthenticatedUrl(newGitConfig) // Pulls use --depth which can leave the clone shallow. A shallow clone cannot be pushed to a // fresh empty remote because referenced parent objects are missing. Unshallow first to restore // full history; ignore failures when the repo is already complete. @@ -327,14 +289,14 @@ export class Git { debug('Unshallow fetch skipped (repo is not shallow or fetch failed)') } try { - await this.git.remote(['add', 'migration-remote', authUrl!]) + await this.git.remote(['add', 'migration-remote', authUrl]) // Push HEAD so the worktree's session branch commit is included, not the stale local main - await this.git.push('migration-remote', `HEAD:refs/heads/${branch}`) + await this.git.push('migration-remote', `HEAD:refs/heads/${newGitConfig.branch}`) } finally { try { await this.git.remote(['remove', 'migration-remote']) } catch (e) { - debug(`Could not remove migration-remote: ${getSanitizedErrorMessage(e)}`) + debug(`Could not remove migration-remote: ${getSanitizedErrorMessage(e, newGitConfig.password)}`) } } } @@ -358,13 +320,13 @@ export class Git { await this.git.raw(['worktree', 'remove', worktreePath]) debug(`Worktree removed successfully: ${worktreePath}`) } catch (error) { - const errorMessage = getSanitizedErrorMessage(error) + const errorMessage = getSanitizedErrorMessage(error, this.password) debug(`Error removing worktree: ${errorMessage}`) try { await this.git.raw(['worktree', 'remove', '--force', worktreePath]) debug(`Worktree force removed: ${worktreePath}`) } catch (err) { - const errMessage = getSanitizedErrorMessage(err) + const errMessage = getSanitizedErrorMessage(err, this.password) debug(`Failed to force remove worktree: ${errMessage}`) if (await pathExists(worktreePath)) { rmSync(worktreePath, { recursive: true, force: true }) @@ -398,8 +360,8 @@ export class Git { } } } catch (e) { - const sanitizedMessage = getSanitizedErrorMessage(e) - const sanitizedCommands = sanitizeGitPassword(JSON.stringify(e.task?.commands)) + const sanitizedMessage = getSanitizedErrorMessage(e, this.password) + const sanitizedCommands = sanitizeGitPassword(JSON.stringify(e.task?.commands), this.password) debug(`${sanitizedMessage} for command ${sanitizedCommands}`) debug('Git save error') throw new GitPullError() @@ -432,17 +394,14 @@ export async function getWorktreeRepo( export default async function getRepo( path: string, - url: string, - user: string, - email: string, - password: string, - branch: string, + gitConfig: GitConfig, method: 'clone' | 'init' = 'clone', ): Promise { await ensureDir(path, { mode: 0o744 }) - const urlNormalized = getUrl(url) - const urlAuth = getUrlAuth(urlNormalized, user, password) - const repo = new Git(path, urlNormalized, user, email, urlAuth, branch) + const { repoUrl, branch, username, email } = gitConfig + const urlNormalized = getUrl(repoUrl) + const urlAuth = getAuthenticatedUrl(gitConfig) + const repo = new Git(path, urlNormalized, username ?? 'otomi-admin', email, urlAuth, branch) await repo[method]() return repo } diff --git a/src/git/connect.ts b/src/git/connect.ts index 0ed69d738..e17d2e46f 100644 --- a/src/git/connect.ts +++ b/src/git/connect.ts @@ -1,26 +1,36 @@ import Debug from 'debug' -import simpleGit from 'simple-git' -import { cleanEnv, GIT_BRANCH, GIT_PASSWORD, GIT_REPO_URL, GIT_USER } from 'src/validators' +import { GitConfig } from '../otomi-models' +import { Git } from '../git' const debug = Debug('otomi:git-connect') -const env = cleanEnv({ - GIT_REPO_URL, - GIT_BRANCH, - GIT_USER, - GIT_PASSWORD, -}) +export function getProtocol(url: string | undefined): string { + return url && url.includes('://') ? url.split('://')[0] : 'file' +} + +export function getAuthenticatedUrl(gitConfig: GitConfig): string { + const protocol = getProtocol(gitConfig.repoUrl) + if (protocol === 'file') { + return gitConfig.repoUrl + } + const { repoUrl, username, password } = gitConfig + const url = new URL(repoUrl) + if (username) { + url.username = username + url.password = password + } else { + url.username = password + url.password = '' + } + return url.toString() +} -export default async function getLatestRemoteCommitSha(): Promise { +export default async function getLatestRemoteCommitSha(git: Git): Promise { try { - const git = simpleGit() - const repoUrl = new URL(env.GIT_REPO_URL) - repoUrl.username = encodeURIComponent(env.GIT_USER) - repoUrl.password = encodeURIComponent(env.GIT_PASSWORD) - const result = await git.listRemote(['--refs', repoUrl.toString(), env.GIT_BRANCH]) + const result = await git.git.listRemote(['--refs', git.remote, `refs/heads/${git.branch}`]) const [sha] = result.trim().split(/\s/) if (!sha) { - debug('No remote commit found for branch %s', env.GIT_BRANCH) + debug('No remote commit found for branch %s', git.branch) return undefined } return sha diff --git a/src/middleware/session.ts b/src/middleware/session.ts index 8c615e6da..bbd6ae850 100644 --- a/src/middleware/session.ts +++ b/src/middleware/session.ts @@ -75,7 +75,7 @@ export const cleanSession = async (sessionId: string): Promise => { try { await readOnlyStack.git.removeWorktree(worktreePath) } catch (error) { - const errorMessage = getSanitizedErrorMessage(error) + const errorMessage = getSanitizedErrorMessage(error, readOnlyStack.gitConfig.password) debug(`Error removing worktree for session ${sessionId}: ${errorMessage}`) await remove(worktreePath) } diff --git a/src/otomi-models.ts b/src/otomi-models.ts index 89a8c25f1..f0c6e63f9 100644 --- a/src/otomi-models.ts +++ b/src/otomi-models.ts @@ -290,36 +290,12 @@ export interface Core { version: number } -export interface Repo { - apps: App[] - alerts: Alerts - cluster: Cluster - databases: Record - dns: Dns - ingress: Ingress - kms: Kms - obj: Record - oidc: Oidc - otomi: Otomi - platformBackups: Record - users: User[] - versions: Versions - teamConfig: Record - files: Record -} - -export interface TeamConfig { - apps: App[] - builds: AplBuildResponse[] - codeRepos: AplCodeRepoResponse[] - knowledgeBases: AplKnowledgeBaseResponse[] - agents: AplAgentResponse[] - netpols: AplNetpolResponse[] - policies: AplPolicyResponse[] - sealedsecrets: SealedSecretManifestResponse[] - services: AplServiceResponse[] - settings: AplTeamSettingsResponse - workloads: AplWorkloadResponse[] +export interface GitConfig { + repoUrl: string + branch: string + email: string + username?: string + password: string } export function toTeamObject(teamId: string, request: AplRequestObject): AplTeamObject { diff --git a/src/otomi-stack.test.ts b/src/otomi-stack.test.ts index 8e5b7fbce..d10695273 100644 --- a/src/otomi-stack.test.ts +++ b/src/otomi-stack.test.ts @@ -287,7 +287,7 @@ describe('Work with values', () => { otomiStack = new OtomiStack() await otomiStack.init() - otomiStack.git = new Git('./test', undefined, 'someuser', 'some@ema.il', undefined, undefined) + otomiStack.git = new Git('./test', undefined, 'someuser', 'some@ema.il', '', 'main') jest.spyOn(otomiStack, 'doDeleteDeployment').mockResolvedValue() jest.spyOn(otomiStack, 'doDeployment').mockResolvedValue() }) @@ -302,7 +302,7 @@ describe('Workload values', () => { beforeEach(async () => { otomiStack = new OtomiStack() await otomiStack.init() - otomiStack.git = new Git('./test', undefined, 'someuser', 'some@ema.il', undefined, undefined) + otomiStack.git = new Git('./test', undefined, 'someuser', 'some@ema.il', '', 'main') jest.spyOn(otomiStack, 'doDeleteDeployment').mockResolvedValue() jest.spyOn(otomiStack, 'doDeployment').mockResolvedValue() }) @@ -1390,50 +1390,23 @@ describe('OtomiStack.migrateGitSettings', () => { fileStore: { set: mockRootFileStoreSet }, }) jest.spyOn(require('src/middleware/session'), 'cleanSession').mockResolvedValue(undefined) + ;(stack as any).getApiClient = jest.fn().mockReturnValue({ + createNamespacedSecret: jest.fn(), + }) }) afterEach(() => jest.restoreAllMocks()) it('calls saveSettings, commit, pushToNewRemote, pushWithRetry in order', async () => { - const order: string[] = [] - const saveSettingsSpy = jest.spyOn(stack as any, 'saveSettings').mockImplementation(async () => { - order.push('saveSettings') - }) - mockCommit.mockImplementation(async () => order.push('commit')) - mockPushToNewRemote.mockImplementation(async () => order.push('pushToNewRemote')) - mockPushWithRetry.mockImplementation(async () => order.push('pushWithRetry')) - - await stack.migrateGitSettings({ - repoUrl: 'https://new.example.com/repo.git', - username: 'user', - password: 'pass', - email: 'new@example.com', - branch: 'main', - remoteHasContent: false, - }) - - expect(saveSettingsSpy).toHaveBeenCalled() - expect(order).toEqual(['saveSettings', 'commit', 'pushToNewRemote', 'pushWithRetry']) - }) - - it('skips pushToNewRemote when remote already has content', async () => { - const order: string[] = [] - jest.spyOn(stack as any, 'saveSettings').mockImplementation(async () => order.push('saveSettings')) - mockCommit.mockImplementation(async () => order.push('commit')) - mockPushToNewRemote.mockImplementation(async () => order.push('pushToNewRemote')) - mockPushWithRetry.mockImplementation(async () => order.push('pushWithRetry')) - await stack.migrateGitSettings({ repoUrl: 'https://new.example.com/repo.git', username: 'user', password: 'pass', email: 'new@example.com', branch: 'main', - remoteHasContent: true, }) - expect(order).toEqual(['saveSettings', 'commit', 'pushWithRetry']) - expect(mockPushToNewRemote).not.toHaveBeenCalled() + expect(mockPushToNewRemote).toHaveBeenCalled() }) }) diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index a19627aed..28dd8ddd5 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -1,4 +1,11 @@ -import { CoreV1Api, User as k8sUser, KubeConfig, V1ObjectReference } from '@kubernetes/client-node' +import { + ApiException, + CoreV1Api, + KubeConfig, + User as k8sUser, + V1ObjectReference, + V1Secret, +} from '@kubernetes/client-node' import Debug from 'debug' import { getRegions, ObjectStorageKeyRegions, Region, ResourcePage } from '@linode/api-v4' @@ -7,7 +14,15 @@ import { readFile } from 'fs/promises' import { generate as generatePassword } from 'generate-password' import { cloneDeep, isEmpty, map, merge, omit, pick, set, unset } from 'lodash' import { getAppList, getAppSchema } from 'src/app' -import { APL_SECRETS_NAMESPACE, APL_USERS_NAMESPACE, GITEA_SECRETS_NAME, PLATFORM_SECRETS_NAME } from 'src/constants' +import { + APL_SECRETS_NAMESPACE, + APL_USERS_NAMESPACE, + GIT_CONFIG_SECRET_NAME, + GIT_DEFAULT_CONFIG, + GIT_LEGACY_CONFIG, + GITEA_SECRETS_NAME, + PLATFORM_SECRETS_NAME, +} from 'src/constants' import { AlreadyExists, ForbiddenError, HttpError, NotExistError, OtomiError, ValidationError } from 'src/error' import { getSettingsFileMaps } from 'src/fileStore/file-map' import { FileStore } from 'src/fileStore/file-store' @@ -48,6 +63,7 @@ import { Cloudtty, Core, DeepPartial, + GitConfig, K8sService, Netpol, ObjWizard, @@ -209,30 +225,13 @@ function getTeamDatabaseValuesFilePath(teamId: string, databaseName: string): st return `env/teams/${teamId}/databases/${databaseName}` } -function buildUpdatedOtomiSettings( - otomi: Settings['otomi'], - params: { repoUrl: string; username?: string; password: string; email: string; branch: string }, -): Record { - const { repoUrl, username, password, email, branch } = params - return { - ...otomi, - git: { - ...(otomi?.git ?? {}), - repoUrl, - email, - branch, - ...(username !== undefined && { username }), - password, - }, - } -} - export default class OtomiStack { private coreValues: Core editor?: string sessionId?: string isLoaded = false public locked = false + gitConfig: GitConfig git: Git fileStore: FileStore private cloudTty: CloudTty @@ -291,24 +290,24 @@ export default class OtomiStack { await this.init() // every editor gets their own folder to detect conflicts upon deploy const path = this.getRepoPath() - const branch = env.GIT_BRANCH - const url = env.GIT_REPO_URL + this.gitConfig = await this.getGitConfig() + const maxRetries = env.GIT_INIT_MAX_RETRIES const timeoutMs = env.GIT_INIT_RETRY_INTERVAL_MS let attempt = 0 // Use the env var password as the initial value; refresh from K8s on retries // to pick up ESO credential syncs that happen after pod startup (e.g. git provider switch). - let password = env.GIT_PASSWORD for (;;) { + const { repoUrl: url, branch } = this.gitConfig try { - this.git = await getRepo(path, url, env.GIT_USER, env.GIT_EMAIL, password, branch) + this.git = await getRepo(path, this.gitConfig) await this.git.pull() //TODO fetch this url from the repo if (await this.git.fileExists(clusterSettingsFilePath)) break debug(`Values are not present at ${url}:${branch}`) } catch (e) { // Remove password from error message - const errorMessage = getSanitizedErrorMessage(e) + const errorMessage = getSanitizedErrorMessage(e, this.gitConfig.password) debug(`Error while initializing git repository: ${errorMessage}`) debug(`Git repository is not ready: ${url}:${branch}`) } @@ -321,8 +320,7 @@ export default class OtomiStack { await new Promise((resolve) => setTimeout(resolve, timeoutMs)) // Re-read password from K8s in case ESO has just synced fresh credentials try { - const secret = await getSecretValues('otomi-api-git-credentials', 'otomi') - if (secret?.GIT_PASSWORD) password = secret.GIT_PASSWORD + this.gitConfig = await this.getGitConfig() } catch { // K8s not reachable or secret absent — keep the current password } @@ -339,13 +337,12 @@ export default class OtomiStack { await this.init() debug(`Creating worktree for session ${this.sessionId}`) + const { branch, password } = this.gitConfig try { - await mainRepo.git.revparse(`--verify refs/heads/${env.GIT_BRANCH}`) + await mainRepo.git.revparse(`--verify refs/heads/${branch}`) } catch (error) { - const errorMessage = getSanitizedErrorMessage(error) - throw new Error( - `Main repository does not have branch '${env.GIT_BRANCH}'. Cannot create worktree. ${errorMessage}`, - ) + const errorMessage = getSanitizedErrorMessage(error, password) + throw new Error(`Main repository does not have branch '${branch}'. Cannot create worktree. ${errorMessage}`) } // Pull latest changes so the worktree starts from up-to-date state @@ -353,11 +350,11 @@ export default class OtomiStack { debug(`Pulled latest changes before creating worktree for session ${this.sessionId}`) await mainRepo.pull(true, true) } catch (e) { - debug(`Warning: could not pull latest before creating worktree: ${getSanitizedErrorMessage(e)}`) + debug(`Warning: could not pull latest before creating worktree: ${getSanitizedErrorMessage(e, password)}`) } const worktreePath = this.getRepoPath() - this.git = await getWorktreeRepo(mainRepo, worktreePath, env.GIT_BRANCH) + this.git = await getWorktreeRepo(mainRepo, worktreePath, this.gitConfig.branch) this.fileStore = new FileStore() debug(`Worktree created for ${this.editor} in ${this.sessionId}`) @@ -637,70 +634,111 @@ export default class OtomiStack { return settings } - async migrateGitSettings(params: { - repoUrl: string - username?: string - password: string - email: string - branch: string - remoteHasContent: boolean - }): Promise { - const { otomi } = await this.getSettings() - const updatedOtomi = buildUpdatedOtomiSettings(otomi, params) + async migrateGitSettings(params: GitConfig): Promise { + await this.commitAndPushMigration({ ...GIT_DEFAULT_CONFIG, ...params }) + await this.storeGitConfig(params) + } - // Encrypt the password into the otomi-secrets SealedSecret and strip it from the settings YAML - const sealedSecretRecord = await this.extractAndStoreSettingsSecrets('otomi', { otomi: updatedOtomi }) - const valuesSchema = await getValuesSchema() - const subSchema = valuesSchema.properties?.otomi - if (subSchema) { - removeSettingsSecrets(extractSecretPaths(subSchema), updatedOtomi) + private async getClusterGitConfig(): Promise { + const api = this.getApiClient() + const decodedData = {} + const defaults = { + password: '', + ...GIT_DEFAULT_CONFIG, + } + try { + const { data } = await api.readNamespacedSecret({ + name: GIT_CONFIG_SECRET_NAME, + namespace: APL_SECRETS_NAMESPACE, + }) + if (data) { + Object.entries(data || {}).forEach(([key, value]) => { + decodedData[key] = Buffer.from(value, 'base64').toString('utf-8') + }) + } + } catch (error) { + if (process.env.NODE_ENV !== 'development') { + debug('Could not read Git config from cluster, continuing with development defaults') + defaults.repoUrl = '' + } else if (!(error instanceof ApiException && error.code === 404)) { + throw error + } + } + return { + ...defaults, + ...decodedData, + } + } + + /** + * Retrieves Git configuration from the cluster (if available). + * + * Missing data is filled with defaults. Environment variables take precedence if set. + */ + private async getGitConfig(): Promise { + let gitConfig + if (process.env.NODE_ENV === 'test') { + gitConfig = { password: '', ...GIT_DEFAULT_CONFIG, repoUrl: '' } + } else { + gitConfig = await this.getClusterGitConfig() + } + if ([GIT_DEFAULT_CONFIG.repoUrl, GIT_LEGACY_CONFIG.repoUrl].includes(gitConfig.repoUrl) && !gitConfig.username) { + // On legacy (Gitea) and default configurations, assume otomi-admin login + gitConfig.username = 'otomi-admin' } - const { filePath, aplObject } = await this.persistOtomiSettings(updatedOtomi) - await this.commitAndPushMigration({ ...params, filePath, aplObject, sealedSecretRecord }) + const envConfig = { + branch: env.GIT_BRANCH, + email: env.GIT_EMAIL, + repoUrl: env.GIT_REPO_URL, + username: env.GIT_USER, + password: env.GIT_PASSWORD, + } + Object.values(envConfig).forEach(([key, value]) => { + if (value) { + gitConfig[key] = value + } + }) + return gitConfig } - private async persistOtomiSettings( - updatedOtomi: Record, - ): Promise<{ filePath: string; aplObject: AplObject }> { - const filePath = getResourceFilePath('AplCapabilitySet', 'otomi') - const aplObject = toPlatformObject('AplCapabilitySet', 'otomi', updatedOtomi) - this.fileStore.set(filePath, aplObject) - await this.saveSettings() - return { filePath, aplObject } - } - - private async commitAndPushMigration(params: { - repoUrl: string - branch: string - password: string - username?: string - remoteHasContent: boolean - filePath: string - aplObject: AplObject - sealedSecretRecord?: AplRecord - }): Promise { - const { repoUrl, branch, password, username, remoteHasContent, filePath, aplObject, sealedSecretRecord } = params - const rootStack = await getSessionStack() - try { - await this.git.commit(this.editor!) - if (!remoteHasContent) { - // Remote is empty: push so the new remote has the config pointing to itself - await this.git.pushToNewRemote(repoUrl, branch, password, username) + private async storeGitConfig(gitConfig: Partial): Promise { + const api = this.getApiClient() + const encodedData = {} + Object.entries(gitConfig).forEach(([key, value]) => { + if (value) { + encodedData[key] = Buffer.from(value).toString('base64') } - // Push to current remote so the operator picks up the git config change - await this.git.pushWithRetry() - await rootStack.git.git.pull() - rootStack.fileStore.set(filePath, aplObject) - if (sealedSecretRecord) { - rootStack.fileStore.set(sealedSecretRecord.filePath, sealedSecretRecord.content) + }) + const name = GIT_CONFIG_SECRET_NAME + const namespace = APL_SECRETS_NAMESPACE + const secret: V1Secret = { + metadata: { + name, + namespace, + }, + data: encodedData, + type: 'Opaque', + } + try { + await api.createNamespacedSecret({ namespace, body: secret }) + } catch (error) { + if (error instanceof ApiException && error.code === 409) { + await api.replaceNamespacedSecret({ namespace, name, body: secret }) + } else { + throw error } - debug(`Updated root stack values with ${this.sessionId} migration changes`) + } + } + + private async commitAndPushMigration(newGitConfig: GitConfig): Promise { + try { + await this.git.commit(this.editor!) + await this.git.pushToNewRemote(newGitConfig) + await cleanSession(this.sessionId!) } catch (e) { - e.message = getSanitizedErrorMessage(e) + e.message = getSanitizedErrorMessage(e, newGitConfig.password) throw e - } finally { - await cleanSession(this.sessionId!) } } @@ -2007,7 +2045,7 @@ export default class OtomiStack { debug(`Updated root stack values with ${this.sessionId} changes`) } catch (e) { - e.message = getSanitizedErrorMessage(e) + e.message = getSanitizedErrorMessage(e, this.gitConfig.password) throw e } finally { // Clean up the session @@ -2028,7 +2066,7 @@ export default class OtomiStack { debug(`Updated root stack values with ${this.sessionId} changes`) } catch (e) { - e.message = getSanitizedErrorMessage(e) + e.message = getSanitizedErrorMessage(e, this.gitConfig.password) throw e } finally { // Clean up the session @@ -2051,7 +2089,7 @@ export default class OtomiStack { debug(`Updated root stack values with ${this.sessionId} changes`) } catch (e) { - e.message = getSanitizedErrorMessage(e) + e.message = getSanitizedErrorMessage(e, this.gitConfig.password) throw e } finally { // Clean up the session diff --git a/src/utils.test.ts b/src/utils.test.ts index ee2a2dfd9..e7672ea07 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -3,7 +3,6 @@ import os from 'os' import path from 'path' import { Cluster } from 'src/otomi-models' import { getSanitizedErrorMessage, getServiceUrl, safeReadTextFile, sanitizeGitPassword } from 'src/utils' -import { cleanEnv, GIT_PASSWORD } from './validators' describe('Utils', () => { const cluster: Cluster = { @@ -63,26 +62,26 @@ describe('Utils', () => { }) describe('sanitizeGitPassword should remove git credentials', () => { - const env = cleanEnv({ - GIT_PASSWORD, - }) + const gitPassword = 'test*Password' test('from strings', () => { - expect(sanitizeGitPassword('test string')).toBe('test string') - expect(sanitizeGitPassword(`${env.GIT_PASSWORD} test string ${env.GIT_PASSWORD}`)).toBe('**** test string ****') + expect(sanitizeGitPassword('test string', '')).toEqual('test string') + expect(sanitizeGitPassword(`${gitPassword} test string ${gitPassword}`, gitPassword)).toBe( + '**** test string ****', + ) }) test('from objects', () => { - expect(sanitizeGitPassword(JSON.stringify({ test: 'some string' }))).toEqual('{"test":"some string"}') - expect(sanitizeGitPassword(JSON.stringify({ test: `some string ${env.GIT_PASSWORD}` }))).toEqual( + expect(sanitizeGitPassword(JSON.stringify({ test: 'some string' }), '')).toEqual('{"test":"some string"}') + expect(sanitizeGitPassword(JSON.stringify({ test: `some string ${gitPassword}` }), gitPassword)).toEqual( '{"test":"some string ****"}', ) }) test('return empty string on empty or undefined input', () => { - expect(sanitizeGitPassword('')).toEqual('') - expect(sanitizeGitPassword(undefined)).toEqual('') + expect(sanitizeGitPassword('', '')).toEqual('') + expect(sanitizeGitPassword(undefined, '')).toEqual('') }) test('extract message from exception', () => { - expect(getSanitizedErrorMessage(new Error('test error'))).toEqual('test error') - expect(getSanitizedErrorMessage(new Error(`test error ${env.GIT_PASSWORD}`))).toEqual('test error ****') + expect(getSanitizedErrorMessage(new Error('test error'), '')).toEqual('test error') + expect(getSanitizedErrorMessage(new Error(`test error ${gitPassword}`), gitPassword)).toEqual('test error ****') }) }) diff --git a/src/utils.ts b/src/utils.ts index 0af710f59..701f142f9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -234,14 +234,19 @@ export const objectToYaml = (obj: Record, indent = 4, lineWidth = 2 return isEmpty(obj) ? '' : stringify(obj, { indent, lineWidth }) } -export function sanitizeGitPassword(str?: string) { - return str ? str.replaceAll(env.GIT_PASSWORD, '****') : '' +export function sanitizeGitPassword(str: string | undefined, password: string) { + if (!str) { + return '' + } + return password ? str.replaceAll(password, '****') : str } -export function getSanitizedErrorMessage(error) { +export function getSanitizedErrorMessage(error, password: string) { const message = error?.message if (!message) { return '' } - return typeof message === 'string' ? sanitizeGitPassword(message) : `[unprocessable message type ${typeof message}]` + return typeof message === 'string' + ? sanitizeGitPassword(message, password) + : `[unprocessable message type ${typeof message}]` } From de27a4cea5f4b346c7ef6277a78d7f2d8f0593c2 Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Fri, 19 Jun 2026 16:46:03 +0200 Subject: [PATCH 4/9] chore: notes for later --- src/utils/codeRepoUtils.ts | 1 + src/utils/workloadUtils.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/utils/codeRepoUtils.ts b/src/utils/codeRepoUtils.ts index c6694e424..c31db1021 100644 --- a/src/utils/codeRepoUtils.ts +++ b/src/utils/codeRepoUtils.ts @@ -171,6 +171,7 @@ export async function extractRepositoryRefs(repoUrl: string, git: SimpleGit = si GIT_TERMINAL_PROMPT: '0', GIT_SSL_NO_VERIFY: 'true', }) + // FIXME: When values is not on Gitea, this is broken const username = process.env.GIT_USER as string const accessToken = process.env.GIT_PASSWORD as string formattedRepoUrl = repoUrl.replace( diff --git a/src/utils/workloadUtils.ts b/src/utils/workloadUtils.ts index 299d6f7f0..ec68e54c2 100644 --- a/src/utils/workloadUtils.ts +++ b/src/utils/workloadUtils.ts @@ -380,6 +380,7 @@ export class chartRepo { function encodeGitCredentials(url: string, clusterDomainSuffix?: string): string { if (!isInteralGiteaURL(url, clusterDomainSuffix)) return url + // FIXME: When Gitea is present and hosting charts, but the Values repo is somewhere else, this breaks const [protocol, bareUrl] = url.split('://') const encodedUser = encodeURIComponent(process.env.GIT_USER as string) const encodedPassword = encodeURIComponent(process.env.GIT_PASSWORD as string) From ed8ef8f407f64fcc677ee249f34068eeb368d0f6 Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Fri, 19 Jun 2026 16:48:01 +0200 Subject: [PATCH 5/9] revert: add git settings back to schema --- src/openapi/settings.yaml | 34 ++++++++++++++++++++++++++++++++++ src/openapi/settingsinfo.yaml | 9 +++++++++ 2 files changed, 43 insertions(+) diff --git a/src/openapi/settings.yaml b/src/openapi/settings.yaml index d44804ba8..99ccff0ab 100644 --- a/src/openapi/settings.yaml +++ b/src/openapi/settings.yaml @@ -228,6 +228,40 @@ Settings: type: object additionalProperties: false properties: + 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). + 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 + - email + - branch adminPassword: description: Master admin password that will be used for all apps that are not configured to use their own password. $ref: 'definitions.yaml#/adminPassword' diff --git a/src/openapi/settingsinfo.yaml b/src/openapi/settingsinfo.yaml index 0dadc2d09..43479813b 100644 --- a/src/openapi/settingsinfo.yaml +++ b/src/openapi/settingsinfo.yaml @@ -50,6 +50,15 @@ SettingsInfo: hasExternalIDP: type: boolean default: false + git: + properties: + repoUrl: + type: string + description: The base URL of the Git repository (without credentials). + pattern: '^https?://.+' + branch: + type: string + description: The branch to use in the Git repository. ingressClassNames: description: Ingress class names that are used by the cluster. items: From df1a95d67e2ff27c1dcad8aedd687754029bcf5e Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Fri, 19 Jun 2026 17:02:19 +0200 Subject: [PATCH 6/9] feat: refresh git config --- src/app.ts | 1 + src/otomi-stack.ts | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/app.ts b/src/app.ts index 0cb33c29f..ab7fa3a34 100644 --- a/src/app.ts +++ b/src/app.ts @@ -54,6 +54,7 @@ type OtomiSpec = { // get the latest commit from Git and checks it against the local values const checkAgainstGit = async () => { const otomiStack = await getSessionStack() + await otomiStack.refreshGitConfig() const latestOtomiVersion = await getLatestRemoteCommitSha(otomiStack.git) // check the local version against the latest online version // if the latest online is newer it will be pulled locally diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 28dd8ddd5..4b12189b0 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -286,6 +286,14 @@ export default class OtomiStack { this.fileStore = await FileStore.load(this.getRepoPath()) } + async refreshGitConfig() { + try { + this.gitConfig = await this.getGitConfig() + } catch { + // Do not raise errors, but keep existing configuration + } + } + async initGit(inflateValues = true): Promise { await this.init() // every editor gets their own folder to detect conflicts upon deploy @@ -637,6 +645,7 @@ export default class OtomiStack { async migrateGitSettings(params: GitConfig): Promise { await this.commitAndPushMigration({ ...GIT_DEFAULT_CONFIG, ...params }) await this.storeGitConfig(params) + this.gitConfig = await this.getGitConfig(params) } private async getClusterGitConfig(): Promise { @@ -671,16 +680,16 @@ export default class OtomiStack { } /** - * Retrieves Git configuration from the cluster (if available). + * Retrieves Git configuration from given values or the cluster (if available). * * Missing data is filled with defaults. Environment variables take precedence if set. */ - private async getGitConfig(): Promise { + private async getGitConfig(inputConfig?: Partial): Promise { let gitConfig if (process.env.NODE_ENV === 'test') { gitConfig = { password: '', ...GIT_DEFAULT_CONFIG, repoUrl: '' } } else { - gitConfig = await this.getClusterGitConfig() + gitConfig = inputConfig || (await this.getClusterGitConfig()) } if ([GIT_DEFAULT_CONFIG.repoUrl, GIT_LEGACY_CONFIG.repoUrl].includes(gitConfig.repoUrl) && !gitConfig.username) { // On legacy (Gitea) and default configurations, assume otomi-admin login From 3d7c4aeb5b746809f0e854e903bdc2cd78f56e22 Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Fri, 19 Jun 2026 17:19:05 +0200 Subject: [PATCH 7/9] fix: extract git settings before storing --- src/otomi-stack.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 4b12189b0..198dc9e9a 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -480,9 +480,11 @@ export default class OtomiStack { } }) - // Apply otomi nodeSelector transformation if needed if (keys.includes('otomi')) { + // Apply otomi nodeSelector transformation if needed this.transformOtomiNodeSelector(settings) + // Merge in Git configuration + set(settings, 'otomi.git', omit(this.gitConfig, ['password'])) } } else { // No keys specified: fetch all settings @@ -605,6 +607,11 @@ export default class OtomiStack { }, {}) updatedSettingsData.otomi.nodeSelector = nodeSelectorObject } + if (updatedSettingsData.otomi?.git) { + await this.storeGitConfig(updatedSettingsData.otomi?.git) + this.gitConfig = await this.getGitConfig(updatedSettingsData.otomi?.git) + unset(updatedSettingsData, 'otomi.git') + } } const sealedSecretRecord = await this.extractAndStoreSettingsSecrets(settingId, updatedSettingsData) From d44113b8511db76b079138d34fcef11983625355 Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Mon, 22 Jun 2026 09:30:40 +0200 Subject: [PATCH 8/9] fix: do not require password in environment --- src/validators.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/validators.ts b/src/validators.ts index 76a2f1d34..844e8a4b5 100644 --- a/src/validators.ts +++ b/src/validators.ts @@ -48,7 +48,7 @@ export const GIT_LOCAL_PATH = str({ desc: 'The local file path to the repo', default: '/tmp/otomi/values/main', }) -export const GIT_PASSWORD = str({ desc: 'The git password' }) +export const GIT_PASSWORD = str({ desc: 'The git password', default: undefined }) export const GIT_REPO_URL = str({ desc: 'The git repo url', devDefault: `file://${process.env.HOME}/workspace/linode/values-ofld1`, From 3276c4c7324cbb29863f34ce6ed707ca847d3287 Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Mon, 22 Jun 2026 10:24:35 +0200 Subject: [PATCH 9/9] fix: additional variables are no longer required --- src/validators.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/validators.ts b/src/validators.ts index 844e8a4b5..c05d60078 100644 --- a/src/validators.ts +++ b/src/validators.ts @@ -51,9 +51,10 @@ export const GIT_LOCAL_PATH = str({ export const GIT_PASSWORD = str({ desc: 'The git password', default: undefined }) export const GIT_REPO_URL = str({ desc: 'The git repo url', + default: undefined, devDefault: `file://${process.env.HOME}/workspace/linode/values-ofld1`, }) -export const GIT_USER = str({ desc: 'The git username' }) +export const GIT_USER = str({ desc: 'The git username', default: undefined }) export const SSO_ISSUER = str({ desc: 'Expected JWT issuer URL', example: 'https://keycloak.example.com/realms/otomi',