From 4ce6f32baed85e5bbed3f07c85d961d68c5659ca Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Wed, 26 Aug 2026 11:07:42 +0200 Subject: [PATCH 1/2] feat: implement reserved service name validation for Apl services --- package-lock.json | 37 ++++---------------------- src/otomi-stack.test.ts | 57 +++++++++++++++++++++++++++++++++++++++++ src/otomi-stack.ts | 19 ++++++++++++++ src/validators.ts | 4 +++ 4 files changed, 85 insertions(+), 32 deletions(-) diff --git a/package-lock.json b/package-lock.json index 729e2880..52bfbe3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -233,7 +233,6 @@ "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", @@ -2752,7 +2751,6 @@ "integrity": "sha512-kLgLShnWADDVreKC63pBrWkcvxgZzFIfO34Jhx/SWfuOIA3cD8AXT+HjyuLfoGJ7mUb58hv2kUziKzEy4INb1w==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=22.18.0" } @@ -2834,8 +2832,7 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.1.2.tgz", "integrity": "sha512-+ylGoKdwZ2sVOCOnU2Eq5wDZx+RaVX3HoKyNHGGsFvhSw6IidQ6tH/mAPKBDofViHJoWCPNlklE0lTr6MDG3QA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@cspell/dict-dart": { "version": "2.3.2", @@ -2975,16 +2972,14 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.15.tgz", "integrity": "sha512-GJYnYKoD9fmo2OI0aySEGZOjThnx3upSUvV7mmqUu8oG+mGgzqm82P/f7OqsuvTaInZZwZbo+PwJQd/yHcyFIw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "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", - "peer": true + "license": "MIT" }, "node_modules/@cspell/dict-java": { "version": "5.0.12", @@ -3182,8 +3177,7 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz", "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@cspell/dict-vue": { "version": "3.0.5", @@ -4410,7 +4404,6 @@ "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/environment": "30.4.1", "@jest/expect": "30.4.1", @@ -4945,7 +4938,6 @@ "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", @@ -6444,8 +6436,7 @@ "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", - "peer": true + "license": "MIT" }, "node_modules/@types/jsonfile": { "version": "6.1.4", @@ -6520,7 +6511,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -6694,7 +6684,6 @@ "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.66.0", "@typescript-eslint/types": "8.66.0", @@ -7263,7 +7252,6 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8155,7 +8143,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9405,7 +9392,6 @@ "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -10890,7 +10876,6 @@ "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -11386,7 +11371,6 @@ "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", @@ -11473,7 +11457,6 @@ "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", @@ -14137,7 +14120,6 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -15028,7 +15010,6 @@ "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" } @@ -15910,7 +15891,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -19165,7 +19145,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -20350,7 +20329,6 @@ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -21488,7 +21466,6 @@ "integrity": "sha512-bxve7csK0/Txr++CkfrmV+X1r4jqiSOw2WsSad9E2S68R+ZfLBwDn8IceM8WfiOmKQIHgsQc1cNA8Dzg7U75pg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -23599,7 +23576,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -23810,7 +23786,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -24074,7 +24049,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -24687,7 +24661,6 @@ "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/otomi-stack.test.ts b/src/otomi-stack.test.ts index 09fa11f1..59b070c3 100644 --- a/src/otomi-stack.test.ts +++ b/src/otomi-stack.test.ts @@ -252,6 +252,63 @@ describe('Data validation', () => { expect(teamSettings).toBeDefined() expect(teamSettings?.metadata.name).toBe('short') }) + + describe('Reserved service names', () => { + const buildService = (name: string): AplServiceRequest => ({ + kind: 'AplTeamService', + metadata: { name, labels: { 'apl.io/teamId': teamId } }, + spec: {}, + }) + + it('rejects a reserved name on create', async () => { + await expect(otomiStack.createAplService(teamId, buildService('grafana'))).rejects.toMatchObject({ + code: 422, + }) + }) + + it('rejects a reserved name case-insensitively and trims whitespace', async () => { + await expect(otomiStack.createAplService(teamId, buildService(' Grafana '))).rejects.toMatchObject({ + code: 422, + }) + }) + + it('allows a non-reserved name on create', async () => { + await expect(otomiStack.createAplService(teamId, buildService('my-service'))).resolves.not.toThrow() + }) + + it('rejects a reserved name on update', async () => { + createTestService(otomiStack, teamId, 'alertmanager', { domain: 'alertmanager.example.com' }) + + await expect(otomiStack.editAplService(teamId, 'alertmanager', { spec: { port: 8080 } })).rejects.toMatchObject({ + code: 422, + }) + }) + + it('honours a custom RESERVED_SERVICE_NAMES value', async () => { + const previous = process.env.RESERVED_SERVICE_NAMES + process.env.RESERVED_SERVICE_NAMES = 'custom-reserved' + try { + await expect(otomiStack.createAplService(teamId, buildService('custom-reserved'))).rejects.toMatchObject({ + code: 422, + }) + await expect(otomiStack.createAplService(teamId, buildService('grafana'))).resolves.not.toThrow() + } finally { + if (previous === undefined) delete process.env.RESERVED_SERVICE_NAMES + else process.env.RESERVED_SERVICE_NAMES = previous + } + }) + + it('disables the check when RESERVED_SERVICE_NAMES is empty', async () => { + const previous = process.env.RESERVED_SERVICE_NAMES + process.env.RESERVED_SERVICE_NAMES = '' + try { + await expect(otomiStack.createAplService(teamId, buildService('grafana'))).resolves.not.toThrow() + } finally { + if (previous === undefined) delete process.env.RESERVED_SERVICE_NAMES + else process.env.RESERVED_SERVICE_NAMES = previous + } + }) + }) }) describe('Work with values', () => { diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 6f6362b7..88c3f9ee 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -126,6 +126,7 @@ import { OBJ_STORAGE_APPS, OBJECT_STORAGE_UI_EXCLUSIONS, PREINSTALLED_EXCLUDED_APPS, + RESERVED_SERVICE_NAMES, TTY_IMAGE_REPOSITORY, TTY_IMAGE_TAG, VERSIONS, @@ -210,8 +211,24 @@ const env = cleanEnv({ OBJECT_STORAGE_UI_EXCLUSIONS, TTY_IMAGE_REPOSITORY, TTY_IMAGE_TAG, + RESERVED_SERVICE_NAMES, }) +function getReservedServiceNames(): Set { + return new Set( + env.RESERVED_SERVICE_NAMES.split(',') + .map((name) => name.trim().toLowerCase()) + .filter((name) => name.length > 0), + ) +} + +function assertServiceNameNotReserved(name: string): void { + const reserved = getReservedServiceNames() + if (reserved.has(name.trim().toLowerCase())) { + throw new ValidationError(`Service name is reserved. Reserved names: ${Array.from(reserved).join(', ')}`) + } +} + export const rootPath = '/tmp/otomi/values' const clusterSettingsFilePath = 'env/settings/cluster.yaml' @@ -2101,6 +2118,7 @@ export default class OtomiStack { } async createAplService(teamId: string, data: AplServiceRequest): Promise { + assertServiceNameNotReserved(data.metadata.name) if (data.metadata.name.length < 2) throw new ValidationError('Service name must be at least 2 characters long') if (data.spec.cname?.tlsSecretName && data.spec.cname?.tlsSecretName.length < 2) throw new ValidationError('Secret name must be at least 2 characters long') @@ -2127,6 +2145,7 @@ export default class OtomiStack { data: DeepPartial, patch = false, ): Promise { + assertServiceNameNotReserved(name) const existing = this.getAplService(teamId, name) const updatedSpec = patch ? merge(cloneDeep(existing.spec), data.spec) : { ...existing.spec, ...data.spec } diff --git a/src/validators.ts b/src/validators.ts index 54aa95ca..65bf8a27 100644 --- a/src/validators.ts +++ b/src/validators.ts @@ -219,6 +219,10 @@ export const TTY_IMAGE_TAG = str({ desc: 'Tag of the cloud shell image', default: '1.2.8', }) +export const RESERVED_SERVICE_NAMES = str({ + desc: 'Comma-separated team service names that are rejected because they collide with per-team platform hostnames (-.)', + default: 'grafana,alertmanager,tekton', +}) const { env } = process export function cleanEnv(validators: { [K in keyof T]: ValidatorSpec }, options: CleanOptions = {}) { if (env.NODE_ENV === 'test') { From 39cb1a3cb0f1ade3a7fd294c32d519028a4386e8 Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Wed, 26 Aug 2026 11:32:42 +0200 Subject: [PATCH 2/2] fix: tests --- src/otomi-stack.test.ts | 25 ----------------- src/otomi-stack.ts | 18 +------------ src/utils/serviceUtils.test.ts | 49 ++++++++++++++++++++++++++++++++++ src/utils/serviceUtils.ts | 18 +++++++++++++ 4 files changed, 68 insertions(+), 42 deletions(-) create mode 100644 src/utils/serviceUtils.test.ts create mode 100644 src/utils/serviceUtils.ts diff --git a/src/otomi-stack.test.ts b/src/otomi-stack.test.ts index 59b070c3..36f49ce6 100644 --- a/src/otomi-stack.test.ts +++ b/src/otomi-stack.test.ts @@ -283,31 +283,6 @@ describe('Data validation', () => { code: 422, }) }) - - it('honours a custom RESERVED_SERVICE_NAMES value', async () => { - const previous = process.env.RESERVED_SERVICE_NAMES - process.env.RESERVED_SERVICE_NAMES = 'custom-reserved' - try { - await expect(otomiStack.createAplService(teamId, buildService('custom-reserved'))).rejects.toMatchObject({ - code: 422, - }) - await expect(otomiStack.createAplService(teamId, buildService('grafana'))).resolves.not.toThrow() - } finally { - if (previous === undefined) delete process.env.RESERVED_SERVICE_NAMES - else process.env.RESERVED_SERVICE_NAMES = previous - } - }) - - it('disables the check when RESERVED_SERVICE_NAMES is empty', async () => { - const previous = process.env.RESERVED_SERVICE_NAMES - process.env.RESERVED_SERVICE_NAMES = '' - try { - await expect(otomiStack.createAplService(teamId, buildService('grafana'))).resolves.not.toThrow() - } finally { - if (previous === undefined) delete process.env.RESERVED_SERVICE_NAMES - else process.env.RESERVED_SERVICE_NAMES = previous - } - }) }) }) diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 88c3f9ee..daea0bd4 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -102,6 +102,7 @@ import { getValuesSchema, removeBlankAttributes, } from 'src/utils' +import { assertServiceNameNotReserved } from 'src/utils/serviceUtils' import { deepQuote } from 'src/utils/yamlUtils' import { API_NAMESPACE, @@ -126,7 +127,6 @@ import { OBJ_STORAGE_APPS, OBJECT_STORAGE_UI_EXCLUSIONS, PREINSTALLED_EXCLUDED_APPS, - RESERVED_SERVICE_NAMES, TTY_IMAGE_REPOSITORY, TTY_IMAGE_TAG, VERSIONS, @@ -211,24 +211,8 @@ const env = cleanEnv({ OBJECT_STORAGE_UI_EXCLUSIONS, TTY_IMAGE_REPOSITORY, TTY_IMAGE_TAG, - RESERVED_SERVICE_NAMES, }) -function getReservedServiceNames(): Set { - return new Set( - env.RESERVED_SERVICE_NAMES.split(',') - .map((name) => name.trim().toLowerCase()) - .filter((name) => name.length > 0), - ) -} - -function assertServiceNameNotReserved(name: string): void { - const reserved = getReservedServiceNames() - if (reserved.has(name.trim().toLowerCase())) { - throw new ValidationError(`Service name is reserved. Reserved names: ${Array.from(reserved).join(', ')}`) - } -} - export const rootPath = '/tmp/otomi/values' const clusterSettingsFilePath = 'env/settings/cluster.yaml' diff --git a/src/utils/serviceUtils.test.ts b/src/utils/serviceUtils.test.ts new file mode 100644 index 00000000..46546586 --- /dev/null +++ b/src/utils/serviceUtils.test.ts @@ -0,0 +1,49 @@ +describe('assertServiceNameNotReserved', () => { + const previous = process.env.RESERVED_SERVICE_NAMES + + afterEach(() => { + if (previous === undefined) delete process.env.RESERVED_SERVICE_NAMES + else process.env.RESERVED_SERVICE_NAMES = previous + }) + + it('rejects the default reserved names', () => { + delete process.env.RESERVED_SERVICE_NAMES + let assertServiceNameNotReserved: typeof import('./serviceUtils').assertServiceNameNotReserved + jest.isolateModules(() => { + ;({ assertServiceNameNotReserved } = require('./serviceUtils')) + }) + + expect(() => assertServiceNameNotReserved('grafana')).toThrow('reserved') + expect(() => assertServiceNameNotReserved('my-service')).not.toThrow() + }) + + it('honours a custom RESERVED_SERVICE_NAMES value', () => { + process.env.RESERVED_SERVICE_NAMES = 'custom-reserved' + let assertServiceNameNotReserved: typeof import('./serviceUtils').assertServiceNameNotReserved + jest.isolateModules(() => { + ;({ assertServiceNameNotReserved } = require('./serviceUtils')) + }) + + expect(() => assertServiceNameNotReserved('custom-reserved')).toThrow('reserved') + expect(() => assertServiceNameNotReserved('grafana')).not.toThrow() + }) + + it('disables the check when RESERVED_SERVICE_NAMES is empty', () => { + process.env.RESERVED_SERVICE_NAMES = '' + let assertServiceNameNotReserved: typeof import('./serviceUtils').assertServiceNameNotReserved + jest.isolateModules(() => { + ;({ assertServiceNameNotReserved } = require('./serviceUtils')) + }) + + expect(() => assertServiceNameNotReserved('grafana')).not.toThrow() + }) + + it('trims whitespace and ignores case', () => { + let assertServiceNameNotReserved: typeof import('./serviceUtils').assertServiceNameNotReserved + jest.isolateModules(() => { + ;({ assertServiceNameNotReserved } = require('./serviceUtils')) + }) + + expect(() => assertServiceNameNotReserved(' Grafana ')).toThrow('reserved') + }) +}) diff --git a/src/utils/serviceUtils.ts b/src/utils/serviceUtils.ts new file mode 100644 index 00000000..a9f44526 --- /dev/null +++ b/src/utils/serviceUtils.ts @@ -0,0 +1,18 @@ +import { ValidationError } from 'src/error' +import { cleanEnv, RESERVED_SERVICE_NAMES } from 'src/validators' + +const env = cleanEnv({ RESERVED_SERVICE_NAMES }) + +const reservedServiceNames = new Set( + env.RESERVED_SERVICE_NAMES.split(',') + .map((name) => name.trim().toLowerCase()) + .filter((name) => name.length > 0), +) + +export function assertServiceNameNotReserved(name: string): void { + if (reservedServiceNames.has(name.trim().toLowerCase())) { + throw new ValidationError( + `Service name is reserved. Reserved names: ${Array.from(reservedServiceNames).join(', ')}`, + ) + } +}