From 4fee8c3f1fd42b06715ec408b1e99797268a2e30 Mon Sep 17 00:00:00 2001 From: dvankeke Date: Tue, 12 May 2026 09:56:43 +0200 Subject: [PATCH 1/3] fix: remove createworkloadcatalog endpoint --- src/api/v1/createWorkloadCatalog.ts | 15 ---------- src/middleware/session.ts | 4 +-- src/openapi/api.yaml | 24 --------------- src/otomi-stack.ts | 46 +++-------------------------- 4 files changed, 6 insertions(+), 83 deletions(-) delete mode 100644 src/api/v1/createWorkloadCatalog.ts diff --git a/src/api/v1/createWorkloadCatalog.ts b/src/api/v1/createWorkloadCatalog.ts deleted file mode 100644 index c01b85a44..000000000 --- a/src/api/v1/createWorkloadCatalog.ts +++ /dev/null @@ -1,15 +0,0 @@ -import Debug from 'debug' -import { Response } from 'express' -import { OpenApiRequestExt } from 'src/otomi-models' - -const debug = Debug('otomi:api:v1:workloadCatalog') - -/** - * POST /v1/createWorkloadCatalog - * Create workload catalog - */ -export const createWorkloadCatalog = async (req: OpenApiRequestExt, res: Response): Promise => { - debug(`workloadCatalog(${req.body.name})`) - const data = await req.otomi.createWorkloadCatalog(req.body) - res.json(data) -} diff --git a/src/middleware/session.ts b/src/middleware/session.ts index 281b35f8b..8c615e6da 100644 --- a/src/middleware/session.ts +++ b/src/middleware/session.ts @@ -9,8 +9,8 @@ import { OpenApiRequestExt } from 'src/otomi-models' import { default as OtomiStack, rootPath } from 'src/otomi-stack' import { API_NAMESPACE, cleanEnv, EDITOR_INACTIVITY_TIMEOUT } from 'src/validators' import { v4 as uuidv4 } from 'uuid' -import { getSanitizedErrorMessage } from '../utils' import { setApiStatusInConfigMap } from '../k8s-operations' +import { getSanitizedErrorMessage } from '../utils' const debug = Debug('otomi:session') const env = cleanEnv({ @@ -121,7 +121,7 @@ export function sessionMiddleware(server: http.Server): RequestHandler { if (['post', 'put', 'delete'].includes(req.method.toLowerCase())) { // in the workloadCatalog endpoint(s), don't need to create a session - if (req.path === '/v1/workloadCatalog' || req.path === '/v1/createWorkloadCatalog') return next() + if (req.path === '/v1/workloadCatalog') return next() // Block all write operations when the API is locked (git migration completed) if (readOnlyStack?.locked) throw new ApiLockedError() diff --git a/src/openapi/api.yaml b/src/openapi/api.yaml index 6bd339466..4d94166f4 100644 --- a/src/openapi/api.yaml +++ b/src/openapi/api.yaml @@ -2358,30 +2358,6 @@ paths: type: object error: type: string - '/v1/createWorkloadCatalog': - post: - operationId: createWorkloadCatalog - x-eov-operation-handler: v1/createWorkloadCatalog - x-aclSchema: Workload - description: Create workload catalog from a repository - requestBody: - content: - application/json: - schema: - type: object - description: Workload catalog object that contains updated values - required: true - responses: - '400': - $ref: '#/components/responses/BadRequest' - '404': - $ref: '#/components/responses/NotFound' - '200': - description: Successfully updated a team workload catalog - content: - application/json: - schema: - type: object '/v1/teams/{teamId}/workloads': parameters: diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 7381f3e43..ba9ab673c 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -1,4 +1,4 @@ -import { CoreV1Api, KubeConfig, User as k8sUser, V1ObjectReference } from '@kubernetes/client-node' +import { CoreV1Api, User as k8sUser, KubeConfig, V1ObjectReference } from '@kubernetes/client-node' import Debug from 'debug' import { getRegions, ObjectStorageKeyRegions, Region, ResourcePage } from '@linode/api-v4' @@ -99,6 +99,8 @@ import { EDITOR_INACTIVITY_TIMEOUT, GIT_BRANCH, GIT_EMAIL, + GIT_INIT_MAX_RETRIES, + GIT_INIT_RETRY_INTERVAL_MS, GIT_LOCAL_PATH, GIT_PASSWORD, GIT_REPO_URL, @@ -111,8 +113,6 @@ import { PREINSTALLED_EXCLUDED_APPS, TOOLS_HOST, VERSIONS, - GIT_INIT_MAX_RETRIES, - GIT_INIT_RETRY_INTERVAL_MS, } from 'src/validators' import { v4 as uuidv4 } from 'uuid' import { parse as parseYaml, stringify as stringifyYaml } from 'yaml' @@ -163,14 +163,7 @@ import { userSecretDataToUser, } from './utils/userUtils' import { defineClusterId, ObjectStorageClient } from './utils/wizardUtils' -import { - fetchChartYaml, - fetchWorkloadCatalog, - isInteralGiteaURL, - NewHelmChartValues, - sparseCloneChart, - validateGitUrl, -} from './utils/workloadUtils' +import { fetchChartYaml, fetchWorkloadCatalog, isInteralGiteaURL, validateGitUrl } from './utils/workloadUtils' interface ExcludedApp extends App { managed: boolean @@ -2015,37 +2008,6 @@ export default class OtomiStack { return await fetchChartYaml(url) } - async createWorkloadCatalog(body: NewHelmChartValues): Promise { - const { gitRepositoryUrl, chartTargetDirName, chartIcon, allowTeams } = body - - const uuid = uuidv4() - const localHelmChartsDir = `/tmp/otomi/charts/${uuid}` - const helmChartCatalogUrl = env.HELM_CHART_CATALOG - const { user, email } = this.git - const { cluster } = await this.getSettings(['cluster']) - - try { - await sparseCloneChart( - gitRepositoryUrl, - localHelmChartsDir, - helmChartCatalogUrl, - user, - email, - chartTargetDirName, - chartIcon, - allowTeams, - cluster?.domainSuffix, - ) - return true - } catch (error) { - debug('Error adding new Helm chart to catalog') - return false - } finally { - // Clean up: if the temporary directory exists, remove it. - if (existsSync(localHelmChartsDir)) rmSync(localHelmChartsDir, { recursive: true, force: true }) - } - } - getTeamWorkloads(teamId: string): Workload[] { return this.getTeamAplWorkloads(teamId).map( (workload) => omit(getV1ObjectFromApl(workload), ['values']) as Workload, From 3c79606607c4ef98791de0ce6c004edd6f35adbb Mon Sep 17 00:00:00 2001 From: dvankeke Date: Tue, 19 May 2026 10:42:51 +0200 Subject: [PATCH 2/3] fix: removed unnused endpoints --- src/api/v1/helmChartContent.ts | 15 -- src/openapi/api.yaml | 28 --- src/otomi-stack.ts | 6 +- src/utils/workloadUtils.test.ts | 409 -------------------------------- src/utils/workloadUtils.ts | 246 +------------------ 5 files changed, 3 insertions(+), 701 deletions(-) delete mode 100644 src/api/v1/helmChartContent.ts diff --git a/src/api/v1/helmChartContent.ts b/src/api/v1/helmChartContent.ts deleted file mode 100644 index 68a770e99..000000000 --- a/src/api/v1/helmChartContent.ts +++ /dev/null @@ -1,15 +0,0 @@ -import Debug from 'debug' -import { Response } from 'express' -import { OpenApiRequestExt } from 'src/otomi-models' - -const debug = Debug('otomi:api:v1:helmChartContent') - -/** - * GET /v1/helmChartContent - * Get Helm chart content - */ -export const getHelmChartContent = async (req: OpenApiRequestExt, res: Response): Promise => { - debug(`gethelmChartContent ${req.query?.url}`) - const v = await req.otomi.getHelmChartContent(req.query?.url as string) - res.json(v) -} diff --git a/src/openapi/api.yaml b/src/openapi/api.yaml index 4d94166f4..068a75854 100644 --- a/src/openapi/api.yaml +++ b/src/openapi/api.yaml @@ -2331,34 +2331,6 @@ paths: schema: type: object - '/v1/helmChartContent': - get: - operationId: getHelmChartContent - x-eov-operation-handler: v1/helmChartContent - x-aclSchema: Workload - parameters: - - name: url - in: query - description: URL of the helm chart - schema: - type: string - responses: - '400': - $ref: '#/components/responses/BadRequest' - '404': - $ref: '#/components/responses/NotFound' - '200': - description: Successfully obtained helm chart content - content: - application/json: - schema: - type: object - properties: - values: - type: object - error: - type: string - '/v1/teams/{teamId}/workloads': parameters: - $ref: '#/components/parameters/teamParams' diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index ba9ab673c..f9908eb79 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -163,7 +163,7 @@ import { userSecretDataToUser, } from './utils/userUtils' import { defineClusterId, ObjectStorageClient } from './utils/wizardUtils' -import { fetchChartYaml, fetchWorkloadCatalog, isInteralGiteaURL, validateGitUrl } from './utils/workloadUtils' +import { fetchWorkloadCatalog, isInteralGiteaURL, validateGitUrl } from './utils/workloadUtils' interface ExcludedApp extends App { managed: boolean @@ -2004,10 +2004,6 @@ export default class OtomiStack { } } - async getHelmChartContent(url: string): Promise { - return await fetchChartYaml(url) - } - getTeamWorkloads(teamId: string): Workload[] { return this.getTeamAplWorkloads(teamId).map( (workload) => omit(getV1ObjectFromApl(workload), ['values']) as Workload, diff --git a/src/utils/workloadUtils.test.ts b/src/utils/workloadUtils.test.ts index 79de2053c..c2464732a 100644 --- a/src/utils/workloadUtils.test.ts +++ b/src/utils/workloadUtils.test.ts @@ -3,22 +3,17 @@ import axios from 'axios' import * as fs from 'fs' import * as fsExtra from 'fs-extra' import * as fsPromises from 'fs/promises' -import path from 'path' import simpleGit from 'simple-git' import YAML from 'yaml' import * as utils from '../utils' import * as workloadUtils from './workloadUtils' import { - chartRepo, detectGitProvider, fetchChartYaml, fetchWorkloadCatalog, findRevision, getBranchesAndTags, getGitCloneUrl, - sparseCloneChart, - updateChartIconInYaml, - updateRbacForNewChart, } from './workloadUtils' jest.mock('axios') @@ -247,327 +242,6 @@ describe('fetchChartYaml', () => { }) }) -// ---------------------------------------------------------------- -// Tests for updateChartIconInYaml -describe('updateChartIconInYaml', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - test('updates the icon field when newIcon is provided', async () => { - const chartObject = { name: 'Test Chart', version: '1.0.0' } - const fileContent = YAML.stringify(chartObject) - ;(fsExtra.readFile as unknown as jest.Mock).mockResolvedValue(fileContent) - const fakePath = '/tmp/test/Chart.yaml' - const newIcon = 'https://example.com/new-icon.png' - const expectedObject = { name: 'Test Chart', version: '1.0.0', icon: newIcon } - const expectedContent = YAML.stringify(expectedObject) - - await updateChartIconInYaml(fakePath, newIcon) - - expect(fsExtra.readFile).toHaveBeenCalledWith(fakePath, 'utf-8') - expect(fsPromises.writeFile).toHaveBeenCalledWith(fakePath, expectedContent, 'utf-8') - }) - - test('replaces existing icon when newIcon is provided', async () => { - const chartObject = { name: 'Test Chart', version: '1.0.0', icon: 'https://example.com/old-icon.png' } - const fileContent = YAML.stringify(chartObject) - ;(fsExtra.readFile as unknown as jest.Mock).mockResolvedValue(fileContent) - const fakePath = '/tmp/test/Chart.yaml' - const newIcon = 'https://example.com/new-icon.png' - const expectedObject = { name: 'Test Chart', version: '1.0.0', icon: newIcon } - const expectedContent = YAML.stringify(expectedObject) - - await updateChartIconInYaml(fakePath, newIcon) - - expect(fsPromises.writeFile).toHaveBeenCalledWith(fakePath, expectedContent, 'utf-8') - }) - - test('does not change icon when newIcon is empty', async () => { - const chartObject = { name: 'Test Chart', version: '1.0.0', icon: 'https://example.com/old-icon.png' } - const fileContent = YAML.stringify(chartObject) - ;(fsExtra.readFile as unknown as jest.Mock).mockResolvedValue(fileContent) - const fakePath = '/tmp/test/Chart.yaml' - const newIcon = '' - - await updateChartIconInYaml(fakePath, newIcon) - - // Verify writeFile was called, but the icon wasn't changed - expect(fsPromises.writeFile).toHaveBeenCalled() - const writeFileArgs = (fsPromises.writeFile as jest.Mock).mock.calls[0] - const writtenContent = writeFileArgs[1] - const parsedContent = YAML.parse(writtenContent) - expect(parsedContent.icon).toBe('https://example.com/old-icon.png') - }) - - test('handles errors gracefully', async () => { - ;(fsExtra.readFile as unknown as jest.Mock).mockRejectedValue(new Error('File not found')) - const fakePath = '/tmp/test/Chart.yaml' - const newIcon = 'https://example.com/new-icon.png' - - // Should not throw - await expect(updateChartIconInYaml(fakePath, newIcon)).resolves.not.toThrow() - expect(fsPromises.writeFile).not.toHaveBeenCalled() - }) -}) - -// ---------------------------------------------------------------- -// Tests for updateRbacForNewChart -describe('updateRbacForNewChart', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - test('updates rbac.yaml with new chart key when allowTeams is true', async () => { - const rbacObject = { rbac: {}, betaCharts: [] } - const fileContent = YAML.stringify(rbacObject) - ;(fsExtra.readFile as unknown as jest.Mock).mockResolvedValue(fileContent) - const fakeSparsePath = '/tmp/test' - const chartKey = 'quickstart-cassandra' - - await updateRbacForNewChart(fakeSparsePath, chartKey, true) - - const expected = { rbac: { [chartKey]: null }, betaCharts: [] } - expect(fsPromises.writeFile).toHaveBeenCalledWith(`${fakeSparsePath}/rbac.yaml`, YAML.stringify(expected), 'utf-8') - }) - - test('updates rbac.yaml with new chart key when allowTeams is false', async () => { - const rbacObject = { rbac: {}, betaCharts: [] } - const fileContent = YAML.stringify(rbacObject) - ;(fsExtra.readFile as unknown as jest.Mock).mockResolvedValue(fileContent) - const fakeSparsePath = '/tmp/test' - const chartKey = 'quickstart-cassandra' - - await updateRbacForNewChart(fakeSparsePath, chartKey, false) - - const expected = { rbac: { [chartKey]: [] }, betaCharts: [] } - expect(fsPromises.writeFile).toHaveBeenCalledWith(`${fakeSparsePath}/rbac.yaml`, YAML.stringify(expected), 'utf-8') - }) - - test('creates rbac.yaml when it does not exist', async () => { - ;(fsExtra.readFile as unknown as jest.Mock).mockRejectedValue(new Error('File not found')) - const fakeSparsePath = '/tmp/test' - const chartKey = 'quickstart-cassandra' - - await updateRbacForNewChart(fakeSparsePath, chartKey, true) - - const expected = { rbac: { [chartKey]: null }, betaCharts: [] } - expect(fsPromises.writeFile).toHaveBeenCalledWith(`${fakeSparsePath}/rbac.yaml`, YAML.stringify(expected), 'utf-8') - }) - - test('preserves existing rbac entries when adding new chart', async () => { - const rbacObject = { rbac: { 'existing-chart': ['team-1'] }, betaCharts: ['existing-chart'] } - const fileContent = YAML.stringify(rbacObject) - ;(fsExtra.readFile as unknown as jest.Mock).mockResolvedValue(fileContent) - const fakeSparsePath = '/tmp/test' - const chartKey = 'quickstart-cassandra' - - await updateRbacForNewChart(fakeSparsePath, chartKey, false) - - const expected = { - rbac: { - 'existing-chart': ['team-1'], - [chartKey]: [], - }, - betaCharts: ['existing-chart'], - } - expect(fsPromises.writeFile).toHaveBeenCalledWith(`${fakeSparsePath}/rbac.yaml`, YAML.stringify(expected), 'utf-8') - }) -}) - -// ---------------------------------------------------------------- -// Tests for sparseCloneChart -describe('sparseCloneChart', () => { - const gitRepositoryUrl = 'https://github.com/bitnami/charts/blob/main/bitnami/cassandra/Chart.yaml' - const localHelmChartsDir = '/tmp/otomi/charts/uuid' - const helmChartCatalogUrl = 'https://gitea.example.com/otomi/charts.git' - const user = 'test-user' - const email = 'test@example.com' - const chartTargetDirName = 'cassandra' - const chartIcon = 'https://example.com/icon.png' - const allowTeams = true - const clusterDomainSuffix = 'example.com' - - beforeEach(() => { - jest.clearAllMocks() - // Set up environment variables for tests - process.env = { ...originalEnv, GIT_USER: 'git-user', GIT_PASSWORD: 'git-password' } - // Mock necessary function responses - ;(fs.existsSync as jest.Mock).mockReturnValue(false) - }) - - afterEach(() => { - // Restore original environment - process.env = originalEnv - }) - - test('successfully clones and processes a chart repository', async () => { - // Setup mock git instance - const mockGit = { - clone: jest.fn().mockResolvedValue(undefined), - cwd: jest.fn().mockResolvedValue(undefined), - raw: jest.fn().mockResolvedValue(undefined), - checkout: jest.fn().mockResolvedValue(undefined), - addConfig: jest.fn().mockResolvedValue(undefined), - add: jest.fn().mockResolvedValue(undefined), - commit: jest.fn().mockResolvedValue(undefined), - pull: jest.fn().mockResolvedValue(undefined), - push: jest.fn().mockResolvedValue(undefined), - listRemote: jest.fn().mockResolvedValue(''), - } - ;(simpleGit as jest.Mock).mockReturnValue(mockGit) - - const result = await sparseCloneChart( - gitRepositoryUrl, - localHelmChartsDir, - helmChartCatalogUrl, - user, - email, - chartTargetDirName, - chartIcon, - allowTeams, - clusterDomainSuffix, - ) - - expect(result).toBe(true) - expect(fs.mkdirSync).toHaveBeenCalledWith(localHelmChartsDir, { recursive: true }) - expect(fs.mkdirSync).toHaveBeenCalledWith(`${localHelmChartsDir}-newChart`, { recursive: true }) - expect(mockGit.clone).toHaveBeenCalledTimes(2) // Once for catalog repo, once for chart repo - expect(mockGit.listRemote).toHaveBeenCalled() - expect(mockGit.raw).toHaveBeenCalledWith(['sparse-checkout', 'init', '--cone']) - expect(mockGit.raw).toHaveBeenCalledWith(['sparse-checkout', 'set', 'main/bitnami/cassandra/']) - expect(mockGit.checkout).toHaveBeenCalled() - expect(fs.renameSync).toHaveBeenCalled() - expect(fs.rmSync).toHaveBeenCalledWith(`${localHelmChartsDir}-newChart`, { recursive: true, force: true }) - expect(fs.rmSync).toHaveBeenCalledWith(`${localHelmChartsDir}/${chartTargetDirName}/.git`, { - recursive: true, - force: true, - }) - // Verify addConfig was called with correct user/email - expect(mockGit.addConfig).toHaveBeenCalledWith('user.name', user) - expect(mockGit.addConfig).toHaveBeenCalledWith('user.email', email) - // Verify commit and push were called - expect(mockGit.add).toHaveBeenCalledWith('.') - expect(mockGit.commit).toHaveBeenCalledWith(`Add ${chartTargetDirName} helm chart`) - expect(mockGit.pull).toHaveBeenCalledWith('origin', 'refs/heads/main', { '--rebase': null }) - expect(mockGit.push).toHaveBeenCalledWith('origin', 'refs/heads/main') - }) - - test('handles Gitea URLs by encoding credentials', async () => { - const mockGit = { - clone: jest.fn().mockResolvedValue(undefined), - cwd: jest.fn().mockResolvedValue(undefined), - raw: jest.fn().mockResolvedValue(undefined), - checkout: jest.fn().mockResolvedValue(undefined), - addConfig: jest.fn().mockResolvedValue(undefined), - add: jest.fn().mockResolvedValue(undefined), - commit: jest.fn().mockResolvedValue(undefined), - pull: jest.fn().mockResolvedValue(undefined), - push: jest.fn().mockResolvedValue(undefined), - listRemote: jest.fn().mockResolvedValue(''), - } - ;(simpleGit as jest.Mock).mockReturnValue(mockGit) - jest.spyOn(workloadUtils, 'isGiteaURL').mockImplementation(() => true) - jest.spyOn(workloadUtils, 'isInteralGiteaURL').mockImplementation(() => true) - - await sparseCloneChart( - gitRepositoryUrl, - localHelmChartsDir, - helmChartCatalogUrl, - user, - email, - chartTargetDirName, - chartIcon, - allowTeams, - clusterDomainSuffix, - ) - - // Check that clone was called with encoded URL - const encodedUrl = `https://git-user:git-password@gitea.example.com/otomi/charts.git` - expect(mockGit.clone.mock.calls[0][0]).toBe(encodedUrl) - }) - - test('properly handles empty chartIcon parameter', async () => { - const mockGit = { - clone: jest.fn().mockResolvedValue(undefined), - cwd: jest.fn().mockResolvedValue(undefined), - raw: jest.fn().mockResolvedValue(undefined), - checkout: jest.fn().mockResolvedValue(undefined), - addConfig: jest.fn().mockResolvedValue(undefined), - add: jest.fn().mockResolvedValue(undefined), - commit: jest.fn().mockResolvedValue(undefined), - pull: jest.fn().mockResolvedValue(undefined), - push: jest.fn().mockResolvedValue(undefined), - listRemote: jest.fn().mockResolvedValue(''), - } - ;(simpleGit as jest.Mock).mockReturnValue(mockGit) - - const result = await sparseCloneChart( - gitRepositoryUrl, - localHelmChartsDir, - helmChartCatalogUrl, - user, - email, - chartTargetDirName, - '', // Empty chart icon - allowTeams, - ) - - expect(result).toBe(true) - // Should not attempt to update the chart icon - const chartYamlPath = `${localHelmChartsDir}/${chartTargetDirName}/Chart.yaml` - expect(fsExtra.readFile).not.toHaveBeenCalledWith(chartYamlPath, 'utf-8') - }) - - test('creates directory if it does not exist', async () => { - const mockGit = { - clone: jest.fn().mockResolvedValue(undefined), - cwd: jest.fn().mockResolvedValue(undefined), - raw: jest.fn().mockResolvedValue(undefined), - checkout: jest.fn().mockResolvedValue(undefined), - addConfig: jest.fn().mockResolvedValue(undefined), - add: jest.fn().mockResolvedValue(undefined), - commit: jest.fn().mockResolvedValue(undefined), - pull: jest.fn().mockResolvedValue(undefined), - push: jest.fn().mockResolvedValue(undefined), - listRemote: jest.fn().mockResolvedValue(''), - } - ;(simpleGit as jest.Mock).mockReturnValue(mockGit) - ;(fs.existsSync as jest.Mock).mockReturnValueOnce(false) - - await sparseCloneChart( - gitRepositoryUrl, - localHelmChartsDir, - helmChartCatalogUrl, - user, - email, - chartTargetDirName, - chartIcon, - allowTeams, - ) - - expect(fs.mkdirSync).toHaveBeenCalledWith(localHelmChartsDir, { recursive: true }) - }) - - test('returns false if git provider detection fails', async () => { - // Mock the detectGitProvider to return null - jest.spyOn(workloadUtils, 'detectGitProvider').mockImplementation(() => null) - - const result = await sparseCloneChart( - 'https://invalid-url.com', - localHelmChartsDir, - helmChartCatalogUrl, - user, - email, - chartTargetDirName, - chartIcon, - allowTeams, - ) - - expect(result).toBe(false) - }) -}) - // ---------------------------------------------------------------- // Tests for fetchWorkloadCatalog describe('fetchWorkloadCatalog', () => { @@ -780,89 +454,6 @@ describe('fetchWorkloadCatalog', () => { }) }) -// ---------------------------------------------------------------- -// Tests for chartRepo class -describe('chartRepo', () => { - const localPath = '/tmp/repo' - const chartRepoUrl = 'https://github.com/example/repo.git' - const gitUser = 'test-user' - const gitEmail = 'test@example.com' - - let mockGit - - beforeEach(() => { - jest.clearAllMocks() - mockGit = { - clone: jest.fn().mockResolvedValue(undefined), - cwd: jest.fn().mockResolvedValue(undefined), - raw: jest.fn().mockResolvedValue(undefined), - checkout: jest.fn().mockResolvedValue(undefined), - addConfig: jest.fn().mockResolvedValue(undefined), - add: jest.fn().mockResolvedValue(undefined), - commit: jest.fn().mockResolvedValue(undefined), - pull: jest.fn().mockResolvedValue(undefined), - push: jest.fn().mockResolvedValue(undefined), - listRemote: jest.fn().mockResolvedValue(''), - } - ;(simpleGit as jest.Mock).mockReturnValue(mockGit) - }) - - test('clone method clones the repository', async () => { - const repo = new chartRepo(localPath, chartRepoUrl, gitUser, gitEmail) - await repo.clone() - - expect(mockGit.clone).toHaveBeenCalledWith(chartRepoUrl, localPath, [ - '--branch', - 'main', - '--single-branch', - '--depth', - '1', - ]) - }) - - test('cloneSingleChart method performs sparse checkout', async () => { - const repo = new chartRepo(localPath, chartRepoUrl, gitUser, gitEmail) - const refAndPath = 'main/charts/my-chart' - const finalDestinationPath = '/tmp/final/my-chart' - - // Mock getBranchesAndTags to return 'main' as a branch - mockGit.listRemote.mockResolvedValue( - '1234567890abcdef1234567890abcdef12345678\trefs/heads/main\n' + - 'abcdef1234567890abcdef1234567890abcdef12\trefs/tags/v1.0.0', - ) - - await repo.cloneSingleChart(refAndPath, finalDestinationPath) - - expect(mockGit.listRemote).toHaveBeenCalledWith([chartRepoUrl]) - expect(mockGit.clone).toHaveBeenCalledWith(chartRepoUrl, localPath, ['--filter=blob:none', '--no-checkout']) - expect(mockGit.cwd).toHaveBeenCalledWith(localPath) - expect(mockGit.raw).toHaveBeenCalledWith(['sparse-checkout', 'init', '--cone']) - expect(mockGit.raw).toHaveBeenCalledWith(['sparse-checkout', 'set', 'charts/my-chart']) - expect(mockGit.checkout).toHaveBeenCalledWith('main') - expect(fs.renameSync).toHaveBeenCalledWith(path.join(localPath, 'charts/my-chart'), finalDestinationPath) - }) - - test('addConfig method sets git config', async () => { - const repo = new chartRepo(localPath, chartRepoUrl, gitUser, gitEmail) - await repo.addConfig() - - expect(mockGit.addConfig).toHaveBeenCalledWith('user.name', gitUser) - expect(mockGit.addConfig).toHaveBeenCalledWith('user.email', gitEmail) - }) - - test('commitAndPush method commits and pushes changes', async () => { - const repo = new chartRepo(localPath, chartRepoUrl, gitUser, gitEmail) - const chartName = 'my-chart' - - await repo.commitAndPush(chartName) - - expect(mockGit.add).toHaveBeenCalledWith('.') - expect(mockGit.commit).toHaveBeenCalledWith(`Add ${chartName} helm chart`) - expect(mockGit.pull).toHaveBeenCalledWith('origin', 'refs/heads/main', { '--rebase': null }) - expect(mockGit.push).toHaveBeenCalledWith('origin', 'refs/heads/main') - }) -}) - // ---------------------------------------------------------------- // Tests for findRevision describe('findRevision', () => { diff --git a/src/utils/workloadUtils.ts b/src/utils/workloadUtils.ts index b52df6232..fc0b5b443 100644 --- a/src/utils/workloadUtils.ts +++ b/src/utils/workloadUtils.ts @@ -1,10 +1,9 @@ import axios from 'axios' import Debug from 'debug' -import { existsSync, lstatSync, mkdirSync, renameSync, rmSync } from 'fs' +import { existsSync, lstatSync, mkdirSync } from 'fs' import { readFile } from 'fs-extra' -import { readdir, writeFile } from 'fs/promises' +import { readdir } from 'fs/promises' import path from 'path' -import simpleGit, { SimpleGit } from 'simple-git' import { safeReadTextFile } from 'src/utils' import { CATALOG_CACHE_REFRESH_INTERVAL_MS, @@ -43,13 +42,6 @@ export interface NewHelmChartValues { allowTeams: boolean } -function throwChartError(message: string) { - const err = { - code: 404, - message, - } - throw err -} export function isGiteaURL(url: string) { let hostname = '' if (url) { @@ -175,235 +167,6 @@ export function findRevision(branches, tags, refAndPath) { return null } -/** - * Updates (or sets) icon field, - * - * @param chartYamlPath - Path to Chart.yaml (e.g. "/tmp/otomi/charts/uuid/cassandra/Chart.yaml") - * @param newIcon - The user-selected icon URL. - */ -export async function updateChartIconInYaml(chartYamlPath: string, newIcon: string): Promise { - try { - const fileContent = await readFile(chartYamlPath, 'utf-8') - const chartObject = YAML.parse(fileContent) - if (newIcon && newIcon.trim() !== '') chartObject.icon = newIcon - const newContent = YAML.stringify(chartObject) - await writeFile(chartYamlPath, newContent, 'utf-8') - } catch (error) { - debug(`Error updating chart icon in ${chartYamlPath}:`, error) - } -} -/** - * Updates the rbac.yaml file in the specified folder by adding a new chart key. - * - * @param sparsePath - The folder where rbac.yaml resides (e.g. "/tmp/otomi/charts/uuid") - * @param chartKey - The key to add under the "rbac" section (e.g. "quickstart-cassandra") - * @param allowTeams - Boolean indicating if teams are allowed to use the chart. - * - */ -export async function updateRbacForNewChart(sparsePath: string, chartKey: string, allowTeams: boolean): Promise { - const rbacFilePath = `${sparsePath}/rbac.yaml` - let rbacData: any = {} - debug('update rbac reach rbacFilePath', rbacFilePath) - try { - const fileContent = await readFile(rbacFilePath, 'utf-8') - rbacData = YAML.parse(fileContent) || {} - } catch (error) { - debug('Error reading rbac.yaml:', error) - // Create a default structure if the file doesn't exist. - rbacData = { rbac: {}, betaCharts: [] } - } - // Ensure the "rbac" section exists. - if (!rbacData.rbac) rbacData.rbac = {} - // Add the new chart entry if it doesn't exist. - if (!(chartKey in rbacData.rbac)) rbacData.rbac[chartKey] = allowTeams ? null : [] - // Stringify the updated YAML content and write it back. - const newContent = YAML.stringify(rbacData) - await writeFile(rbacFilePath, newContent, 'utf-8') - debug(`Updated rbac.yaml: added ${chartKey}: ${allowTeams ? 'null' : '[]'}`) -} - -export class chartRepo { - localPath: string - chartRepoUrl: string - gitUser?: string - gitEmail?: string - git: SimpleGit - constructor(localPath: string, chartRepoUrl: string, gitUser?: string, gitEmail?: string) { - this.localPath = localPath - this.chartRepoUrl = chartRepoUrl - this.gitUser = gitUser - this.gitEmail = gitEmail - this.git = simpleGit(this.localPath) - } - async clone(branch: string = 'main') { - await this.git.clone(this.chartRepoUrl, this.localPath, ['--branch', branch, '--single-branch', '--depth', '1']) - } - async ensureLatest(branch: string = 'main', forceRefresh: boolean = false) { - const gitDir = path.join(this.localPath, '.git') - const cacheSyncMarkerPath = path.join(this.localPath, env.CATALOG_CACHE_SYNC_MARKER) - const canRefreshExistingRepo = - typeof this.git.cwd === 'function' && - typeof this.git.fetch === 'function' && - typeof this.git.checkout === 'function' && - typeof this.git.pull === 'function' - - if (!existsSync(gitDir) || !canRefreshExistingRepo) { - debug(`Catalog cache miss at ${this.localPath}; cloning branch '${branch}'`) - await this.clone(branch) - await writeFile(cacheSyncMarkerPath, new Date().toISOString(), 'utf-8') - debug(`Catalog cache initialized at ${this.localPath}`) - return - } - - if (!forceRefresh && existsSync(cacheSyncMarkerPath)) { - const cacheAgeMs = Date.now() - lstatSync(cacheSyncMarkerPath).mtimeMs - if (cacheAgeMs < env.CATALOG_CACHE_REFRESH_INTERVAL_MS) { - debug( - `Catalog cache hit at ${this.localPath}; age=${Math.round(cacheAgeMs / 1000)}s, ttl=${Math.round(env.CATALOG_CACHE_REFRESH_INTERVAL_MS / 1000)}s`, - ) - return - } - debug( - `Catalog cache expired at ${this.localPath}; age=${Math.round(cacheAgeMs / 1000)}s, ttl=${Math.round(env.CATALOG_CACHE_REFRESH_INTERVAL_MS / 1000)}s`, - ) - } else if (forceRefresh) { - debug(`Catalog cache force-refresh requested at ${this.localPath}`) - } else { - debug(`Catalog cache marker missing at ${this.localPath}; refreshing`) - } - - debug(`Refreshing catalog cache at ${this.localPath} for branch '${branch}'`) - await this.git.cwd(this.localPath) - await this.git.fetch('origin', branch) - try { - await this.git.checkout(branch) - } catch { - await this.git.checkout(['-B', branch, `origin/${branch}`]) - } - await this.git.pull('origin', branch, { '--ff-only': null }) - await writeFile(cacheSyncMarkerPath, new Date().toISOString(), 'utf-8') - debug(`Catalog cache refreshed at ${this.localPath}`) - } - async cloneSingleChart(refAndPath: string, finalDestinationPath: string) { - const remoteResult = await this.git.listRemote([this.chartRepoUrl]) - const { branches, tags } = getBranchesAndTags(remoteResult) - const finalRevision = findRevision(branches, tags, refAndPath) as string - const finalFilePath = refAndPath.slice(finalRevision?.length + 1) - - debug(`Cloning repository: ${this.chartRepoUrl} into ${this.localPath}`) - await this.git.clone(this.chartRepoUrl, this.localPath, ['--filter=blob:none', '--no-checkout']) - - debug(`Initializing sparse checkout in cone mode at ${this.localPath}`) - await this.git.cwd(this.localPath) - await this.git.raw(['sparse-checkout', 'init', '--cone']) - - debug(`Setting sparse checkout path to ${finalFilePath}`) - await this.git.raw(['sparse-checkout', 'set', finalFilePath]) - - debug(`Checking out the desired revision (branch or commit): ${finalRevision}`) - await this.git.checkout(finalRevision) - - // Move files from "temporaryCloneDir/chartPath/*" to "finalDestinationPath/" - renameSync(path.join(this.localPath, finalFilePath), finalDestinationPath) - } - async addConfig() { - await this.git.addConfig('user.name', this.gitUser!) - await this.git.addConfig('user.email', this.gitEmail!) - } - async commitAndPush(chartName: string) { - await this.git.add('.') - await this.git.commit(`Add ${chartName} helm chart`) - await this.git.pull('origin', 'refs/heads/main', { '--rebase': null }) - await this.git.push('origin', 'refs/heads/main') - } -} - -/** - * Clones a repository using sparse checkout, checks out a specific revision, - * and moves the contents of the desired subdirectory (sparsePath) to the root of the target folder. - * - * @param gitRepositoryUrl - The base Git repository URL (e.g. "https://github.com/nats-io/k8s.git") - * @param localHelmChartsDir - The subdirectory to sparse checkout (e.g. "/tmp/otomi/charts/uuid") - * @param helmChartCatalogUrl - The URL of the (Gitea) Helm Chart Catalog (e.g. "https://gitea./otomi/charts.git") - * @param user - The Git username (e.g. "otomi-admin") - * @param email - The Git email (e.g. "not@us.ed") - * @param chartTargetDirName - The target folder name for the clone (will be the final chart folder, e.g. "nats") - * @param chartIcon - the icon URL path (e.g https://myimage.com/imageurl) - * @param allowTeams - Boolean indicating if teams are allowed to use the chart. - * @param clusterDomainSuffix - domainSuffix set in cluster settings, used to check if URL is an interal Gitea URL - */ -export async function sparseCloneChart( - gitRepositoryUrl: string, - localHelmChartsDir: string, - helmChartCatalogUrl: string, - user: string, - email: string, - chartTargetDirName: string, - chartIcon?: string, - allowTeams?: boolean, - clusterDomainSuffix?: string, -): Promise { - const details = detectGitProvider(gitRepositoryUrl) - if (!details) return false - const gitCloneUrl = getGitCloneUrl(details) as string - const refAndPath = `${details.branch}/${details.filePath.replace('Chart.yaml', '')}` - const temporaryCloneDir = `${localHelmChartsDir}-newChart` - const finalDestinationPath = `${localHelmChartsDir}/${chartTargetDirName}` - - if (!existsSync(localHelmChartsDir)) mkdirSync(localHelmChartsDir, { recursive: true }) - let gitUrl = helmChartCatalogUrl - if (isInteralGiteaURL(helmChartCatalogUrl, clusterDomainSuffix)) { - const [protocol, bareUrl] = helmChartCatalogUrl.split('://') - const encodedUser = encodeURIComponent(process.env.GIT_USER as string) - const encodedPassword = encodeURIComponent(process.env.GIT_PASSWORD as string) - gitUrl = `${protocol}://${encodedUser}:${encodedPassword}@${bareUrl}` - } - const gitRepo = new chartRepo(localHelmChartsDir, gitUrl, user, email) - await gitRepo.clone() - - if (!existsSync(temporaryCloneDir)) mkdirSync(temporaryCloneDir, { recursive: true }) - else { - rmSync(temporaryCloneDir, { recursive: true, force: true }) - mkdirSync(temporaryCloneDir, { recursive: true }) - } - - const gitSingleChart = new chartRepo(temporaryCloneDir, gitCloneUrl) - await gitSingleChart.cloneSingleChart(refAndPath, finalDestinationPath) - - // Remove the .git directory from the final destination. - rmSync(`${finalDestinationPath}/.git`, { recursive: true, force: true }) - - // Remove the leftover temporary clone directory. - rmSync(temporaryCloneDir, { recursive: true, force: true }) - - // Update Chart.yaml with the new icon if one is provided. - if (chartIcon && chartIcon.trim() !== '') { - const chartYamlPath = `${finalDestinationPath}/Chart.yaml` - await updateChartIconInYaml(chartYamlPath, chartIcon) - } - - // update rbac file - await updateRbacForNewChart(localHelmChartsDir, chartTargetDirName, allowTeams as boolean) - - // pull&push new chart changes - await gitRepo.addConfig() - await gitRepo.commitAndPush(chartTargetDirName) - - return true -} - -/** - * Encodes Git credentials into the URL for internal Gitea repositories - */ -function encodeGitCredentials(url: string, clusterDomainSuffix?: string): string { - if (!isInteralGiteaURL(url, clusterDomainSuffix)) return url - - const [protocol, bareUrl] = url.split('://') - const encodedUser = encodeURIComponent(process.env.GIT_USER as string) - const encodedPassword = encodeURIComponent(process.env.GIT_PASSWORD as string) - return `${protocol}://${encodedUser}:${encodedPassword}@${bareUrl}` -} - /** * Reads and parses the rbac.yaml file from a helm charts directory */ @@ -545,11 +308,6 @@ export async function fetchWorkloadCatalog( // Ensure directory exists if (!existsSync(resolvedHelmChartsDir)) mkdirSync(resolvedHelmChartsDir, { recursive: true }) - // Clone repository - const gitUrl = encodeGitCredentials(url, clusterDomainSuffix) - const gitRepo = new chartRepo(resolvedHelmChartsDir, gitUrl) - await gitRepo.ensureLatest(branch, forceRefresh) - // Determine the charts directory path const chartsDir = chartsPath ? path.resolve(resolvedHelmChartsDir, chartsPath) : resolvedHelmChartsDir From f2f52d963231d632f46a7c66bb68c037440523d9 Mon Sep 17 00:00:00 2001 From: dvankeke Date: Tue, 19 May 2026 10:45:12 +0200 Subject: [PATCH 3/3] fix: removed unused interface --- src/utils/workloadUtils.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/utils/workloadUtils.ts b/src/utils/workloadUtils.ts index fc0b5b443..47742a7c8 100644 --- a/src/utils/workloadUtils.ts +++ b/src/utils/workloadUtils.ts @@ -35,13 +35,6 @@ export async function validateGitUrl(url: string): Promise { } } -export interface NewHelmChartValues { - gitRepositoryUrl: string - chartTargetDirName: string - chartIcon?: string - allowTeams: boolean -} - export function isGiteaURL(url: string) { let hostname = '' if (url) {