-
Notifications
You must be signed in to change notification settings - Fork 19
feat(compute): compute logs <id> — container stdout/stderr from the CLI
#287
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
Changes from 5 commits
4a8805e
51e7e36
7574fca
ae71eba
db86956
5e2168d
7d29de0
01f4759
6b0dcea
6bbdf43
525a8b7
bd25fde
cd3bb32
2cb375d
1b94f6f
e514b59
e1ca55c
e784f82
67ba77d
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,148 @@ | ||
| import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; | ||
| import type * as ErrorsModule from '../../lib/errors.js'; | ||
|
|
||
| const ossFetchMock = vi.hoisted(() => vi.fn()); | ||
| const outputJsonMock = vi.hoisted(() => vi.fn()); | ||
| vi.mock('../../lib/api/oss.js', () => ({ ossFetch: ossFetchMock })); | ||
| vi.mock('../../lib/credentials.js', () => ({ requireAuth: vi.fn().mockResolvedValue(undefined) })); | ||
| vi.mock('../../lib/command-telemetry.js', () => ({ trackCommandUsage: vi.fn() })); | ||
| vi.mock('../../lib/output.js', () => ({ outputJson: outputJsonMock })); | ||
| vi.mock('../../lib/errors.js', async (importOriginal) => { | ||
| const actual = await importOriginal<typeof ErrorsModule>(); | ||
| return { | ||
| ...actual, | ||
| handleError: (err: unknown) => { throw err; }, | ||
| }; | ||
| }); | ||
|
|
||
| import { Command } from 'commander'; | ||
| import { registerComputeLogsCommand, sanitizeLogMessage, parseLimit } from './logs.js'; | ||
|
|
||
| const ESC = String.fromCharCode(0x1b); | ||
| const BEL = String.fromCharCode(0x07); | ||
| const CSI_C1 = String.fromCharCode(0x9b); // 8-bit CSI | ||
|
|
||
| function run(args: string[]) { | ||
| const cmd = new Command(); | ||
| cmd.exitOverride(); | ||
| cmd.option('--json'); | ||
| const compute = cmd.command('compute'); | ||
| registerComputeLogsCommand(compute); | ||
| return cmd.parseAsync(['node', 'insforge', ...args]); | ||
| } | ||
|
|
||
| function page(lines: unknown[], nextToken: string | null = null) { | ||
| return { json: async () => ({ lines, nextToken }) }; | ||
| } | ||
|
|
||
| describe('compute logs', () => { | ||
| let logSpy: ReturnType<typeof vi.spyOn>; | ||
| let errSpy: ReturnType<typeof vi.spyOn>; | ||
| beforeEach(() => { | ||
| ossFetchMock.mockReset(); | ||
| outputJsonMock.mockReset(); | ||
| logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); | ||
| errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); | ||
| }); | ||
| afterEach(() => { | ||
| logSpy.mockRestore(); | ||
| errSpy.mockRestore(); | ||
| vi.useRealTimers(); | ||
| }); | ||
|
|
||
| it('calls the container logs endpoint with a clamped limit', async () => { | ||
| ossFetchMock.mockResolvedValueOnce(page([])); | ||
| await run(['compute', 'logs', 'my api', '--limit', '5000']); | ||
| expect(ossFetchMock).toHaveBeenCalledWith('/api/compute/services/my%20api/logs?limit=1000'); | ||
| expect(logSpy).toHaveBeenCalledWith('No logs found.'); | ||
| }); | ||
|
|
||
| it('forwards --next-token as next_token', async () => { | ||
| ossFetchMock.mockResolvedValueOnce(page([])); | ||
| await run(['compute', 'logs', 'svc', '--next-token', 'abc']); | ||
| expect(ossFetchMock.mock.calls[0][0]).toBe('/api/compute/services/svc/logs?limit=100&next_token=abc'); | ||
| }); | ||
|
|
||
| it('prints formatted lines', async () => { | ||
| ossFetchMock.mockResolvedValueOnce(page( | ||
| [{ timestamp: 0, message: 'hello', region: 'sjc', instance: 'abc123' }], 'tok', | ||
| )); | ||
| await run(['compute', 'logs', 'svc']); | ||
| expect(logSpy).toHaveBeenCalledWith('1970-01-01T00:00:00.000Z [sjc abc123] hello'); | ||
| }); | ||
|
|
||
| it('emits the full result (with cursor) under --json, sanitized', async () => { | ||
| ossFetchMock.mockResolvedValueOnce(page([{ timestamp: 1, message: `x${CSI_C1}31my` }], 'tok')); | ||
| await run(['--json', 'compute', 'logs', 'svc']); | ||
| expect(outputJsonMock).toHaveBeenCalledWith({ | ||
| lines: [{ timestamp: 1, message: 'x31my' }], | ||
| nextToken: 'tok', | ||
| }); | ||
| }); | ||
|
|
||
| it('--follow forwards the cursor on the next poll and prints per batch', async () => { | ||
| vi.useFakeTimers(); | ||
| ossFetchMock.mockResolvedValueOnce(page([{ timestamp: 1, message: 'one' }], 'tokA')); | ||
| ossFetchMock.mockResolvedValueOnce(page([{ timestamp: 2, message: 'two' }], 'tokB')); | ||
| ossFetchMock.mockResolvedValue(page([])); | ||
| void run(['compute', 'logs', 'svc', '--follow']); | ||
| await vi.advanceTimersByTimeAsync(0); | ||
| expect(logSpy).toHaveBeenCalledWith('1970-01-01T00:00:00.001Z one'); | ||
| await vi.advanceTimersByTimeAsync(2000); | ||
| expect(ossFetchMock.mock.calls[1][0]).toBe('/api/compute/services/svc/logs?limit=100&next_token=tokA'); | ||
| expect(logSpy).toHaveBeenCalledWith('1970-01-01T00:00:00.002Z two'); | ||
| await vi.advanceTimersByTimeAsync(2000); | ||
| expect(ossFetchMock.mock.calls[2][0]).toBe('/api/compute/services/svc/logs?limit=100&next_token=tokB'); | ||
| }); | ||
|
|
||
| it('--follow without a cursor drops already-printed lines on refetch', async () => { | ||
| vi.useFakeTimers(); | ||
| ossFetchMock.mockResolvedValueOnce(page([{ timestamp: 5, message: 'seen' }], null)); | ||
| ossFetchMock.mockResolvedValueOnce(page([ | ||
| { timestamp: 5, message: 'seen' }, | ||
| { timestamp: 9, message: 'fresh' }, | ||
| ], null)); | ||
| ossFetchMock.mockResolvedValue(page([])); | ||
| void run(['compute', 'logs', 'svc', '--follow']); | ||
| await vi.advanceTimersByTimeAsync(0); | ||
| await vi.advanceTimersByTimeAsync(2000); | ||
| expect(ossFetchMock.mock.calls[1][0]).toBe('/api/compute/services/svc/logs?limit=100'); | ||
| const printed = logSpy.mock.calls.map((c: unknown[]) => String(c[0])); | ||
| expect(printed.filter((l: string) => l.includes('seen'))).toHaveLength(1); | ||
| expect(printed.some((l: string) => l.includes('fresh'))).toBe(true); | ||
| }); | ||
|
|
||
| it('--json --follow emits NDJSON per line', async () => { | ||
| vi.useFakeTimers(); | ||
| ossFetchMock.mockResolvedValueOnce(page([{ timestamp: 1, message: 'a' }], 'tok')); | ||
| ossFetchMock.mockResolvedValue(page([])); | ||
| void run(['--json', 'compute', 'logs', 'svc', '--follow']); | ||
| await vi.advanceTimersByTimeAsync(0); | ||
| expect(logSpy).toHaveBeenCalledWith(JSON.stringify({ timestamp: 1, message: 'a' })); | ||
| expect(outputJsonMock).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('sanitizeLogMessage', () => { | ||
| it('strips ANSI CSI/OSC sequences and control chars, keeps tabs', () => { | ||
| expect(sanitizeLogMessage(`${ESC}[31mred${ESC}[0m ok`)).toBe('red ok'); | ||
| expect(sanitizeLogMessage(`${ESC}]0;evil title${BEL}text`)).toBe('text'); | ||
| expect(sanitizeLogMessage('a\rb\nc')).toBe('abc'); | ||
| expect(sanitizeLogMessage('keep\ttabs')).toBe('keep\ttabs'); | ||
| }); | ||
|
|
||
| it('strips 8-bit C1 controls (CSI/OSC without ESC)', () => { | ||
| expect(sanitizeLogMessage(`x${CSI_C1}31my`)).toBe('x31my'); | ||
| expect(sanitizeLogMessage(String.fromCharCode(0x90) + 'dcs')).toBe('dcs'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('parseLimit', () => { | ||
| it('clamps into 1-1000 and defaults malformed input', () => { | ||
| expect(parseLimit('0')).toBe(1); | ||
| expect(parseLimit('5000')).toBe(1000); | ||
| expect(parseLimit('abc')).toBe(100); | ||
| expect(parseLimit(undefined)).toBe(100); | ||
| expect(parseLimit('250')).toBe(250); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,141 @@ | ||||||||||||||||||||||||||||
| import type { Command } from 'commander'; | ||||||||||||||||||||||||||||
| import { ossFetch } from '../../lib/api/oss.js'; | ||||||||||||||||||||||||||||
| import { requireAuth } from '../../lib/credentials.js'; | ||||||||||||||||||||||||||||
| import { handleError, getRootOpts } from '../../lib/errors.js'; | ||||||||||||||||||||||||||||
| import { outputJson } from '../../lib/output.js'; | ||||||||||||||||||||||||||||
| import { trackCommandUsage } from '../../lib/command-telemetry.js'; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // `compute logs <id>` returns container stdout/stderr ("application logs") — | ||||||||||||||||||||||||||||
| // the same data the dashboard Logs panel shows. For machine lifecycle events | ||||||||||||||||||||||||||||
| // (start/stop/exit/restart) use `compute events <id>` instead. | ||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||
| // Endpoint: GET /api/compute/services/:id/logs?limit=&next_token= | ||||||||||||||||||||||||||||
| // Response: { lines: { timestamp, message, instance?, region? }[], nextToken: string | null } | ||||||||||||||||||||||||||||
| // `nextToken` is an opaque forward cursor; `--follow` polls with it. The | ||||||||||||||||||||||||||||
| // endpoint is rate-limited server-side (dashboard polls every ~2s), so the | ||||||||||||||||||||||||||||
| // follow interval stays at 2s. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export interface ComputeLogLine { | ||||||||||||||||||||||||||||
| timestamp: number; | ||||||||||||||||||||||||||||
| message: string; | ||||||||||||||||||||||||||||
| instance?: string; | ||||||||||||||||||||||||||||
| region?: string; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export interface ComputeLogsResult { | ||||||||||||||||||||||||||||
| lines: ComputeLogLine[]; | ||||||||||||||||||||||||||||
| nextToken: string | null; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const FOLLOW_INTERVAL_MS = 2000; | ||||||||||||||||||||||||||||
| const DEFAULT_LIMIT = 100; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Container output is attacker-adjacent data: a compromised (or just chatty) | ||||||||||||||||||||||||||||
| // app can emit ANSI/OSC escape sequences that reprogram the reader's | ||||||||||||||||||||||||||||
| // terminal. Strip ESC-led sequences, C0 controls except tab, and 8-bit C1 | ||||||||||||||||||||||||||||
| // controls (which encode CSI/OSC without ESC). Applied at the fetch boundary | ||||||||||||||||||||||||||||
| // so every output mode — including --json, where JSON.stringify leaves C1 | ||||||||||||||||||||||||||||
| // bytes raw — is covered. | ||||||||||||||||||||||||||||
| // eslint-disable-next-line no-control-regex | ||||||||||||||||||||||||||||
| const TERMINAL_CONTROLS = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-_]|[\u0000-\u0008\u000a-\u001f\u007f\u0080-\u009f]/g; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export function sanitizeLogMessage(message: string): string { | ||||||||||||||||||||||||||||
| return message.replace(TERMINAL_CONTROLS, ''); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Exact 1-1000 contract: malformed input falls back to the default (same as | ||||||||||||||||||||||||||||
| // omitting the flag); finite values clamp into range, so `--limit 0` -> 1. | ||||||||||||||||||||||||||||
| export function parseLimit(raw: unknown): number { | ||||||||||||||||||||||||||||
| const n = Number(raw); | ||||||||||||||||||||||||||||
| if (!Number.isFinite(n)) return DEFAULT_LIMIT; | ||||||||||||||||||||||||||||
| return Math.max(1, Math.min(Math.trunc(n), 1000)); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export function formatLogLine(line: ComputeLogLine): string { | ||||||||||||||||||||||||||||
| const ts = new Date(line.timestamp).toISOString(); | ||||||||||||||||||||||||||||
| const where = [line.region, line.instance].filter(Boolean).join(' '); | ||||||||||||||||||||||||||||
| return where ? `${ts} [${where}] ${line.message}` : `${ts} ${line.message}`; | ||||||||||||||||||||||||||||
|
greptile-apps[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export async function fetchComputeLogs( | ||||||||||||||||||||||||||||
| id: string, | ||||||||||||||||||||||||||||
| opts: { limit: number; nextToken?: string }, | ||||||||||||||||||||||||||||
| ): Promise<ComputeLogsResult> { | ||||||||||||||||||||||||||||
| const params = new URLSearchParams({ limit: String(opts.limit) }); | ||||||||||||||||||||||||||||
| if (opts.nextToken) params.set('next_token', opts.nextToken); | ||||||||||||||||||||||||||||
| const res = await ossFetch( | ||||||||||||||||||||||||||||
| `/api/compute/services/${encodeURIComponent(id)}/logs?${params.toString()}`, | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| const body = await res.json() as Partial<ComputeLogsResult> | null; | ||||||||||||||||||||||||||||
| const lines = (Array.isArray(body?.lines) ? body.lines : []).map((l) => ({ | ||||||||||||||||||||||||||||
| ...l, | ||||||||||||||||||||||||||||
| message: sanitizeLogMessage(String(l.message ?? '')), | ||||||||||||||||||||||||||||
| })); | ||||||||||||||||||||||||||||
|
Comment on lines
+97
to
+102
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. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Guard against non-object elements in
Filter to object elements before mapping. 🛡️ Proposed fix- const lines = (Array.isArray(body?.lines) ? body.lines : []).map((l): ComputeLogLine => ({
+ const raw = Array.isArray(body?.lines) ? body.lines : [];
+ const lines = raw.filter((l): l is ComputeLogLine => typeof l === 'object' && l !== null).map((l): ComputeLogLine => ({📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||
| lines, | ||||||||||||||||||||||||||||
| nextToken: typeof body?.nextToken === 'string' && body.nextToken.length > 0 ? body.nextToken : null, | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export function registerComputeLogsCommand(computeCmd: Command): void { | ||||||||||||||||||||||||||||
| computeCmd | ||||||||||||||||||||||||||||
| .command('logs <id>') | ||||||||||||||||||||||||||||
| .description('Get compute service container logs (stdout/stderr)') | ||||||||||||||||||||||||||||
| .option('--limit <n>', 'Max number of log lines per fetch (1-1000)', String(DEFAULT_LIMIT)) | ||||||||||||||||||||||||||||
| .option('-f, --follow', 'Keep polling for new lines (Ctrl+C to stop). With --json, emits NDJSON: one log-line object per line') | ||||||||||||||||||||||||||||
| .option('--next-token <token>', 'Resume from a cursor returned by a previous --json call') | ||||||||||||||||||||||||||||
| .action(async (id: string, opts, cmd) => { | ||||||||||||||||||||||||||||
| const { json } = getRootOpts(cmd); | ||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||
| await requireAuth(); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const limit = parseLimit(opts.limit); | ||||||||||||||||||||||||||||
| const result = await fetchComputeLogs(id, { limit, nextToken: opts.nextToken }); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| await trackCommandUsage('compute', 'logs', true, { | ||||||||||||||||||||||||||||
| result_count: result.lines.length, | ||||||||||||||||||||||||||||
| follow: Boolean(opts.follow), | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if (json && !opts.follow) { | ||||||||||||||||||||||||||||
| outputJson(result); | ||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const print = (lines: ComputeLogLine[]) => { | ||||||||||||||||||||||||||||
| for (const line of lines) { | ||||||||||||||||||||||||||||
| console.log(json ? JSON.stringify(line) : formatLogLine(line)); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
Comment on lines
+169
to
+172
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. When Knowledge Base Used: CLI command runtime |
||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if (result.lines.length === 0 && !opts.follow) { | ||||||||||||||||||||||||||||
| console.log('No logs found.'); | ||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| print(result.lines); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if (opts.follow) { | ||||||||||||||||||||||||||||
| if (!json) console.error('Following logs... (Ctrl+C to stop)'); | ||||||||||||||||||||||||||||
| let token = result.nextToken; | ||||||||||||||||||||||||||||
| // When the provider stops returning a cursor, each poll re-fetches | ||||||||||||||||||||||||||||
| // the recent window; drop lines at or before the newest timestamp | ||||||||||||||||||||||||||||
| // already printed so they don't repeat. Cursor-based pages don't | ||||||||||||||||||||||||||||
| // overlap, so no filter is applied while a token advances. | ||||||||||||||||||||||||||||
| let lastTs = result.lines.length > 0 ? result.lines[result.lines.length - 1].timestamp : 0; | ||||||||||||||||||||||||||||
| for (;;) { | ||||||||||||||||||||||||||||
| await new Promise((r) => setTimeout(r, FOLLOW_INTERVAL_MS)); | ||||||||||||||||||||||||||||
| const page = await fetchComputeLogs(id, { limit, nextToken: token ?? undefined }); | ||||||||||||||||||||||||||||
| const fresh = token ? page.lines : page.lines.filter((l) => l.timestamp > lastTs); | ||||||||||||||||||||||||||||
| print(fresh); | ||||||||||||||||||||||||||||
| if (fresh.length > 0) { | ||||||||||||||||||||||||||||
| lastTs = Math.max(lastTs, fresh[fresh.length - 1].timestamp); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| if (page.nextToken) token = page.nextToken; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||
| await trackCommandUsage('compute', 'logs', false, {}, err); | ||||||||||||||||||||||||||||
| handleError(err, json); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.