Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 5 additions & 32 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions src/otomi-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment on lines +275 to +276
})

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', () => {
Expand Down
3 changes: 3 additions & 0 deletions src/otomi-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2101,6 +2102,7 @@ export default class OtomiStack {
}

async createAplService(teamId: string, data: AplServiceRequest): Promise<AplServiceResponse> {
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')
Expand All @@ -2127,6 +2129,7 @@ export default class OtomiStack {
data: DeepPartial<AplServiceRequest>,
patch = false,
): Promise<AplServiceResponse> {
assertServiceNameNotReserved(name)
const existing = this.getAplService(teamId, name)
const updatedSpec = patch ? merge(cloneDeep(existing.spec), data.spec) : { ...existing.spec, ...data.spec }

Expand Down
49 changes: 49 additions & 0 deletions src/utils/serviceUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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'))
})
Comment on lines +41 to +45

expect(() => assertServiceNameNotReserved(' Grafana ')).toThrow('reserved')
})
})
18 changes: 18 additions & 0 deletions src/utils/serviceUtils.ts
Original file line number Diff line number Diff line change
@@ -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(', ')}`,
)
}
}
4 changes: 4 additions & 0 deletions src/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<name>-<teamId>.<domainSuffix>)',
default: 'grafana,alertmanager,tekton',
})
const { env } = process
export function cleanEnv<T>(validators: { [K in keyof T]: ValidatorSpec<T[K]> }, options: CleanOptions<T> = {}) {
if (env.NODE_ENV === 'test') {
Expand Down
Loading