From 93c2cc80187076bb169f4c43c21907c4f0be0e64 Mon Sep 17 00:00:00 2001 From: ElderMatt <18527012+ElderMatt@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:45:23 +0100 Subject: [PATCH 01/10] feat: getall and create catalog --- src/api/v2/catalogs.ts | 25 ++++ src/api/v2/catalogs/{catalogId}.ts | 49 ++++++++ src/openapi/api.yaml | 184 +++++++++++++++++++++++++++++ src/openapi/catalog.yaml | 42 +++++++ src/openapi/definitions.yaml | 28 +++++ src/otomi-models.ts | 3 + src/otomi-stack.ts | 29 ++++- 7 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 src/api/v2/catalogs.ts create mode 100644 src/api/v2/catalogs/{catalogId}.ts create mode 100644 src/openapi/catalog.yaml diff --git a/src/api/v2/catalogs.ts b/src/api/v2/catalogs.ts new file mode 100644 index 00000000..9b4a4e15 --- /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:workloads') + +/** + * 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..67851391 --- /dev/null +++ b/src/api/v2/catalogs/{catalogId}.ts @@ -0,0 +1,49 @@ +import Debug from 'debug' +import { Response } from 'express' +import { OpenApiRequestExt } from 'src/otomi-models' + +const debug = Debug('otomi:api:v2:catalogs') + +/** + * GET /v2/catalogs/{catalogId} + * Get a specific catalog + */ +export const getCatalog = (req: OpenApiRequestExt, res: Response): void => { + const { catalogId } = req.params + debug(`getCatalog(${catalogId})`) + // const data = req.otomi.getAplCatalog(decodeURIComponent(catalogId)) + // res.json(data) +} + +/** + * PUT /v2/catalogs/{catalogId} + * Edit a catalog + */ +export const editCatalog = async (req: OpenApiRequestExt, res: Response): Promise => { + const { catalogId } = req.params + debug(`editCatalog(${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(`editCatalog(${catalogId}, patch)`) + // const data = await req.otomi.editAplCatalog(catalogId, req.body as AplCatalogRequest, true) + // res.json(data) +} + +/** + * DELETE /v2/catalogs/{catalogId} + * Delete a catalog + */ +export const deleteCatalog = async (req: OpenApiRequestExt, res: Response): Promise => { + const { catalogId } = req.params + debug(`deleteCatalog(${catalogId})`) + // await req.otomi.deleteAplCatalog(decodeURIComponent(catalogId)) + // res.json({}) +} diff --git a/src/openapi/api.yaml b/src/openapi/api.yaml index c18a6525..b837dcdd 100644 --- a/src/openapi/api.yaml +++ b/src/openapi/api.yaml @@ -1718,6 +1718,134 @@ 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: AplCatalogSpec + 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: AplCatalogSpec + 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/{name}': + put: + operationId: updateAplCatalog + x-eov-operation-handler: v2/catalogs + description: Update an existing app catalog configuration + x-aclSchema: AplCatalogSpec + parameters: + - name: name + in: path + required: true + description: Catalog name + schema: + type: string + 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' + delete: + operationId: deleteAplCatalog + x-eov-operation-handler: v2/catalogs + description: Delete a app catalog configuration + x-aclSchema: AplCatalogSpec + parameters: + - name: name + in: path + required: true + description: Catalog name + schema: + type: string + responses: + '200': + description: Successfully deleted app catalog + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/OtomiStackError' + +'/v2/catalogs/{catalogName}/charts': + get: + operationId: getChartsFromCatalog + x-eov-operation-handler: v2/catalogsCharts + description: Get app catalogs charts list + x-aclSchema: AplCatalogSpec + responses: + '200': + description: Successfully obtained app catalogs charts + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AplCatalogChartResponse' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/OpenApiValidationError' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/OtomiStackError' /v1/coderepos: get: @@ -2711,6 +2839,13 @@ components: required: true schema: type: string + catalogParams: + name: catalogName + in: path + description: Name of the catalog + required: true + schema: + type: string codeRepoParams: name: codeRepositoryName in: path @@ -2803,6 +2938,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 +3088,48 @@ components: - $ref: '#/components/schemas/AplBuild' - $ref: '#/components/schemas/aplTeamMetadata' - $ref: '#/components/schemas/aplStatusResponse' + AplCatalog: + type: object + 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 + AplCatalogChartRequest: + allOf: + - $ref: '#/components/schemas/AplCatalogChart' + - $ref: '#/components/schemas/aplMetadata' + 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..8f938f2a --- /dev/null +++ b/src/openapi/catalog.yaml @@ -0,0 +1,42 @@ +AplCatalogSpec: + type: object + 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 + x-acl: + platformAdmin: + - create-any + - read-any + - update-any + - delete-any + teamAdmin: + - read + teamMember: + - read + +AplCatalogChartSpec: + type: array + items: + type: object + properties: + name: + type: string + version: + type: string + chart: + type: object + required: + - name + - version diff --git a/src/openapi/definitions.yaml b/src/openapi/definitions.yaml index b378cfcd..7ceb7838 100644 --- a/src/openapi/definitions.yaml +++ b/src/openapi/definitions.yaml @@ -244,6 +244,34 @@ azureTenantId: title: Azure tenant id description: An Azure tenant id. Defaults to one found in metadata. type: string +appCatalog: + type: object + additionalProperties: false + properties: + name: + type: string + description: 'A lowercase name that starts with a letter and may contain dashes.' + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])$' + url: + type: string + description: 'Git repository URL for the catalog' + pattern: "^(https?|git|ssh)://.*\\.git$" + branch: + type: string + description: 'Git branch or tag to use' + default: 'main' + enabled: + type: boolean + description: 'Whether this catalog is active' + default: true + secretName: + type: string + description: 'Kubernetes secret name containing git credentials (for private repos)' + pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$' + required: + - name + - url + - branch backupTtl: default: 168h description: Expiration of the backup. Defaults to 7 days. diff --git a/src/otomi-models.ts b/src/otomi-models.ts index 76dad9bc..b8c2dd28 100644 --- a/src/otomi-models.ts +++ b/src/otomi-models.ts @@ -62,6 +62,8 @@ 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 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 +101,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 a14e57ac..4f74ea7e 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,8 @@ import { AplAIModelResponse, AplBuildRequest, AplBuildResponse, + AplCatalogRequest, + AplCatalogResponse, AplCodeRepoRequest, AplCodeRepoResponse, AplKind, @@ -829,6 +831,15 @@ export default class OtomiStack { return aplRecord } + async saveCatalog(data: AplCatalogRequest): 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,6 +1573,22 @@ export default class OtomiStack { } } + getAllAplCatalogs(): AplCatalogResponse[] { + const files = this.fileStore.getAllTeamResourcesByKind('AplCatalog') + return Array.from(files.values()) as AplCatalogResponse[] + } + + async createAplCatalog(data: AplCatalogRequest): Promise { + if (this.fileStore.getPlatformResourcesByKind('AplCatalog')) { + throw new AlreadyExists('AplCatalog name already exists') + } + + const aplRecord = await this.saveCatalog(data) + + await this.doDeployments([aplRecord], false) + return aplRecord.content as AplCatalogResponse + } + async getWorkloadCatalog(data: { url?: string teamId: string From f83dca45975ba533c2329d24c412ec0c2718a4a6 Mon Sep 17 00:00:00 2001 From: ElderMatt <18527012+ElderMatt@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:44:39 +0100 Subject: [PATCH 02/10] feat: catalog endpoints --- src/api/v2/catalogs.ts | 2 +- src/api/v2/catalogs/{catalogId}.ts | 32 ++--- src/fileStore/file-map.ts | 8 ++ src/fileStore/file-store.ts | 15 ++- src/openapi/api.yaml | 208 +++++++++++++++-------------- src/openapi/catalog.yaml | 38 ++++-- src/otomi-models.ts | 1 + src/otomi-stack.ts | 56 ++++++-- src/utils/workloadUtils.test.ts | 6 +- src/utils/workloadUtils.ts | 2 +- 10 files changed, 227 insertions(+), 141 deletions(-) diff --git a/src/api/v2/catalogs.ts b/src/api/v2/catalogs.ts index 9b4a4e15..a618d162 100644 --- a/src/api/v2/catalogs.ts +++ b/src/api/v2/catalogs.ts @@ -2,7 +2,7 @@ import Debug from 'debug' import { Response } from 'express' import { AplCatalogRequest, OpenApiRequestExt } from 'src/otomi-models' -const debug = Debug('otomi:api:v2:workloads') +const debug = Debug('otomi:api:v2:catalogs') /** * GET /v2/catalogs diff --git a/src/api/v2/catalogs/{catalogId}.ts b/src/api/v2/catalogs/{catalogId}.ts index 67851391..b776c858 100644 --- a/src/api/v2/catalogs/{catalogId}.ts +++ b/src/api/v2/catalogs/{catalogId}.ts @@ -1,6 +1,6 @@ import Debug from 'debug' import { Response } from 'express' -import { OpenApiRequestExt } from 'src/otomi-models' +import { AplCatalogRequest, OpenApiRequestExt } from 'src/otomi-models' const debug = Debug('otomi:api:v2:catalogs') @@ -8,22 +8,22 @@ const debug = Debug('otomi:api:v2:catalogs') * GET /v2/catalogs/{catalogId} * Get a specific catalog */ -export const getCatalog = (req: OpenApiRequestExt, res: Response): void => { +export const getAplCatalog = (req: OpenApiRequestExt, res: Response): void => { const { catalogId } = req.params - debug(`getCatalog(${catalogId})`) - // const data = req.otomi.getAplCatalog(decodeURIComponent(catalogId)) - // res.json(data) + debug(`getAplCatalog(${catalogId})`) + const data = req.otomi.getAplCatalog(decodeURIComponent(catalogId)) + res.json(data) } /** * PUT /v2/catalogs/{catalogId} * Edit a catalog */ -export const editCatalog = async (req: OpenApiRequestExt, res: Response): Promise => { +export const editAplCatalog = async (req: OpenApiRequestExt, res: Response): Promise => { const { catalogId } = req.params - debug(`editCatalog(${catalogId})`) - // const data = await req.otomi.editAplCatalog(decodeURIComponent(catalogId), req.body as AplCatalogRequest) - // res.json(data) + debug(`editAplCatalog(${catalogId})`) + const data = await req.otomi.editAplCatalog(decodeURIComponent(catalogId), req.body as AplCatalogRequest) + res.json(data) } /** @@ -32,18 +32,18 @@ export const editCatalog = async (req: OpenApiRequestExt, res: Response): Promis */ export const patchAplCatalog = async (req: OpenApiRequestExt, res: Response): Promise => { const { catalogId } = req.params - debug(`editCatalog(${catalogId}, patch)`) - // const data = await req.otomi.editAplCatalog(catalogId, req.body as AplCatalogRequest, true) - // res.json(data) + 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 deleteCatalog = async (req: OpenApiRequestExt, res: Response): Promise => { +export const deleteAplCatalog = async (req: OpenApiRequestExt, res: Response): Promise => { const { catalogId } = req.params - debug(`deleteCatalog(${catalogId})`) - // await req.otomi.deleteAplCatalog(decodeURIComponent(catalogId)) - // res.json({}) + debug(`deleteAplCatalog(${catalogId})`) + await req.otomi.deleteAplCatalog(decodeURIComponent(catalogId)) + res.json({}) } diff --git a/src/fileStore/file-map.ts b/src/fileStore/file-map.ts index 5c497155..e4bb764f 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/settings/*catalog.{yaml,yaml.dec}`, + pathTemplate: 'env/settings/catalog.yaml', + name: 'catalog', + }) + 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 b837dcdd..d4110ab1 100644 --- a/src/openapi/api.yaml +++ b/src/openapi/api.yaml @@ -1723,7 +1723,7 @@ paths: operationId: getAllAplCatalogs x-eov-operation-handler: v2/catalogs description: Get all catalogs - x-aclSchema: AplCatalogSpec + x-aclSchema: Catalog responses: '200': description: Successfully obtained app catalogs @@ -1739,113 +1739,113 @@ paths: application/json: schema: $ref: '#/components/schemas/OpenApiValidationError' - post: - operationId: createAplCatalog - x-eov-operation-handler: v2/catalogs - description: Create a app catalog - x-aclSchema: AplCatalogSpec - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/AplCatalogRequest' - description: catalog object - required: true - responses: - '200': - description: Successfully stored app catalog configuration + post: + operationId: createAplCatalog + x-eov-operation-handler: v2/catalogs + description: Create a app catalog + x-aclSchema: Catalog + requestBody: 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/{name}': - put: - operationId: updateAplCatalog - x-eov-operation-handler: v2/catalogs - description: Update an existing app catalog configuration - x-aclSchema: AplCatalogSpec - parameters: - - name: name - in: path + $ref: '#/components/schemas/AplCatalogRequest' + description: catalog object required: true - description: Catalog name - schema: - type: string - 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' - delete: - operationId: deleteAplCatalog - x-eov-operation-handler: v2/catalogs - description: Delete a app catalog configuration - x-aclSchema: AplCatalogSpec + 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: - - name: name - in: path - required: true - description: Catalog name - schema: - type: string - responses: - '200': - description: Successfully deleted app catalog - '400': - $ref: '#/components/responses/BadRequest' - '403': - $ref: '#/components/responses/Forbidden' - '409': - $ref: '#/components/responses/OtomiStackError' - -'/v2/catalogs/{catalogName}/charts': - get: - operationId: getChartsFromCatalog - x-eov-operation-handler: v2/catalogsCharts - description: Get app catalogs charts list - x-aclSchema: AplCatalogSpec - responses: - '200': - description: Successfully obtained app catalogs charts - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/AplCatalogChartResponse' - '400': - description: Bad Request + - $ref: '#/components/parameters/catalogParams' + get: + operationId: getAplCatalog + x-eov-operation-handler: v2/catalogs/{catalogId} + description: Get a specific catalog + x-aclSchema: Catalog + 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: Catalog + requestBody: content: application/json: schema: - $ref: '#/components/schemas/OpenApiValidationError' - '401': - description: Unauthorized + $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: Catalog + requestBody: content: application/json: schema: - $ref: '#/components/schemas/OtomiStackError' + $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: Catalog + responses: + '200': + description: Successfully deleted app catalog + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/OtomiStackError' /v1/coderepos: get: @@ -2840,9 +2840,9 @@ components: schema: type: string catalogParams: - name: catalogName + name: catalogId in: path - description: Name of the catalog + description: ID of the catalog required: true schema: type: string @@ -3090,6 +3090,14 @@ components: - $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 @@ -3312,6 +3320,8 @@ components: $ref: 'app.yaml#/AppList' Build: $ref: 'build.yaml#/Build' + Catalog: + $ref: 'catalog.yaml#/Catalog' Cloudtty: $ref: 'cloudtty.yaml#/Cloudtty' Cluster: diff --git a/src/openapi/catalog.yaml b/src/openapi/catalog.yaml index 8f938f2a..246b8411 100644 --- a/src/openapi/catalog.yaml +++ b/src/openapi/catalog.yaml @@ -1,3 +1,31 @@ +Catalog: + x-acl: + platformAdmin: + - create-any + - read-any + - update-any + - delete-any + teamAdmin: + - read + teamMembers: + - read + type: object + 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 + AplCatalogSpec: type: object properties: @@ -15,16 +43,6 @@ AplCatalogSpec: - name - repositoryUrl - branch - x-acl: - platformAdmin: - - create-any - - read-any - - update-any - - delete-any - teamAdmin: - - read - teamMember: - - read AplCatalogChartSpec: type: array diff --git a/src/otomi-models.ts b/src/otomi-models.ts index b8c2dd28..29383037 100644 --- a/src/otomi-models.ts +++ b/src/otomi-models.ts @@ -62,6 +62,7 @@ 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 Catalog = components['schemas']['Catalog'] export type AplCatalogRequest = components['schemas']['AplCatalogRequest'] export type AplCatalogResponse = components['schemas']['AplCatalogResponse'] // Derived setting models diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 4f74ea7e..422cb89a 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -52,6 +52,7 @@ import { Build, buildPlatformObject, buildTeamObject, + Catalog, Cloudtty, CodeRepo, Core, @@ -831,7 +832,7 @@ export default class OtomiStack { return aplRecord } - async saveCatalog(data: AplCatalogRequest): Promise { + async saveCatalog(data: AplPlatformObject): Promise { debug(`Saving catalog: ${data.metadata.name}`) const filePath = this.fileStore.setPlatformResource(data) @@ -1574,7 +1575,7 @@ export default class OtomiStack { } getAllAplCatalogs(): AplCatalogResponse[] { - const files = this.fileStore.getAllTeamResourcesByKind('AplCatalog') + const files = this.fileStore.getPlatformResourcesByKind('AplCatalog') return Array.from(files.values()) as AplCatalogResponse[] } @@ -1589,21 +1590,58 @@ export default class OtomiStack { 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 Catalog) + 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 + url: string + catalogName: string teamId: string + branch: string }): Promise<{ url: string; helmCharts: any; catalog: any }> { - const { url: clientUrl, teamId } = data + const { url: clientUrl, teamId, catalogName } = data const uuid = uuidv4() - const helmChartsDir = `/tmp/otomi/charts/${uuid}` - - const url = clientUrl || env?.HELM_CHART_CATALOG + const helmChartsDir = `/tmp/otomi/charts/${teamId}/${catalogName}-${uuid}` - if (!url) throw new OtomiError(400, 'Helm chart catalog URL is not set') + const url = clientUrl const { cluster } = this.getSettings(['cluster']) try { - const { helmCharts, catalog } = await fetchWorkloadCatalog(url, helmChartsDir, teamId, cluster?.domainSuffix) + const { helmCharts, catalog } = await fetchWorkloadCatalog( + url, + helmChartsDir, + teamId, + data.branch, + cluster?.domainSuffix, + ) return { url, helmCharts, catalog } } catch (error) { debug('Error fetching workload catalog') diff --git a/src/utils/workloadUtils.test.ts b/src/utils/workloadUtils.test.ts index 348a9a9f..fc0beb59 100644 --- a/src/utils/workloadUtils.test.ts +++ b/src/utils/workloadUtils.test.ts @@ -690,7 +690,7 @@ describe('fetchWorkloadCatalog', () => { throw new Error('missing') }) - const result = await fetchWorkloadCatalog(url, helmChartsDir, '1') + const result = await fetchWorkloadCatalog(url, helmChartsDir, '1', 'main') // 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, 'admin', 'main') // 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, 'admin', 'main') // Should include charts in the catalog expect(result.helmCharts).toEqual(['chart1']) diff --git a/src/utils/workloadUtils.ts b/src/utils/workloadUtils.ts index c9ef6aa4..3bba8217 100644 --- a/src/utils/workloadUtils.ts +++ b/src/utils/workloadUtils.ts @@ -335,6 +335,7 @@ export async function fetchWorkloadCatalog( url: string, helmChartsDir: string, teamId: string, + branch: string, clusterDomainSuffix?: string, ): Promise> { if (!existsSync(helmChartsDir)) mkdirSync(helmChartsDir, { recursive: true }) @@ -346,7 +347,6 @@ export async function fetchWorkloadCatalog( gitUrl = `${protocol}://${encodedUser}:${encodedPassword}@${bareUrl}` } const gitRepo = new chartRepo(helmChartsDir, gitUrl) - const branch = 'main' await gitRepo.clone(branch) const files = await readdir(helmChartsDir, 'utf-8') From 3d20ed9ff23ea6d8439fee164e670550e13bdd52 Mon Sep 17 00:00:00 2001 From: ElderMatt <18527012+ElderMatt@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:42:28 +0100 Subject: [PATCH 03/10] fix: create and filemap for catalogs --- src/fileStore/file-map.ts | 6 +++--- src/otomi-stack.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/fileStore/file-map.ts b/src/fileStore/file-map.ts index e4bb764f..d430f02a 100644 --- a/src/fileStore/file-map.ts +++ b/src/fileStore/file-map.ts @@ -94,9 +94,9 @@ export function getFileMaps(envDir: string): Map { maps.set('AplCatalog', { kind: 'AplCatalog', envDir, - pathGlob: `${envDir}/env/settings/*catalog.{yaml,yaml.dec}`, - pathTemplate: 'env/settings/catalog.yaml', - name: 'catalog', + pathGlob: `${envDir}/env/catalogs/*.{yaml,yaml.dec}`, + pathTemplate: 'env/catalogs/{name}.yaml', + name: 'catalogs', }) maps.set('AplBackupCollection', { diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 422cb89a..9398812f 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -1580,7 +1580,7 @@ export default class OtomiStack { } async createAplCatalog(data: AplCatalogRequest): Promise { - if (this.fileStore.getPlatformResourcesByKind('AplCatalog')) { + if (this.fileStore.getPlatformResource('AplCatalog', data.metadata.name)) { throw new AlreadyExists('AplCatalog name already exists') } From 42301b9a2e789a9cfc23aafea702d793f2a16188 Mon Sep 17 00:00:00 2001 From: ElderMatt <18527012+ElderMatt@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:15:47 +0100 Subject: [PATCH 04/10] fix: workload catalog --- src/otomi-stack.ts | 20 +++++++------------- src/utils/workloadUtils.ts | 4 +++- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 9398812f..b0f39a21 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -1622,26 +1622,20 @@ export default class OtomiStack { } async getWorkloadCatalog(data: { - url: string - catalogName: string + url?: string teamId: string - branch: string }): Promise<{ url: string; helmCharts: any; catalog: any }> { - const { url: clientUrl, teamId, catalogName } = data + const { url: clientUrl, teamId } = data const uuid = uuidv4() - const helmChartsDir = `/tmp/otomi/charts/${teamId}/${catalogName}-${uuid}` + const helmChartsDir = `/tmp/otomi/charts/${uuid}` - const url = clientUrl + const url = clientUrl || env?.HELM_CHART_CATALOG + + if (!url) throw new OtomiError(400, 'Helm chart catalog URL is not set') const { cluster } = this.getSettings(['cluster']) try { - const { helmCharts, catalog } = await fetchWorkloadCatalog( - url, - helmChartsDir, - teamId, - data.branch, - cluster?.domainSuffix, - ) + const { helmCharts, catalog } = await fetchWorkloadCatalog(url, helmChartsDir, teamId, cluster?.domainSuffix) return { url, helmCharts, catalog } } catch (error) { debug('Error fetching workload catalog') diff --git a/src/utils/workloadUtils.ts b/src/utils/workloadUtils.ts index 3bba8217..4a5b85b4 100644 --- a/src/utils/workloadUtils.ts +++ b/src/utils/workloadUtils.ts @@ -335,8 +335,8 @@ export async function fetchWorkloadCatalog( url: string, helmChartsDir: string, teamId: string, - branch: string, clusterDomainSuffix?: string, + branch?: string, ): Promise> { if (!existsSync(helmChartsDir)) mkdirSync(helmChartsDir, { recursive: true }) let gitUrl = url @@ -347,6 +347,8 @@ export async function fetchWorkloadCatalog( gitUrl = `${protocol}://${encodedUser}:${encodedPassword}@${bareUrl}` } const gitRepo = new chartRepo(helmChartsDir, gitUrl) + // eslint-disable-next-line no-param-reassign + branch = branch || 'main' await gitRepo.clone(branch) const files = await readdir(helmChartsDir, 'utf-8') From b82bbde8148e96264215f16d9d68fb8fdcd7e7f3 Mon Sep 17 00:00:00 2001 From: ElderMatt <18527012+ElderMatt@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:27:13 +0100 Subject: [PATCH 05/10] feat: get charts from byo catalog --- src/api/v2/catalogs/{catalogId}.ts | 22 ++++++++ src/api/v2/catalogs/{catalogId}/charts.ts | 16 ++++++ src/openapi/api.yaml | 28 +++++++++-- src/otomi-stack.ts | 28 +++++++++++ src/utils/workloadUtils.ts | 61 +++++++++++++++++++++++ 5 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 src/api/v2/catalogs/{catalogId}/charts.ts diff --git a/src/api/v2/catalogs/{catalogId}.ts b/src/api/v2/catalogs/{catalogId}.ts index b776c858..d75f85c4 100644 --- a/src/api/v2/catalogs/{catalogId}.ts +++ b/src/api/v2/catalogs/{catalogId}.ts @@ -47,3 +47,25 @@ export const deleteAplCatalog = async (req: OpenApiRequestExt, res: Response): P await req.otomi.deleteAplCatalog(decodeURIComponent(catalogId)) res.json({}) } + +// /** +// * 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 = req.otomi.getAplCatalogCharts(decodeURIComponent(catalogId)) +// res.json(data) +// } + +// /** +// * 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(`getAplCatalogsCharts(${catalogId})`) +// const data = req.otomi.getBYOWorkloadCatalog(req.body) +// res.json(data) +// } 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/openapi/api.yaml b/src/openapi/api.yaml index d4110ab1..2b95ee23 100644 --- a/src/openapi/api.yaml +++ b/src/openapi/api.yaml @@ -1847,6 +1847,30 @@ paths: '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: Catalog + 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: operationId: getAllCodeRepos @@ -3128,10 +3152,6 @@ components: required: - kind - spec - AplCatalogChartRequest: - allOf: - - $ref: '#/components/schemas/AplCatalogChart' - - $ref: '#/components/schemas/aplMetadata' AplCatalogChartResponse: type: object allOf: diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index b0f39a21..10901286 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -136,6 +136,7 @@ import { ensureSealedSecretMetadata, getSealedSecretsPEM, sealedSecretManifest } import { getKeycloakUsers, isValidUsername } from './utils/userUtils' import { defineClusterId, ObjectStorageClient } from './utils/wizardUtils' import { + fetchBYOWorkloadCatalog, fetchChartYaml, fetchWorkloadCatalog, isInteralGiteaURL, @@ -1645,6 +1646,33 @@ export default class OtomiStack { } } + async getAplCatalogCharts(name: string): Promise<{ url: string; helmCharts: any; catalog: any }> { + const catalog = this.getAplCatalog(name) + const { repositoryUrl, branch, name: catalogName } = catalog.spec + const charts = await this.getBYOWorkloadCatalog(repositoryUrl, branch, catalogName) + return charts + } + + 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 fetchBYOWorkloadCatalog(url, helmChartsDir, branch, 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 }) + } + } + async getHelmChartContent(url: string): Promise { return await fetchChartYaml(url) } diff --git a/src/utils/workloadUtils.ts b/src/utils/workloadUtils.ts index 4a5b85b4..e0d40638 100644 --- a/src/utils/workloadUtils.ts +++ b/src/utils/workloadUtils.ts @@ -402,3 +402,64 @@ export async function fetchWorkloadCatalog( if (!catalog.length) throwChartError(`There are no directories at '${url}'`) return { helmCharts, catalog } } + +export async function fetchBYOWorkloadCatalog( + url: string, + helmChartsDir: string, + branch: string, + clusterDomainSuffix?: string, +): Promise> { + 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}` + } + const gitRepo = new chartRepo(helmChartsDir, gitUrl) + + 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)) + + let betaCharts: string[] = [] + + 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) + 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}`) + } + } + if (!catalog.length) throwChartError(`There are no directories at '${url}'`) + return { helmCharts, catalog } +} From a2cf3955fa688043cf80b1712de8a2c689c32bc1 Mon Sep 17 00:00:00 2001 From: ElderMatt <18527012+ElderMatt@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:45:41 +0100 Subject: [PATCH 06/10] feat: refactor getting catalog charts --- src/otomi-stack.ts | 65 +++++----- src/utils/workloadUtils.ts | 235 +++++++++++++++++++------------------ 2 files changed, 159 insertions(+), 141 deletions(-) diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 10901286..7d360abd 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -136,7 +136,6 @@ import { ensureSealedSecretMetadata, getSealedSecretsPEM, sealedSecretManifest } import { getKeycloakUsers, isValidUsername } from './utils/userUtils' import { defineClusterId, ObjectStorageClient } from './utils/wizardUtils' import { - fetchBYOWorkloadCatalog, fetchChartYaml, fetchWorkloadCatalog, isInteralGiteaURL, @@ -1575,6 +1574,30 @@ 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[] @@ -1627,30 +1650,14 @@ export default class OtomiStack { teamId: string }): Promise<{ url: string; helmCharts: any; catalog: any }> { const { url: clientUrl, teamId } = data - const uuid = uuidv4() - const helmChartsDir = `/tmp/otomi/charts/${uuid}` - const url = clientUrl || env?.HELM_CHART_CATALOG if (!url) throw new OtomiError(400, 'Helm chart catalog URL is not set') - 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 }) - } - } + const uuid = uuidv4() + const helmChartsDir = `/tmp/otomi/charts/${uuid}` - async getAplCatalogCharts(name: string): Promise<{ url: string; helmCharts: any; catalog: any }> { - const catalog = this.getAplCatalog(name) - const { repositoryUrl, branch, name: catalogName } = catalog.spec - const charts = await this.getBYOWorkloadCatalog(repositoryUrl, branch, catalogName) - return charts + return this.fetchCatalog(url, helmChartsDir, 'main', teamId) } async getBYOWorkloadCatalog( @@ -1661,16 +1668,14 @@ export default class OtomiStack { const uuid = uuidv4() const helmChartsDir = `/tmp/otomi/charts/${catalogName}/${branch}/charts/${uuid}` - const { cluster } = this.getSettings(['cluster']) - try { - const { helmCharts, catalog } = await fetchBYOWorkloadCatalog(url, helmChartsDir, branch, 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 }> { + const catalog = this.getAplCatalog(name) + const { repositoryUrl, branch, name: catalogName } = catalog.spec + const charts = await this.getBYOWorkloadCatalog(repositoryUrl, branch, catalogName) + return charts } async getHelmChartContent(url: string): Promise { diff --git a/src/utils/workloadUtils.ts b/src/utils/workloadUtils.ts index e0d40638..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,135 +325,154 @@ export async function sparseCloneChart( return true } -export async function fetchWorkloadCatalog( - url: string, - helmChartsDir: string, - teamId: string, - clusterDomainSuffix?: string, - branch?: string, -): Promise> { - 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}` - } - const gitRepo = new chartRepo(helmChartsDir, gitUrl) - // eslint-disable-next-line no-param-reassign - branch = branch || 'main' - await gitRepo.clone(branch) +/** + * Encodes Git credentials into the URL for internal Gitea repositories + */ +function encodeGitCredentials(url: string, clusterDomainSuffix?: string): string { + if (!isInteralGiteaURL(url, clusterDomainSuffix)) return url - const files = await readdir(helmChartsDir, 'utf-8') - const filesToExclude = ['.git', '.gitignore', '.vscode', 'LICENSE', 'README.md'] - const folders = files.filter((f) => !filesToExclude.includes(f)) + 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}` +} - let rbac = {} - let betaCharts: string[] = [] +/** + * Reads and parses the rbac.yaml file from a helm charts directory + */ +async function readRbacConfig(helmChartsDir: string): Promise<{ rbac: Record; 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 + 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: [] } } - 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}`) +} + +/** + * 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 } - if (!catalog.length) throwChartError(`There are no directories at '${url}'`) - return { helmCharts, catalog } } -export async function fetchBYOWorkloadCatalog( +/** + * 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, - branch: 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}` - } - const gitRepo = new chartRepo(helmChartsDir, gitUrl) + // Clone repository + const gitUrl = encodeGitCredentials(url, clusterDomainSuffix) + const gitRepo = new chartRepo(helmChartsDir, gitUrl) 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 betaCharts: string[] = [] + // 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) - const catalogItem = { - name: folder, - values: values || '{}', - valuesSchema: valuesSchema || '{}', - icon: chartMetadata?.icon, - chartVersion: chartMetadata?.version, - chartDescription: chartMetadata?.description, - readme, - isBeta: betaCharts.includes(folder), - } + // Check RBAC access + if (!isChartAccessible(folder, rbac, teamId)) continue + + const catalogItem = await processChartFolder(helmChartsDir, folder, betaCharts) + if (catalogItem) { catalog.push(catalogItem) helmCharts.push(folder) - } catch (error) { - debug(`Error while parsing ${folder}/Chart.yaml and ${folder}/values.yaml files : ${error.message}`) } } + if (!catalog.length) throwChartError(`There are no directories at '${url}'`) + return { helmCharts, catalog } } From 7e5bdde9fc52d286ddfeeadf90bf2337ed7f61ab Mon Sep 17 00:00:00 2001 From: ElderMatt <18527012+ElderMatt@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:55:27 +0100 Subject: [PATCH 07/10] fix: tests --- src/utils/workloadUtils.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/utils/workloadUtils.test.ts b/src/utils/workloadUtils.test.ts index fc0beb59..365d7579 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', 'main') + 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', 'main') + 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', 'main') + const result = await fetchWorkloadCatalog(url, helmChartsDir, 'main', 'example.com', 'admin') // Should include charts in the catalog expect(result.helmCharts).toEqual(['chart1']) From 53f3ded88a875fd7e15ff0c7a9c8ecddf7d1853b Mon Sep 17 00:00:00 2001 From: ElderMatt <18527012+ElderMatt@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:10:24 +0100 Subject: [PATCH 08/10] feat: added tests for helper functions --- src/utils/workloadUtils.test.ts | 331 ++++++++++++++++++++++++++++++++ 1 file changed, 331 insertions(+) diff --git a/src/utils/workloadUtils.test.ts b/src/utils/workloadUtils.test.ts index 365d7579..f0a94e88 100644 --- a/src/utils/workloadUtils.test.ts +++ b/src/utils/workloadUtils.test.ts @@ -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']) + }) + }) +}) From b263c729ea4139ee03336757ac8780aeae569b5f Mon Sep 17 00:00:00 2001 From: ElderMatt <18527012+ElderMatt@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:05:22 +0100 Subject: [PATCH 09/10] feat: removed v1 object and additional properties --- src/openapi/api.yaml | 16 +++++++--------- src/openapi/catalog.yaml | 29 +---------------------------- src/otomi-models.ts | 2 +- src/otomi-stack.ts | 10 ++++++---- 4 files changed, 15 insertions(+), 42 deletions(-) diff --git a/src/openapi/api.yaml b/src/openapi/api.yaml index 2b95ee23..d790ee8d 100644 --- a/src/openapi/api.yaml +++ b/src/openapi/api.yaml @@ -1723,7 +1723,7 @@ paths: operationId: getAllAplCatalogs x-eov-operation-handler: v2/catalogs description: Get all catalogs - x-aclSchema: Catalog + x-aclSchema: AplCatalog responses: '200': description: Successfully obtained app catalogs @@ -1743,7 +1743,7 @@ paths: operationId: createAplCatalog x-eov-operation-handler: v2/catalogs description: Create a app catalog - x-aclSchema: Catalog + x-aclSchema: AplCatalog requestBody: content: application/json: @@ -1772,7 +1772,7 @@ paths: operationId: getAplCatalog x-eov-operation-handler: v2/catalogs/{catalogId} description: Get a specific catalog - x-aclSchema: Catalog + x-aclSchema: AplCatalog responses: '200': description: Successfully obtained app catalog @@ -1788,7 +1788,7 @@ paths: operationId: editAplCatalog x-eov-operation-handler: v2/catalogs/{catalogId} description: Update an existing app catalog configuration - x-aclSchema: Catalog + x-aclSchema: AplCatalog requestBody: content: application/json: @@ -1812,7 +1812,7 @@ paths: operationId: patchAplCatalog x-eov-operation-handler: v2/catalogs/{catalogId} description: Partially update a catalog - x-aclSchema: Catalog + x-aclSchema: AplCatalog requestBody: content: application/json: @@ -1836,7 +1836,7 @@ paths: operationId: deleteAplCatalog x-eov-operation-handler: v2/catalogs/{catalogId} description: Delete a app catalog configuration - x-aclSchema: Catalog + x-aclSchema: AplCatalog responses: '200': description: Successfully deleted app catalog @@ -1854,7 +1854,7 @@ paths: operationId: getAplCatalogsCharts x-eov-operation-handler: v2/catalogs/{catalogId}/charts description: Get charts for a specific catalog - x-aclSchema: Catalog + x-aclSchema: AplCatalog responses: '200': description: Successfully obtained app catalog charts @@ -3340,8 +3340,6 @@ components: $ref: 'app.yaml#/AppList' Build: $ref: 'build.yaml#/Build' - Catalog: - $ref: 'catalog.yaml#/Catalog' Cloudtty: $ref: 'cloudtty.yaml#/Cloudtty' Cluster: diff --git a/src/openapi/catalog.yaml b/src/openapi/catalog.yaml index 246b8411..f0578299 100644 --- a/src/openapi/catalog.yaml +++ b/src/openapi/catalog.yaml @@ -1,33 +1,6 @@ -Catalog: - x-acl: - platformAdmin: - - create-any - - read-any - - update-any - - delete-any - teamAdmin: - - read - teamMembers: - - read - type: object - 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 - AplCatalogSpec: type: object + additionalProperties: false properties: name: $ref: 'definitions.yaml#/idName' diff --git a/src/otomi-models.ts b/src/otomi-models.ts index 29383037..0980fd2a 100644 --- a/src/otomi-models.ts +++ b/src/otomi-models.ts @@ -62,7 +62,7 @@ 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 Catalog = components['schemas']['Catalog'] +export type AplCatalog = components['schemas']['AplCatalog'] export type AplCatalogRequest = components['schemas']['AplCatalogRequest'] export type AplCatalogResponse = components['schemas']['AplCatalogResponse'] // Derived setting models diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 7d360abd..6769bbb4 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -27,6 +27,7 @@ import { AplAIModelResponse, AplBuildRequest, AplBuildResponse, + AplCatalog, AplCatalogRequest, AplCatalogResponse, AplCodeRepoRequest, @@ -52,7 +53,6 @@ import { Build, buildPlatformObject, buildTeamObject, - Catalog, Cloudtty, CodeRepo, Core, @@ -1628,7 +1628,9 @@ export default class OtomiStack { patch = false, ): Promise { const existing = this.getAplCatalog(name) - const updatedSpec = patch ? merge(cloneDeep(existing.spec), data.spec) : ({ ...existing, ...data.spec } as Catalog) + 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) @@ -1671,11 +1673,11 @@ export default class OtomiStack { return this.fetchCatalog(url, helmChartsDir, branch) } - async getAplCatalogCharts(name: string): Promise<{ url: string; helmCharts: any; catalog: any }> { + 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 + return { ...charts, branch } } async getHelmChartContent(url: string): Promise { From e1a377d6404a7aba7c8c5639f2ae7a090be3c72f Mon Sep 17 00:00:00 2001 From: ElderMatt <18527012+ElderMatt@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:26:10 +0100 Subject: [PATCH 10/10] fix: remove commented code and definition appcatalog --- package-lock.json | 40 ++++++++++++++++++++++++++---- src/api/v2/catalogs/{catalogId}.ts | 22 ---------------- src/openapi/definitions.yaml | 28 --------------------- 3 files changed, 35 insertions(+), 55 deletions(-) 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/{catalogId}.ts b/src/api/v2/catalogs/{catalogId}.ts index d75f85c4..b776c858 100644 --- a/src/api/v2/catalogs/{catalogId}.ts +++ b/src/api/v2/catalogs/{catalogId}.ts @@ -47,25 +47,3 @@ export const deleteAplCatalog = async (req: OpenApiRequestExt, res: Response): P await req.otomi.deleteAplCatalog(decodeURIComponent(catalogId)) res.json({}) } - -// /** -// * 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 = req.otomi.getAplCatalogCharts(decodeURIComponent(catalogId)) -// res.json(data) -// } - -// /** -// * 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(`getAplCatalogsCharts(${catalogId})`) -// const data = req.otomi.getBYOWorkloadCatalog(req.body) -// res.json(data) -// } diff --git a/src/openapi/definitions.yaml b/src/openapi/definitions.yaml index 7ceb7838..b378cfcd 100644 --- a/src/openapi/definitions.yaml +++ b/src/openapi/definitions.yaml @@ -244,34 +244,6 @@ azureTenantId: title: Azure tenant id description: An Azure tenant id. Defaults to one found in metadata. type: string -appCatalog: - type: object - additionalProperties: false - properties: - name: - type: string - description: 'A lowercase name that starts with a letter and may contain dashes.' - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])$' - url: - type: string - description: 'Git repository URL for the catalog' - pattern: "^(https?|git|ssh)://.*\\.git$" - branch: - type: string - description: 'Git branch or tag to use' - default: 'main' - enabled: - type: boolean - description: 'Whether this catalog is active' - default: true - secretName: - type: string - description: 'Kubernetes secret name containing git credentials (for private repos)' - pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$' - required: - - name - - url - - branch backupTtl: default: 168h description: Expiration of the backup. Defaults to 7 days.