From 8f2d4491821543ecd079448f6065eaad44e254df Mon Sep 17 00:00:00 2001 From: caballeto Date: Thu, 16 Apr 2026 16:25:22 +0200 Subject: [PATCH] Fix transform schema mapping --- src/lib/yaml/transform.ts | 40 ++++++++++++++++++++--------------- test/yaml/applier.test.ts | 2 +- test/yaml/differ.test.ts | 4 ++-- test/yaml/idempotency.test.ts | 6 +++--- test/yaml/transform.test.ts | 26 +++++++++++------------ 5 files changed, 42 insertions(+), 36 deletions(-) diff --git a/src/lib/yaml/transform.ts b/src/lib/yaml/transform.ts index 9d3c5b4..562d9a4 100644 --- a/src/lib/yaml/transform.ts +++ b/src/lib/yaml/transform.ts @@ -8,22 +8,28 @@ import type { YamlResourceGroup, YamlWebhook, YamlTag, YamlEnvironment, YamlSecret, YamlAssertion, YamlAuth, YamlIncidentPolicy, YamlEscalationStep, YamlMatchRule, - ChannelType, YamlStatusPage, + YamlStatusPage, } from './schema.js' import type {ResolvedRefs} from './resolver.js' type Schemas = components['schemas'] -// ── Channel type discriminator mapping ───────────────────────────────── +// ── Discriminator wire-format derivation ─────────────────────────────── +// The API's Jackson @JsonSubTypes wire names follow a consistent rule: +// strip class suffix → PascalCase → snake_case +// This function derives the wire name algorithmically so we never +// maintain a hardcoded map that drifts from the API source of truth. -const CHANNEL_TYPE_DISCRIMINATOR: Record = { - slack: 'SlackChannelConfig', - discord: 'DiscordChannelConfig', - email: 'EmailChannelConfig', - webhook: 'WebhookChannelConfig', - pagerduty: 'PagerDutyChannelConfig', - opsgenie: 'OpsGenieChannelConfig', - teams: 'TeamsChannelConfig', +function pascalToSnake(s: string): string { + return s.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/([A-Z])([A-Z][a-z])/g, '$1_$2').toLowerCase() +} + +function assertionWireType(schemaName: string): string { + return pascalToSnake(schemaName.replace(/Assertion$/, '')) +} + +function authWireType(schemaName: string): string { + return pascalToSnake(schemaName.replace(/AuthConfig$/, '')) } // ── Tag ──────────────────────────────────────────────────────────────── @@ -55,8 +61,7 @@ export function toCreateSecretRequest(secret: YamlSecret): Schemas['CreateSecret // ── Alert Channel ────────────────────────────────────────────────────── export function toCreateAlertChannelRequest(channel: YamlAlertChannel): Schemas['CreateAlertChannelRequest'] { - const channelType = CHANNEL_TYPE_DISCRIMINATOR[channel.type] - const config = {channelType, ...channel.config} as Schemas['CreateAlertChannelRequest']['config'] + const config = {channelType: channel.type, ...channel.config} as Schemas['CreateAlertChannelRequest']['config'] return {name: channel.name, config} } @@ -189,21 +194,22 @@ export function toUpdateMonitorRequest( } export function toCreateAssertionRequest(a: YamlAssertion): Schemas['CreateAssertionRequest'] { - const config = {type: a.type, ...(a.config ?? {})} as Schemas['CreateAssertionRequest']['config'] + const config = {type: assertionWireType(a.type), ...(a.config ?? {})} as Schemas['CreateAssertionRequest']['config'] return {config, severity: a.severity} } export function toAuthConfig(auth: YamlAuth, refs: ResolvedRefs): Schemas['CreateMonitorRequest']['auth'] { const secretId = refs.resolve('secrets', auth.secret) ?? undefined + const wireType = authWireType(auth.type) switch (auth.type) { case 'BearerAuthConfig': - return {type: 'BearerAuthConfig', vaultSecretId: secretId ?? null} as Schemas['BearerAuthConfig'] + return {type: wireType, vaultSecretId: secretId ?? null} as Schemas['BearerAuthConfig'] case 'BasicAuthConfig': - return {type: 'BasicAuthConfig', vaultSecretId: secretId ?? null} as Schemas['BasicAuthConfig'] + return {type: wireType, vaultSecretId: secretId ?? null} as Schemas['BasicAuthConfig'] case 'ApiKeyAuthConfig': - return {type: 'ApiKeyAuthConfig', headerName: auth.headerName, vaultSecretId: secretId ?? null} as Schemas['ApiKeyAuthConfig'] + return {type: wireType, headerName: auth.headerName, vaultSecretId: secretId ?? null} as Schemas['ApiKeyAuthConfig'] case 'HeaderAuthConfig': - return {type: 'HeaderAuthConfig', headerName: auth.headerName, vaultSecretId: secretId ?? null} as Schemas['HeaderAuthConfig'] + return {type: wireType, headerName: auth.headerName, vaultSecretId: secretId ?? null} as Schemas['HeaderAuthConfig'] } } diff --git a/test/yaml/applier.test.ts b/test/yaml/applier.test.ts index 393e0bf..4f68a97 100644 --- a/test/yaml/applier.test.ts +++ b/test/yaml/applier.test.ts @@ -290,7 +290,7 @@ describe('applier', () => { '/api/v1/alert-channels/{id}', {params: {path: {id: 'ch-1'}}, body: expect.objectContaining({ name: 'slack', - config: expect.objectContaining({channelType: 'SlackChannelConfig', webhookUrl: 'url'}), + config: expect.objectContaining({channelType: 'slack', webhookUrl: 'url'}), })}, ) }) diff --git a/test/yaml/differ.test.ts b/test/yaml/differ.test.ts index 0076d45..5b712d2 100644 --- a/test/yaml/differ.test.ts +++ b/test/yaml/differ.test.ts @@ -644,7 +644,7 @@ describe('differ', () => { refs.set('secrets', 'creds', {id: 'sec-1', refKey: 'creds', raw: {}}) refs.set('monitors', 'M', {id: 'mon-1', refKey: 'M', raw: { name: 'M', type: 'HTTP', - auth: {type: 'BearerAuthConfig', vaultSecretId: 'sec-1'}, + auth: {type: 'bearer', vaultSecretId: 'sec-1'}, config: {url: 'https://x.com', method: 'GET'}, }}) const config: DevhelmConfig = { @@ -663,7 +663,7 @@ describe('differ', () => { refs.set('secrets', 'token', {id: 'sec-1', refKey: 'token', raw: {}}) refs.set('monitors', 'M', {id: 'mon-1', refKey: 'M', raw: { name: 'M', type: 'HTTP', - auth: {type: 'BearerAuthConfig', vaultSecretId: 'sec-1'}, + auth: {type: 'bearer', vaultSecretId: 'sec-1'}, config: {url: 'https://x.com', method: 'GET'}, }}) const config: DevhelmConfig = { diff --git a/test/yaml/idempotency.test.ts b/test/yaml/idempotency.test.ts index 203056a..30a450a 100644 --- a/test/yaml/idempotency.test.ts +++ b/test/yaml/idempotency.test.ts @@ -66,7 +66,7 @@ describe('idempotency', () => { }) it('same YAML + same API state for alert channels (hash match) → zero changes', () => { - const channelConfig = {channelType: 'SlackChannelConfig', webhookUrl: 'https://hooks.slack.com/test'} + const channelConfig = {channelType: 'slack', webhookUrl: 'https://hooks.slack.com/test'} const configHash = sha256Hex(stableStringify(channelConfig)) const config: DevhelmConfig = { @@ -87,7 +87,7 @@ describe('idempotency', () => { }) it('alert channel config change (different hash) → one update', () => { - const oldConfig = {channelType: 'SlackChannelConfig', webhookUrl: 'https://hooks.slack.com/old'} + const oldConfig = {channelType: 'slack', webhookUrl: 'https://hooks.slack.com/old'} const oldHash = sha256Hex(stableStringify(oldConfig)) const config: DevhelmConfig = { @@ -203,7 +203,7 @@ describe('idempotency', () => { it('full stack config unchanged → zero changes across all resource types', () => { const secretValue = 'my-api-token' - const channelConfig = {channelType: 'SlackChannelConfig', webhookUrl: 'https://hooks.slack.com/test'} + const channelConfig = {channelType: 'slack', webhookUrl: 'https://hooks.slack.com/test'} const channelHash = sha256Hex(stableStringify(channelConfig)) const config: DevhelmConfig = { diff --git a/test/yaml/transform.test.ts b/test/yaml/transform.test.ts index c5cb82c..99c1dd7 100644 --- a/test/yaml/transform.test.ts +++ b/test/yaml/transform.test.ts @@ -84,7 +84,7 @@ describe('transforms', () => { } const req = toCreateAlertChannelRequest(channel) expect(req.name).toBe('ops') - expect(req.config).toHaveProperty('channelType', 'SlackChannelConfig') + expect(req.config).toHaveProperty('channelType', 'slack') expect(req.config).toHaveProperty('webhookUrl', 'https://hooks.slack.com/test') }) @@ -94,19 +94,19 @@ describe('transforms', () => { config: {recipients: ['a@test.com']}, } const req = toCreateAlertChannelRequest(channel) - expect(req.config).toHaveProperty('channelType', 'EmailChannelConfig') + expect(req.config).toHaveProperty('channelType', 'email') expect(req.config).toHaveProperty('recipients', ['a@test.com']) }) it('transforms all 7 channel types', () => { const types = [ - {type: 'slack' as const, config: {webhookUrl: 'url'}, expected: 'SlackChannelConfig'}, - {type: 'discord' as const, config: {webhookUrl: 'url'}, expected: 'DiscordChannelConfig'}, - {type: 'email' as const, config: {recipients: ['a@b.com']}, expected: 'EmailChannelConfig'}, - {type: 'pagerduty' as const, config: {routingKey: 'key'}, expected: 'PagerDutyChannelConfig'}, - {type: 'opsgenie' as const, config: {apiKey: 'key'}, expected: 'OpsGenieChannelConfig'}, - {type: 'teams' as const, config: {webhookUrl: 'url'}, expected: 'TeamsChannelConfig'}, - {type: 'webhook' as const, config: {url: 'url'}, expected: 'WebhookChannelConfig'}, + {type: 'slack' as const, config: {webhookUrl: 'url'}, expected: 'slack'}, + {type: 'discord' as const, config: {webhookUrl: 'url'}, expected: 'discord'}, + {type: 'email' as const, config: {recipients: ['a@b.com']}, expected: 'email'}, + {type: 'pagerduty' as const, config: {routingKey: 'key'}, expected: 'pagerduty'}, + {type: 'opsgenie' as const, config: {apiKey: 'key'}, expected: 'opsgenie'}, + {type: 'teams' as const, config: {webhookUrl: 'url'}, expected: 'teams'}, + {type: 'webhook' as const, config: {url: 'url'}, expected: 'webhook'}, ] for (const {type, config, expected} of types) { const req = toCreateAlertChannelRequest({name: 'test', type, config}) @@ -337,10 +337,10 @@ describe('transforms', () => { it('transforms all 4 auth types', () => { const refs = refsWithChannels() const authTypes = [ - {type: 'BearerAuthConfig' as const, expectedType: 'BearerAuthConfig'}, - {type: 'BasicAuthConfig' as const, expectedType: 'BasicAuthConfig'}, - {type: 'ApiKeyAuthConfig' as const, expectedType: 'ApiKeyAuthConfig', headerName: 'X-Key'}, - {type: 'HeaderAuthConfig' as const, expectedType: 'HeaderAuthConfig', headerName: 'Authorization'}, + {type: 'BearerAuthConfig' as const, expectedType: 'bearer'}, + {type: 'BasicAuthConfig' as const, expectedType: 'basic'}, + {type: 'ApiKeyAuthConfig' as const, expectedType: 'api_key', headerName: 'X-Key'}, + {type: 'HeaderAuthConfig' as const, expectedType: 'header', headerName: 'Authorization'}, ] for (const at of authTypes) { const monitor: YamlMonitor = {