-
Notifications
You must be signed in to change notification settings - Fork 8
Phase 1: Make KDM an AI CLI tool for K8s #92
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { mergeLegacyConfig, notificationFromLegacy } from '../config/migration'; | ||
|
|
||
| describe('config migration', () => { | ||
| it('returns undefined for empty legacy input', () => { | ||
| expect(notificationFromLegacy({})).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('converts legacy notification keys into the new notification shape', () => { | ||
| const notifications = notificationFromLegacy({ | ||
| notification_service: 'email', | ||
| discord_webhook: 'https://discord.com/api/webhooks/123/token', | ||
| email_host: 'smtp.test.com', | ||
| email_port: 587, | ||
| email_user: 'user@test.com', | ||
| email_to: 'to@test.com', | ||
| email_password: 'secret', | ||
| alert_cooldown: 120, | ||
| }); | ||
|
|
||
| expect(notifications).toEqual({ | ||
| service: 'email', | ||
| discordWebhook: 'https://discord.com/api/webhooks/123/token', | ||
| emailHost: 'smtp.test.com', | ||
| emailPort: 587, | ||
| emailUser: 'user@test.com', | ||
| emailTo: 'to@test.com', | ||
| emailPassword: 'secret', | ||
| alertCooldown: 120, | ||
| }); | ||
| }); | ||
|
|
||
| it('prefers explicit new notification config over migrated legacy values', () => { | ||
| const config = mergeLegacyConfig({ | ||
| notification_service: 'discord', | ||
| discord_webhook: 'legacy-webhook', | ||
| notifications: { | ||
| service: 'email', | ||
| discordWebhook: 'new-webhook', | ||
| }, | ||
| }); | ||
|
|
||
| expect(config.notifications).toMatchObject({ | ||
| service: 'email', | ||
| discordWebhook: 'new-webhook', | ||
| }); | ||
| }); | ||
|
|
||
| it('ignores malformed legacy notification values', () => { | ||
| const notifications = notificationFromLegacy({ | ||
| notification_service: { invalid: 'object' } as never, | ||
| email_port: 'not-a-number' as never, | ||
| alert_cooldown: 'not-a-number' as never, | ||
| }); | ||
|
|
||
| expect(notifications).toEqual({ | ||
| service: 'none', | ||
| emailPort: undefined, | ||
| alertCooldown: undefined, | ||
| discordWebhook: undefined, | ||
| emailHost: undefined, | ||
| emailUser: undefined, | ||
| emailTo: undefined, | ||
| emailPassword: undefined, | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| vi.mock('conf', () => { | ||
| const mockConfigStore = new Map<string, any>(); | ||
| const mockConfInstance = { | ||
| get store() { | ||
| return Object.fromEntries(mockConfigStore.entries()); | ||
| }, | ||
| set: vi.fn((key, val) => { | ||
| mockConfigStore.set(key, val); | ||
| }), | ||
| get: vi.fn((key) => mockConfigStore.get(key)), | ||
| delete: vi.fn((key) => { | ||
| mockConfigStore.delete(key); | ||
| }), | ||
| clear: vi.fn(() => { | ||
| mockConfigStore.clear(); | ||
| }), | ||
| }; | ||
|
|
||
| (globalThis as any).mockConfigStore = mockConfigStore; | ||
|
|
||
| return { | ||
| default: class MockConf { | ||
| constructor() { | ||
| return mockConfInstance; | ||
| } | ||
| }, | ||
| }; | ||
| }); | ||
| import { | ||
| clearConfig, | ||
| getActiveFilters, | ||
| getCacheConfig, | ||
| getConfig, | ||
| getLegacyConfig, | ||
| getLegacyValue, | ||
| getOutputConfig, | ||
| setActiveFilters, | ||
| setAIConfig, | ||
| setCacheConfig, | ||
| setConfigValue, | ||
| setKubernetesConfig, | ||
| setLegacyValue, | ||
| setOutputConfig, | ||
| } from '../config/store'; | ||
|
|
||
| describe('config store', () => { | ||
| beforeEach(() => { | ||
| clearConfig(); | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| it('returns defaults for new configuration sections', () => { | ||
| expect(getActiveFilters()).toEqual([]); | ||
| expect(getCacheConfig()).toEqual({ type: 'file', enabled: true }); | ||
| expect(getOutputConfig()).toEqual({ format: 'text', language: 'english' }); | ||
| }); | ||
|
|
||
| it('stores and reads new typed configuration sections', () => { | ||
| setAIConfig({ | ||
| providers: [{ name: 'openai', model: 'gpt-4o' }], | ||
| defaultProvider: 'openai', | ||
| }); | ||
| setActiveFilters(['Pod', 'Deployment']); | ||
| setKubernetesConfig({ namespace: 'default', kubecontext: 'minikube' }); | ||
| setCacheConfig({ type: 'file', enabled: false, path: '/tmp/kdm-cache' }); | ||
| setOutputConfig({ format: 'json', language: 'english' }); | ||
|
|
||
| expect(getConfig()).toMatchObject({ | ||
| ai: { | ||
| providers: [{ name: 'openai', model: 'gpt-4o' }], | ||
| defaultProvider: 'openai', | ||
| }, | ||
| activeFilters: ['Pod', 'Deployment'], | ||
| kubernetes: { namespace: 'default', kubecontext: 'minikube' }, | ||
| cache: { type: 'file', enabled: false, path: '/tmp/kdm-cache' }, | ||
| output: { format: 'json', language: 'english' }, | ||
| }); | ||
| }); | ||
|
|
||
| it('keeps legacy notification reads compatible', () => { | ||
| setLegacyValue('notification_service', 'discord'); | ||
| setLegacyValue('discord_webhook', 'https://discord.com/api/webhooks/123/token'); | ||
| setLegacyValue('alert_cooldown', 300); | ||
|
|
||
| expect(getLegacyConfig()).toEqual({ | ||
| notification_service: 'discord', | ||
| discord_webhook: 'https://discord.com/api/webhooks/123/token', | ||
| alert_cooldown: 300, | ||
| email_host: undefined, | ||
| email_port: undefined, | ||
| email_user: undefined, | ||
| email_to: undefined, | ||
| email_password: undefined, | ||
| }); | ||
| }); | ||
|
|
||
| it('keeps legacy value reads compatible with migrated notification config', () => { | ||
| setConfigValue('notifications', { | ||
| service: 'email', | ||
| emailHost: 'smtp.test.com', | ||
| emailPort: 587, | ||
| emailUser: 'user@test.com', | ||
| emailTo: 'to@test.com', | ||
| }); | ||
|
|
||
| expect(getLegacyValue('notification_service')).toBe('email'); | ||
| expect(getLegacyValue('email_host')).toBe('smtp.test.com'); | ||
| expect(getLegacyValue('email_port')).toBe(587); | ||
| }); | ||
|
|
||
| it('returns safe legacy defaults for malformed notification values', () => { | ||
| setLegacyValue('notification_service', { invalid: 'object' } as never); | ||
| setLegacyValue('alert_cooldown', 'not-a-number' as never); | ||
|
|
||
| expect(getLegacyConfig()).toMatchObject({ | ||
| notification_service: 'none', | ||
| alert_cooldown: undefined, | ||
| }); | ||
| }); | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,7 +3,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | |||||||||||||||||||||
| vi.mock('conf', () => { | ||||||||||||||||||||||
| const mockConfigStore = new Map<string, any>(); | ||||||||||||||||||||||
| const mockConfInstance = { | ||||||||||||||||||||||
| store: {}, | ||||||||||||||||||||||
| get store() { | ||||||||||||||||||||||
| return Object.fromEntries(mockConfigStore.entries()); | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| set: vi.fn((key, val) => { | ||||||||||||||||||||||
| mockConfigStore.set(key, val); | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
|
|
@@ -30,7 +32,7 @@ vi.mock('conf', () => { | |||||||||||||||||||||
| }; | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import { getSMTPSettings, clearNotificationCredentials } from '../utils/config'; | ||||||||||||||||||||||
| import { getSMTPSettings, clearNotificationCredentials, setConfig } from '../utils/config'; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| describe('config utils', () => { | ||||||||||||||||||||||
| const originalEnv = process.env; | ||||||||||||||||||||||
|
|
@@ -89,4 +91,9 @@ describe('config utils', () => { | |||||||||||||||||||||
| settings = getSMTPSettings(); | ||||||||||||||||||||||
| expect(settings.auth.pass).toBe(''); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| it('should reject storing SMTP passwords in config', () => { | ||||||||||||||||||||||
| expect(() => setConfig('email_password', 'secret')).toThrow(/KDM_SMTP_PASSWORD/); | ||||||||||||||||||||||
| expect(mockConfInstance.set).not.toHaveBeenCalledWith('email_password', 'secret'); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
Comment on lines
+95
to
+98
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Strengthen the negative assertion to reject any The current check only blocks one exact value ( Suggested fix it('should reject storing SMTP passwords in config', () => {
expect(() => setConfig('email_password', 'secret')).toThrow(/KDM_SMTP_PASSWORD/);
- expect(mockConfInstance.set).not.toHaveBeenCalledWith('email_password', 'secret');
+ expect(
+ mockConfInstance.set.mock.calls.some(([key]: [string]) => key === 'email_password'),
+ ).toBe(false);
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import type { KDMConfig, LegacyNotificationConfig, NotificationConfig, StoredKDMConfig } from './schema'; | ||
|
|
||
| const isNotificationService = (value: unknown): value is NotificationConfig['service'] => | ||
| value === 'discord' || value === 'email' || value === 'none'; | ||
|
|
||
| const stringOrUndefined = (value: unknown) => (typeof value === 'string' ? value : undefined); | ||
| const numberOrUndefined = (value: unknown) => (typeof value === 'number' ? value : undefined); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate migrated numeric fields before accepting them. On Line 30 and Line 34, any numeric value is accepted, including negatives/out-of-range values. That can disable cooldown behavior downstream (e.g., negative Suggested fix-const numberOrUndefined = (value: unknown) => (typeof value === 'number' ? value : undefined);
+const finiteNumberOrUndefined = (value: unknown) =>
+ typeof value === 'number' && Number.isFinite(value) ? value : undefined;
+
+const emailPortOrUndefined = (value: unknown) => {
+ const n = finiteNumberOrUndefined(value);
+ return n !== undefined && Number.isInteger(n) && n >= 1 && n <= 65535 ? n : undefined;
+};
+
+const alertCooldownOrUndefined = (value: unknown) => {
+ const n = finiteNumberOrUndefined(value);
+ return n !== undefined && n >= 0 ? n : undefined;
+};
@@
- emailPort: numberOrUndefined(legacy.email_port),
+ emailPort: emailPortOrUndefined(legacy.email_port),
@@
- alertCooldown: numberOrUndefined(legacy.alert_cooldown),
+ alertCooldown: alertCooldownOrUndefined(legacy.alert_cooldown),Also applies to: 30-35 🤖 Prompt for AI Agents |
||
|
|
||
| const hasLegacyNotificationConfig = (config: LegacyNotificationConfig) => | ||
| config.notification_service !== undefined || | ||
| config.discord_webhook !== undefined || | ||
| config.email_host !== undefined || | ||
| config.email_port !== undefined || | ||
| config.email_user !== undefined || | ||
| config.email_to !== undefined || | ||
| config.email_password !== undefined || | ||
| config.alert_cooldown !== undefined; | ||
|
|
||
| export const notificationFromLegacy = ( | ||
| legacy: LegacyNotificationConfig, | ||
| ): NotificationConfig | undefined => { | ||
| if (!hasLegacyNotificationConfig(legacy)) { | ||
| return undefined; | ||
| } | ||
|
|
||
| return { | ||
| service: isNotificationService(legacy.notification_service) ? legacy.notification_service : 'none', | ||
| discordWebhook: stringOrUndefined(legacy.discord_webhook), | ||
| emailHost: stringOrUndefined(legacy.email_host), | ||
| emailPort: numberOrUndefined(legacy.email_port), | ||
| emailUser: stringOrUndefined(legacy.email_user), | ||
| emailTo: stringOrUndefined(legacy.email_to), | ||
| emailPassword: stringOrUndefined(legacy.email_password), | ||
| alertCooldown: numberOrUndefined(legacy.alert_cooldown), | ||
| }; | ||
| }; | ||
| export const mergeLegacyConfig = (stored: StoredKDMConfig): KDMConfig => { | ||
| const legacyNotifications = notificationFromLegacy(stored); | ||
|
|
||
| return { | ||
| ai: stored.ai, | ||
| activeFilters: stored.activeFilters, | ||
| kubernetes: stored.kubernetes, | ||
| cache: stored.cache, | ||
| output: stored.output, | ||
| notifications: { | ||
| ...legacyNotifications, | ||
| ...stored.notifications, | ||
| }, | ||
| }; | ||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.