diff --git a/package-lock.json b/package-lock.json index 82a03787..a68d7c91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -196,6 +196,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2218,6 +2219,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -2651,6 +2653,7 @@ "integrity": "sha512-RsUFrSB0oQHEBnR8yarKIReUPwSu2ROpbjhdVKi4T/nQhMaS+TnIQPBwkMtb2r8A1KS2Hijw4D/4bV/XHoFQWw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=20" } @@ -2732,7 +2735,8 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.0.19.tgz", "integrity": "sha512-VYHtPnZt/Zd/ATbW3rtexWpBnHUohUrQOHff/2JBhsVgxOrksAxJnLAO43Q1ayLJBJUUwNVo+RU0sx0aaysZfg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-dart": { "version": "2.3.2", @@ -2872,14 +2876,16 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.14.tgz", "integrity": "sha512-2bf7n+kS92g+cMKV0wr9o/Oq9n8JzU7CcrB96gIh2GHgnF+0xDOqO2W/1KeFAqOfqosoOVE48t+4dnEMkkoJ2Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-html-symbol-entities": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-4.0.5.tgz", "integrity": "sha512-429alTD4cE0FIwpMucvSN35Ld87HCyuM8mF731KU5Rm4Je2SG6hmVx7nkBsLyrmH3sQukTcr1GaiZsiEg8svPA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-java": { "version": "5.0.12", @@ -3077,7 +3083,8 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz", "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-vue": { "version": "3.0.5", @@ -4300,6 +4307,7 @@ "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "30.2.0", "@jest/expect": "30.2.0", @@ -4835,6 +4843,7 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -6289,7 +6298,8 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/json5": { "version": "0.0.29", @@ -6371,6 +6381,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -6537,6 +6548,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -7092,6 +7104,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8120,6 +8133,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -10859,6 +10873,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10919,6 +10934,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -11045,6 +11061,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -11608,6 +11625,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -11694,6 +11712,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14318,6 +14337,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -15201,6 +15221,7 @@ "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 10.16.0" } @@ -16575,6 +16596,7 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -19806,6 +19828,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -21062,6 +21085,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -22181,6 +22205,7 @@ "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -24353,6 +24378,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -24575,6 +24601,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -24839,6 +24866,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -25018,6 +25046,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -25449,6 +25478,7 @@ "version": "7.5.7", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", "integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==", + "peer": true, "engines": { "node": ">=8.3.0" }, diff --git a/src/api/v2/catalogs.ts b/src/api/v2/catalogs.ts new file mode 100644 index 00000000..a618d162 --- /dev/null +++ b/src/api/v2/catalogs.ts @@ -0,0 +1,25 @@ +import Debug from 'debug' +import { Response } from 'express' +import { AplCatalogRequest, OpenApiRequestExt } from 'src/otomi-models' + +const debug = Debug('otomi:api:v2:catalogs') + +/** + * GET /v2/catalogs + * Get all catalogs + */ +export const getAllAplCatalogs = (req: OpenApiRequestExt, res: Response): void => { + debug('getAllCatalogs') + const v = req.otomi.getAllAplCatalogs() + res.json(v) +} + +/** + * POST /v2/catalogs + * Create a catalog + */ +export const createAplCatalog = async (req: OpenApiRequestExt, res: Response): Promise => { + debug('createCatalog') + const data = await req.otomi.createAplCatalog(req.body as AplCatalogRequest) + res.json(data) +} diff --git a/src/api/v2/catalogs/{catalogId}.ts b/src/api/v2/catalogs/{catalogId}.ts new file mode 100644 index 00000000..b776c858 --- /dev/null +++ b/src/api/v2/catalogs/{catalogId}.ts @@ -0,0 +1,49 @@ +import Debug from 'debug' +import { Response } from 'express' +import { AplCatalogRequest, OpenApiRequestExt } from 'src/otomi-models' + +const debug = Debug('otomi:api:v2:catalogs') + +/** + * GET /v2/catalogs/{catalogId} + * Get a specific catalog + */ +export const getAplCatalog = (req: OpenApiRequestExt, res: Response): void => { + const { catalogId } = req.params + debug(`getAplCatalog(${catalogId})`) + const data = req.otomi.getAplCatalog(decodeURIComponent(catalogId)) + res.json(data) +} + +/** + * PUT /v2/catalogs/{catalogId} + * Edit a catalog + */ +export const editAplCatalog = async (req: OpenApiRequestExt, res: Response): Promise => { + const { catalogId } = req.params + debug(`editAplCatalog(${catalogId})`) + const data = await req.otomi.editAplCatalog(decodeURIComponent(catalogId), req.body as AplCatalogRequest) + res.json(data) +} + +/** + * PATCH /v2/catalogs/{catalogId} + * Partially update a catalog + */ +export const patchAplCatalog = async (req: OpenApiRequestExt, res: Response): Promise => { + const { catalogId } = req.params + debug(`patchAplCatalog(${catalogId})`) + const data = await req.otomi.editAplCatalog(decodeURIComponent(catalogId), req.body as AplCatalogRequest, true) + res.json(data) +} + +/** + * DELETE /v2/catalogs/{catalogId} + * Delete a catalog + */ +export const deleteAplCatalog = async (req: OpenApiRequestExt, res: Response): Promise => { + const { catalogId } = req.params + debug(`deleteAplCatalog(${catalogId})`) + await req.otomi.deleteAplCatalog(decodeURIComponent(catalogId)) + res.json({}) +} diff --git a/src/api/v2/catalogs/{catalogId}/charts.ts b/src/api/v2/catalogs/{catalogId}/charts.ts new file mode 100644 index 00000000..dee8626f --- /dev/null +++ b/src/api/v2/catalogs/{catalogId}/charts.ts @@ -0,0 +1,16 @@ +import Debug from 'debug' +import { Response } from 'express' +import { OpenApiRequestExt } from 'src/otomi-models' + +const debug = Debug('otomi:api:v2:catalogs:charts') + +/** + * GET /v2/catalogs/{catalogId}/charts + * Get charts for a specific catalog + */ +export const getAplCatalogsCharts = async (req: OpenApiRequestExt, res: Response): Promise => { + const { catalogId } = req.params + debug(`getAplCatalog(${catalogId})`) + const data = await req.otomi.getAplCatalogCharts(decodeURIComponent(catalogId)) + res.json(data) +} diff --git a/src/fileStore/file-map.ts b/src/fileStore/file-map.ts index 5c497155..d430f02a 100644 --- a/src/fileStore/file-map.ts +++ b/src/fileStore/file-map.ts @@ -91,6 +91,14 @@ export function getFileMaps(envDir: string): Map { name: 'otomi', }) + maps.set('AplCatalog', { + kind: 'AplCatalog', + envDir, + pathGlob: `${envDir}/env/catalogs/*.{yaml,yaml.dec}`, + pathTemplate: 'env/catalogs/{name}.yaml', + name: 'catalogs', + }) + maps.set('AplBackupCollection', { kind: 'AplBackupCollection', envDir, diff --git a/src/fileStore/file-store.ts b/src/fileStore/file-store.ts index 64de3fa3..99dd1987 100644 --- a/src/fileStore/file-store.ts +++ b/src/fileStore/file-store.ts @@ -167,14 +167,25 @@ export class FileStore { return filePath } + deleteTeamResource(kind: AplKind, teamId: string, name: string): string { + const filePath = getResourceFilePath(kind, name, teamId) + this.store.delete(filePath) + return filePath + } + + getPlatformResource(kind: AplKind, name: string): AplObject | undefined { + const filePath = getResourceFilePath(kind, name) + return this.store.get(filePath) + } + setPlatformResource(aplPlatformObject: AplPlatformObject): string { const filePath = getResourceFilePath(aplPlatformObject.kind, aplPlatformObject.metadata.name) this.store.set(filePath, aplPlatformObject) return filePath } - deleteTeamResource(kind: AplKind, teamId: string, name: string): string { - const filePath = getResourceFilePath(kind, name, teamId) + deletePlatformResource(kind: AplKind, name: string): string { + const filePath = getResourceFilePath(kind, name) this.store.delete(filePath) return filePath } diff --git a/src/openapi/api.yaml b/src/openapi/api.yaml index 301e3d26..f72a66c7 100644 --- a/src/openapi/api.yaml +++ b/src/openapi/api.yaml @@ -1718,6 +1718,158 @@ paths: $ref: '#/components/schemas/User/properties/id' teams: $ref: '#/components/schemas/User/properties/teams' + /v2/catalogs: + get: + operationId: getAllAplCatalogs + x-eov-operation-handler: v2/catalogs + description: Get all catalogs + x-aclSchema: AplCatalog + responses: + '200': + description: Successfully obtained app catalogs + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AplCatalogResponse' + '400': + $ref: '#/components/responses/BadRequest' + content: + application/json: + schema: + $ref: '#/components/schemas/OpenApiValidationError' + post: + operationId: createAplCatalog + x-eov-operation-handler: v2/catalogs + description: Create a app catalog + x-aclSchema: AplCatalog + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AplCatalogRequest' + description: catalog object + required: true + responses: + '200': + description: Successfully stored app catalog configuration + content: + application/json: + schema: + $ref: '#/components/schemas/AplCatalogResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/OtomiStackError' + + '/v2/catalogs/{catalogId}': + parameters: + - $ref: '#/components/parameters/catalogParams' + get: + operationId: getAplCatalog + x-eov-operation-handler: v2/catalogs/{catalogId} + description: Get a specific catalog + x-aclSchema: AplCatalog + responses: + '200': + description: Successfully obtained app catalog + content: + application/json: + schema: + $ref: '#/components/schemas/AplCatalogResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + put: + operationId: editAplCatalog + x-eov-operation-handler: v2/catalogs/{catalogId} + description: Update an existing app catalog configuration + x-aclSchema: AplCatalog + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AplCatalogRequest' + required: true + responses: + '200': + description: Successfully updated app catalog + content: + application/json: + schema: + $ref: '#/components/schemas/AplCatalogResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/OtomiStackError' + patch: + operationId: patchAplCatalog + x-eov-operation-handler: v2/catalogs/{catalogId} + description: Partially update a catalog + x-aclSchema: AplCatalog + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AplCatalogRequest' + required: true + responses: + '200': + description: Successfully patched app catalog + content: + application/json: + schema: + $ref: '#/components/schemas/AplCatalogResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/OtomiStackError' + delete: + operationId: deleteAplCatalog + x-eov-operation-handler: v2/catalogs/{catalogId} + description: Delete a app catalog configuration + x-aclSchema: AplCatalog + responses: + '200': + description: Successfully deleted app catalog + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/OtomiStackError' + + '/v2/catalogs/{catalogId}/charts': + parameters: + - $ref: '#/components/parameters/catalogParams' + get: + operationId: getAplCatalogsCharts + x-eov-operation-handler: v2/catalogs/{catalogId}/charts + description: Get charts for a specific catalog + x-aclSchema: AplCatalog + responses: + '200': + description: Successfully obtained app catalog charts + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AplCatalogChartResponse' + '400': + $ref: '#/components/responses/BadRequest' + content: + application/json: + schema: + $ref: '#/components/schemas/OpenApiValidationError' /v1/coderepos: get: @@ -2711,6 +2863,13 @@ components: required: true schema: type: string + catalogParams: + name: catalogId + in: path + description: ID of the catalog + required: true + schema: + type: string codeRepoParams: name: codeRepositoryName in: path @@ -2803,6 +2962,13 @@ components: application/json: schema: $ref: '#/components/schemas/OtomiStackError' + Forbidden: + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/OtomiStackError' + OtomiStackError: description: Resource already exists content: @@ -2946,6 +3112,52 @@ components: - $ref: '#/components/schemas/AplBuild' - $ref: '#/components/schemas/aplTeamMetadata' - $ref: '#/components/schemas/aplStatusResponse' + AplCatalog: + type: object + x-acl: + platformAdmin: + - create-any + - read-any + - update-any + - delete-any + teamAdmin: [read-any] + teamMember: [read-any] + properties: + kind: + type: string + enum: [AplCatalog] + spec: + $ref: 'catalog.yaml#/AplCatalogSpec' + required: + - kind + - spec + AplCatalogRequest: + allOf: + - $ref: '#/components/schemas/AplCatalog' + - $ref: '#/components/schemas/aplMetadata' + AplCatalogResponse: + type: object + allOf: + - $ref: '#/components/schemas/AplCatalog' + - $ref: '#/components/schemas/aplMetadata' + - $ref: '#/components/schemas/aplStatusResponse' + AplCatalogChart: + type: object + properties: + kind: + type: string + enum: [AplCatalogChart] + spec: + $ref: 'catalog.yaml#/AplCatalogChartSpec' + required: + - kind + - spec + AplCatalogChartResponse: + type: object + allOf: + - $ref: '#/components/schemas/AplCatalogChart' + - $ref: '#/components/schemas/aplMetadata' + - $ref: '#/components/schemas/aplStatusResponse' AplCodeRepo: type: object properties: diff --git a/src/openapi/catalog.yaml b/src/openapi/catalog.yaml new file mode 100644 index 00000000..f0578299 --- /dev/null +++ b/src/openapi/catalog.yaml @@ -0,0 +1,33 @@ +AplCatalogSpec: + type: object + additionalProperties: false + properties: + name: + $ref: 'definitions.yaml#/idName' + repositoryUrl: + $ref: 'definitions.yaml#/repoUrl' + branch: + type: string + default: main + enabled: + type: boolean + default: true + required: + - name + - repositoryUrl + - branch + +AplCatalogChartSpec: + type: array + items: + type: object + properties: + name: + type: string + version: + type: string + chart: + type: object + required: + - name + - version diff --git a/src/otomi-models.ts b/src/otomi-models.ts index 76dad9bc..0980fd2a 100644 --- a/src/otomi-models.ts +++ b/src/otomi-models.ts @@ -62,6 +62,9 @@ export type AplPolicyRequest = components['schemas']['AplPolicyRequest'] export type AplPolicyResponse = components['schemas']['AplPolicyResponse'] export type Cloudtty = components['schemas']['Cloudtty'] export type TeamAuthz = components['schemas']['TeamAuthz'] +export type AplCatalog = components['schemas']['AplCatalog'] +export type AplCatalogRequest = components['schemas']['AplCatalogRequest'] +export type AplCatalogResponse = components['schemas']['AplCatalogResponse'] // Derived setting models export type Alerts = Settings['alerts'] export type Cluster = Settings['cluster'] @@ -99,6 +102,7 @@ export const APL_KINDS = [ 'AplApp', 'AplAlertSet', 'AplCluster', + 'AplCatalog', 'AplDatabase', 'AplDns', 'AplIngress', diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 5ec8c014..6551aeb1 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 } from '@linode/api-v4' @@ -27,6 +27,9 @@ import { AplAIModelResponse, AplBuildRequest, AplBuildResponse, + AplCatalog, + AplCatalogRequest, + AplCatalogResponse, AplCodeRepoRequest, AplCodeRepoResponse, AplKind, @@ -829,6 +832,15 @@ export default class OtomiStack { return aplRecord } + async saveCatalog(data: AplPlatformObject): Promise { + debug(`Saving catalog: ${data.metadata.name}`) + + const filePath = this.fileStore.setPlatformResource(data) + await this.git.writeFile(filePath, data) + + return { filePath, content: data } + } + async saveTeamSealedSecret(teamId: string, data: SealedSecretManifestRequest): Promise { debug(`Saving sealed secrets of team: ${teamId}`) const { metadata } = data @@ -1562,28 +1574,110 @@ export default class OtomiStack { } } + private async fetchCatalog( + url: string, + helmChartsDir: string, + branch: string, + teamId?: string, + ): Promise<{ url: string; helmCharts: any; catalog: any }> { + const { cluster } = this.getSettings(['cluster']) + try { + const { helmCharts, catalog } = await fetchWorkloadCatalog( + url, + helmChartsDir, + branch, + cluster?.domainSuffix, + teamId, + ) + return { url, helmCharts, catalog } + } catch (error) { + debug('Error fetching workload catalog') + throw new OtomiError(404, 'No helm chart catalog found!') + } finally { + if (existsSync(helmChartsDir)) rmSync(helmChartsDir, { recursive: true, force: true }) + } + } + + getAllAplCatalogs(): AplCatalogResponse[] { + const files = this.fileStore.getPlatformResourcesByKind('AplCatalog') + return Array.from(files.values()) as AplCatalogResponse[] + } + + async createAplCatalog(data: AplCatalogRequest): Promise { + if (this.fileStore.getPlatformResource('AplCatalog', data.metadata.name)) { + throw new AlreadyExists('AplCatalog name already exists') + } + + const aplRecord = await this.saveCatalog(data) + + await this.doDeployments([aplRecord], false) + return aplRecord.content as AplCatalogResponse + } + + getAplCatalog(name: string): AplCatalogResponse { + const catalog = this.fileStore.getPlatformResource('AplCatalog', name) + if (!catalog) { + throw new NotExistError(`AplCatalog with name: ${name} not found`) + } + return catalog as AplCatalogResponse + } + + async editAplCatalog( + name: string, + data: AplCatalogRequest | DeepPartial, + patch = false, + ): Promise { + const existing = this.getAplCatalog(name) + const updatedSpec = patch + ? merge(cloneDeep(existing.spec), data.spec) + : ({ ...existing, ...data.spec } as AplCatalog) + const platformObject = buildPlatformObject(existing.kind, existing.metadata.name, updatedSpec) + + const aplRecord = await this.saveCatalog(platformObject) + const catalogResponse = aplRecord.content as AplCatalogResponse + await this.doDeployment(aplRecord, false) + + return catalogResponse + } + + async deleteAplCatalog(name: string): Promise { + const filePath = this.fileStore.deletePlatformResource('AplCatalog', name) + + await this.git.removeFile(filePath) + await this.doDeleteDeployment([filePath]) + } + async getWorkloadCatalog(data: { url?: string teamId: string }): Promise<{ url: string; helmCharts: any; catalog: any }> { const { url: clientUrl, teamId } = data + const url = clientUrl || env?.HELM_CHART_CATALOG + + if (!url) throw new OtomiError(400, 'Helm chart catalog URL is not set') + const uuid = uuidv4() const helmChartsDir = `/tmp/otomi/charts/${uuid}` - const url = clientUrl || env?.HELM_CHART_CATALOG + return this.fetchCatalog(url, helmChartsDir, 'main', teamId) + } - if (!url) throw new OtomiError(400, 'Helm chart catalog URL is not set') + async getBYOWorkloadCatalog( + url: string, + branch: string, + catalogName: string, + ): Promise<{ url: string; helmCharts: any; catalog: any }> { + const uuid = uuidv4() + const helmChartsDir = `/tmp/otomi/charts/${catalogName}/${branch}/charts/${uuid}` - const { cluster } = this.getSettings(['cluster']) - try { - const { helmCharts, catalog } = await fetchWorkloadCatalog(url, helmChartsDir, teamId, cluster?.domainSuffix) - return { url, helmCharts, catalog } - } catch (error) { - debug('Error fetching workload catalog') - throw new OtomiError(404, 'No helm chart catalog found!') - } finally { - if (existsSync(helmChartsDir)) rmSync(helmChartsDir, { recursive: true, force: true }) - } + return this.fetchCatalog(url, helmChartsDir, branch) + } + + async getAplCatalogCharts(name: string): Promise<{ url: string; helmCharts: any; catalog: any; branch: string }> { + const catalog = this.getAplCatalog(name) + const { repositoryUrl, branch, name: catalogName } = catalog.spec + const charts = await this.getBYOWorkloadCatalog(repositoryUrl, branch, catalogName) + return { ...charts, branch } } async getHelmChartContent(url: string): Promise { diff --git a/src/utils/workloadUtils.test.ts b/src/utils/workloadUtils.test.ts index 348a9a9f..f0a94e88 100644 --- a/src/utils/workloadUtils.test.ts +++ b/src/utils/workloadUtils.test.ts @@ -643,7 +643,7 @@ describe('fetchWorkloadCatalog', () => { throw new Error('missing') }) - const result = await fetchWorkloadCatalog(url, helmChartsDir, 'admin', 'example.com') + const result = await fetchWorkloadCatalog(url, helmChartsDir, 'main', 'example.com', 'admin') expect(fs.mkdirSync).toHaveBeenCalledWith(helmChartsDir, { recursive: true }) expect(mockGit.clone).toHaveBeenCalledWith( @@ -690,7 +690,7 @@ describe('fetchWorkloadCatalog', () => { throw new Error('missing') }) - const result = await fetchWorkloadCatalog(url, helmChartsDir, '1') + const result = await fetchWorkloadCatalog(url, helmChartsDir, 'main', undefined, '1') // Only chart1 should be accessible to team-1 expect(result).toEqual({ @@ -748,7 +748,7 @@ describe('fetchWorkloadCatalog', () => { throw new Error('missing') }) - const result = await fetchWorkloadCatalog(url, helmChartsDir, 'admin') + const result = await fetchWorkloadCatalog(url, helmChartsDir, 'main', undefined, 'admin') // Should include chart1 with default README message expect(result.catalog[0].readme).toBe('There is no `README` for this chart.') @@ -781,7 +781,7 @@ describe('fetchWorkloadCatalog', () => { return Promise.reject(new Error(`File not found: ${filePath}`)) }) - const result = await fetchWorkloadCatalog(url, helmChartsDir, 'admin') + const result = await fetchWorkloadCatalog(url, helmChartsDir, 'main', 'example.com', 'admin') // Should include charts in the catalog expect(result.helmCharts).toEqual(['chart1']) @@ -997,3 +997,334 @@ describe('getBranchesAndTags', () => { }) }) }) + +// ---------------------------------------------------------------- +// Tests for helper functions used in fetchWorkloadCatalog +describe('Helper functions integration tests', () => { + beforeEach(() => { + jest.clearAllMocks() + process.env = { ...originalEnv, GIT_USER: 'git-user', GIT_PASSWORD: 'git-password' } + }) + + afterEach(() => { + process.env = originalEnv + }) + + describe('encodeGitCredentials (tested via fetchWorkloadCatalog)', () => { + test('encodes credentials for internal Gitea URLs', async () => { + const mockGit = { + clone: jest.fn().mockResolvedValue(undefined), + } + ;(simpleGit as jest.Mock).mockReturnValue(mockGit) + ;(fs.existsSync as jest.Mock).mockReturnValue(false) + ;(fsPromises.readdir as jest.Mock).mockResolvedValue([]) + + const internalGiteaUrl = 'https://gitea.cluster.local/otomi/charts.git' + const helmChartsDir = '/tmp/test' + + // Mock isInteralGiteaURL to return true + jest.spyOn(workloadUtils, 'isInteralGiteaURL').mockReturnValue(true) + + try { + await fetchWorkloadCatalog(internalGiteaUrl, helmChartsDir, 'main', 'cluster.local') + } catch (error) { + // Expected to throw because no charts found + } + + // Verify that clone was called with encoded credentials + expect(mockGit.clone).toHaveBeenCalledWith( + 'https://git-user:git-password@gitea.cluster.local/otomi/charts.git', + helmChartsDir, + ['--branch', 'main', '--single-branch'], + ) + }) + + test('does not encode credentials for non-Gitea URLs', async () => { + const mockGit = { + clone: jest.fn().mockResolvedValue(undefined), + } + ;(simpleGit as jest.Mock).mockReturnValue(mockGit) + ;(fs.existsSync as jest.Mock).mockReturnValue(false) + ;(fsPromises.readdir as jest.Mock).mockResolvedValue([]) + + const githubUrl = 'https://github.com/example/charts.git' + const helmChartsDir = '/tmp/test' + + jest.spyOn(workloadUtils, 'isInteralGiteaURL').mockReturnValue(false) + + try { + await fetchWorkloadCatalog(githubUrl, helmChartsDir, 'main') + } catch (error) { + // Expected to throw because no charts found + } + + // Verify that clone was called with original URL + expect(mockGit.clone).toHaveBeenCalledWith(githubUrl, helmChartsDir, ['--branch', 'main', '--single-branch']) + }) + }) + + describe('readRbacConfig (tested via fetchWorkloadCatalog)', () => { + test('parses rbac.yaml correctly when present', async () => { + const mockGit = { clone: jest.fn().mockResolvedValue(undefined) } + ;(simpleGit as jest.Mock).mockReturnValue(mockGit) + ;(fs.existsSync as jest.Mock).mockReturnValue(false) + ;(fsPromises.readdir as jest.Mock).mockResolvedValue(['chart1', 'rbac.yaml']) + + const rbacContent = YAML.stringify({ + rbac: { chart1: ['team-1'] }, + betaCharts: ['chart1'], + }) + + ;(fsExtra.readFile as unknown as jest.Mock).mockImplementation((filePath) => { + if (filePath.endsWith('rbac.yaml')) return Promise.resolve(rbacContent) + if (filePath.endsWith('chart1/values.yaml')) return Promise.resolve('key: value') + if (filePath.endsWith('chart1/Chart.yaml')) { + return Promise.resolve(YAML.stringify({ name: 'chart1', version: '1.0.0' })) + } + return Promise.reject(new Error('File not found')) + }) + + jest.spyOn(utils, 'safeReadTextFile').mockResolvedValue('# README') + + const result = await fetchWorkloadCatalog('https://example.com/charts.git', '/tmp/test', 'main', undefined, '1') + + // Should include chart1 and it should be marked as beta + expect(result.catalog[0].isBeta).toBe(true) + }) + + test('returns empty rbac when file is missing', async () => { + const mockGit = { clone: jest.fn().mockResolvedValue(undefined) } + ;(simpleGit as jest.Mock).mockReturnValue(mockGit) + ;(fs.existsSync as jest.Mock).mockReturnValue(false) + ;(fsPromises.readdir as jest.Mock).mockResolvedValue(['chart1']) + ;(fsExtra.readFile as unknown as jest.Mock).mockImplementation((filePath) => { + if (filePath.endsWith('rbac.yaml')) return Promise.reject(new Error('File not found')) + if (filePath.endsWith('chart1/values.yaml')) return Promise.resolve('key: value') + if (filePath.endsWith('chart1/Chart.yaml')) { + return Promise.resolve(YAML.stringify({ name: 'chart1', version: '1.0.0' })) + } + return Promise.reject(new Error('File not found')) + }) + + jest.spyOn(utils, 'safeReadTextFile').mockResolvedValue('# README') + + const result = await fetchWorkloadCatalog('https://example.com/charts.git', '/tmp/test', 'main') + + // Should include chart1 even without rbac.yaml + expect(result.helmCharts).toContain('chart1') + }) + }) + + describe('isChartAccessible (tested via fetchWorkloadCatalog)', () => { + test('allows access when no teamId provided (BYO catalog)', async () => { + const mockGit = { clone: jest.fn().mockResolvedValue(undefined) } + ;(simpleGit as jest.Mock).mockReturnValue(mockGit) + ;(fs.existsSync as jest.Mock).mockReturnValue(false) + ;(fsPromises.readdir as jest.Mock).mockResolvedValue(['restricted-chart']) + + const rbacContent = YAML.stringify({ + rbac: { 'restricted-chart': [] }, + betaCharts: [], + }) + + ;(fsExtra.readFile as unknown as jest.Mock).mockImplementation((filePath) => { + if (filePath.endsWith('rbac.yaml')) return Promise.resolve(rbacContent) + if (filePath.endsWith('restricted-chart/values.yaml')) return Promise.resolve('key: value') + if (filePath.endsWith('restricted-chart/Chart.yaml')) { + return Promise.resolve(YAML.stringify({ name: 'restricted-chart', version: '1.0.0' })) + } + return Promise.reject(new Error('File not found')) + }) + + jest.spyOn(utils, 'safeReadTextFile').mockResolvedValue('# README') + + // No teamId means BYO catalog - should include even restricted charts + const result = await fetchWorkloadCatalog('https://example.com/charts.git', '/tmp/test', 'main') + + expect(result.helmCharts).toContain('restricted-chart') + }) + + test('filters charts based on team RBAC permissions', async () => { + const mockGit = { clone: jest.fn().mockResolvedValue(undefined) } + ;(simpleGit as jest.Mock).mockReturnValue(mockGit) + ;(fs.existsSync as jest.Mock).mockReturnValue(false) + ;(fsPromises.readdir as jest.Mock).mockResolvedValue(['chart1', 'chart2']) + + const rbacContent = YAML.stringify({ + rbac: { + chart1: ['team-1'], + chart2: ['team-2'], + }, + betaCharts: [], + }) + + ;(fsExtra.readFile as unknown as jest.Mock).mockImplementation((filePath) => { + if (filePath.endsWith('rbac.yaml')) return Promise.resolve(rbacContent) + if (filePath.includes('chart1') && filePath.endsWith('values.yaml')) return Promise.resolve('key: value') + if (filePath.includes('chart1') && filePath.endsWith('Chart.yaml')) { + return Promise.resolve(YAML.stringify({ name: 'chart1', version: '1.0.0' })) + } + if (filePath.includes('chart2') && filePath.endsWith('values.yaml')) return Promise.resolve('key: value') + if (filePath.includes('chart2') && filePath.endsWith('Chart.yaml')) { + return Promise.resolve(YAML.stringify({ name: 'chart2', version: '1.0.0' })) + } + return Promise.reject(new Error('File not found')) + }) + + jest.spyOn(utils, 'safeReadTextFile').mockResolvedValue('# README') + + const result = await fetchWorkloadCatalog('https://example.com/charts.git', '/tmp/test', 'main', undefined, '1') + + // Should only include chart1 for team-1 + expect(result.helmCharts).toEqual(['chart1']) + expect(result.helmCharts).not.toContain('chart2') + }) + + test('admin team can access all charts', async () => { + const mockGit = { clone: jest.fn().mockResolvedValue(undefined) } + ;(simpleGit as jest.Mock).mockReturnValue(mockGit) + ;(fs.existsSync as jest.Mock).mockReturnValue(false) + ;(fsPromises.readdir as jest.Mock).mockResolvedValue(['restricted-chart']) + + const rbacContent = YAML.stringify({ + rbac: { 'restricted-chart': [] }, + betaCharts: [], + }) + + ;(fsExtra.readFile as unknown as jest.Mock).mockImplementation((filePath) => { + if (filePath.endsWith('rbac.yaml')) return Promise.resolve(rbacContent) + if (filePath.endsWith('restricted-chart/values.yaml')) return Promise.resolve('key: value') + if (filePath.endsWith('restricted-chart/Chart.yaml')) { + return Promise.resolve(YAML.stringify({ name: 'restricted-chart', version: '1.0.0' })) + } + return Promise.reject(new Error('File not found')) + }) + + jest.spyOn(utils, 'safeReadTextFile').mockResolvedValue('# README') + + const result = await fetchWorkloadCatalog( + 'https://example.com/charts.git', + '/tmp/test', + 'main', + undefined, + 'admin', + ) + + // Admin should see even restricted charts + expect(result.helmCharts).toContain('restricted-chart') + }) + }) + + describe('readChartReadme (tested via fetchWorkloadCatalog)', () => { + test('returns fallback message when README is missing', async () => { + const mockGit = { clone: jest.fn().mockResolvedValue(undefined) } + ;(simpleGit as jest.Mock).mockReturnValue(mockGit) + ;(fs.existsSync as jest.Mock).mockReturnValue(false) + ;(fsPromises.readdir as jest.Mock).mockResolvedValue(['chart1']) + ;(fsExtra.readFile as unknown as jest.Mock).mockImplementation((filePath) => { + if (filePath.endsWith('rbac.yaml')) return Promise.reject(new Error('Not found')) + if (filePath.endsWith('chart1/values.yaml')) return Promise.resolve('key: value') + if (filePath.endsWith('chart1/Chart.yaml')) { + return Promise.resolve(YAML.stringify({ name: 'chart1', version: '1.0.0' })) + } + return Promise.reject(new Error('File not found')) + }) + + jest.spyOn(utils, 'safeReadTextFile').mockRejectedValue(new Error('README not found')) + + const result = await fetchWorkloadCatalog('https://example.com/charts.git', '/tmp/test', 'main') + + expect(result.catalog[0].readme).toBe('There is no `README` for this chart.') + }) + }) + + describe('processChartFolder (tested via fetchWorkloadCatalog)', () => { + test('skips charts with missing required files', async () => { + const mockGit = { clone: jest.fn().mockResolvedValue(undefined) } + ;(simpleGit as jest.Mock).mockReturnValue(mockGit) + ;(fs.existsSync as jest.Mock).mockReturnValue(false) + ;(fsPromises.readdir as jest.Mock).mockResolvedValue(['broken-chart', 'valid-chart']) + ;(fsExtra.readFile as unknown as jest.Mock).mockImplementation((filePath) => { + if (filePath.endsWith('rbac.yaml')) return Promise.reject(new Error('Not found')) + // broken-chart missing files + if (filePath.includes('broken-chart')) return Promise.reject(new Error('File not found')) + // valid-chart has all files + if (filePath.includes('valid-chart') && filePath.endsWith('values.yaml')) return Promise.resolve('key: value') + if (filePath.includes('valid-chart') && filePath.endsWith('Chart.yaml')) { + return Promise.resolve(YAML.stringify({ name: 'valid-chart', version: '1.0.0' })) + } + return Promise.reject(new Error('File not found')) + }) + + jest.spyOn(utils, 'safeReadTextFile').mockResolvedValue('# README') + + const result = await fetchWorkloadCatalog('https://example.com/charts.git', '/tmp/test', 'main') + + // Should only include valid-chart, broken-chart should be skipped + expect(result.helmCharts).toEqual(['valid-chart']) + expect(result.catalog).toHaveLength(1) + }) + + test('handles optional valuesSchema gracefully', async () => { + const mockGit = { clone: jest.fn().mockResolvedValue(undefined) } + ;(simpleGit as jest.Mock).mockReturnValue(mockGit) + ;(fs.existsSync as jest.Mock).mockReturnValue(false) + ;(fsPromises.readdir as jest.Mock).mockResolvedValue(['chart1']) + ;(fsExtra.readFile as unknown as jest.Mock).mockImplementation((filePath) => { + if (filePath.endsWith('rbac.yaml')) return Promise.reject(new Error('Not found')) + if (filePath.endsWith('chart1/values.yaml')) return Promise.resolve('key: value') + if (filePath.endsWith('chart1/values.schema.json')) return Promise.reject(new Error('Not found')) + if (filePath.endsWith('chart1/Chart.yaml')) { + return Promise.resolve(YAML.stringify({ name: 'chart1', version: '1.0.0', description: 'Test' })) + } + return Promise.reject(new Error('File not found')) + }) + + jest.spyOn(utils, 'safeReadTextFile').mockResolvedValue('# README') + + const result = await fetchWorkloadCatalog('https://example.com/charts.git', '/tmp/test', 'main') + + // Should still include chart1 even without valuesSchema + expect(result.helmCharts).toContain('chart1') + expect(result.catalog[0].valuesSchema).toBe('{}') + }) + }) + + describe('getChartFolders (tested via fetchWorkloadCatalog)', () => { + test('excludes system files from chart list', async () => { + const mockGit = { clone: jest.fn().mockResolvedValue(undefined) } + ;(simpleGit as jest.Mock).mockReturnValue(mockGit) + ;(fs.existsSync as jest.Mock).mockReturnValue(false) + + // Include system files that should be filtered out + ;(fsPromises.readdir as jest.Mock).mockResolvedValue([ + '.git', + '.gitignore', + '.vscode', + 'LICENSE', + 'README.md', + 'chart1', + 'chart2', + ]) + ;(fsExtra.readFile as unknown as jest.Mock).mockImplementation((filePath) => { + if (filePath.endsWith('rbac.yaml')) return Promise.reject(new Error('Not found')) + if (filePath.includes('chart1') && filePath.endsWith('values.yaml')) return Promise.resolve('key: value') + if (filePath.includes('chart1') && filePath.endsWith('Chart.yaml')) { + return Promise.resolve(YAML.stringify({ name: 'chart1', version: '1.0.0' })) + } + if (filePath.includes('chart2') && filePath.endsWith('values.yaml')) return Promise.resolve('key: value') + if (filePath.includes('chart2') && filePath.endsWith('Chart.yaml')) { + return Promise.resolve(YAML.stringify({ name: 'chart2', version: '1.0.0' })) + } + return Promise.reject(new Error('File not found')) + }) + + jest.spyOn(utils, 'safeReadTextFile').mockResolvedValue('# README') + + const result = await fetchWorkloadCatalog('https://example.com/charts.git', '/tmp/test', 'main') + + // Should only include chart1 and chart2, not system files + expect(result.helmCharts).toEqual(['chart1', 'chart2']) + }) + }) +}) diff --git a/src/utils/workloadUtils.ts b/src/utils/workloadUtils.ts index c9ef6aa4..bb705ce6 100644 --- a/src/utils/workloadUtils.ts +++ b/src/utils/workloadUtils.ts @@ -155,8 +155,7 @@ export function findRevision(branches, tags, refAndPath) { } /** - * Reads the Chart.yaml file at the given path, updates (or sets) its icon field, - * and writes the updated content back to disk. + * 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. @@ -178,8 +177,7 @@ export async function updateChartIconInYaml(chartYamlPath: string, newIcon: stri * @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. - * If false, the key is set to []. - * If true, the key is set to null. + * */ export async function updateRbacForNewChart(sparsePath: string, chartKey: string, allowTeams: boolean): Promise { const rbacFilePath = `${sparsePath}/rbac.yaml` @@ -196,8 +194,6 @@ export async function updateRbacForNewChart(sparsePath: string, chartKey: string // Ensure the "rbac" section exists. if (!rbacData.rbac) rbacData.rbac = {} // Add the new chart entry if it doesn't exist. - // If allowTeams is false, set the value to an empty array ([]), - // otherwise (if true) set it to null. if (!(chartKey in rbacData.rbac)) rbacData.rbac[chartKey] = allowTeams ? null : [] // Stringify the updated YAML content and write it back. const newContent = YAML.stringify(rbacData) @@ -267,8 +263,6 @@ export class chartRepo { * @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. - * If false, the key is set to []. - * If true, the key is set to null. * @param clusterDomainSuffix - domainSuffix set in cluster settings, used to check if URL is an interal Gitea URL */ export async function sparseCloneChart( @@ -331,72 +325,154 @@ export async function sparseCloneChart( 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 + */ +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 readFile(`${helmChartsDir}/${folder}/values.yaml`, 'utf-8') + const valuesSchema = await readFile(`${helmChartsDir}/${folder}/values.schema.json`, 'utf-8').catch(() => null) + const chart = await readFile(`${helmChartsDir}/${folder}/Chart.yaml`, 'utf-8') + const chartMetadata = YAML.parse(chart) + + return { + name: folder, + values: values || '{}', + valuesSchema: 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 filesToExclude = ['.git', '.gitignore', '.vscode', 'LICENSE', 'README.md'] + return files.filter((f) => !filesToExclude.includes(f)) +} + +/** + * Fetches workload catalog from a Git repository + * + * @param url - Git repository URL + * @param helmChartsDir - Local directory to clone charts into + * @param branch - Git branch to checkout (defaults to 'main') + * @param clusterDomainSuffix - Cluster domain suffix for internal Gitea URL detection + * @param teamId - Optional team ID for RBAC filtering. If not provided, all charts are returned + */ export async function fetchWorkloadCatalog( url: string, helmChartsDir: string, - teamId: string, + branch: string = 'main', clusterDomainSuffix?: string, -): Promise> { + teamId?: string, +): Promise<{ helmCharts: string[]; catalog: any[] }> { + // Ensure directory exists if (!existsSync(helmChartsDir)) mkdirSync(helmChartsDir, { recursive: true }) - let gitUrl = url - if (isInteralGiteaURL(url, clusterDomainSuffix)) { - const [protocol, bareUrl] = url.split('://') - const encodedUser = encodeURIComponent(process.env.GIT_USER as string) - const encodedPassword = encodeURIComponent(process.env.GIT_PASSWORD as string) - gitUrl = `${protocol}://${encodedUser}:${encodedPassword}@${bareUrl}` - } + + // Clone repository + const gitUrl = encodeGitCredentials(url, clusterDomainSuffix) const gitRepo = new chartRepo(helmChartsDir, gitUrl) - const branch = 'main' await gitRepo.clone(branch) - const files = await readdir(helmChartsDir, 'utf-8') - const filesToExclude = ['.git', '.gitignore', '.vscode', 'LICENSE', 'README.md'] - const folders = files.filter((f) => !filesToExclude.includes(f)) + // Get chart folders + const folders = await getChartFolders(helmChartsDir) - let rbac = {} - let betaCharts: string[] = [] - try { - const r = await readFile(`${helmChartsDir}/rbac.yaml`, 'utf-8') - rbac = YAML.parse(r).rbac - if (YAML.parse(r)?.betaCharts) betaCharts = YAML.parse(r).betaCharts - } catch (error) { - debug(`Error while parsing rbac.yaml file : ${error.message}`) - } + // Read RBAC configuration + const { rbac, betaCharts } = await readRbacConfig(helmChartsDir) + + // Process each chart folder const catalog: any[] = [] const helmCharts: string[] = [] + for (const folder of folders) { - let readme = '' - try { - const chartReadme = await safeReadTextFile(helmChartsDir, `${folder}/README.md`) - readme = chartReadme - } catch (error) { - debug(`Error while parsing chart README.md file : ${error.message}`) - readme = 'There is no `README` for this chart.' - } - try { - const values = await readFile(`${helmChartsDir}/${folder}/values.yaml`, 'utf-8') - // valuesSchema is optional, hence we add a catch at the end to mitigate a loop break out - const valuesSchema = await readFile(`${helmChartsDir}/${folder}/values.schema.json`, 'utf-8').catch(() => null) - const chart = await readFile(`${helmChartsDir}/${folder}/Chart.yaml`, 'utf-8') - const chartMetadata = YAML.parse(chart) - if (!rbac[folder] || rbac[folder].includes(`team-${teamId}`) || teamId === 'admin') { - const catalogItem = { - name: folder, - values: values || '{}', - valuesSchema: valuesSchema || '{}', - icon: chartMetadata?.icon, - chartVersion: chartMetadata?.version, - chartDescription: chartMetadata?.description, - readme, - isBeta: betaCharts.includes(folder), - } - catalog.push(catalogItem) - helmCharts.push(folder) - } - } catch (error) { - debug(`Error while parsing ${folder}/Chart.yaml and ${folder}/values.yaml files : ${error.message}`) + // Check RBAC access + if (!isChartAccessible(folder, rbac, teamId)) continue + + const catalogItem = await processChartFolder(helmChartsDir, folder, betaCharts) + if (catalogItem) { + catalog.push(catalogItem) + helmCharts.push(folder) } } + if (!catalog.length) throwChartError(`There are no directories at '${url}'`) + return { helmCharts, catalog } }