From 31f5a11e344d7e1c564f89f6b28eafb12cb5f11e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Birm=C3=A9?= Date: Wed, 11 Nov 2026 09:08:09 +0100 Subject: [PATCH] feat(core): add instance events command and getInstanceEvents Part of Eyevinn/osaas-deploy-manager#2146 Co-Authored-By: Claude --- packages/cli/readme.md | 1 + packages/cli/src/cli.ts | 1 + packages/cli/src/user/cmd.ts | 97 ++++++++++++++++ packages/cli/src/user/events.test.ts | 131 ++++++++++++++++++++++ packages/core/src/index.ts | 6 + packages/core/src/instance-events.test.ts | 62 ++++++++++ packages/core/src/instance-events.ts | 98 ++++++++++++++++ 7 files changed, 396 insertions(+) create mode 100644 packages/cli/src/user/events.test.ts create mode 100644 packages/core/src/instance-events.test.ts create mode 100644 packages/core/src/instance-events.ts diff --git a/packages/cli/readme.md b/packages/cli/readme.md index 657ee32..7354b1e 100644 --- a/packages/cli/readme.md +++ b/packages/cli/readme.md @@ -65,6 +65,7 @@ Commands: remove [options] Remove a service instance logs Get logs for a service instance restart Restart a service instance + events [options] Show lifecycle events (restarts, crashes, replacements) of a service instance get-instance-replicas Get the number of replicas for a service instance set-instance-replicas Set the number of replicas for a service instance service-access-token Generate a service access token for a service diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 83d4528..c99814f 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -56,6 +56,7 @@ cli.addCommand(cmdUser.cmdDescribe()); cli.addCommand(cmdUser.cmdRemove()); cli.addCommand(cmdUser.cmdLogs()); cli.addCommand(cmdUser.cmdRestart()); +cli.addCommand(cmdUser.cmdEvents()); cli.addCommand(cmdUser.cmdGetInstanceReplicas()); cli.addCommand(cmdUser.cmdSetInstanceReplicas()); cli.addCommand(cmdUser.cmdServiceAccessToken()); diff --git a/packages/cli/src/user/cmd.ts b/packages/cli/src/user/cmd.ts index df6fff4..cb23f00 100644 --- a/packages/cli/src/user/cmd.ts +++ b/packages/cli/src/user/cmd.ts @@ -5,6 +5,7 @@ import { createValkeyPasswordSecret, deleteSecret, getInstance, + getInstanceEvents, getLogsForInstance, getPortsForInstance, listInstances, @@ -332,6 +333,102 @@ export function cmdLogs() { return logs; } +export const EVENTS_HELP = + 'Show the recorded lifecycle events of a service instance, newest first. ' + + 'The second slot of a High-Availability My App is the instance b, and ' + + 'the serving instance can be b after a blue-green roll. The history of ' + + 'b stays readable after HA is disabled (until retention removes it). ' + + 'The whole history of such an app needs this command for each name.'; + +export function cmdEvents() { + const events = new Command('events'); + + events + .description(EVENTS_HELP) + .argument('', 'The Service Id') + .argument('', 'The instance name') + .option( + '--from ', + 'Only events last seen at or after (ISO-8601 UTC)' + ) + .option('--to ', 'Only events last seen before (ISO-8601 UTC)') + .option( + '--type ', + 'Comma-separated event types, for example crash,oom,restart' + ) + .option('--limit ', 'Maximum number of events (1-200, default 50)') + .option('--cursor ', 'The nextCursor of a previous response') + .action(async (serviceId, name, options, command) => { + const isJson = command.optsWithGlobals().json; + try { + const globalOpts = command.optsWithGlobals(); + const environment = globalOpts?.env || 'prod'; + const ctx = new Context({ environment }); + + const result = await getInstanceEvents(ctx, serviceId, name, { + from: options.from, + to: options.to, + type: options.type + ? String(options.type) + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0) + : undefined, + limit: + options.limit !== undefined ? Number(options.limit) : undefined, + cursor: options.cursor + }); + if (isJson) { + process.stdout.write(JSON.stringify(result) + '\n'); + return; + } + console.log( + `Events captured since ${result.capturedSince} (retention ${result.retentionDays} days)` + ); + if (result.truncated) { + console.log( + `Notice: the history is truncated. The oldest retained event starts at ${ + result.oldestRetainedAt ?? 'unknown' + }.` + ); + } + if (result.events.length === 0) { + console.log('No events found'); + } + for (const e of result.events) { + const times = + e.count > 1 ? `${e.firstAt} - ${e.lastAt} (x${e.count})` : e.lastAt; + const details = [ + e.reason, + e.container ? `container=${e.container}` : undefined, + e.exitCode !== undefined ? `exitCode=${e.exitCode}` : undefined + ] + .filter((d) => d !== undefined) + .join(' '); + console.log( + `${times} ${e.type} ${e.outcome} actor=${e.actor} trigger=${ + e.trigger + }${details ? ' ' + details : ''}` + ); + } + if (result.nextCursor) { + console.log( + `More events exist. Use --cursor ${result.nextCursor} to get the next page.` + ); + } + } catch (err) { + if (isJson) { + process.stderr.write( + JSON.stringify({ error: (err as Error).message }) + '\n' + ); + } else { + console.log((err as Error).message); + } + } + }); + return events; +} + export function cmdGetInstanceReplicas() { const replicas = new Command('get-instance-replicas'); replicas diff --git a/packages/cli/src/user/events.test.ts b/packages/cli/src/user/events.test.ts new file mode 100644 index 0000000..90dae84 --- /dev/null +++ b/packages/cli/src/user/events.test.ts @@ -0,0 +1,131 @@ +import { Command } from 'commander'; +import { cmdEvents } from './cmd'; + +jest.mock('@osaas/client-core', () => ({ + Context: jest.fn().mockImplementation(() => ({})), + getInstanceEvents: jest.fn() +})); + +import { getInstanceEvents } from '@osaas/client-core'; + +const mockEvents = getInstanceEvents as jest.Mock; + +const base = { + events: [ + { + id: 'a1', + type: 'crash', + actor: 'platform', + trigger: 'kubelet', + outcome: 'observed', + reason: 'OOMKilled', + count: 1, + firstAt: '2026-11-10T10:00:00.000Z', + lastAt: '2026-11-10T10:00:00.000Z' + } + ], + retentionDays: 90, + capturedSince: '2026-11-01T00:00:00.000Z', + truncated: false +}; + +describe('events command', () => { + let logSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockEvents.mockResolvedValue(base); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + async function run(...args: string[]) { + const cli = new Command(); + cli.option('--env ', 'Environment to use', 'prod'); + cli.addCommand(cmdEvents()); + await cli.parseAsync(['node', 'test', 'events', ...args]); + } + + const printed = () => logSpy.mock.calls.flat().join('\n'); + const lastOptions = () => mockEvents.mock.calls[0][3]; + + test('prints capturedSince and retentionDays, no notice when not truncated', async () => { + await run('svc', 'name'); + expect(mockEvents).toHaveBeenCalledWith( + expect.anything(), + 'svc', + 'name', + expect.anything() + ); + expect(printed()).toContain('captured since 2026-11-01T00:00:00.000Z'); + expect(printed()).toContain('retention 90 days'); + expect(printed()).toContain('crash'); + expect(printed()).not.toContain('truncated'); + expect(printed()).not.toContain('--cursor'); + }); + + test('prints a notice with oldestRetainedAt when truncated', async () => { + mockEvents.mockResolvedValue({ + ...base, + truncated: true, + oldestRetainedAt: '2026-11-05T00:00:00.000Z' + }); + await run('svc', 'name'); + expect(printed()).toContain('truncated'); + expect(printed()).toContain('2026-11-05T00:00:00.000Z'); + }); + + test('prints the next cursor with the --cursor hint', async () => { + mockEvents.mockResolvedValue({ ...base, nextCursor: 'next123' }); + await run('svc', 'name'); + expect(printed()).toContain('More events exist'); + expect(printed()).toContain('--cursor next123'); + }); + + test('sends none of the filters without flags', async () => { + await run('svc', 'name'); + expect(lastOptions()).toEqual({ + from: undefined, + to: undefined, + type: undefined, + limit: undefined, + cursor: undefined + }); + }); + + test('passes --from through', async () => { + await run('svc', 'name', '--from', '2026-11-01T00:00:00Z'); + expect(lastOptions().from).toEqual('2026-11-01T00:00:00Z'); + }); + + test('passes --to through', async () => { + await run('svc', 'name', '--to', '2026-11-02T00:00:00Z'); + expect(lastOptions().to).toEqual('2026-11-02T00:00:00Z'); + }); + + test('passes --type through as a list', async () => { + await run('svc', 'name', '--type', 'crash,oom'); + expect(lastOptions().type).toEqual(['crash', 'oom']); + }); + + test('passes --limit through as a number', async () => { + await run('svc', 'name', '--limit', '25'); + expect(lastOptions().limit).toEqual(25); + }); + + test('passes --cursor through unchanged', async () => { + await run('svc', 'name', '--cursor', 'abc'); + expect(lastOptions().cursor).toEqual('abc'); + }); + + test('help text explains the HA standby slot', () => { + const help = cmdEvents().helpInformation(); + expect(help).toContain('b'); + expect(help).toContain('blue-green'); + expect(help).toContain('after HA is disabled'); + expect(help).toContain('for each name'); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b132cbc..3847659 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -50,3 +50,9 @@ export * from './platform/maker'; export * from './errors'; export * from './myapp'; export { clearSuspensionRecord } from './suspension'; +export { + getInstanceEvents, + InstanceEvent, + InstanceEventsOptions, + InstanceEventsResponse +} from './instance-events'; diff --git a/packages/core/src/instance-events.test.ts b/packages/core/src/instance-events.test.ts new file mode 100644 index 0000000..bdb66e0 --- /dev/null +++ b/packages/core/src/instance-events.test.ts @@ -0,0 +1,62 @@ +import { Context } from './context'; +import { createFetch } from './fetch'; +import { getInstanceEvents } from './instance-events'; + +jest.mock('./fetch', () => ({ + ...jest.requireActual('./fetch'), + createFetch: jest.fn() +})); + +describe('getInstanceEvents', () => { + const ctx = new Context({ personalAccessToken: 'dummy', environment: 'dev' }); + const mockFetch = createFetch as jest.Mock; + const response = { + events: [], + retentionDays: 90, + capturedSince: '2026-11-01T00:00:00.000Z', + truncated: false + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockFetch.mockResolvedValue(response); + }); + + test('sends GET with the PAT and no query parameters by default', async () => { + const res = await getInstanceEvents(ctx, 'svc-id', 'myname'); + expect(res).toEqual(response); + const [url, options] = mockFetch.mock.calls[0]; + expect(url.toString()).toEqual( + 'https://deploy.svc.dev.osaas.io/myinstances/svc-id/myname/events' + ); + expect(options.method).toEqual('GET'); + expect(options.headers['x-pat-jwt']).toEqual('Bearer dummy'); + }); + + test('sends cursor unchanged when given', async () => { + await getInstanceEvents(ctx, 'svc-id', 'myname', { cursor: 'abc' }); + const url: URL = mockFetch.mock.calls[0][0]; + expect(url.searchParams.get('cursor')).toEqual('abc'); + }); + + test('sends from, to, limit and a comma-separated type', async () => { + await getInstanceEvents(ctx, 'svc-id', 'myname', { + from: '2026-11-01T00:00:00Z', + to: '2026-11-02T00:00:00Z', + type: ['crash', 'oom'], + limit: 10 + }); + const url: URL = mockFetch.mock.calls[0][0]; + expect(url.searchParams.get('from')).toEqual('2026-11-01T00:00:00Z'); + expect(url.searchParams.get('to')).toEqual('2026-11-02T00:00:00Z'); + expect(url.searchParams.get('type')).toEqual('crash,oom'); + expect(url.searchParams.get('limit')).toEqual('10'); + expect(url.searchParams.has('cursor')).toBe(false); + }); + + test('encodes the path segments', async () => { + await getInstanceEvents(ctx, 'svc-id', 'my name'); + const url: URL = mockFetch.mock.calls[0][0]; + expect(url.pathname).toEqual('/myinstances/svc-id/my%20name/events'); + }); +}); diff --git a/packages/core/src/instance-events.ts b/packages/core/src/instance-events.ts new file mode 100644 index 0000000..bc9c8c8 --- /dev/null +++ b/packages/core/src/instance-events.ts @@ -0,0 +1,98 @@ +import { Context } from './context'; +import { createFetch } from './fetch'; + +/** + * One recorded lifecycle event of a service instance. Enumerated fields are + * typed as string so that new values added by the platform do not break + * existing clients. + */ +export interface InstanceEvent { + id: string; + /** For example create, restart, update, suspend, resume, delete, replace, crash, oom, evict */ + type: string; + /** user, service, platform or unknown */ + actor: string; + actorRef?: string; + trigger: string; + /** success, failure or observed */ + outcome: string; + reason?: string; + container?: string; + exitCode?: number; + restartCount?: number; + count: number; + /** ISO-8601 UTC */ + firstAt: string; + /** ISO-8601 UTC */ + lastAt: string; + resourceIds?: Record; +} + +export interface InstanceEventsResponse { + events: InstanceEvent[]; + nextCursor?: string; + retentionDays: number; + capturedSince: string; + truncated: boolean; + oldestRetainedAt?: string; +} + +export interface InstanceEventsOptions { + /** ISO-8601 UTC, inclusive (filters on lastAt) */ + from?: string; + /** ISO-8601 UTC, exclusive (filters on lastAt) */ + to?: string; + /** Event types to include */ + type?: string[]; + /** 1..200, the service default is 50 */ + limit?: number; + /** The nextCursor of a previous response, passed through unchanged */ + cursor?: string; +} + +/** + * Get the recorded lifecycle events (restarts, crashes, replacements, volume + * changes and more) of a service instance, newest first. For a High + * Availability My App the second slot is the instance `b`; call this + * function for each name to read the whole history. + * @memberof module:@osaas/client-core + * @param {Context} ctx - Open Source Cloud configuration context + * @param {string} serviceId - The service identifier + * @param {string} name - The name of the service instance + * @param {InstanceEventsOptions} options - Optional filters and paging + * @returns {Promise} - The events and retention metadata + * @example + * import { Context, getInstanceEvents } from '@osaas/client-core'; + * const ctx = new Context(); + * const res = await getInstanceEvents(ctx, 'eyevinn-test-adserver', 'my-instance', { type: ['crash', 'oom'] }); + */ +export async function getInstanceEvents( + ctx: Context, + serviceId: string, + name: string, + options: InstanceEventsOptions = {} +): Promise { + const url = new URL( + `/myinstances/${encodeURIComponent(serviceId)}/${encodeURIComponent( + name + )}/events`, + `https://deploy.svc.${ctx.getEnvironment()}.osaas.io` + ); + if (options.from !== undefined) url.searchParams.set('from', options.from); + if (options.to !== undefined) url.searchParams.set('to', options.to); + if (options.type !== undefined && options.type.length > 0) { + url.searchParams.set('type', options.type.join(',')); + } + if (options.limit !== undefined) { + url.searchParams.set('limit', String(options.limit)); + } + if (options.cursor !== undefined) { + url.searchParams.set('cursor', options.cursor); + } + return await createFetch(url, { + method: 'GET', + headers: { + 'x-pat-jwt': `Bearer ${ctx.getPersonalAccessToken()}` + } + }); +}