Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/cli/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Commands:
remove [options] <serviceId> <name> Remove a service instance
logs <serviceId> <name> Get logs for a service instance
restart <serviceId> <name> Restart a service instance
events [options] <serviceId> <name> Show lifecycle events (restarts, crashes, replacements) of a service instance
get-instance-replicas <serviceId> <name> Get the number of replicas for a service instance
set-instance-replicas <serviceId> <name> <n> Set the number of replicas for a service instance
service-access-token <serviceId> Generate a service access token for a service
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
97 changes: 97 additions & 0 deletions packages/cli/src/user/cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createValkeyPasswordSecret,
deleteSecret,
getInstance,
getInstanceEvents,
getLogsForInstance,
getPortsForInstance,
listInstances,
Expand Down Expand Up @@ -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 <name>b, and ' +
'the serving instance can be <name>b after a blue-green roll. The history of ' +
'<name>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('<serviceId>', 'The Service Id')
.argument('<name>', 'The instance name')
.option(
'--from <timestamp>',
'Only events last seen at or after (ISO-8601 UTC)'
)
.option('--to <timestamp>', 'Only events last seen before (ISO-8601 UTC)')
.option(
'--type <types>',
'Comma-separated event types, for example crash,oom,restart'
)
.option('--limit <n>', 'Maximum number of events (1-200, default 50)')
.option('--cursor <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
Expand Down
131 changes: 131 additions & 0 deletions packages/cli/src/user/events.test.ts
Original file line number Diff line number Diff line change
@@ -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>', '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('<name>b');
expect(help).toContain('blue-green');
expect(help).toContain('after HA is disabled');
expect(help).toContain('for each name');
});
});
6 changes: 6 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
62 changes: 62 additions & 0 deletions packages/core/src/instance-events.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading