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..36f49ce6 100644 --- a/src/otomi-stack.test.ts +++ b/src/otomi-stack.test.ts @@ -252,6 +252,38 @@ 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, + }) + }) + }) }) describe('Work with values', () => { diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 6f6362b7..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, @@ -2101,6 +2102,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 +2129,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/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(', ')}`, + ) + } +} 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') {