first commit
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
import {
|
||||
type CloudWatchLogsClient,
|
||||
CreateLogGroupCommand,
|
||||
CreateLogStreamCommand,
|
||||
DeleteLogGroupCommand,
|
||||
DeleteLogStreamCommand,
|
||||
DescribeLogGroupsCommand,
|
||||
DescribeLogStreamsCommand,
|
||||
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,
|
||||
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(`Floci 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),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user