diff --git a/src/api/v1/createWorkloadCatalog.ts b/src/api/v1/createWorkloadCatalog.ts deleted file mode 100644 index c01b85a4..00000000 --- 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/api/v1/helmChartContent.ts b/src/api/v1/helmChartContent.ts deleted file mode 100644 index 68a770e9..00000000 --- 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/middleware/session.ts b/src/middleware/session.ts index 281b35f8..8c615e6d 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 6bd33946..068a7585 100644 --- a/src/openapi/api.yaml +++ b/src/openapi/api.yaml @@ -2331,58 +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/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: - $ref: '#/components/parameters/teamParams' diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 80b873f1..f8ba9728 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' @@ -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 { fetchWorkloadCatalog, isInteralGiteaURL, validateGitUrl } from './utils/workloadUtils' interface ExcludedApp extends App { managed: boolean @@ -1952,41 +1945,6 @@ export default class OtomiStack { } } - async getHelmChartContent(url: string): Promise { - 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, diff --git a/src/utils/workloadUtils.test.ts b/src/utils/workloadUtils.test.ts index 79de2053..848810aa 100644 --- a/src/utils/workloadUtils.test.ts +++ b/src/utils/workloadUtils.test.ts @@ -9,16 +9,12 @@ 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 +243,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', () => { @@ -808,7 +483,7 @@ describe('chartRepo', () => { }) test('clone method clones the repository', async () => { - const repo = new chartRepo(localPath, chartRepoUrl, gitUser, gitEmail) + const repo = new workloadUtils.chartRepo(localPath, chartRepoUrl, gitUser, gitEmail) await repo.clone() expect(mockGit.clone).toHaveBeenCalledWith(chartRepoUrl, localPath, [ @@ -821,7 +496,7 @@ describe('chartRepo', () => { }) test('cloneSingleChart method performs sparse checkout', async () => { - const repo = new chartRepo(localPath, chartRepoUrl, gitUser, gitEmail) + const repo = new workloadUtils.chartRepo(localPath, chartRepoUrl, gitUser, gitEmail) const refAndPath = 'main/charts/my-chart' const finalDestinationPath = '/tmp/final/my-chart' @@ -843,7 +518,7 @@ describe('chartRepo', () => { }) test('addConfig method sets git config', async () => { - const repo = new chartRepo(localPath, chartRepoUrl, gitUser, gitEmail) + const repo = new workloadUtils.chartRepo(localPath, chartRepoUrl, gitUser, gitEmail) await repo.addConfig() expect(mockGit.addConfig).toHaveBeenCalledWith('user.name', gitUser) @@ -851,7 +526,7 @@ describe('chartRepo', () => { }) test('commitAndPush method commits and pushes changes', async () => { - const repo = new chartRepo(localPath, chartRepoUrl, gitUser, gitEmail) + const repo = new workloadUtils.chartRepo(localPath, chartRepoUrl, gitUser, gitEmail) const chartName = 'my-chart' await repo.commitAndPush(chartName) diff --git a/src/utils/workloadUtils.ts b/src/utils/workloadUtils.ts index b52df623..299d6f7f 100644 --- a/src/utils/workloadUtils.ts +++ b/src/utils/workloadUtils.ts @@ -1,6 +1,6 @@ import axios from 'axios' import Debug from 'debug' -import { existsSync, lstatSync, mkdirSync, renameSync, rmSync } from 'fs' +import { existsSync, lstatSync, mkdirSync, renameSync } from 'fs' import { readFile } from 'fs-extra' import { readdir, writeFile } from 'fs/promises' import path from 'path' @@ -36,20 +36,6 @@ export async function validateGitUrl(url: string): Promise { } } -export interface NewHelmChartValues { - gitRepositoryUrl: string - chartTargetDirName: string - chartIcon?: string - allowTeams: boolean -} - -function throwChartError(message: string) { - const err = { - code: 404, - message, - } - throw err -} export function isGiteaURL(url: string) { let hostname = '' if (url) { @@ -176,50 +162,120 @@ export function findRevision(branches, tags, refAndPath) { } /** - * 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. + * Reads and parses the rbac.yaml file from a helm charts directory */ -export async function updateChartIconInYaml(chartYamlPath: string, newIcon: string): Promise { +async function readRbacConfig(helmChartsDir: string): Promise<{ rbac: Record; betaCharts: string[] }> { 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') + const fileContent = await readFile(`${helmChartsDir}/rbac.yaml`, 'utf-8') + const parsed = YAML.parse(fileContent) + return { + rbac: parsed?.rbac || {}, + betaCharts: parsed?.betaCharts || [], + } } catch (error) { - debug(`Error updating chart icon in ${chartYamlPath}:`, error) + debug(`Error while parsing rbac.yaml file : ${error.message}`) + return { rbac: {}, betaCharts: [] } } } + /** - * 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. - * + * Checks if a chart is accessible to a team based on RBAC rules */ -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) +function isChartAccessible(chartName: string, rbac: Record, teamId?: string): boolean { + // If no teamId provided, allow access (BYO catalog case) + if (!teamId) return true + + // If chart not in rbac config, or rbac allows this team, or team is admin + return !rbac[chartName] || rbac[chartName].includes(`team-${teamId}`) || teamId === 'admin' +} + +/** + * Reads chart README file with fallback message + */ +async function readChartReadme(helmChartsDir: string, folder: string): Promise { try { - const fileContent = await readFile(rbacFilePath, 'utf-8') - rbacData = YAML.parse(fileContent) || {} + return await safeReadTextFile(helmChartsDir, `${folder}/README.md`) } catch (error) { - debug('Error reading rbac.yaml:', error) - // Create a default structure if the file doesn't exist. - rbacData = { rbac: {}, betaCharts: [] } + debug(`Error while parsing chart README.md file : ${error.message}`) + return 'There is no `README` for this chart.' } - // 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' : '[]'}`) +} + +/** + * Processes a single chart folder and returns catalog item + */ +async function processChartFolder( + helmChartsDir: string, + folder: string, + betaCharts: string[], +): Promise<{ + name: string + values: string + valuesSchema: string + icon?: string + chartVersion?: string + chartDescription?: string + readme: string + isBeta: boolean +} | null> { + const readme = await readChartReadme(helmChartsDir, folder) + + try { + const values = await safeReadTextFile(helmChartsDir, `${folder}/values.yaml`) + + let valuesSchema = '{}' + try { + const schemaContent = await safeReadTextFile(helmChartsDir, `${folder}/values.schema.json`) + valuesSchema = schemaContent || '{}' + } catch { + // values.schema.json is optional + } + + const chart = await safeReadTextFile(helmChartsDir, `${folder}/Chart.yaml`) + const chartMetadata = YAML.parse(chart) + + return { + name: folder, + values: values || '{}', + valuesSchema, + icon: chartMetadata?.icon, + chartVersion: chartMetadata?.version, + chartDescription: chartMetadata?.description, + readme, + isBeta: betaCharts.includes(folder), + } + } catch (error) { + debug(`Error while parsing ${folder}/Chart.yaml and ${folder}/values.yaml files : ${error.message}`) + return null + } +} + +/** + * Gets list of chart folders from directory, excluding system files + */ +async function getChartFolders(helmChartsDir: string): Promise { + const files = await readdir(helmChartsDir, 'utf-8') + const chartFolders = await Promise.all( + files.map(async (fileName) => { + try { + if (fileName.startsWith('.')) return null + const filePath = path.join(helmChartsDir, fileName) + if (!lstatSync(filePath).isDirectory()) return null + + try { + await safeReadTextFile(helmChartsDir, `${fileName}/Chart.yaml`) + return fileName + } catch { + await safeReadTextFile(helmChartsDir, `${fileName}/chart.yaml`) + return fileName + } + } catch { + return null + } + }), + ) + + return chartFolders.filter((folder): folder is string => folder !== null) } export class chartRepo { @@ -318,80 +374,6 @@ export class chartRepo { } } -/** - * 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 */ @@ -404,123 +386,6 @@ function encodeGitCredentials(url: string, clusterDomainSuffix?: string): string return `${protocol}://${encodedUser}:${encodedPassword}@${bareUrl}` } -/** - * Reads and parses the rbac.yaml file from a helm charts directory - */ -async function readRbacConfig(helmChartsDir: string): Promise<{ rbac: Record; betaCharts: string[] }> { - try { - const fileContent = await readFile(`${helmChartsDir}/rbac.yaml`, 'utf-8') - const parsed = YAML.parse(fileContent) - return { - rbac: parsed?.rbac || {}, - betaCharts: parsed?.betaCharts || [], - } - } catch (error) { - debug(`Error while parsing rbac.yaml file : ${error.message}`) - return { rbac: {}, betaCharts: [] } - } -} - -/** - * Checks if a chart is accessible to a team based on RBAC rules - */ -function isChartAccessible(chartName: string, rbac: Record, teamId?: string): boolean { - // If no teamId provided, allow access (BYO catalog case) - if (!teamId) return true - - // If chart not in rbac config, or rbac allows this team, or team is admin - return !rbac[chartName] || rbac[chartName].includes(`team-${teamId}`) || teamId === 'admin' -} - -/** - * Reads chart README file with fallback message - */ -async function readChartReadme(helmChartsDir: string, folder: string): Promise { - try { - return await safeReadTextFile(helmChartsDir, `${folder}/README.md`) - } catch (error) { - debug(`Error while parsing chart README.md file : ${error.message}`) - return 'There is no `README` for this chart.' - } -} - -/** - * Processes a single chart folder and returns catalog item - */ -async function processChartFolder( - helmChartsDir: string, - folder: string, - betaCharts: string[], -): Promise<{ - name: string - values: string - valuesSchema: string - icon?: string - chartVersion?: string - chartDescription?: string - readme: string - isBeta: boolean -} | null> { - const readme = await readChartReadme(helmChartsDir, folder) - - try { - const values = await safeReadTextFile(helmChartsDir, `${folder}/values.yaml`) - - let valuesSchema = '{}' - try { - const schemaContent = await safeReadTextFile(helmChartsDir, `${folder}/values.schema.json`) - valuesSchema = schemaContent || '{}' - } catch { - // values.schema.json is optional - } - - const chart = await safeReadTextFile(helmChartsDir, `${folder}/Chart.yaml`) - const chartMetadata = YAML.parse(chart) - - return { - name: folder, - values: values || '{}', - valuesSchema, - icon: chartMetadata?.icon, - chartVersion: chartMetadata?.version, - chartDescription: chartMetadata?.description, - readme, - isBeta: betaCharts.includes(folder), - } - } catch (error) { - debug(`Error while parsing ${folder}/Chart.yaml and ${folder}/values.yaml files : ${error.message}`) - return null - } -} - -/** - * Gets list of chart folders from directory, excluding system files - */ -async function getChartFolders(helmChartsDir: string): Promise { - const files = await readdir(helmChartsDir, 'utf-8') - const chartFolders = await Promise.all( - files.map(async (fileName) => { - try { - if (fileName.startsWith('.')) return null - const filePath = path.join(helmChartsDir, fileName) - if (!lstatSync(filePath).isDirectory()) return null - - try { - await safeReadTextFile(helmChartsDir, `${fileName}/Chart.yaml`) - return fileName - } catch { - await safeReadTextFile(helmChartsDir, `${fileName}/chart.yaml`) - return fileName - } - } catch { - return null - } - }), - ) - - return chartFolders.filter((folder): folder is string => folder !== null) -} - /** * Fetches workload catalog from a Git repository *