91ce11274d
CI / checks (pull_request) Successful in 25m47s
Labels, empty states, error messages and defaults the console shows now name TheCloud or its runtimes. Identifiers that must match the emulator (FLOCI_* settings, the X-Floci-CSRF header, emulator-returned host names) are unchanged.
331 lines
13 KiB
TypeScript
331 lines
13 KiB
TypeScript
import {
|
|
type CloudWatchLogsClient,
|
|
CreateLogGroupCommand,
|
|
CreateLogStreamCommand,
|
|
DeleteLogGroupCommand,
|
|
DeleteLogStreamCommand,
|
|
DescribeLogGroupsCommand,
|
|
DescribeLogStreamsCommand,
|
|
FilterLogEventsCommand,
|
|
GetLogEventsCommand,
|
|
GetQueryResultsCommand,
|
|
StartQueryCommand,
|
|
} from '@aws-sdk/client-cloudwatch-logs'
|
|
import type {
|
|
ChildCollection,
|
|
ChildItem,
|
|
CollectionPage,
|
|
DocumentStoreAdapter,
|
|
PageQuery,
|
|
} from '../cloud-spi/childCollections'
|
|
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
|
import {awsLogsSchema} from '../cloud-spi/logsSchema'
|
|
import type {
|
|
CloudResource,
|
|
CloudServiceAdapter,
|
|
CreateResourceInput,
|
|
LogsInsightsQueryInput,
|
|
LogsInsightsQueryResult,
|
|
LogsFilterInput,
|
|
ResourceQuery,
|
|
ServiceSchema,
|
|
} from '../cloud-spi/types'
|
|
|
|
// Real StartQuery is async and queries can run up to an hour server-side. Floci
|
|
// completes instantly by default (FLOCI_SERVICES_CLOUDWATCHLOGS_QUERY_COMPLETION_DELAY_MS=0),
|
|
// but this poll loop is bounded rather than unconditional so a future non-zero delay,
|
|
// or a real backing AWS account, cannot hang the request indefinitely.
|
|
const QUERY_POLL_INTERVAL_MS = 150
|
|
const QUERY_POLL_TIMEOUT_MS = 10_000
|
|
const ACTIVE_QUERY_STATUSES = new Set(['Running', 'Scheduled'])
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
function toRow(fields: Array<{field?: string; value?: string}>): Record<string, string> {
|
|
const row: Record<string, string> = {}
|
|
for (const {field, value} of fields) {
|
|
if (field) row[field] = value ?? ''
|
|
}
|
|
return row
|
|
}
|
|
|
|
/**
|
|
* `creationTime` is the field real AWS returns and the only one the SDK models.
|
|
*
|
|
* The local runtime sends `createdTime` instead, and the SDK deserializer drops
|
|
* wire fields it does not model — so that value is unrecoverable here and
|
|
* `createdAt` is null for every log group and stream against the emulator.
|
|
* Reading the emulator's spelling instead would be dead code in production and
|
|
* would only ever pass against hand-built stubs, which is how this shipped
|
|
* wrong the first time. See
|
|
* .claude/memory/aws-sdk-error-names-differ-from-wire-codes.md — same failure
|
|
* mode, different field.
|
|
*/
|
|
interface LogGroupShape {
|
|
logGroupName?: string
|
|
creationTime?: number
|
|
arn?: string
|
|
storedBytes?: number
|
|
metricFilterCount?: number
|
|
retentionInDays?: number
|
|
}
|
|
|
|
function createdAtOf(record: {creationTime?: number}): string | null {
|
|
return record.creationTime ? new Date(record.creationTime).toISOString() : null
|
|
}
|
|
|
|
export class AwsLogsAdapter implements CloudServiceAdapter {
|
|
readonly cloud = 'aws' as const
|
|
readonly service = 'logs' as const
|
|
|
|
readonly documents: DocumentStoreAdapter
|
|
|
|
constructor(
|
|
private readonly client: CloudWatchLogsClient,
|
|
/** Overridable so tests can shrink the wait instead of taking QUERY_POLL_TIMEOUT_MS for real. */
|
|
private readonly queryPoll: {timeoutMs?: number; intervalMs?: number} = {},
|
|
) {
|
|
this.documents = new LogGroupDocumentStore(client)
|
|
}
|
|
|
|
schema(): ServiceSchema {
|
|
return awsLogsSchema()
|
|
}
|
|
|
|
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
|
const prefix = query.search?.trim()
|
|
const response = await this.client.send(
|
|
new DescribeLogGroupsCommand(prefix ? {logGroupNamePrefix: prefix} : {}),
|
|
)
|
|
return (response.logGroups ?? []).map(toResource)
|
|
}
|
|
|
|
async get(id: string): Promise<CloudResource | null> {
|
|
const group = await this.describeExact(id)
|
|
return group ? toResource(group) : null
|
|
}
|
|
|
|
async create(input: CreateResourceInput): Promise<CloudResource> {
|
|
const name = String(input.values.name ?? '').trim()
|
|
if (!name) throw new ValidationError('A log group name is required.')
|
|
|
|
await this.client.send(new CreateLogGroupCommand({logGroupName: name}))
|
|
|
|
const group = await this.describeExact(name)
|
|
if (!group) throw new NotFoundError(`Log group ${name} was created but could not be read back.`)
|
|
return toResource(group)
|
|
}
|
|
|
|
async delete(id: string): Promise<void> {
|
|
await this.client.send(new DeleteLogGroupCommand({logGroupName: id}))
|
|
}
|
|
|
|
async queryLogs(logGroupName: string, input: LogsInsightsQueryInput): Promise<LogsInsightsQueryResult> {
|
|
const queryString = input.queryString.trim()
|
|
if (!queryString) throw new ValidationError('A query string is required.')
|
|
|
|
const {queryId} = await this.client.send(
|
|
new StartQueryCommand({
|
|
logGroupName,
|
|
startTime: input.startTime,
|
|
endTime: input.endTime,
|
|
queryString,
|
|
limit: input.limit,
|
|
}),
|
|
)
|
|
if (!queryId) throw new NotFoundError(`The runtime did not return a query id for ${logGroupName}.`)
|
|
|
|
const deadline = Date.now() + (this.queryPoll.timeoutMs ?? QUERY_POLL_TIMEOUT_MS)
|
|
const pollInterval = this.queryPoll.intervalMs ?? QUERY_POLL_INTERVAL_MS
|
|
let response = await this.client.send(new GetQueryResultsCommand({queryId}))
|
|
while (ACTIVE_QUERY_STATUSES.has(response.status ?? '') && Date.now() < deadline) {
|
|
await sleep(pollInterval)
|
|
response = await this.client.send(new GetQueryResultsCommand({queryId}))
|
|
}
|
|
|
|
return {
|
|
queryId,
|
|
status: response.status ?? 'Unknown',
|
|
rows: (response.results ?? []).map(toRow),
|
|
}
|
|
}
|
|
|
|
async filterLogEvents(logGroupName: string, input: LogsFilterInput, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
|
|
const response = await this.client.send(new FilterLogEventsCommand({
|
|
logGroupName,
|
|
filterPattern: input.filterPattern || undefined,
|
|
logStreamNames: input.logStreamNames?.length ? input.logStreamNames : undefined,
|
|
startTime: input.startTime,
|
|
endTime: input.endTime,
|
|
limit: page.limit,
|
|
nextToken: page.cursor,
|
|
}))
|
|
return {
|
|
items: (response.events ?? []).map((event, index) => ({
|
|
id: event.eventId ?? `${event.logStreamName ?? ''}-${event.timestamp ?? 0}-${index}`,
|
|
collectionId: event.logStreamName ?? null,
|
|
timestamp: event.timestamp ? new Date(event.timestamp).toISOString() : null,
|
|
body: {message: event.message ?? ''},
|
|
metadata: {
|
|
ingestionTime: event.ingestionTime ? new Date(event.ingestionTime).toISOString() : null,
|
|
},
|
|
})),
|
|
nextCursor: response.nextToken ?? null,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* DescribeLogGroups filters by prefix, so `/floci/probe` also returns
|
|
* `/floci/probe-two`. Filtering to an exact match keeps `get` honest.
|
|
*/
|
|
private async describeExact(name: string): Promise<LogGroupShape | null> {
|
|
const response = await this.client.send(new DescribeLogGroupsCommand({logGroupNamePrefix: name}))
|
|
return (response.logGroups ?? []).find((group) => group.logGroupName === name) ?? null
|
|
}
|
|
}
|
|
|
|
/** AWS rejects a DescribeLogStreams Limit above this. GetLogEvents allows 10,000. */
|
|
const MAX_DESCRIBE_LOG_STREAMS_LIMIT = 50
|
|
|
|
interface LogStreamShape {
|
|
logStreamName?: string
|
|
arn?: string
|
|
creationTime?: number
|
|
storedBytes?: number
|
|
firstEventTimestamp?: number
|
|
lastEventTimestamp?: number
|
|
}
|
|
|
|
/**
|
|
* Streams are the collection level, events the leaf. There is deliberately no
|
|
* putItem/deleteItem/queryItems: AWS has no DeleteLogEvent, and PutLogEvents is
|
|
* not something a resource browser should expose.
|
|
*/
|
|
class LogGroupDocumentStore implements DocumentStoreAdapter {
|
|
constructor(private readonly client: CloudWatchLogsClient) {}
|
|
|
|
async listCollections(resourceId: string, page: PageQuery = {}): Promise<CollectionPage<ChildCollection>> {
|
|
const response = await this.client.send(
|
|
new DescribeLogStreamsCommand({
|
|
logGroupName: resourceId,
|
|
orderBy: 'LastEventTime',
|
|
descending: true,
|
|
// Real AWS caps this at 50 and rejects anything higher with a
|
|
// ValidationException; the SPI defaults to 100 and permits 1000.
|
|
// The local runtime does not enforce the cap, so an unclamped
|
|
// value passes here and 400s in production.
|
|
//
|
|
// Clamping is safe where rejecting would be wrong: `limit` is a
|
|
// maximum, not a quota, and the response still carries
|
|
// `nextCursor`, so a short page is honest rather than a silent
|
|
// truncation. That is the opposite of `clampLimit`, which
|
|
// rejects — there the caller has no cursor to tell them more
|
|
// exists.
|
|
limit: Math.min(page.limit ?? MAX_DESCRIBE_LOG_STREAMS_LIMIT, MAX_DESCRIBE_LOG_STREAMS_LIMIT),
|
|
nextToken: page.cursor,
|
|
}),
|
|
)
|
|
return {
|
|
items: (response.logStreams ?? []).map((stream) => toCollection(stream, resourceId)),
|
|
nextCursor: response.nextToken ?? null,
|
|
}
|
|
}
|
|
|
|
async createCollection(resourceId: string, input: CreateResourceInput): Promise<ChildCollection> {
|
|
const name = String(input.values.name ?? '').trim()
|
|
if (!name) throw new ValidationError('A log stream name is required.')
|
|
|
|
await this.client.send(new CreateLogStreamCommand({logGroupName: resourceId, logStreamName: name}))
|
|
|
|
const response = await this.client.send(
|
|
new DescribeLogStreamsCommand({logGroupName: resourceId, logStreamNamePrefix: name}),
|
|
)
|
|
const stream = (response.logStreams ?? []).find((candidate) => candidate.logStreamName === name)
|
|
if (!stream) throw new NotFoundError(`Log stream ${name} was created but could not be read back.`)
|
|
return toCollection(stream, resourceId)
|
|
}
|
|
|
|
async deleteCollection(resourceId: string, collectionId: string): Promise<void> {
|
|
await this.client.send(new DeleteLogStreamCommand({logGroupName: resourceId, logStreamName: collectionId}))
|
|
}
|
|
|
|
async listItems(resourceId: string, collectionId: string, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
|
|
const response = await this.client.send(
|
|
new GetLogEventsCommand({
|
|
logGroupName: resourceId,
|
|
logStreamName: collectionId,
|
|
// Without this the runtime returns the newest page, so paging
|
|
// forward from a fresh cursor would skip the history.
|
|
startFromHead: true,
|
|
limit: page.limit,
|
|
nextToken: page.cursor,
|
|
}),
|
|
)
|
|
|
|
const events = response.events ?? []
|
|
const forward = response.nextForwardToken ?? null
|
|
|
|
return {
|
|
items: events.map((event, index) => ({
|
|
// Real AWS gives log events no identifier — the SDK models only
|
|
// timestamp/message/ingestionTime, and the emulator's `eventId`
|
|
// is discarded by the deserializer. Position within the page is
|
|
// the only thing available, so it is not stable across calls and
|
|
// must not be used as a durable key.
|
|
id: `${event.timestamp ?? 0}-${index}`,
|
|
collectionId,
|
|
timestamp: event.timestamp ? new Date(event.timestamp).toISOString() : null,
|
|
body: {message: event.message ?? ''},
|
|
metadata: {
|
|
ingestionTime: event.ingestionTime ? new Date(event.ingestionTime).toISOString() : null,
|
|
},
|
|
})),
|
|
// GetLogEvents echoes the supplied token back at the end of the
|
|
// stream instead of returning null, so a naive loop never
|
|
// terminates. Verified against the runtime on 2026-07-28.
|
|
nextCursor: events.length === 0 || forward === page.cursor ? null : forward,
|
|
}
|
|
}
|
|
}
|
|
|
|
function toCollection(stream: LogStreamShape, parentId: string): ChildCollection {
|
|
const name = stream.logStreamName ?? ''
|
|
return {
|
|
id: name,
|
|
name,
|
|
parentId,
|
|
createdAt: createdAtOf(stream),
|
|
metadata: {
|
|
arn: stream.arn,
|
|
storedBytes: stream.storedBytes ?? 0,
|
|
firstEventTimestamp: stream.firstEventTimestamp
|
|
? new Date(stream.firstEventTimestamp).toISOString()
|
|
: null,
|
|
lastEventTimestamp: stream.lastEventTimestamp
|
|
? new Date(stream.lastEventTimestamp).toISOString()
|
|
: null,
|
|
},
|
|
}
|
|
}
|
|
|
|
function toResource(group: LogGroupShape): CloudResource {
|
|
const name = group.logGroupName ?? ''
|
|
return {
|
|
id: name,
|
|
name,
|
|
cloud: 'aws',
|
|
service: 'logs',
|
|
type: 'log-group',
|
|
region: null,
|
|
createdAt: createdAtOf(group),
|
|
metadata: {
|
|
arn: group.arn,
|
|
storedBytes: group.storedBytes ?? 0,
|
|
metricFilterCount: group.metricFilterCount ?? 0,
|
|
retentionInDays: group.retentionInDays ?? null,
|
|
},
|
|
}
|
|
}
|