diff --git a/apps/sim/app/api/tools/athena/batch-get-query-execution/route.ts b/apps/sim/app/api/tools/athena/batch-get-query-execution/route.ts new file mode 100644 index 00000000000..825a1b3efa3 --- /dev/null +++ b/apps/sim/app/api/tools/athena/batch-get-query-execution/route.ts @@ -0,0 +1,78 @@ +import { BatchGetQueryExecutionCommand } from '@aws-sdk/client-athena' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsAthenaBatchGetQueryExecutionContract } from '@/lib/api/contracts/tools/aws/athena-batch-get-query-execution' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createAthenaClient } from '@/app/api/tools/athena/utils' + +const logger = createLogger('AthenaBatchGetQueryExecution') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsAthenaBatchGetQueryExecutionContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body + + const client = createAthenaClient({ + region: data.region, + accessKeyId: data.accessKeyId, + secretAccessKey: data.secretAccessKey, + }) + + try { + const command = new BatchGetQueryExecutionCommand({ + QueryExecutionIds: data.queryExecutionIds, + }) + + const response = await client.send(command) + + return NextResponse.json({ + success: true, + output: { + queryExecutions: (response.QueryExecutions ?? []).map((execution) => ({ + queryExecutionId: execution.QueryExecutionId ?? '', + query: execution.Query ?? null, + state: execution.Status?.State ?? null, + stateChangeReason: execution.Status?.StateChangeReason ?? null, + statementType: execution.StatementType ?? null, + database: execution.QueryExecutionContext?.Database ?? null, + catalog: execution.QueryExecutionContext?.Catalog ?? null, + workGroup: execution.WorkGroup ?? null, + submissionDateTime: execution.Status?.SubmissionDateTime?.getTime() ?? null, + completionDateTime: execution.Status?.CompletionDateTime?.getTime() ?? null, + dataScannedInBytes: execution.Statistics?.DataScannedInBytes ?? null, + engineExecutionTimeInMillis: execution.Statistics?.EngineExecutionTimeInMillis ?? null, + queryPlanningTimeInMillis: execution.Statistics?.QueryPlanningTimeInMillis ?? null, + queryQueueTimeInMillis: execution.Statistics?.QueryQueueTimeInMillis ?? null, + totalExecutionTimeInMillis: execution.Statistics?.TotalExecutionTimeInMillis ?? null, + outputLocation: execution.ResultConfiguration?.OutputLocation ?? null, + })), + unprocessedQueryExecutionIds: (response.UnprocessedQueryExecutionIds ?? []).map( + (item) => ({ + queryExecutionId: item.QueryExecutionId ?? null, + errorCode: item.ErrorCode ?? null, + errorMessage: item.ErrorMessage ?? null, + }) + ), + }, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Failed to batch get Athena query executions') + logger.error('BatchGetQueryExecution failed', { error: errorMessage }) + return NextResponse.json({ error: errorMessage }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/athena/delete-named-query/route.ts b/apps/sim/app/api/tools/athena/delete-named-query/route.ts new file mode 100644 index 00000000000..3026156a0fd --- /dev/null +++ b/apps/sim/app/api/tools/athena/delete-named-query/route.ts @@ -0,0 +1,54 @@ +import { DeleteNamedQueryCommand } from '@aws-sdk/client-athena' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsAthenaDeleteNamedQueryContract } from '@/lib/api/contracts/tools/aws/athena-delete-named-query' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createAthenaClient } from '@/app/api/tools/athena/utils' + +const logger = createLogger('AthenaDeleteNamedQuery') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsAthenaDeleteNamedQueryContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body + + const client = createAthenaClient({ + region: data.region, + accessKeyId: data.accessKeyId, + secretAccessKey: data.secretAccessKey, + }) + + try { + const command = new DeleteNamedQueryCommand({ + NamedQueryId: data.namedQueryId, + }) + + await client.send(command) + + return NextResponse.json({ + success: true, + output: { + success: true, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Failed to delete Athena named query') + logger.error('DeleteNamedQuery failed', { error: errorMessage }) + return NextResponse.json({ error: errorMessage }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/athena/list-databases/route.ts b/apps/sim/app/api/tools/athena/list-databases/route.ts new file mode 100644 index 00000000000..28e7109c091 --- /dev/null +++ b/apps/sim/app/api/tools/athena/list-databases/route.ts @@ -0,0 +1,61 @@ +import { ListDatabasesCommand } from '@aws-sdk/client-athena' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsAthenaListDatabasesContract } from '@/lib/api/contracts/tools/aws/athena-list-databases' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createAthenaClient } from '@/app/api/tools/athena/utils' + +const logger = createLogger('AthenaListDatabases') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsAthenaListDatabasesContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body + + const client = createAthenaClient({ + region: data.region, + accessKeyId: data.accessKeyId, + secretAccessKey: data.secretAccessKey, + }) + + try { + const command = new ListDatabasesCommand({ + CatalogName: data.catalogName, + ...(data.workGroup && { WorkGroup: data.workGroup }), + ...(data.maxResults !== undefined && { MaxResults: data.maxResults }), + ...(data.nextToken && { NextToken: data.nextToken }), + }) + + const response = await client.send(command) + + return NextResponse.json({ + success: true, + output: { + databases: (response.DatabaseList ?? []).map((db) => ({ + name: db.Name ?? '', + description: db.Description ?? null, + })), + nextToken: response.NextToken ?? null, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Failed to list Athena databases') + logger.error('ListDatabases failed', { error: errorMessage }) + return NextResponse.json({ error: errorMessage }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/athena/list-table-metadata/route.ts b/apps/sim/app/api/tools/athena/list-table-metadata/route.ts new file mode 100644 index 00000000000..51db9af6be3 --- /dev/null +++ b/apps/sim/app/api/tools/athena/list-table-metadata/route.ts @@ -0,0 +1,75 @@ +import { ListTableMetadataCommand } from '@aws-sdk/client-athena' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsAthenaListTableMetadataContract } from '@/lib/api/contracts/tools/aws/athena-list-table-metadata' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createAthenaClient } from '@/app/api/tools/athena/utils' + +const logger = createLogger('AthenaListTableMetadata') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsAthenaListTableMetadataContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const data = parsed.data.body + + const client = createAthenaClient({ + region: data.region, + accessKeyId: data.accessKeyId, + secretAccessKey: data.secretAccessKey, + }) + + try { + const command = new ListTableMetadataCommand({ + CatalogName: data.catalogName, + DatabaseName: data.databaseName, + ...(data.expression && { Expression: data.expression }), + ...(data.workGroup && { WorkGroup: data.workGroup }), + ...(data.maxResults !== undefined && { MaxResults: data.maxResults }), + ...(data.nextToken && { NextToken: data.nextToken }), + }) + + const response = await client.send(command) + + return NextResponse.json({ + success: true, + output: { + tables: (response.TableMetadataList ?? []).map((table) => ({ + name: table.Name ?? '', + tableType: table.TableType ?? null, + createTime: table.CreateTime?.getTime() ?? null, + lastAccessTime: table.LastAccessTime?.getTime() ?? null, + columns: (table.Columns ?? []).map((col) => ({ + name: col.Name ?? '', + type: col.Type ?? null, + comment: col.Comment ?? null, + })), + partitionKeys: (table.PartitionKeys ?? []).map((col) => ({ + name: col.Name ?? '', + type: col.Type ?? null, + comment: col.Comment ?? null, + })), + })), + nextToken: response.NextToken ?? null, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Failed to list Athena table metadata') + logger.error('ListTableMetadata failed', { error: errorMessage }) + return NextResponse.json({ error: errorMessage }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/cloudformation/cancel-update-stack/route.ts b/apps/sim/app/api/tools/cloudformation/cancel-update-stack/route.ts new file mode 100644 index 00000000000..5158bfcb125 --- /dev/null +++ b/apps/sim/app/api/tools/cloudformation/cancel-update-stack/route.ts @@ -0,0 +1,57 @@ +import { CancelUpdateStackCommand, CloudFormationClient } from '@aws-sdk/client-cloudformation' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCloudformationCancelUpdateStackContract } from '@/lib/api/contracts/tools/aws/cloudformation-cancel-update-stack' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('CloudFormationCancelUpdateStack') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCloudformationCancelUpdateStackContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + const client = new CloudFormationClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + logger.info(`Cancelling update for CloudFormation stack "${validatedData.stackName}"`) + + try { + const command = new CancelUpdateStackCommand({ + StackName: validatedData.stackName, + }) + + await client.send(command) + + return NextResponse.json({ + success: true, + output: { + message: `Update for stack "${validatedData.stackName}" is being cancelled and rolled back`, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Failed to cancel CloudFormation stack update') + logger.error('CancelUpdateStack failed', { error: errorMessage }) + return NextResponse.json({ error: errorMessage }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/cloudformation/create-change-set/route.ts b/apps/sim/app/api/tools/cloudformation/create-change-set/route.ts new file mode 100644 index 00000000000..cb3bff94456 --- /dev/null +++ b/apps/sim/app/api/tools/cloudformation/create-change-set/route.ts @@ -0,0 +1,68 @@ +import { CloudFormationClient, CreateChangeSetCommand } from '@aws-sdk/client-cloudformation' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCloudformationCreateChangeSetContract } from '@/lib/api/contracts/tools/aws/cloudformation-create-change-set' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { parseCapabilities, toStackParameters } from '../utils' + +const logger = createLogger('CloudFormationCreateChangeSet') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCloudformationCreateChangeSetContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + const client = new CloudFormationClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + logger.info( + `Creating change set "${validatedData.changeSetName}" for stack "${validatedData.stackName}"` + ) + + try { + const command = new CreateChangeSetCommand({ + StackName: validatedData.stackName, + ChangeSetName: validatedData.changeSetName, + TemplateBody: validatedData.templateBody, + UsePreviousTemplate: validatedData.usePreviousTemplate, + Parameters: toStackParameters(validatedData.parameters), + Capabilities: parseCapabilities(validatedData.capabilities), + ChangeSetType: validatedData.changeSetType, + Description: validatedData.description, + }) + + const response = await client.send(command) + + return NextResponse.json({ + success: true, + output: { + changeSetId: response.Id ?? '', + stackId: response.StackId ?? '', + }, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Failed to create CloudFormation change set') + logger.error('CreateChangeSet failed', { error: errorMessage }) + return NextResponse.json({ error: errorMessage }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/cloudformation/create-stack/route.ts b/apps/sim/app/api/tools/cloudformation/create-stack/route.ts new file mode 100644 index 00000000000..a08b04683df --- /dev/null +++ b/apps/sim/app/api/tools/cloudformation/create-stack/route.ts @@ -0,0 +1,64 @@ +import { CloudFormationClient, CreateStackCommand } from '@aws-sdk/client-cloudformation' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCloudformationCreateStackContract } from '@/lib/api/contracts/tools/aws/cloudformation-create-stack' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { parseCapabilities, toStackParameters, toStackTags } from '../utils' + +const logger = createLogger('CloudFormationCreateStack') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCloudformationCreateStackContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + const client = new CloudFormationClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + logger.info(`Creating CloudFormation stack "${validatedData.stackName}"`) + + try { + const command = new CreateStackCommand({ + StackName: validatedData.stackName, + TemplateBody: validatedData.templateBody, + Parameters: toStackParameters(validatedData.parameters), + Capabilities: parseCapabilities(validatedData.capabilities), + Tags: toStackTags(validatedData.tags), + OnFailure: validatedData.onFailure, + TimeoutInMinutes: validatedData.timeoutInMinutes, + }) + + const response = await client.send(command) + + return NextResponse.json({ + success: true, + output: { + stackId: response.StackId ?? '', + }, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Failed to create CloudFormation stack') + logger.error('CreateStack failed', { error: errorMessage }) + return NextResponse.json({ error: errorMessage }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/cloudformation/delete-stack/route.ts b/apps/sim/app/api/tools/cloudformation/delete-stack/route.ts new file mode 100644 index 00000000000..4b176ffd679 --- /dev/null +++ b/apps/sim/app/api/tools/cloudformation/delete-stack/route.ts @@ -0,0 +1,67 @@ +import { CloudFormationClient, DeleteStackCommand } from '@aws-sdk/client-cloudformation' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCloudformationDeleteStackContract } from '@/lib/api/contracts/tools/aws/cloudformation-delete-stack' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('CloudFormationDeleteStack') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCloudformationDeleteStackContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + const client = new CloudFormationClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + logger.info(`Deleting CloudFormation stack "${validatedData.stackName}"`) + + try { + const retainResources = validatedData.retainResources + ?.split(',') + .map((r) => r.trim()) + .filter(Boolean) + + const command = new DeleteStackCommand({ + StackName: validatedData.stackName, + ...(retainResources && retainResources.length > 0 && { RetainResources: retainResources }), + }) + + await client.send(command) + + logger.info( + `Successfully requested deletion of CloudFormation stack "${validatedData.stackName}"` + ) + + return NextResponse.json({ + success: true, + output: { + message: `Deletion of stack "${validatedData.stackName}" has been initiated`, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Failed to delete CloudFormation stack') + logger.error('DeleteStack failed', { error: errorMessage }) + return NextResponse.json({ error: errorMessage }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/cloudformation/describe-change-set/route.ts b/apps/sim/app/api/tools/cloudformation/describe-change-set/route.ts new file mode 100644 index 00000000000..16d81a82862 --- /dev/null +++ b/apps/sim/app/api/tools/cloudformation/describe-change-set/route.ts @@ -0,0 +1,74 @@ +import { CloudFormationClient, DescribeChangeSetCommand } from '@aws-sdk/client-cloudformation' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCloudformationDescribeChangeSetContract } from '@/lib/api/contracts/tools/aws/cloudformation-describe-change-set' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('CloudFormationDescribeChangeSet') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCloudformationDescribeChangeSetContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + const client = new CloudFormationClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new DescribeChangeSetCommand({ + ChangeSetName: validatedData.changeSetName, + ...(validatedData.stackName && { StackName: validatedData.stackName }), + }) + + const response = await client.send(command) + + const changes = (response.Changes ?? []).map((c) => ({ + action: c.ResourceChange?.Action, + logicalResourceId: c.ResourceChange?.LogicalResourceId, + physicalResourceId: c.ResourceChange?.PhysicalResourceId, + resourceType: c.ResourceChange?.ResourceType, + replacement: c.ResourceChange?.Replacement, + })) + + return NextResponse.json({ + success: true, + output: { + changeSetName: response.ChangeSetName, + changeSetId: response.ChangeSetId, + stackId: response.StackId, + stackName: response.StackName, + description: response.Description, + executionStatus: response.ExecutionStatus, + status: response.Status, + statusReason: response.StatusReason, + creationTime: response.CreationTime?.getTime(), + capabilities: response.Capabilities ?? [], + changes, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Failed to describe CloudFormation change set') + logger.error('DescribeChangeSet failed', { error: errorMessage }) + return NextResponse.json({ error: errorMessage }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/cloudformation/execute-change-set/route.ts b/apps/sim/app/api/tools/cloudformation/execute-change-set/route.ts new file mode 100644 index 00000000000..3229e0b73e9 --- /dev/null +++ b/apps/sim/app/api/tools/cloudformation/execute-change-set/route.ts @@ -0,0 +1,58 @@ +import { CloudFormationClient, ExecuteChangeSetCommand } from '@aws-sdk/client-cloudformation' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCloudformationExecuteChangeSetContract } from '@/lib/api/contracts/tools/aws/cloudformation-execute-change-set' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('CloudFormationExecuteChangeSet') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCloudformationExecuteChangeSetContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + const client = new CloudFormationClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + logger.info(`Executing change set "${validatedData.changeSetName}"`) + + try { + const command = new ExecuteChangeSetCommand({ + ChangeSetName: validatedData.changeSetName, + ...(validatedData.stackName && { StackName: validatedData.stackName }), + }) + + await client.send(command) + + return NextResponse.json({ + success: true, + output: { + message: `Change set "${validatedData.changeSetName}" execution has been initiated`, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Failed to execute CloudFormation change set') + logger.error('ExecuteChangeSet failed', { error: errorMessage }) + return NextResponse.json({ error: errorMessage }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/cloudformation/get-template-summary/route.ts b/apps/sim/app/api/tools/cloudformation/get-template-summary/route.ts new file mode 100644 index 00000000000..555db0118a5 --- /dev/null +++ b/apps/sim/app/api/tools/cloudformation/get-template-summary/route.ts @@ -0,0 +1,68 @@ +import { CloudFormationClient, GetTemplateSummaryCommand } from '@aws-sdk/client-cloudformation' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCloudformationGetTemplateSummaryContract } from '@/lib/api/contracts/tools/aws/cloudformation-get-template-summary' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('CloudFormationGetTemplateSummary') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCloudformationGetTemplateSummaryContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + const client = new CloudFormationClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new GetTemplateSummaryCommand({ + ...(validatedData.templateBody && { TemplateBody: validatedData.templateBody }), + ...(validatedData.stackName && { StackName: validatedData.stackName }), + }) + + const response = await client.send(command) + + return NextResponse.json({ + success: true, + output: { + description: response.Description, + parameters: (response.Parameters ?? []).map((p) => ({ + parameterKey: p.ParameterKey, + defaultValue: p.DefaultValue, + parameterType: p.ParameterType, + noEcho: p.NoEcho, + description: p.Description, + })), + capabilities: response.Capabilities ?? [], + capabilitiesReason: response.CapabilitiesReason, + resourceTypes: response.ResourceTypes ?? [], + version: response.Version, + declaredTransforms: response.DeclaredTransforms ?? [], + }, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Failed to get CloudFormation template summary') + logger.error('GetTemplateSummary failed', { error: errorMessage }) + return NextResponse.json({ error: errorMessage }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/cloudformation/get-template/route.ts b/apps/sim/app/api/tools/cloudformation/get-template/route.ts index 3b695de8ca0..ed1617c2504 100644 --- a/apps/sim/app/api/tools/cloudformation/get-template/route.ts +++ b/apps/sim/app/api/tools/cloudformation/get-template/route.ts @@ -33,6 +33,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const command = new GetTemplateCommand({ StackName: validatedData.stackName, + ...(validatedData.templateStage && { TemplateStage: validatedData.templateStage }), }) const response = await client.send(command) diff --git a/apps/sim/app/api/tools/cloudformation/update-stack/route.ts b/apps/sim/app/api/tools/cloudformation/update-stack/route.ts new file mode 100644 index 00000000000..0377e188371 --- /dev/null +++ b/apps/sim/app/api/tools/cloudformation/update-stack/route.ts @@ -0,0 +1,63 @@ +import { CloudFormationClient, UpdateStackCommand } from '@aws-sdk/client-cloudformation' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCloudformationUpdateStackContract } from '@/lib/api/contracts/tools/aws/cloudformation-update-stack' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { parseCapabilities, toStackParameters, toStackTags } from '../utils' + +const logger = createLogger('CloudFormationUpdateStack') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCloudformationUpdateStackContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + const client = new CloudFormationClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + logger.info(`Updating CloudFormation stack "${validatedData.stackName}"`) + + try { + const command = new UpdateStackCommand({ + StackName: validatedData.stackName, + TemplateBody: validatedData.templateBody, + UsePreviousTemplate: validatedData.usePreviousTemplate, + Parameters: toStackParameters(validatedData.parameters), + Capabilities: parseCapabilities(validatedData.capabilities), + Tags: toStackTags(validatedData.tags), + }) + + const response = await client.send(command) + + return NextResponse.json({ + success: true, + output: { + stackId: response.StackId ?? '', + }, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Failed to update CloudFormation stack') + logger.error('UpdateStack failed', { error: errorMessage }) + return NextResponse.json({ error: errorMessage }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/cloudformation/utils.ts b/apps/sim/app/api/tools/cloudformation/utils.ts new file mode 100644 index 00000000000..44d3611afc8 --- /dev/null +++ b/apps/sim/app/api/tools/cloudformation/utils.ts @@ -0,0 +1,36 @@ +import type { Capability, Parameter, Tag } from '@aws-sdk/client-cloudformation' + +/** + * Parses a comma-separated capabilities string (e.g. "CAPABILITY_IAM,CAPABILITY_NAMED_IAM") + * into the array shape the CloudFormation SDK expects. + */ +export function parseCapabilities(value?: string): Capability[] | undefined { + if (!value) return undefined + const capabilities = value + .split(',') + .map((c) => c.trim()) + .filter(Boolean) + return capabilities.length > 0 ? (capabilities as Capability[]) : undefined +} + +/** + * Maps camelCase stack parameter inputs to the PascalCase `Parameter` shape CloudFormation expects. + */ +export function toStackParameters( + parameters?: { parameterKey: string; parameterValue?: string; usePreviousValue?: boolean }[] +): Parameter[] | undefined { + if (!parameters || parameters.length === 0) return undefined + return parameters.map((p) => ({ + ParameterKey: p.parameterKey, + ParameterValue: p.parameterValue, + UsePreviousValue: p.usePreviousValue, + })) +} + +/** + * Maps camelCase tag inputs to the PascalCase `Tag` shape CloudFormation expects. + */ +export function toStackTags(tags?: { key: string; value: string }[]): Tag[] | undefined { + if (!tags || tags.length === 0) return undefined + return tags.map((t) => ({ Key: t.key, Value: t.value })) +} diff --git a/apps/sim/app/api/tools/cloudwatch/describe-alarm-history/route.ts b/apps/sim/app/api/tools/cloudwatch/describe-alarm-history/route.ts new file mode 100644 index 00000000000..25fbc4e18a0 --- /dev/null +++ b/apps/sim/app/api/tools/cloudwatch/describe-alarm-history/route.ts @@ -0,0 +1,123 @@ +import { + type AlarmType, + CloudWatchClient, + DescribeAlarmHistoryCommand, +} from '@aws-sdk/client-cloudwatch' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCloudwatchDescribeAlarmHistoryContract } from '@/lib/api/contracts/tools/aws/cloudwatch-describe-alarm-history' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('CloudWatchDescribeAlarmHistory') + +/** AWS DescribeAlarmHistory caps `MaxRecords` at 100 items per page. */ +const ALARM_HISTORY_PAGE_SIZE = 100 + +/** Upper bound on pages drained to avoid unbounded loops on long-lived alarms. */ +const MAX_ALARM_HISTORY_PAGES = 20 + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCloudwatchDescribeAlarmHistoryContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info('Describing CloudWatch alarm history') + + const client = new CloudWatchClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const totalLimit = validatedData.limit + const alarmHistoryItems: { + alarmName: string | undefined + alarmType: string | undefined + timestamp: number | undefined + historyItemType: string | undefined + historySummary: string | undefined + }[] = [] + let nextToken: string | undefined + + for (let page = 0; page < MAX_ALARM_HISTORY_PAGES; page++) { + const pageLimit = + totalLimit !== undefined + ? Math.min(ALARM_HISTORY_PAGE_SIZE, totalLimit - alarmHistoryItems.length) + : ALARM_HISTORY_PAGE_SIZE + + const command = new DescribeAlarmHistoryCommand({ + ...(validatedData.alarmName && { AlarmName: validatedData.alarmName }), + // AWS defaults AlarmTypes to MetricAlarm-only, so always request both kinds explicitly. + AlarmTypes: ['MetricAlarm', 'CompositeAlarm'] as AlarmType[], + ...(validatedData.historyItemType && { + HistoryItemType: validatedData.historyItemType, + }), + ...(validatedData.startDate !== undefined && { + StartDate: new Date(validatedData.startDate * 1000), + }), + ...(validatedData.endDate !== undefined && { + EndDate: new Date(validatedData.endDate * 1000), + }), + ScanBy: validatedData.scanBy ?? 'TimestampDescending', + MaxRecords: pageLimit, + ...(nextToken && { NextToken: nextToken }), + }) + + const response = await client.send(command) + + for (const item of response.AlarmHistoryItems ?? []) { + alarmHistoryItems.push({ + alarmName: item.AlarmName, + alarmType: item.AlarmType, + timestamp: item.Timestamp?.getTime(), + historyItemType: item.HistoryItemType, + historySummary: item.HistorySummary, + }) + } + + nextToken = response.NextToken + if (!nextToken) break + if (totalLimit !== undefined && alarmHistoryItems.length >= totalLimit) break + + if (page === MAX_ALARM_HISTORY_PAGES - 1) { + logger.warn( + `DescribeAlarmHistory hit pagination cap of ${MAX_ALARM_HISTORY_PAGES} pages; history may be incomplete` + ) + } + } + + const cappedItems = + totalLimit !== undefined ? alarmHistoryItems.slice(0, totalLimit) : alarmHistoryItems + + logger.info(`Successfully described ${cappedItems.length} alarm history items`) + + return NextResponse.json({ + success: true, + output: { alarmHistoryItems: cappedItems }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('DescribeAlarmHistory failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to describe CloudWatch alarm history: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/cloudwatch/filter-log-events/route.ts b/apps/sim/app/api/tools/cloudwatch/filter-log-events/route.ts new file mode 100644 index 00000000000..318d0890dc4 --- /dev/null +++ b/apps/sim/app/api/tools/cloudwatch/filter-log-events/route.ts @@ -0,0 +1,62 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCloudwatchFilterLogEventsContract } from '@/lib/api/contracts/tools/aws/cloudwatch-filter-log-events' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createCloudWatchLogsClient, filterLogEvents } from '@/app/api/tools/cloudwatch/utils' + +const logger = createLogger('CloudWatchFilterLogEvents') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCloudwatchFilterLogEventsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info(`Filtering log events in ${validatedData.logGroupName}`) + + const client = createCloudWatchLogsClient({ + region: validatedData.region, + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }) + + try { + const result = await filterLogEvents(client, validatedData.logGroupName, { + filterPattern: validatedData.filterPattern, + logStreamNamePrefix: validatedData.logStreamNamePrefix, + // CloudWatch Logs timestamps are epoch milliseconds; our params are epoch seconds. + startTime: + validatedData.startTime !== undefined ? validatedData.startTime * 1000 : undefined, + endTime: validatedData.endTime !== undefined ? validatedData.endTime * 1000 : undefined, + startFromHead: validatedData.startFromHead, + limit: validatedData.limit, + }) + + logger.info(`Successfully filtered ${result.events.length} log events`) + + return NextResponse.json({ + success: true, + output: { events: result.events }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('FilterLogEvents failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to filter CloudWatch log events: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/cloudwatch/list-metrics/route.ts b/apps/sim/app/api/tools/cloudwatch/list-metrics/route.ts index 62660f290cb..3aafe8b8c37 100644 --- a/apps/sim/app/api/tools/cloudwatch/list-metrics/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/list-metrics/route.ts @@ -9,6 +9,12 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudWatchListMetrics') +/** AWS ListMetrics returns up to 500 results per page. */ +const METRICS_PAGE_SIZE = 500 + +/** Upper bound on pages drained to avoid unbounded loops on accounts with many metrics. */ +const MAX_METRICS_PAGES = 20 + export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) @@ -34,30 +40,53 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) try { - const limit = validatedData.limit ?? 500 + const totalLimit = validatedData.limit ?? METRICS_PAGE_SIZE + const metrics: { + namespace: string + metricName: string + dimensions: { name: string; value: string }[] + }[] = [] + let nextToken: string | undefined - const command = new ListMetricsCommand({ - ...(validatedData.namespace && { Namespace: validatedData.namespace }), - ...(validatedData.metricName && { MetricName: validatedData.metricName }), - ...(validatedData.recentlyActive && { RecentlyActive: 'PT3H' }), - }) + for (let page = 0; page < MAX_METRICS_PAGES; page++) { + const command = new ListMetricsCommand({ + ...(validatedData.namespace && { Namespace: validatedData.namespace }), + ...(validatedData.metricName && { MetricName: validatedData.metricName }), + ...(validatedData.recentlyActive && { RecentlyActive: 'PT3H' }), + ...(nextToken && { NextToken: nextToken }), + }) + + const response = await client.send(command) + + for (const m of response.Metrics ?? []) { + metrics.push({ + namespace: m.Namespace ?? '', + metricName: m.MetricName ?? '', + dimensions: (m.Dimensions ?? []).map((d) => ({ + name: d.Name ?? '', + value: d.Value ?? '', + })), + }) + } + + nextToken = response.NextToken + if (!nextToken) break + if (metrics.length >= totalLimit) break - const response = await client.send(command) + if (page === MAX_METRICS_PAGES - 1) { + logger.warn( + `ListMetrics hit pagination cap of ${MAX_METRICS_PAGES} pages; metric list may be incomplete` + ) + } + } - const metrics = (response.Metrics ?? []).slice(0, limit).map((m) => ({ - namespace: m.Namespace ?? '', - metricName: m.MetricName ?? '', - dimensions: (m.Dimensions ?? []).map((d) => ({ - name: d.Name ?? '', - value: d.Value ?? '', - })), - })) + const cappedMetrics = metrics.slice(0, totalLimit) - logger.info(`Successfully listed ${metrics.length} metrics`) + logger.info(`Successfully listed ${cappedMetrics.length} metrics`) return NextResponse.json({ success: true, - output: { metrics }, + output: { metrics: cappedMetrics }, }) } finally { client.destroy() diff --git a/apps/sim/app/api/tools/cloudwatch/put-log-group-retention/route.ts b/apps/sim/app/api/tools/cloudwatch/put-log-group-retention/route.ts new file mode 100644 index 00000000000..ae3cef9fd35 --- /dev/null +++ b/apps/sim/app/api/tools/cloudwatch/put-log-group-retention/route.ts @@ -0,0 +1,74 @@ +import { + DeleteRetentionPolicyCommand, + PutRetentionPolicyCommand, +} from '@aws-sdk/client-cloudwatch-logs' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCloudwatchPutLogGroupRetentionContract } from '@/lib/api/contracts/tools/aws/cloudwatch-put-log-group-retention' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createCloudWatchLogsClient } from '@/app/api/tools/cloudwatch/utils' + +const logger = createLogger('CloudWatchPutLogGroupRetention') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCloudwatchPutLogGroupRetentionContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + const client = createCloudWatchLogsClient({ + region: validatedData.region, + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }) + + try { + if (validatedData.retentionInDays !== undefined) { + logger.info( + `Setting retention for log group "${validatedData.logGroupName}" to ${validatedData.retentionInDays} days` + ) + await client.send( + new PutRetentionPolicyCommand({ + logGroupName: validatedData.logGroupName, + retentionInDays: validatedData.retentionInDays, + }) + ) + } else { + logger.info( + `Removing retention policy for log group "${validatedData.logGroupName}" (events never expire)` + ) + await client.send( + new DeleteRetentionPolicyCommand({ logGroupName: validatedData.logGroupName }) + ) + } + + return NextResponse.json({ + success: true, + output: { + success: true, + logGroupName: validatedData.logGroupName, + retentionInDays: validatedData.retentionInDays ?? null, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('PutLogGroupRetention failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to set CloudWatch log group retention: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/cloudwatch/utils.ts b/apps/sim/app/api/tools/cloudwatch/utils.ts index 2984229e75d..a477fda9e9f 100644 --- a/apps/sim/app/api/tools/cloudwatch/utils.ts +++ b/apps/sim/app/api/tools/cloudwatch/utils.ts @@ -1,6 +1,7 @@ import { CloudWatchLogsClient, DescribeLogStreamsCommand, + FilterLogEventsCommand, GetLogEventsCommand, GetQueryResultsCommand, type ResultField, @@ -176,6 +177,88 @@ export async function describeLogStreams( } } +/** AWS FilterLogEvents caps `limit` at 10,000 events per page. */ +const FILTER_LOG_EVENTS_PAGE_SIZE = 10_000 + +/** Upper bound on pages drained to avoid unbounded loops on very active log groups. */ +const MAX_FILTER_LOG_EVENTS_PAGES = 20 + +interface FilteredLogEventResult { + logStreamName: string | undefined + timestamp: number | undefined + message: string | undefined + ingestionTime: number | undefined +} + +/** + * Searches log events across all streams (or a prefix-matched subset) in a log + * group, following `nextToken` so the complete matching set is returned rather + * than just the first page. Bounded by `MAX_FILTER_LOG_EVENTS_PAGES`. + * + * When `limit` is provided it is treated as a total result cap: draining stops + * once enough events have been collected. When omitted, every page is drained. + */ +export async function filterLogEvents( + client: CloudWatchLogsClient, + logGroupName: string, + options?: { + filterPattern?: string + logStreamNamePrefix?: string + startTime?: number + endTime?: number + startFromHead?: boolean + limit?: number + } +): Promise<{ events: FilteredLogEventResult[] }> { + const totalLimit = options?.limit + const events: FilteredLogEventResult[] = [] + let nextToken: string | undefined + + for (let page = 0; page < MAX_FILTER_LOG_EVENTS_PAGES; page++) { + const pageLimit = + totalLimit !== undefined + ? Math.min(FILTER_LOG_EVENTS_PAGE_SIZE, totalLimit - events.length) + : FILTER_LOG_EVENTS_PAGE_SIZE + + const command = new FilterLogEventsCommand({ + logGroupName, + ...(options?.filterPattern && { filterPattern: options.filterPattern }), + ...(options?.logStreamNamePrefix && { logStreamNamePrefix: options.logStreamNamePrefix }), + ...(options?.startTime !== undefined && { startTime: options.startTime }), + ...(options?.endTime !== undefined && { endTime: options.endTime }), + ...(options?.startFromHead !== undefined && { startFromHead: options.startFromHead }), + limit: pageLimit, + ...(nextToken && { nextToken }), + }) + + const response = await client.send(command) + + for (const e of response.events ?? []) { + events.push({ + logStreamName: e.logStreamName, + timestamp: e.timestamp, + message: e.message, + ingestionTime: e.ingestionTime, + }) + } + + nextToken = response.nextToken + if (!nextToken) break + if (totalLimit !== undefined && events.length >= totalLimit) break + + if (page === MAX_FILTER_LOG_EVENTS_PAGES - 1) { + logger.warn( + `FilterLogEvents hit pagination cap of ${MAX_FILTER_LOG_EVENTS_PAGES} pages; event list may be incomplete`, + { logGroupName } + ) + } + } + + return { + events: totalLimit !== undefined ? events.slice(0, totalLimit) : events, + } +} + export async function getLogEvents( client: CloudWatchLogsClient, logGroupName: string, diff --git a/apps/sim/app/api/tools/codepipeline/disable-stage-transition/route.ts b/apps/sim/app/api/tools/codepipeline/disable-stage-transition/route.ts new file mode 100644 index 00000000000..3d2027d8df0 --- /dev/null +++ b/apps/sim/app/api/tools/codepipeline/disable-stage-transition/route.ts @@ -0,0 +1,71 @@ +import { + CodePipelineClient, + DisableStageTransitionCommand, + type StageTransitionType, +} from '@aws-sdk/client-codepipeline' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCodepipelineDisableStageTransitionContract } from '@/lib/api/contracts/tools/aws/codepipeline-disable-stage-transition' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { awsErrorStatus } from '@/app/api/tools/codepipeline/utils' + +const logger = createLogger('CodePipelineDisableStageTransition') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCodepipelineDisableStageTransitionContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info('Disabling CodePipeline stage transition') + + const client = new CodePipelineClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new DisableStageTransitionCommand({ + pipelineName: validatedData.pipelineName, + stageName: validatedData.stageName, + transitionType: validatedData.transitionType as StageTransitionType, + reason: validatedData.reason, + }) + + await client.send(command) + + logger.info('Successfully disabled stage transition') + + return NextResponse.json({ + success: true, + output: { + pipelineName: validatedData.pipelineName, + stageName: validatedData.stageName, + transitionType: validatedData.transitionType, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('DisableStageTransition failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to disable CodePipeline stage transition: ${toError(error).message}` }, + { status: awsErrorStatus(error) } + ) + } +}) diff --git a/apps/sim/app/api/tools/codepipeline/enable-stage-transition/route.ts b/apps/sim/app/api/tools/codepipeline/enable-stage-transition/route.ts new file mode 100644 index 00000000000..a96c4b993c7 --- /dev/null +++ b/apps/sim/app/api/tools/codepipeline/enable-stage-transition/route.ts @@ -0,0 +1,70 @@ +import { + CodePipelineClient, + EnableStageTransitionCommand, + type StageTransitionType, +} from '@aws-sdk/client-codepipeline' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCodepipelineEnableStageTransitionContract } from '@/lib/api/contracts/tools/aws/codepipeline-enable-stage-transition' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { awsErrorStatus } from '@/app/api/tools/codepipeline/utils' + +const logger = createLogger('CodePipelineEnableStageTransition') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCodepipelineEnableStageTransitionContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info('Enabling CodePipeline stage transition') + + const client = new CodePipelineClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new EnableStageTransitionCommand({ + pipelineName: validatedData.pipelineName, + stageName: validatedData.stageName, + transitionType: validatedData.transitionType as StageTransitionType, + }) + + await client.send(command) + + logger.info('Successfully enabled stage transition') + + return NextResponse.json({ + success: true, + output: { + pipelineName: validatedData.pipelineName, + stageName: validatedData.stageName, + transitionType: validatedData.transitionType, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('EnableStageTransition failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to enable CodePipeline stage transition: ${toError(error).message}` }, + { status: awsErrorStatus(error) } + ) + } +}) diff --git a/apps/sim/app/api/tools/codepipeline/get-pipeline/route.ts b/apps/sim/app/api/tools/codepipeline/get-pipeline/route.ts new file mode 100644 index 00000000000..4db9a2e5326 --- /dev/null +++ b/apps/sim/app/api/tools/codepipeline/get-pipeline/route.ts @@ -0,0 +1,97 @@ +import { CodePipelineClient, GetPipelineCommand } from '@aws-sdk/client-codepipeline' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCodepipelineGetPipelineContract } from '@/lib/api/contracts/tools/aws/codepipeline-get-pipeline' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { awsErrorStatus } from '@/app/api/tools/codepipeline/utils' + +const logger = createLogger('CodePipelineGetPipeline') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCodepipelineGetPipelineContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info('Getting CodePipeline pipeline structure') + + const client = new CodePipelineClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new GetPipelineCommand({ + name: validatedData.pipelineName, + ...(validatedData.version !== undefined && { version: validatedData.version }), + }) + const response = await client.send(command) + const pipeline = response.pipeline + + if (!pipeline) { + throw new Error('Pipeline structure not found in response') + } + + const stages = (pipeline.stages ?? []).map((stage) => ({ + stageName: stage.name ?? '', + actions: (stage.actions ?? []).map((action) => ({ + name: action.name ?? '', + category: action.actionTypeId?.category ?? '', + owner: action.actionTypeId?.owner ?? '', + provider: action.actionTypeId?.provider ?? '', + version: action.actionTypeId?.version ?? '', + runOrder: action.runOrder, + configuration: action.configuration ?? {}, + inputArtifacts: (action.inputArtifacts ?? []).map((a) => a.name ?? ''), + outputArtifacts: (action.outputArtifacts ?? []).map((a) => a.name ?? ''), + })), + })) + + logger.info(`Successfully got pipeline structure with ${stages.length} stages`) + + return NextResponse.json({ + success: true, + output: { + pipelineName: pipeline.name ?? validatedData.pipelineName, + pipelineArn: response.metadata?.pipelineArn, + roleArn: pipeline.roleArn ?? '', + version: pipeline.version, + pipelineType: pipeline.pipelineType, + executionMode: pipeline.executionMode, + artifactStoreType: pipeline.artifactStore?.type, + artifactStoreLocation: pipeline.artifactStore?.location, + stages, + variables: (pipeline.variables ?? []).map((v) => ({ + name: v.name ?? '', + defaultValue: v.defaultValue, + description: v.description, + })), + created: response.metadata?.created?.getTime(), + updated: response.metadata?.updated?.getTime(), + }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('GetPipeline failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to get CodePipeline pipeline: ${toError(error).message}` }, + { status: awsErrorStatus(error) } + ) + } +}) diff --git a/apps/sim/app/api/tools/codepipeline/list-action-executions/route.ts b/apps/sim/app/api/tools/codepipeline/list-action-executions/route.ts new file mode 100644 index 00000000000..56cc7324500 --- /dev/null +++ b/apps/sim/app/api/tools/codepipeline/list-action-executions/route.ts @@ -0,0 +1,85 @@ +import { CodePipelineClient, ListActionExecutionsCommand } from '@aws-sdk/client-codepipeline' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCodepipelineListActionExecutionsContract } from '@/lib/api/contracts/tools/aws/codepipeline-list-action-executions' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { awsErrorStatus } from '@/app/api/tools/codepipeline/utils' + +const logger = createLogger('CodePipelineListActionExecutions') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCodepipelineListActionExecutionsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info('Listing CodePipeline action executions') + + const client = new CodePipelineClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new ListActionExecutionsCommand({ + pipelineName: validatedData.pipelineName, + ...(validatedData.pipelineExecutionId && { + filter: { pipelineExecutionId: validatedData.pipelineExecutionId }, + }), + ...(validatedData.maxResults !== undefined && { maxResults: validatedData.maxResults }), + ...(validatedData.nextToken && { nextToken: validatedData.nextToken }), + }) + + const response = await client.send(command) + + const actionExecutionDetails = (response.actionExecutionDetails ?? []).map((d) => ({ + pipelineExecutionId: d.pipelineExecutionId, + actionExecutionId: d.actionExecutionId, + pipelineVersion: d.pipelineVersion, + stageName: d.stageName, + actionName: d.actionName, + startTime: d.startTime?.getTime(), + lastUpdateTime: d.lastUpdateTime?.getTime(), + updatedBy: d.updatedBy, + status: d.status, + externalExecutionId: d.output?.executionResult?.externalExecutionId, + externalExecutionSummary: d.output?.executionResult?.externalExecutionSummary, + externalExecutionUrl: d.output?.executionResult?.externalExecutionUrl, + errorCode: d.output?.executionResult?.errorDetails?.code, + errorMessage: d.output?.executionResult?.errorDetails?.message, + })) + + logger.info(`Successfully listed ${actionExecutionDetails.length} action executions`) + + return NextResponse.json({ + success: true, + output: { + actionExecutionDetails, + ...(response.nextToken && { nextToken: response.nextToken }), + }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('ListActionExecutions failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to list CodePipeline action executions: ${toError(error).message}` }, + { status: awsErrorStatus(error) } + ) + } +}) diff --git a/apps/sim/app/api/tools/codepipeline/list-pipeline-executions/route.ts b/apps/sim/app/api/tools/codepipeline/list-pipeline-executions/route.ts index 2b8a579ca73..f7083b7b619 100644 --- a/apps/sim/app/api/tools/codepipeline/list-pipeline-executions/route.ts +++ b/apps/sim/app/api/tools/codepipeline/list-pipeline-executions/route.ts @@ -57,6 +57,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { stopTriggerReason: e.stopTrigger?.reason, triggerType: e.trigger?.triggerType, triggerDetail: e.trigger?.triggerDetail, + rollbackTargetPipelineExecutionId: e.rollbackMetadata?.rollbackTargetPipelineExecutionId, sourceRevisions: (e.sourceRevisions ?? []).map((r) => ({ actionName: r.actionName ?? '', revisionId: r.revisionId, diff --git a/apps/sim/blocks/blocks/athena.ts b/apps/sim/blocks/blocks/athena.ts index e295464d5ae..62a6d10cec0 100644 --- a/apps/sim/blocks/blocks/athena.ts +++ b/apps/sim/blocks/blocks/athena.ts @@ -2,12 +2,16 @@ import { AthenaIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { IntegrationType } from '@/blocks/types' import type { + AthenaBatchGetQueryExecutionResponse, AthenaCreateNamedQueryResponse, + AthenaDeleteNamedQueryResponse, AthenaGetNamedQueryResponse, AthenaGetQueryExecutionResponse, AthenaGetQueryResultsResponse, + AthenaListDatabasesResponse, AthenaListNamedQueriesResponse, AthenaListQueryExecutionsResponse, + AthenaListTableMetadataResponse, AthenaStartQueryResponse, AthenaStopQueryResponse, } from '@/tools/athena/types' @@ -18,9 +22,13 @@ export const AthenaBlock: BlockConfig< | AthenaGetQueryResultsResponse | AthenaStopQueryResponse | AthenaListQueryExecutionsResponse + | AthenaBatchGetQueryExecutionResponse | AthenaCreateNamedQueryResponse | AthenaGetNamedQueryResponse | AthenaListNamedQueriesResponse + | AthenaDeleteNamedQueryResponse + | AthenaListDatabasesResponse + | AthenaListTableMetadataResponse > = { type: 'athena', name: 'Athena', @@ -44,9 +52,13 @@ export const AthenaBlock: BlockConfig< { label: 'Get Query Results', id: 'get_query_results' }, { label: 'Stop Query', id: 'stop_query' }, { label: 'List Query Executions', id: 'list_query_executions' }, + { label: 'Batch Get Query Executions', id: 'batch_get_query_execution' }, { label: 'Create Named Query', id: 'create_named_query' }, { label: 'Get Named Query', id: 'get_named_query' }, { label: 'List Named Queries', id: 'list_named_queries' }, + { label: 'Delete Named Query', id: 'delete_named_query' }, + { label: 'List Databases', id: 'list_databases' }, + { label: 'List Table Metadata', id: 'list_table_metadata' }, ], value: () => 'start_query', }, @@ -125,7 +137,14 @@ Return ONLY the SQL query — no explanations, no markdown code blocks.`, placeholder: 'primary', condition: { field: 'operation', - value: ['start_query', 'list_query_executions', 'create_named_query', 'list_named_queries'], + value: [ + 'start_query', + 'list_query_executions', + 'create_named_query', + 'list_named_queries', + 'list_databases', + 'list_table_metadata', + ], }, mode: 'advanced', }, @@ -143,13 +162,45 @@ Return ONLY the SQL query — no explanations, no markdown code blocks.`, value: ['get_query_execution', 'get_query_results', 'stop_query'], }, }, + { + id: 'queryExecutionIds', + title: 'Query Execution IDs', + type: 'long-input', + placeholder: 'Comma-separated IDs, e.g. a1b2c3d4-..., e5f6g7h8-... (up to 50)', + condition: { field: 'operation', value: 'batch_get_query_execution' }, + required: { field: 'operation', value: 'batch_get_query_execution' }, + }, { id: 'namedQueryId', title: 'Named Query ID', type: 'short-input', placeholder: 'e.g., a1b2c3d4-5678-90ab-cdef-example11111', - condition: { field: 'operation', value: 'get_named_query' }, - required: { field: 'operation', value: 'get_named_query' }, + condition: { field: 'operation', value: ['get_named_query', 'delete_named_query'] }, + required: { field: 'operation', value: ['get_named_query', 'delete_named_query'] }, + }, + { + id: 'catalogName', + title: 'Data Catalog', + type: 'short-input', + placeholder: 'AwsDataCatalog', + condition: { field: 'operation', value: ['list_databases', 'list_table_metadata'] }, + required: { field: 'operation', value: ['list_databases', 'list_table_metadata'] }, + }, + { + id: 'databaseName', + title: 'Database', + type: 'short-input', + placeholder: 'my_database', + condition: { field: 'operation', value: 'list_table_metadata' }, + required: { field: 'operation', value: 'list_table_metadata' }, + }, + { + id: 'expression', + title: 'Table Name Filter (regex)', + type: 'short-input', + placeholder: 'my_table_.*', + condition: { field: 'operation', value: 'list_table_metadata' }, + mode: 'advanced', }, { id: 'queryName', @@ -174,7 +225,13 @@ Return ONLY the SQL query — no explanations, no markdown code blocks.`, placeholder: '50', condition: { field: 'operation', - value: ['get_query_results', 'list_query_executions', 'list_named_queries'], + value: [ + 'get_query_results', + 'list_query_executions', + 'list_named_queries', + 'list_databases', + 'list_table_metadata', + ], }, mode: 'advanced', }, @@ -185,7 +242,13 @@ Return ONLY the SQL query — no explanations, no markdown code blocks.`, placeholder: 'Token from previous request', condition: { field: 'operation', - value: ['get_query_results', 'list_query_executions', 'list_named_queries'], + value: [ + 'get_query_results', + 'list_query_executions', + 'list_named_queries', + 'list_databases', + 'list_table_metadata', + ], }, mode: 'advanced', }, @@ -197,9 +260,13 @@ Return ONLY the SQL query — no explanations, no markdown code blocks.`, 'athena_get_query_results', 'athena_stop_query', 'athena_list_query_executions', + 'athena_batch_get_query_execution', 'athena_create_named_query', 'athena_get_named_query', 'athena_list_named_queries', + 'athena_delete_named_query', + 'athena_list_databases', + 'athena_list_table_metadata', ], config: { tool: (params) => { @@ -214,12 +281,20 @@ Return ONLY the SQL query — no explanations, no markdown code blocks.`, return 'athena_stop_query' case 'list_query_executions': return 'athena_list_query_executions' + case 'batch_get_query_execution': + return 'athena_batch_get_query_execution' case 'create_named_query': return 'athena_create_named_query' case 'get_named_query': return 'athena_get_named_query' case 'list_named_queries': return 'athena_list_named_queries' + case 'delete_named_query': + return 'athena_delete_named_query' + case 'list_databases': + return 'athena_list_databases' + case 'list_table_metadata': + return 'athena_list_table_metadata' default: throw new Error(`Invalid Athena operation: ${params.operation}`) } @@ -333,6 +408,61 @@ Return ONLY the SQL query — no explanations, no markdown code blocks.`, ...(rest.nextToken && { nextToken: rest.nextToken }), } + case 'delete_named_query': + if (!rest.namedQueryId) { + throw new Error('Named query ID is required') + } + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + namedQueryId: rest.namedQueryId, + } + + case 'batch_get_query_execution': + if (!rest.queryExecutionIds) { + throw new Error('Query execution IDs are required') + } + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + queryExecutionIds: rest.queryExecutionIds, + } + + case 'list_databases': + if (!rest.catalogName) { + throw new Error('Data catalog name is required') + } + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + catalogName: rest.catalogName, + ...(rest.workGroup && { workGroup: rest.workGroup }), + ...(parsedMaxResults !== undefined && { maxResults: parsedMaxResults }), + ...(rest.nextToken && { nextToken: rest.nextToken }), + } + + case 'list_table_metadata': + if (!rest.catalogName) { + throw new Error('Data catalog name is required') + } + if (!rest.databaseName) { + throw new Error('Database name is required') + } + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + catalogName: rest.catalogName, + databaseName: rest.databaseName, + ...(rest.expression && { expression: rest.expression }), + ...(rest.workGroup && { workGroup: rest.workGroup }), + ...(parsedMaxResults !== undefined && { maxResults: parsedMaxResults }), + ...(rest.nextToken && { nextToken: rest.nextToken }), + } + default: throw new Error(`Invalid Athena operation: ${operation}`) } @@ -350,9 +480,16 @@ Return ONLY the SQL query — no explanations, no markdown code blocks.`, outputLocation: { type: 'string', description: 'S3 output location for results' }, workGroup: { type: 'string', description: 'Athena workgroup name' }, queryExecutionId: { type: 'string', description: 'Query execution ID' }, + queryExecutionIds: { + type: 'string', + description: 'Comma-separated query execution IDs (up to 50)', + }, namedQueryId: { type: 'string', description: 'Named query ID' }, queryName: { type: 'string', description: 'Name for a saved query' }, queryDescription: { type: 'string', description: 'Description for a saved query' }, + catalogName: { type: 'string', description: 'Data catalog name to list databases/tables from' }, + databaseName: { type: 'string', description: 'Database name to list table metadata from' }, + expression: { type: 'string', description: 'Regex filter that pattern-matches table names' }, maxResults: { type: 'number', description: 'Maximum number of results' }, nextToken: { type: 'string', description: 'Pagination token' }, }, @@ -465,6 +602,22 @@ Return ONLY the SQL query — no explanations, no markdown code blocks.`, type: 'array', description: 'List of named query IDs', }, + queryExecutions: { + type: 'array', + description: 'Details for each query execution (from batch get query executions)', + }, + unprocessedQueryExecutionIds: { + type: 'array', + description: 'Query execution IDs that could not be retrieved, with error details', + }, + databases: { + type: 'array', + description: 'List of databases (name, description)', + }, + tables: { + type: 'array', + description: 'Table metadata (name, type, columns, partition keys)', + }, }, } diff --git a/apps/sim/blocks/blocks/cloudformation.ts b/apps/sim/blocks/blocks/cloudformation.ts index 28e5396e6d6..3422058fc7f 100644 --- a/apps/sim/blocks/blocks/cloudformation.ts +++ b/apps/sim/blocks/blocks/cloudformation.ts @@ -2,12 +2,20 @@ import { CloudFormationIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { IntegrationType } from '@/blocks/types' import type { + CloudFormationCancelUpdateStackResponse, + CloudFormationCreateChangeSetResponse, + CloudFormationCreateStackResponse, + CloudFormationDeleteStackResponse, + CloudFormationDescribeChangeSetResponse, CloudFormationDescribeStackDriftDetectionStatusResponse, CloudFormationDescribeStackEventsResponse, CloudFormationDescribeStacksResponse, CloudFormationDetectStackDriftResponse, + CloudFormationExecuteChangeSetResponse, CloudFormationGetTemplateResponse, + CloudFormationGetTemplateSummaryResponse, CloudFormationListStackResourcesResponse, + CloudFormationUpdateStackResponse, CloudFormationValidateTemplateResponse, } from '@/tools/cloudformation/types' @@ -19,12 +27,20 @@ export const CloudFormationBlock: BlockConfig< | CloudFormationDescribeStackEventsResponse | CloudFormationGetTemplateResponse | CloudFormationValidateTemplateResponse + | CloudFormationCreateStackResponse + | CloudFormationUpdateStackResponse + | CloudFormationDeleteStackResponse + | CloudFormationCancelUpdateStackResponse + | CloudFormationCreateChangeSetResponse + | CloudFormationDescribeChangeSetResponse + | CloudFormationExecuteChangeSetResponse + | CloudFormationGetTemplateSummaryResponse > = { type: 'cloudformation', name: 'CloudFormation', description: 'Manage and inspect AWS CloudFormation stacks, resources, and drift', longDescription: - 'Integrate AWS CloudFormation into workflows. Describe stacks, list resources, detect drift, view stack events, retrieve templates, and validate templates. Requires AWS access key and secret access key.', + 'Integrate AWS CloudFormation into workflows. Create, update, and delete stacks, preview changes with change sets, describe stacks, list resources, detect drift, view stack events, and retrieve or validate templates. Requires AWS access key and secret access key.', category: 'tools', integrationType: IntegrationType.DevOps, docsLink: 'https://docs.sim.ai/integrations/cloudformation', @@ -38,11 +54,19 @@ export const CloudFormationBlock: BlockConfig< type: 'dropdown', options: [ { label: 'Describe Stacks', id: 'describe_stacks' }, + { label: 'Create Stack', id: 'create_stack' }, + { label: 'Update Stack', id: 'update_stack' }, + { label: 'Delete Stack', id: 'delete_stack' }, + { label: 'Cancel Update Stack', id: 'cancel_update_stack' }, + { label: 'Create Change Set', id: 'create_change_set' }, + { label: 'Describe Change Set', id: 'describe_change_set' }, + { label: 'Execute Change Set', id: 'execute_change_set' }, { label: 'List Stack Resources', id: 'list_stack_resources' }, { label: 'Describe Stack Events', id: 'describe_stack_events' }, { label: 'Detect Stack Drift', id: 'detect_stack_drift' }, { label: 'Drift Detection Status', id: 'describe_stack_drift_detection_status' }, { label: 'Get Template', id: 'get_template' }, + { label: 'Get Template Summary', id: 'get_template_summary' }, { label: 'Validate Template', id: 'validate_template' }, ], value: () => 'describe_stacks', @@ -79,15 +103,28 @@ export const CloudFormationBlock: BlockConfig< field: 'operation', value: [ 'describe_stacks', + 'create_stack', + 'update_stack', + 'delete_stack', + 'cancel_update_stack', + 'create_change_set', + 'describe_change_set', + 'execute_change_set', 'list_stack_resources', 'describe_stack_events', 'detect_stack_drift', 'get_template', + 'get_template_summary', ], }, required: { field: 'operation', value: [ + 'create_stack', + 'update_stack', + 'delete_stack', + 'cancel_update_stack', + 'create_change_set', 'list_stack_resources', 'describe_stack_events', 'detect_stack_drift', @@ -108,8 +145,147 @@ export const CloudFormationBlock: BlockConfig< title: 'Template Body', type: 'code', placeholder: '{\n "AWSTemplateFormatVersion": "2010-09-09",\n "Resources": { ... }\n}', - condition: { field: 'operation', value: 'validate_template' }, - required: { field: 'operation', value: 'validate_template' }, + condition: { + field: 'operation', + value: [ + 'validate_template', + 'create_stack', + 'update_stack', + 'create_change_set', + 'get_template_summary', + ], + }, + required: { field: 'operation', value: ['validate_template', 'create_stack'] }, + }, + { + id: 'usePreviousTemplate', + title: 'Use Previous Template', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: ['update_stack', 'create_change_set'] }, + mode: 'advanced', + }, + { + id: 'changeSetName', + title: 'Change Set Name', + type: 'short-input', + placeholder: 'my-change-set', + condition: { + field: 'operation', + value: ['create_change_set', 'describe_change_set', 'execute_change_set'], + }, + required: { + field: 'operation', + value: ['create_change_set', 'describe_change_set', 'execute_change_set'], + }, + }, + { + id: 'changeSetType', + title: 'Change Set Type', + type: 'dropdown', + options: [ + { label: 'CREATE (new stack)', id: 'CREATE' }, + { label: 'UPDATE (existing stack)', id: 'UPDATE' }, + { label: 'IMPORT (import resources)', id: 'IMPORT' }, + ], + condition: { field: 'operation', value: 'create_change_set' }, + mode: 'advanced', + }, + { + id: 'changeSetDescription', + title: 'Change Set Description', + type: 'short-input', + placeholder: 'Describe what this change set does', + condition: { field: 'operation', value: 'create_change_set' }, + mode: 'advanced', + }, + { + id: 'parameters', + title: 'Parameters (JSON)', + type: 'code', + placeholder: '[\n {"parameterKey": "InstanceType", "parameterValue": "t3.micro"}\n]', + condition: { + field: 'operation', + value: ['create_stack', 'update_stack', 'create_change_set'], + }, + wandConfig: { + enabled: true, + prompt: `Generate a CloudFormation stack parameters JSON array based on the user's description. +Each item must be an object with "parameterKey" and "parameterValue" string fields. + +Return ONLY valid JSON - no explanations, no markdown code blocks.`, + placeholder: 'Describe the parameters to pass to the template...', + }, + }, + { + id: 'capabilities', + title: 'Capabilities', + type: 'short-input', + placeholder: 'CAPABILITY_IAM,CAPABILITY_NAMED_IAM', + condition: { + field: 'operation', + value: ['create_stack', 'update_stack', 'create_change_set'], + }, + mode: 'advanced', + }, + { + id: 'tags', + title: 'Tags (JSON)', + type: 'code', + placeholder: '[\n {"key": "env", "value": "prod"}\n]', + condition: { field: 'operation', value: ['create_stack', 'update_stack'] }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a CloudFormation stack tags JSON array based on the user's description. +Each item must be an object with "key" and "value" string fields. + +Return ONLY valid JSON - no explanations, no markdown code blocks.`, + placeholder: 'Describe the tags to apply...', + }, + }, + { + id: 'onFailure', + title: 'On Failure', + type: 'dropdown', + options: [ + { label: 'Roll back', id: 'ROLLBACK' }, + { label: 'Delete', id: 'DELETE' }, + { label: 'Do nothing', id: 'DO_NOTHING' }, + ], + condition: { field: 'operation', value: 'create_stack' }, + mode: 'advanced', + }, + { + id: 'timeoutInMinutes', + title: 'Timeout (minutes)', + type: 'short-input', + placeholder: '30', + condition: { field: 'operation', value: 'create_stack' }, + mode: 'advanced', + }, + { + id: 'retainResources', + title: 'Retain Resources', + type: 'short-input', + placeholder: 'LogicalId1,LogicalId2', + condition: { field: 'operation', value: 'delete_stack' }, + mode: 'advanced', + }, + { + id: 'templateStage', + title: 'Template Stage', + type: 'dropdown', + options: [ + { label: 'Processed', id: 'Processed' }, + { label: 'Original', id: 'Original' }, + ], + condition: { field: 'operation', value: 'get_template' }, + mode: 'advanced', }, { id: 'limit', @@ -123,11 +299,19 @@ export const CloudFormationBlock: BlockConfig< tools: { access: [ 'cloudformation_describe_stacks', + 'cloudformation_create_stack', + 'cloudformation_update_stack', + 'cloudformation_delete_stack', + 'cloudformation_cancel_update_stack', + 'cloudformation_create_change_set', + 'cloudformation_describe_change_set', + 'cloudformation_execute_change_set', 'cloudformation_list_stack_resources', 'cloudformation_detect_stack_drift', 'cloudformation_describe_stack_drift_detection_status', 'cloudformation_describe_stack_events', 'cloudformation_get_template', + 'cloudformation_get_template_summary', 'cloudformation_validate_template', ], config: { @@ -135,6 +319,20 @@ export const CloudFormationBlock: BlockConfig< switch (params.operation) { case 'describe_stacks': return 'cloudformation_describe_stacks' + case 'create_stack': + return 'cloudformation_create_stack' + case 'update_stack': + return 'cloudformation_update_stack' + case 'delete_stack': + return 'cloudformation_delete_stack' + case 'cancel_update_stack': + return 'cloudformation_cancel_update_stack' + case 'create_change_set': + return 'cloudformation_create_change_set' + case 'describe_change_set': + return 'cloudformation_describe_change_set' + case 'execute_change_set': + return 'cloudformation_execute_change_set' case 'list_stack_resources': return 'cloudformation_list_stack_resources' case 'detect_stack_drift': @@ -145,6 +343,8 @@ export const CloudFormationBlock: BlockConfig< return 'cloudformation_describe_stack_events' case 'get_template': return 'cloudformation_get_template' + case 'get_template_summary': + return 'cloudformation_get_template_summary' case 'validate_template': return 'cloudformation_validate_template' default: @@ -152,12 +352,40 @@ export const CloudFormationBlock: BlockConfig< } }, params: (params) => { - const { operation, limit, ...rest } = params + const { + operation, + limit, + usePreviousTemplate, + parameters, + tags, + timeoutInMinutes, + ...rest + } = params const awsRegion = rest.awsRegion const awsAccessKeyId = rest.awsAccessKeyId const awsSecretAccessKey = rest.awsSecretAccessKey const parsedLimit = limit ? Number.parseInt(String(limit), 10) : undefined + const parsedUsePreviousTemplate = + usePreviousTemplate === 'true' || usePreviousTemplate === true + const parsedTimeoutInMinutes = timeoutInMinutes + ? Number.parseInt(String(timeoutInMinutes), 10) + : undefined + + const parseJson = (value: unknown, fieldName: string) => { + if (!value) return undefined + if (typeof value === 'object') return value + if (typeof value === 'string' && value.trim()) { + try { + return JSON.parse(value) + } catch (parseError) { + throw new Error( + `Invalid JSON in ${fieldName}: ${parseError instanceof Error ? parseError.message : String(parseError)}` + ) + } + } + return undefined + } switch (operation) { case 'describe_stacks': @@ -168,6 +396,111 @@ export const CloudFormationBlock: BlockConfig< ...(rest.stackName && { stackName: rest.stackName }), } + case 'create_stack': { + if (!rest.stackName) throw new Error('Stack name is required') + if (!rest.templateBody) throw new Error('Template body is required') + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + stackName: rest.stackName, + templateBody: rest.templateBody, + ...(parameters && { parameters: parseJson(parameters, 'parameters') }), + ...(rest.capabilities && { capabilities: rest.capabilities }), + ...(tags && { tags: parseJson(tags, 'tags') }), + ...(rest.onFailure && { onFailure: rest.onFailure }), + ...(parsedTimeoutInMinutes !== undefined && { + timeoutInMinutes: parsedTimeoutInMinutes, + }), + } + } + + case 'update_stack': { + if (!rest.stackName) throw new Error('Stack name is required') + if (!rest.templateBody && !parsedUsePreviousTemplate) { + throw new Error( + 'Template body is required unless Use Previous Template is set to Yes' + ) + } + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + stackName: rest.stackName, + ...(rest.templateBody && { templateBody: rest.templateBody }), + ...(parsedUsePreviousTemplate && { usePreviousTemplate: true }), + ...(parameters && { parameters: parseJson(parameters, 'parameters') }), + ...(rest.capabilities && { capabilities: rest.capabilities }), + ...(tags && { tags: parseJson(tags, 'tags') }), + } + } + + case 'delete_stack': { + if (!rest.stackName) throw new Error('Stack name is required') + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + stackName: rest.stackName, + ...(rest.retainResources && { retainResources: rest.retainResources }), + } + } + + case 'cancel_update_stack': { + if (!rest.stackName) throw new Error('Stack name is required') + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + stackName: rest.stackName, + } + } + + case 'create_change_set': { + if (!rest.stackName) throw new Error('Stack name is required') + if (!rest.changeSetName) throw new Error('Change set name is required') + if (!rest.templateBody && !parsedUsePreviousTemplate) { + throw new Error( + 'Template body is required unless Use Previous Template is set to Yes' + ) + } + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + stackName: rest.stackName, + changeSetName: rest.changeSetName, + ...(rest.templateBody && { templateBody: rest.templateBody }), + ...(parsedUsePreviousTemplate && { usePreviousTemplate: true }), + ...(parameters && { parameters: parseJson(parameters, 'parameters') }), + ...(rest.capabilities && { capabilities: rest.capabilities }), + ...(rest.changeSetType && { changeSetType: rest.changeSetType }), + ...(rest.changeSetDescription && { description: rest.changeSetDescription }), + } + } + + case 'describe_change_set': { + if (!rest.changeSetName) throw new Error('Change set name is required') + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + changeSetName: rest.changeSetName, + ...(rest.stackName && { stackName: rest.stackName }), + } + } + + case 'execute_change_set': { + if (!rest.changeSetName) throw new Error('Change set name is required') + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + changeSetName: rest.changeSetName, + ...(rest.stackName && { stackName: rest.stackName }), + } + } + case 'list_stack_resources': { if (!rest.stackName) { throw new Error('Stack name is required') @@ -226,6 +559,20 @@ export const CloudFormationBlock: BlockConfig< awsAccessKeyId, awsSecretAccessKey, stackName: rest.stackName, + ...(rest.templateStage && { templateStage: rest.templateStage }), + } + } + + case 'get_template_summary': { + if (!rest.templateBody && !rest.stackName) { + throw new Error('Either template body or stack name is required') + } + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + ...(rest.templateBody && { templateBody: rest.templateBody }), + ...(rest.stackName && { stackName: rest.stackName }), } } @@ -255,6 +602,26 @@ export const CloudFormationBlock: BlockConfig< stackName: { type: 'string', description: 'Stack name or ID' }, stackDriftDetectionId: { type: 'string', description: 'Drift detection ID' }, templateBody: { type: 'string', description: 'CloudFormation template body (JSON or YAML)' }, + usePreviousTemplate: { + type: 'string', + description: 'Reuse the template currently on the stack', + }, + changeSetName: { type: 'string', description: 'Change set name' }, + changeSetType: { type: 'string', description: 'Change set type (CREATE, UPDATE, IMPORT)' }, + changeSetDescription: { type: 'string', description: 'Description of the change set' }, + parameters: { type: 'json', description: 'Stack input parameters' }, + capabilities: { type: 'string', description: 'Comma-separated capabilities to acknowledge' }, + tags: { type: 'json', description: 'Tags to apply to the stack' }, + onFailure: { type: 'string', description: 'Action to take on stack creation failure' }, + timeoutInMinutes: { type: 'number', description: 'Stack creation timeout in minutes' }, + retainResources: { + type: 'string', + description: 'Comma-separated logical resource IDs to retain on delete', + }, + templateStage: { + type: 'string', + description: 'Template stage to retrieve (Original or Processed)', + }, limit: { type: 'number', description: 'Maximum number of results' }, }, outputs: { @@ -308,7 +675,7 @@ export const CloudFormationBlock: BlockConfig< }, description: { type: 'string', - description: 'Template description', + description: 'Template or change set description', }, parameters: { type: 'array', @@ -322,10 +689,50 @@ export const CloudFormationBlock: BlockConfig< type: 'string', description: 'Reason capabilities are required', }, + resourceTypes: { + type: 'array', + description: 'AWS resource types declared in the template', + }, + version: { + type: 'string', + description: 'Template format version', + }, declaredTransforms: { type: 'array', description: 'Transforms used in the template (e.g., AWS::Serverless-2016-10-31)', }, + message: { + type: 'string', + description: 'Operation status message', + }, + changeSetId: { + type: 'string', + description: 'The unique ID of the change set', + }, + changeSetName: { + type: 'string', + description: 'Name of the change set', + }, + executionStatus: { + type: 'string', + description: 'Whether the change set can be executed', + }, + status: { + type: 'string', + description: 'Current status of the change set', + }, + statusReason: { + type: 'string', + description: 'Reason for the current change set status', + }, + creationTime: { + type: 'number', + description: 'Timestamp the change set was created', + }, + changes: { + type: 'array', + description: 'List of resource changes a change set would make', + }, }, } @@ -400,6 +807,26 @@ export const CloudFormationBlockMeta = { tags: ['devops', 'automation', 'monitoring'], alsoIntegrations: ['slack'], }, + { + icon: CloudFormationIcon, + title: 'Change set approval pipeline', + prompt: + 'Build a workflow that creates a CloudFormation change set for a stack update, describes the proposed resource changes, posts a summary to Slack for approval, and executes the change set only after an approver replies, otherwise deletes the change set.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'automation', 'infrastructure'], + alsoIntegrations: ['slack'], + }, + { + icon: CloudFormationIcon, + title: 'Environment provisioner', + prompt: + 'Create a workflow that takes an environment name and template parameters as input, creates a new CloudFormation stack for that environment, polls stack events until it reaches CREATE_COMPLETE or fails, and posts the stack outputs to Slack.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'automation', 'infrastructure'], + alsoIntegrations: ['slack'], + }, ], skills: [ { @@ -423,5 +850,12 @@ export const CloudFormationBlockMeta = { content: '# Investigate CloudFormation Stack Failure\n\nDiagnose why a stack operation failed.\n\n## Steps\n1. Describe the target stack and confirm its current status.\n2. Pull recent stack events, ordered newest first.\n3. Find the first FAILED event and read its resource status reason.\n4. Trace any dependent resource failures that cascaded from it.\n\n## Output\nA plain-English root-cause summary naming the failing resource, the error reason, and a suggested fix.', }, + { + name: 'preview-stack-change', + description: + 'Create a CloudFormation change set to preview what a stack update would do before applying it.', + content: + '# Preview a CloudFormation Stack Change\n\nSafely preview changes before they are applied to a live stack.\n\n## Steps\n1. Create a change set against the target stack with the new template or parameters.\n2. Describe the change set and list each resource action (Add, Modify, Remove) and whether it requires replacement.\n3. Flag any replacement or deletion of stateful resources (databases, storage) for extra scrutiny.\n4. Only execute the change set after the changes have been reviewed and approved.\n\n## Output\nA list of proposed resource changes with the replacement risk for each, and confirmation of whether the change set was executed.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/cloudwatch.ts b/apps/sim/blocks/blocks/cloudwatch.ts index 04b72cba3eb..325b5ec17d6 100644 --- a/apps/sim/blocks/blocks/cloudwatch.ts +++ b/apps/sim/blocks/blocks/cloudwatch.ts @@ -2,13 +2,16 @@ import { CloudWatchIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { IntegrationType } from '@/blocks/types' import type { + CloudWatchDescribeAlarmHistoryResponse, CloudWatchDescribeAlarmsResponse, CloudWatchDescribeLogGroupsResponse, CloudWatchDescribeLogStreamsResponse, + CloudWatchFilterLogEventsResponse, CloudWatchGetLogEventsResponse, CloudWatchGetMetricStatisticsResponse, CloudWatchListMetricsResponse, CloudWatchMuteAlarmResponse, + CloudWatchPutLogGroupRetentionResponse, CloudWatchPutMetricDataResponse, CloudWatchQueryLogsResponse, CloudWatchUnmuteAlarmResponse, @@ -19,12 +22,15 @@ export const CloudWatchBlock: BlockConfig< | CloudWatchDescribeLogGroupsResponse | CloudWatchDescribeLogStreamsResponse | CloudWatchGetLogEventsResponse + | CloudWatchFilterLogEventsResponse | CloudWatchDescribeAlarmsResponse + | CloudWatchDescribeAlarmHistoryResponse | CloudWatchListMetricsResponse | CloudWatchGetMetricStatisticsResponse | CloudWatchPutMetricDataResponse | CloudWatchMuteAlarmResponse | CloudWatchUnmuteAlarmResponse + | CloudWatchPutLogGroupRetentionResponse > = { type: 'cloudwatch', name: 'CloudWatch', @@ -44,13 +50,16 @@ export const CloudWatchBlock: BlockConfig< type: 'dropdown', options: [ { label: 'Query Logs (Insights)', id: 'query_logs' }, + { label: 'Filter Log Events', id: 'filter_log_events' }, { label: 'Describe Log Groups', id: 'describe_log_groups' }, { label: 'Get Log Events', id: 'get_log_events' }, { label: 'Describe Log Streams', id: 'describe_log_streams' }, + { label: 'Set Log Group Retention', id: 'put_log_group_retention' }, { label: 'List Metrics', id: 'list_metrics' }, { label: 'Get Metric Statistics', id: 'get_metric_statistics' }, { label: 'Publish Metric', id: 'put_metric_data' }, { label: 'Describe Alarms', id: 'describe_alarms' }, + { label: 'Describe Alarm History', id: 'describe_alarm_history' }, { label: 'Mute Alarm', id: 'mute_alarm' }, { label: 'Unmute Alarm', id: 'unmute_alarm' }, ], @@ -130,7 +139,7 @@ Return ONLY the query — no explanations, no markdown code blocks.`, placeholder: 'e.g., 1711900800', condition: { field: 'operation', - value: ['query_logs', 'get_log_events', 'get_metric_statistics'], + value: ['query_logs', 'get_log_events', 'get_metric_statistics', 'filter_log_events'], }, required: { field: 'operation', value: ['query_logs', 'get_metric_statistics'] }, wandConfig: { @@ -149,7 +158,7 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, placeholder: 'e.g., 1711987200', condition: { field: 'operation', - value: ['query_logs', 'get_log_events', 'get_metric_statistics'], + value: ['query_logs', 'get_log_events', 'get_metric_statistics', 'filter_log_events'], }, required: { field: 'operation', value: ['query_logs', 'get_metric_statistics'] }, wandConfig: { @@ -176,8 +185,24 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, selectorKey: 'cloudwatch.logGroups', dependsOn: ['awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion'], placeholder: 'Select a log group', - condition: { field: 'operation', value: ['get_log_events', 'describe_log_streams'] }, - required: { field: 'operation', value: ['get_log_events', 'describe_log_streams'] }, + condition: { + field: 'operation', + value: [ + 'get_log_events', + 'describe_log_streams', + 'filter_log_events', + 'put_log_group_retention', + ], + }, + required: { + field: 'operation', + value: [ + 'get_log_events', + 'describe_log_streams', + 'filter_log_events', + 'put_log_group_retention', + ], + }, mode: 'basic', }, { @@ -186,8 +211,24 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, type: 'short-input', canonicalParamId: 'logGroupName', placeholder: '/aws/lambda/my-func', - condition: { field: 'operation', value: ['get_log_events', 'describe_log_streams'] }, - required: { field: 'operation', value: ['get_log_events', 'describe_log_streams'] }, + condition: { + field: 'operation', + value: [ + 'get_log_events', + 'describe_log_streams', + 'filter_log_events', + 'put_log_group_retention', + ], + }, + required: { + field: 'operation', + value: [ + 'get_log_events', + 'describe_log_streams', + 'filter_log_events', + 'put_log_group_retention', + ], + }, mode: 'advanced', }, { @@ -197,6 +238,80 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, placeholder: '2024/03/31/', condition: { field: 'operation', value: 'describe_log_streams' }, }, + { + id: 'filterPattern', + title: 'Filter Pattern', + type: 'short-input', + placeholder: 'ERROR, ?ERROR ?Exception, %timeout%', + condition: { field: 'operation', value: 'filter_log_events' }, + wandConfig: { + enabled: true, + prompt: `Generate a CloudWatch Logs filter pattern based on the user's description. +Common patterns: +- ERROR (matches lines containing ERROR) +- ?ERROR ?Exception (matches lines containing either term) +- "Failed to connect" (matches an exact phrase) +- %timeout%error% (matches lines matching a regular expression) + +Return ONLY the filter pattern - no explanations, no extra text.`, + placeholder: 'Describe what you want to find in the logs...', + }, + }, + { + id: 'filterLogStreamPrefix', + title: 'Log Stream Name Prefix', + type: 'short-input', + placeholder: '2024/03/31/', + condition: { field: 'operation', value: 'filter_log_events' }, + mode: 'advanced', + description: 'Only search log streams whose name starts with this prefix', + }, + { + id: 'startFromHead', + title: 'Return Oldest First', + type: 'dropdown', + options: [ + { label: 'No (newest first)', id: 'false' }, + { label: 'Yes (oldest first)', id: 'true' }, + ], + value: () => 'true', + condition: { field: 'operation', value: 'filter_log_events' }, + mode: 'advanced', + }, + { + id: 'retentionInDays', + title: 'Retention Period', + type: 'dropdown', + options: [ + { label: 'Never expire', id: '' }, + { label: '1 day', id: '1' }, + { label: '3 days', id: '3' }, + { label: '5 days', id: '5' }, + { label: '1 week', id: '7' }, + { label: '2 weeks', id: '14' }, + { label: '1 month', id: '30' }, + { label: '2 months', id: '60' }, + { label: '3 months', id: '90' }, + { label: '4 months', id: '120' }, + { label: '5 months', id: '150' }, + { label: '6 months', id: '180' }, + { label: '1 year', id: '365' }, + { label: '13 months', id: '400' }, + { label: '18 months', id: '545' }, + { label: '2 years', id: '731' }, + { label: '3 years', id: '1096' }, + { label: '5 years', id: '1827' }, + { label: '6 years', id: '2192' }, + { label: '7 years', id: '2557' }, + { label: '8 years', id: '2922' }, + { label: '9 years', id: '3288' }, + { label: '10 years', id: '3653' }, + ], + value: () => '30', + condition: { field: 'operation', value: 'put_log_group_retention' }, + description: + 'How long to keep log events. Choose "Never expire" to remove any retention limit.', + }, { id: 'logStreamNameSelector', title: 'Log Stream', @@ -366,6 +481,74 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, value: () => '', condition: { field: 'operation', value: 'describe_alarms' }, }, + { + id: 'historyAlarmName', + title: 'Alarm Name', + type: 'short-input', + placeholder: 'my-service-high-cpu', + condition: { field: 'operation', value: 'describe_alarm_history' }, + description: 'Exact alarm name. Omit to retrieve history across all alarms.', + }, + { + id: 'historyItemType', + title: 'History Item Type', + type: 'dropdown', + options: [ + { label: 'All Types', id: '' }, + { label: 'State Update', id: 'StateUpdate' }, + { label: 'Configuration Update', id: 'ConfigurationUpdate' }, + { label: 'Action', id: 'Action' }, + { label: 'Contributor State Update', id: 'AlarmContributorStateUpdate' }, + { label: 'Contributor Action', id: 'AlarmContributorAction' }, + ], + value: () => '', + condition: { field: 'operation', value: 'describe_alarm_history' }, + mode: 'advanced', + }, + { + id: 'alarmHistoryStartDate', + title: 'Start Date (Unix epoch seconds)', + type: 'short-input', + placeholder: 'e.g., 1711900800', + condition: { field: 'operation', value: 'describe_alarm_history' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a Unix epoch timestamp (in seconds) based on the user's description of a point in time. + +Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, + placeholder: 'Describe the start of the history window (e.g., "7 days ago")...', + generationType: 'timestamp', + }, + }, + { + id: 'alarmHistoryEndDate', + title: 'End Date (Unix epoch seconds)', + type: 'short-input', + placeholder: 'e.g., 1711987200', + condition: { field: 'operation', value: 'describe_alarm_history' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a Unix epoch timestamp (in seconds) based on the user's description of a point in time. + +Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, + placeholder: 'Describe the end of the history window (e.g., "now")...', + generationType: 'timestamp', + }, + }, + { + id: 'scanBy', + title: 'Sort Order', + type: 'dropdown', + options: [ + { label: 'Newest First', id: 'TimestampDescending' }, + { label: 'Oldest First', id: 'TimestampAscending' }, + ], + value: () => 'TimestampDescending', + condition: { field: 'operation', value: 'describe_alarm_history' }, + mode: 'advanced', + }, { id: 'muteRuleName', title: 'Mute Rule Name', @@ -432,8 +615,10 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, 'describe_log_groups', 'get_log_events', 'describe_log_streams', + 'filter_log_events', 'list_metrics', 'describe_alarms', + 'describe_alarm_history', ], }, mode: 'advanced', @@ -442,13 +627,16 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, tools: { access: [ 'cloudwatch_query_logs', + 'cloudwatch_filter_log_events', 'cloudwatch_describe_log_groups', 'cloudwatch_get_log_events', 'cloudwatch_describe_log_streams', + 'cloudwatch_put_log_group_retention', 'cloudwatch_list_metrics', 'cloudwatch_get_metric_statistics', 'cloudwatch_put_metric_data', 'cloudwatch_describe_alarms', + 'cloudwatch_describe_alarm_history', 'cloudwatch_mute_alarm', 'cloudwatch_unmute_alarm', ], @@ -463,6 +651,10 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, return 'cloudwatch_get_log_events' case 'describe_log_streams': return 'cloudwatch_describe_log_streams' + case 'filter_log_events': + return 'cloudwatch_filter_log_events' + case 'put_log_group_retention': + return 'cloudwatch_put_log_group_retention' case 'list_metrics': return 'cloudwatch_list_metrics' case 'get_metric_statistics': @@ -471,6 +663,8 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, return 'cloudwatch_put_metric_data' case 'describe_alarms': return 'cloudwatch_describe_alarms' + case 'describe_alarm_history': + return 'cloudwatch_describe_alarm_history' case 'mute_alarm': return 'cloudwatch_mute_alarm' case 'unmute_alarm': @@ -564,6 +758,49 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, } } + case 'filter_log_events': { + if (!rest.logGroupName) { + throw new Error('Log group name is required') + } + + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + logGroupName: rest.logGroupName, + ...(rest.filterPattern && { filterPattern: rest.filterPattern }), + ...(rest.filterLogStreamPrefix && { + logStreamNamePrefix: rest.filterLogStreamPrefix, + }), + ...(startTime && { startTime: Number(startTime) }), + ...(endTime && { endTime: Number(endTime) }), + ...(rest.startFromHead !== undefined && { + startFromHead: rest.startFromHead === 'true' || rest.startFromHead === true, + }), + ...(parsedLimit !== undefined && { limit: parsedLimit }), + } + } + + case 'put_log_group_retention': { + if (!rest.logGroupName) { + throw new Error('Log group name is required') + } + + const retentionRaw = rest.retentionInDays + const parsedRetention = + retentionRaw === undefined || retentionRaw === '' + ? undefined + : Number.parseInt(String(retentionRaw), 10) + + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + logGroupName: rest.logGroupName, + ...(parsedRetention !== undefined && { retentionInDays: parsedRetention }), + } + } + case 'list_metrics': return { awsRegion, @@ -679,6 +916,21 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, ...(parsedLimit !== undefined && { limit: parsedLimit }), } + case 'describe_alarm_history': + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + ...(rest.historyAlarmName && { alarmName: rest.historyAlarmName }), + ...(rest.historyItemType && { historyItemType: rest.historyItemType }), + ...(rest.alarmHistoryStartDate && { + startDate: Number(rest.alarmHistoryStartDate), + }), + ...(rest.alarmHistoryEndDate && { endDate: Number(rest.alarmHistoryEndDate) }), + ...(rest.scanBy && { scanBy: rest.scanBy }), + ...(parsedLimit !== undefined && { limit: parsedLimit }), + } + case 'mute_alarm': { const alarmNames = rest.alarmNames if (!alarmNames) { @@ -757,10 +1009,42 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, prefix: { type: 'string', description: 'Log group name prefix filter' }, logGroupName: { type: 'string', - description: 'Log group name for get events / describe streams', + description: + 'Log group name for get events / describe streams / filter events / set retention', }, logStreamName: { type: 'string', description: 'Log stream name for get events' }, streamPrefix: { type: 'string', description: 'Log stream name prefix filter' }, + filterPattern: { type: 'string', description: 'CloudWatch Logs filter pattern' }, + filterLogStreamPrefix: { + type: 'string', + description: 'Only search log streams whose name starts with this prefix', + }, + startFromHead: { + type: 'boolean', + description: 'Return the earliest matching events first instead of the latest', + }, + retentionInDays: { + type: 'number', + description: 'Retention period in days for a log group. Omit to never expire.', + }, + historyAlarmName: { type: 'string', description: 'Exact alarm name for history lookup' }, + historyItemType: { + type: 'string', + description: + 'Alarm history item type filter (StateUpdate, ConfigurationUpdate, Action, etc.)', + }, + alarmHistoryStartDate: { + type: 'number', + description: 'Start of the alarm history window as Unix epoch seconds', + }, + alarmHistoryEndDate: { + type: 'number', + description: 'End of the alarm history window as Unix epoch seconds', + }, + scanBy: { + type: 'string', + description: 'Alarm history sort order: TimestampDescending or TimestampAscending', + }, metricNamespace: { type: 'string', description: 'Metric namespace (e.g., AWS/EC2)' }, metricName: { type: 'string', description: 'Metric name (e.g., CPUUtilization)' }, recentlyActive: { type: 'boolean', description: 'Only show recently active metrics' }, @@ -834,6 +1118,10 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, type: 'array', description: 'CloudWatch alarms with state and configuration', }, + alarmHistoryItems: { + type: 'array', + description: 'Alarm history items (state changes, configuration updates, actions)', + }, alarmNames: { type: 'array', description: 'Names of the alarms targeted by the mute rule', @@ -874,6 +1162,14 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, type: 'string', description: 'Timestamp when metric was published', }, + logGroupName: { + type: 'string', + description: 'Log group the retention policy was applied to', + }, + retentionInDays: { + type: 'number', + description: 'Retention period in days, or null if events never expire', + }, }, } diff --git a/apps/sim/blocks/blocks/codepipeline.ts b/apps/sim/blocks/blocks/codepipeline.ts index 8df7d233f8b..04f54231134 100644 --- a/apps/sim/blocks/blocks/codepipeline.ts +++ b/apps/sim/blocks/blocks/codepipeline.ts @@ -7,8 +7,12 @@ import { parseOptionalNumberInput, } from '@/blocks/utils' import type { + CodePipelineDisableStageTransitionResponse, + CodePipelineEnableStageTransitionResponse, CodePipelineGetPipelineExecutionResponse, + CodePipelineGetPipelineResponse, CodePipelineGetPipelineStateResponse, + CodePipelineListActionExecutionsResponse, CodePipelineListPipelineExecutionsResponse, CodePipelineListPipelinesResponse, CodePipelinePutApprovalResultResponse, @@ -19,13 +23,17 @@ import type { export const CodePipelineBlock: BlockConfig< | CodePipelineListPipelinesResponse + | CodePipelineGetPipelineResponse | CodePipelineGetPipelineStateResponse | CodePipelineGetPipelineExecutionResponse | CodePipelineListPipelineExecutionsResponse + | CodePipelineListActionExecutionsResponse | CodePipelineStartExecutionResponse | CodePipelineStopExecutionResponse | CodePipelineRetryStageExecutionResponse | CodePipelinePutApprovalResultResponse + | CodePipelineDisableStageTransitionResponse + | CodePipelineEnableStageTransitionResponse > = { type: 'codepipeline', name: 'CodePipeline', @@ -47,12 +55,16 @@ export const CodePipelineBlock: BlockConfig< options: [ { label: 'Start Execution', id: 'start_execution' }, { label: 'Get Pipeline State', id: 'get_pipeline_state' }, + { label: 'Get Pipeline Structure', id: 'get_pipeline' }, { label: 'List Pipelines', id: 'list_pipelines' }, { label: 'List Executions', id: 'list_pipeline_executions' }, + { label: 'List Action Executions', id: 'list_action_executions' }, { label: 'Get Execution', id: 'get_pipeline_execution' }, { label: 'Stop Execution', id: 'stop_execution' }, { label: 'Retry Stage', id: 'retry_stage_execution' }, { label: 'Approve / Reject Approval', id: 'put_approval_result' }, + { label: 'Disable Stage Transition', id: 'disable_stage_transition' }, + { label: 'Enable Stage Transition', id: 'enable_stage_transition' }, ], value: () => 'start_execution', }, @@ -89,11 +101,15 @@ export const CodePipelineBlock: BlockConfig< value: [ 'start_execution', 'get_pipeline_state', + 'get_pipeline', 'list_pipeline_executions', + 'list_action_executions', 'get_pipeline_execution', 'stop_execution', 'retry_stage_execution', 'put_approval_result', + 'disable_stage_transition', + 'enable_stage_transition', ], }, required: { @@ -101,11 +117,15 @@ export const CodePipelineBlock: BlockConfig< value: [ 'start_execution', 'get_pipeline_state', + 'get_pipeline', 'list_pipeline_executions', + 'list_action_executions', 'get_pipeline_execution', 'stop_execution', 'retry_stage_execution', 'put_approval_result', + 'disable_stage_transition', + 'enable_stage_transition', ], }, }, @@ -116,7 +136,12 @@ export const CodePipelineBlock: BlockConfig< placeholder: 'e.g., 3137f7cb-7cf7-4abc-9f1d-d7eu3471a2b1', condition: { field: 'operation', - value: ['get_pipeline_execution', 'stop_execution', 'retry_stage_execution'], + value: [ + 'get_pipeline_execution', + 'stop_execution', + 'retry_stage_execution', + 'list_action_executions', + ], }, required: { field: 'operation', @@ -128,8 +153,58 @@ export const CodePipelineBlock: BlockConfig< title: 'Stage Name', type: 'short-input', placeholder: 'e.g., Deploy', - condition: { field: 'operation', value: ['retry_stage_execution', 'put_approval_result'] }, - required: { field: 'operation', value: ['retry_stage_execution', 'put_approval_result'] }, + condition: { + field: 'operation', + value: [ + 'retry_stage_execution', + 'put_approval_result', + 'disable_stage_transition', + 'enable_stage_transition', + ], + }, + required: { + field: 'operation', + value: [ + 'retry_stage_execution', + 'put_approval_result', + 'disable_stage_transition', + 'enable_stage_transition', + ], + }, + }, + { + id: 'transitionType', + title: 'Transition Type', + type: 'dropdown', + options: [ + { label: 'Inbound', id: 'Inbound' }, + { label: 'Outbound', id: 'Outbound' }, + ], + value: () => 'Inbound', + condition: { + field: 'operation', + value: ['disable_stage_transition', 'enable_stage_transition'], + }, + required: { + field: 'operation', + value: ['disable_stage_transition', 'enable_stage_transition'], + }, + }, + { + id: 'transitionReason', + title: 'Reason', + type: 'short-input', + placeholder: 'Why the transition is disabled (max 300 characters)', + condition: { field: 'operation', value: 'disable_stage_transition' }, + required: { field: 'operation', value: 'disable_stage_transition' }, + }, + { + id: 'getPipelineVersion', + title: 'Pipeline Version', + type: 'short-input', + placeholder: 'Defaults to the current version', + condition: { field: 'operation', value: 'get_pipeline' }, + mode: 'advanced', }, { id: 'retryMode', @@ -222,7 +297,10 @@ export const CodePipelineBlock: BlockConfig< title: 'Max Results', type: 'short-input', placeholder: '100', - condition: { field: 'operation', value: ['list_pipelines', 'list_pipeline_executions'] }, + condition: { + field: 'operation', + value: ['list_pipelines', 'list_pipeline_executions', 'list_action_executions'], + }, mode: 'advanced', }, { @@ -230,32 +308,43 @@ export const CodePipelineBlock: BlockConfig< title: 'Next Token', type: 'short-input', placeholder: 'Pagination token from a previous call', - condition: { field: 'operation', value: ['list_pipelines', 'list_pipeline_executions'] }, + condition: { + field: 'operation', + value: ['list_pipelines', 'list_pipeline_executions', 'list_action_executions'], + }, mode: 'advanced', }, ], tools: { access: [ 'codepipeline_list_pipelines', + 'codepipeline_get_pipeline', 'codepipeline_get_pipeline_state', 'codepipeline_get_pipeline_execution', 'codepipeline_list_pipeline_executions', + 'codepipeline_list_action_executions', 'codepipeline_start_execution', 'codepipeline_stop_execution', 'codepipeline_retry_stage_execution', 'codepipeline_put_approval_result', + 'codepipeline_disable_stage_transition', + 'codepipeline_enable_stage_transition', ], config: { tool: (params) => { switch (params.operation) { case 'list_pipelines': return 'codepipeline_list_pipelines' + case 'get_pipeline': + return 'codepipeline_get_pipeline' case 'get_pipeline_state': return 'codepipeline_get_pipeline_state' case 'get_pipeline_execution': return 'codepipeline_get_pipeline_execution' case 'list_pipeline_executions': return 'codepipeline_list_pipeline_executions' + case 'list_action_executions': + return 'codepipeline_list_action_executions' case 'start_execution': return 'codepipeline_start_execution' case 'stop_execution': @@ -264,12 +353,16 @@ export const CodePipelineBlock: BlockConfig< return 'codepipeline_retry_stage_execution' case 'put_approval_result': return 'codepipeline_put_approval_result' + case 'disable_stage_transition': + return 'codepipeline_disable_stage_transition' + case 'enable_stage_transition': + return 'codepipeline_enable_stage_transition' default: throw new Error(`Invalid CodePipeline operation: ${params.operation}`) } }, params: (params) => { - const { operation, maxResults, ...rest } = params + const { operation, maxResults, getPipelineVersion, ...rest } = params const awsRegion = rest.awsRegion const awsAccessKeyId = rest.awsAccessKeyId @@ -289,6 +382,20 @@ export const CodePipelineBlock: BlockConfig< ...(rest.nextToken && { nextToken: rest.nextToken }), } + case 'get_pipeline': { + const parsedVersion = parseOptionalNumberInput(getPipelineVersion, 'Pipeline version', { + integer: true, + min: 1, + }) + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + pipelineName: rest.pipelineName, + ...(parsedVersion !== undefined && { version: parsedVersion }), + } + } + case 'get_pipeline_state': return { awsRegion, @@ -317,6 +424,17 @@ export const CodePipelineBlock: BlockConfig< ...(rest.succeededInStage && { succeededInStage: rest.succeededInStage }), } + case 'list_action_executions': + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + pipelineName: rest.pipelineName, + ...(rest.pipelineExecutionId && { pipelineExecutionId: rest.pipelineExecutionId }), + ...(parsedMaxResults !== undefined && { maxResults: parsedMaxResults }), + ...(rest.nextToken && { nextToken: rest.nextToken }), + } + case 'start_execution': { const rows = parseOptionalJsonInput(rest.pipelineVariables, 'Pipeline variables') const variables = (() => { @@ -381,6 +499,27 @@ export const CodePipelineBlock: BlockConfig< summary: rest.approvalSummary, } + case 'disable_stage_transition': + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + pipelineName: rest.pipelineName, + stageName: rest.stageName, + transitionType: rest.transitionType, + reason: rest.transitionReason, + } + + case 'enable_stage_transition': + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + pipelineName: rest.pipelineName, + stageName: rest.stageName, + transitionType: rest.transitionType, + } + default: throw new Error(`Invalid CodePipeline operation: ${operation}`) } @@ -425,6 +564,18 @@ export const CodePipelineBlock: BlockConfig< }, maxResults: { type: 'number', description: 'Maximum number of results' }, nextToken: { type: 'string', description: 'Pagination token from a previous call' }, + transitionType: { + type: 'string', + description: 'Stage transition to enable or disable: Inbound or Outbound', + }, + transitionReason: { + type: 'string', + description: 'Reason the stage transition is disabled, shown in the pipeline console', + }, + getPipelineVersion: { + type: 'number', + description: 'Pipeline version to retrieve (defaults to the current version)', + }, }, outputs: { pipelines: { @@ -499,6 +650,38 @@ export const CodePipelineBlock: BlockConfig< type: 'number', description: 'Epoch ms when the approval or rejection was submitted', }, + pipelineArn: { + type: 'string', + description: 'Pipeline ARN', + }, + roleArn: { + type: 'string', + description: 'IAM role ARN the pipeline assumes', + }, + pipelineType: { + type: 'string', + description: 'Pipeline type (V1 or V2)', + }, + artifactStoreType: { + type: 'string', + description: 'Artifact store type (S3)', + }, + artifactStoreLocation: { + type: 'string', + description: 'Artifact store bucket location', + }, + stages: { + type: 'array', + description: 'Pipeline stages with their actions (name, category, provider, configuration)', + }, + actionExecutionDetails: { + type: 'array', + description: 'Action-level execution history with status, timing, and error details', + }, + transitionType: { + type: 'string', + description: 'Stage transition type that was enabled or disabled (Inbound or Outbound)', + }, }, } @@ -589,7 +772,14 @@ export const CodePipelineBlockMeta = { description: 'Find the failing stage and action of a CodePipeline execution and report the error details.', content: - '# Investigate Failed CodePipeline Execution\n\nDiagnose why a pipeline run failed.\n\n## Steps\n1. List recent executions for the pipeline and identify the failed one (or use the provided execution ID).\n2. Get the pipeline state and find the stage and action with a Failed status.\n3. Capture the action error code, error message, and external execution URL, plus the source revisions that were being deployed.\n\n## Output\nThe failing stage and action, the error details, the commit/revision involved, and a link to the external execution.', + '# Investigate Failed CodePipeline Execution\n\nDiagnose why a pipeline run failed.\n\n## Steps\n1. List recent executions for the pipeline and identify the failed one (or use the provided execution ID).\n2. Get the pipeline state and find the stage and action with a Failed status.\n3. Capture the action error code, error message, and external execution URL, plus the source revisions that were being deployed.\n4. If more history is needed than the current state shows, list action executions for the pipeline execution ID to see every action attempt with its status and error details.\n\n## Output\nThe failing stage and action, the error details, the commit/revision involved, and a link to the external execution.', + }, + { + name: 'freeze-pipeline-for-incident', + description: + 'Disable a CodePipeline stage transition to freeze deployments during an incident, then re-enable it once resolved.', + content: + '# Freeze CodePipeline During an Incident\n\nStop new releases from reaching a stage without stopping an in-flight execution.\n\n## Steps\n1. Disable the inbound (or outbound) transition on the affected stage with a reason describing the incident.\n2. Confirm via the pipeline state or structure that the transition is disabled.\n3. Once the incident is resolved, enable the transition again so releases resume flowing.\n\n## Output\nThe stage and transition type that was disabled or re-enabled, and the reason recorded.', }, { name: 'trigger-pipeline-release', diff --git a/apps/sim/lib/api/contracts/tools/aws/athena-batch-get-query-execution.ts b/apps/sim/lib/api/contracts/tools/aws/athena-batch-get-query-execution.ts new file mode 100644 index 00000000000..f60484ea797 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/athena-batch-get-query-execution.ts @@ -0,0 +1,72 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const BatchGetQueryExecutionSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + queryExecutionIds: z + .array(z.string().trim().min(1)) + .min(1, 'At least one query execution ID is required') + .max(50, 'A maximum of 50 query execution IDs can be requested at once'), +}) + +const BatchGetQueryExecutionResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + queryExecutions: z.array( + z.object({ + queryExecutionId: z.string(), + query: z.string().nullable(), + state: z.string().nullable(), + stateChangeReason: z.string().nullable(), + statementType: z.string().nullable(), + database: z.string().nullable(), + catalog: z.string().nullable(), + workGroup: z.string().nullable(), + submissionDateTime: z.number().nullable(), + completionDateTime: z.number().nullable(), + dataScannedInBytes: z.number().nullable(), + engineExecutionTimeInMillis: z.number().nullable(), + queryPlanningTimeInMillis: z.number().nullable(), + queryQueueTimeInMillis: z.number().nullable(), + totalExecutionTimeInMillis: z.number().nullable(), + outputLocation: z.string().nullable(), + }) + ), + unprocessedQueryExecutionIds: z.array( + z.object({ + queryExecutionId: z.string().nullable(), + errorCode: z.string().nullable(), + errorMessage: z.string().nullable(), + }) + ), + }), +}) + +export const awsAthenaBatchGetQueryExecutionContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/athena/batch-get-query-execution', + body: BatchGetQueryExecutionSchema, + response: { mode: 'json', schema: BatchGetQueryExecutionResponseSchema }, +}) +export type AwsAthenaBatchGetQueryExecutionRequest = ContractBodyInput< + typeof awsAthenaBatchGetQueryExecutionContract +> +export type AwsAthenaBatchGetQueryExecutionBody = ContractBody< + typeof awsAthenaBatchGetQueryExecutionContract +> +export type AwsAthenaBatchGetQueryExecutionResponse = ContractJsonResponse< + typeof awsAthenaBatchGetQueryExecutionContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/athena-delete-named-query.ts b/apps/sim/lib/api/contracts/tools/aws/athena-delete-named-query.ts new file mode 100644 index 00000000000..ce347485970 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/athena-delete-named-query.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const DeleteNamedQuerySchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + namedQueryId: z.string().trim().min(1, 'Named query ID is required'), +}) + +const DeleteNamedQueryResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + success: z.literal(true), + }), +}) + +export const awsAthenaDeleteNamedQueryContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/athena/delete-named-query', + body: DeleteNamedQuerySchema, + response: { mode: 'json', schema: DeleteNamedQueryResponseSchema }, +}) +export type AwsAthenaDeleteNamedQueryRequest = ContractBodyInput< + typeof awsAthenaDeleteNamedQueryContract +> +export type AwsAthenaDeleteNamedQueryBody = ContractBody +export type AwsAthenaDeleteNamedQueryResponse = ContractJsonResponse< + typeof awsAthenaDeleteNamedQueryContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/athena-get-named-query.ts b/apps/sim/lib/api/contracts/tools/aws/athena-get-named-query.ts index 07bcc0e7db2..67128094968 100644 --- a/apps/sim/lib/api/contracts/tools/aws/athena-get-named-query.ts +++ b/apps/sim/lib/api/contracts/tools/aws/athena-get-named-query.ts @@ -10,7 +10,7 @@ const GetNamedQuerySchema = z.object({ region: z.string().min(1, 'AWS region is required'), accessKeyId: z.string().min(1, 'AWS access key ID is required'), secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - namedQueryId: z.string().min(1, 'Named query ID is required'), + namedQueryId: z.string().trim().min(1, 'Named query ID is required'), }) const GetNamedQueryResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/athena-get-query-execution.ts b/apps/sim/lib/api/contracts/tools/aws/athena-get-query-execution.ts index 3afb752f471..258313d941d 100644 --- a/apps/sim/lib/api/contracts/tools/aws/athena-get-query-execution.ts +++ b/apps/sim/lib/api/contracts/tools/aws/athena-get-query-execution.ts @@ -10,7 +10,7 @@ const GetQueryExecutionSchema = z.object({ region: z.string().min(1, 'AWS region is required'), accessKeyId: z.string().min(1, 'AWS access key ID is required'), secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - queryExecutionId: z.string().min(1, 'Query execution ID is required'), + queryExecutionId: z.string().trim().min(1, 'Query execution ID is required'), }) const GetQueryExecutionResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/athena-get-query-results.ts b/apps/sim/lib/api/contracts/tools/aws/athena-get-query-results.ts index f1a81a2c5a1..04bdb921322 100644 --- a/apps/sim/lib/api/contracts/tools/aws/athena-get-query-results.ts +++ b/apps/sim/lib/api/contracts/tools/aws/athena-get-query-results.ts @@ -10,7 +10,7 @@ const GetQueryResultsSchema = z.object({ region: z.string().min(1, 'AWS region is required'), accessKeyId: z.string().min(1, 'AWS access key ID is required'), secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - queryExecutionId: z.string().min(1, 'Query execution ID is required'), + queryExecutionId: z.string().trim().min(1, 'Query execution ID is required'), maxResults: z.preprocess( (v) => (v === '' || v === undefined || v === null ? undefined : v), z.coerce.number().int().positive().max(999).optional() diff --git a/apps/sim/lib/api/contracts/tools/aws/athena-list-databases.ts b/apps/sim/lib/api/contracts/tools/aws/athena-list-databases.ts new file mode 100644 index 00000000000..a9d180910e7 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/athena-list-databases.ts @@ -0,0 +1,51 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const ListDatabasesSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + catalogName: z.string().trim().min(1, 'Data catalog name is required'), + workGroup: z.string().optional(), + maxResults: z.preprocess( + (v) => (v === '' || v === undefined || v === null ? undefined : v), + z.coerce.number().int().min(1).max(50).optional() + ), + nextToken: z.string().optional(), +}) + +const ListDatabasesResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + databases: z.array( + z.object({ + name: z.string(), + description: z.string().nullable(), + }) + ), + nextToken: z.string().nullable(), + }), +}) + +export const awsAthenaListDatabasesContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/athena/list-databases', + body: ListDatabasesSchema, + response: { mode: 'json', schema: ListDatabasesResponseSchema }, +}) +export type AwsAthenaListDatabasesRequest = ContractBodyInput +export type AwsAthenaListDatabasesBody = ContractBody +export type AwsAthenaListDatabasesResponse = ContractJsonResponse< + typeof awsAthenaListDatabasesContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/athena-list-table-metadata.ts b/apps/sim/lib/api/contracts/tools/aws/athena-list-table-metadata.ts new file mode 100644 index 00000000000..25001b1eb5e --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/athena-list-table-metadata.ts @@ -0,0 +1,65 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const ColumnSchema = z.object({ + name: z.string(), + type: z.string().nullable(), + comment: z.string().nullable(), +}) + +const ListTableMetadataSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + catalogName: z.string().trim().min(1, 'Data catalog name is required'), + databaseName: z.string().trim().min(1, 'Database name is required'), + expression: z.string().optional(), + workGroup: z.string().optional(), + maxResults: z.preprocess( + (v) => (v === '' || v === undefined || v === null ? undefined : v), + z.coerce.number().int().min(1).max(50).optional() + ), + nextToken: z.string().optional(), +}) + +const ListTableMetadataResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + tables: z.array( + z.object({ + name: z.string(), + tableType: z.string().nullable(), + createTime: z.number().nullable(), + lastAccessTime: z.number().nullable(), + columns: z.array(ColumnSchema), + partitionKeys: z.array(ColumnSchema), + }) + ), + nextToken: z.string().nullable(), + }), +}) + +export const awsAthenaListTableMetadataContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/athena/list-table-metadata', + body: ListTableMetadataSchema, + response: { mode: 'json', schema: ListTableMetadataResponseSchema }, +}) +export type AwsAthenaListTableMetadataRequest = ContractBodyInput< + typeof awsAthenaListTableMetadataContract +> +export type AwsAthenaListTableMetadataBody = ContractBody +export type AwsAthenaListTableMetadataResponse = ContractJsonResponse< + typeof awsAthenaListTableMetadataContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/athena-stop-query.ts b/apps/sim/lib/api/contracts/tools/aws/athena-stop-query.ts index d15a7463959..7d7c42c63e7 100644 --- a/apps/sim/lib/api/contracts/tools/aws/athena-stop-query.ts +++ b/apps/sim/lib/api/contracts/tools/aws/athena-stop-query.ts @@ -10,7 +10,7 @@ const StopQuerySchema = z.object({ region: z.string().min(1, 'AWS region is required'), accessKeyId: z.string().min(1, 'AWS access key ID is required'), secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - queryExecutionId: z.string().min(1, 'Query execution ID is required'), + queryExecutionId: z.string().trim().min(1, 'Query execution ID is required'), }) const StopQueryResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-cancel-update-stack.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-cancel-update-stack.ts new file mode 100644 index 00000000000..483e96c9d77 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-cancel-update-stack.ts @@ -0,0 +1,43 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const CancelUpdateStackSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + stackName: z.string().min(1, 'Stack name is required'), +}) + +const CancelUpdateStackResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + message: z.string(), + }), +}) + +export const awsCloudformationCancelUpdateStackContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudformation/cancel-update-stack', + body: CancelUpdateStackSchema, + response: { mode: 'json', schema: CancelUpdateStackResponseSchema }, +}) +export type AwsCloudformationCancelUpdateStackRequest = ContractBodyInput< + typeof awsCloudformationCancelUpdateStackContract +> +export type AwsCloudformationCancelUpdateStackBody = ContractBody< + typeof awsCloudformationCancelUpdateStackContract +> +export type AwsCloudformationCancelUpdateStackResponse = ContractJsonResponse< + typeof awsCloudformationCancelUpdateStackContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-change-set.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-change-set.ts new file mode 100644 index 00000000000..35ab5c3f3b2 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-change-set.ts @@ -0,0 +1,86 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const CreateChangeSetSchema = z + .object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + stackName: z.string().min(1, 'Stack name is required'), + changeSetName: z.string().min(1, 'Change set name is required'), + templateBody: z.string().optional(), + usePreviousTemplate: z.boolean().optional(), + parameters: z + .array( + z.object({ + parameterKey: z.string().min(1, 'Parameter key is required'), + parameterValue: z.string().optional(), + usePreviousValue: z.boolean().optional(), + }) + ) + .optional(), + capabilities: z + .string() + .optional() + .refine( + (v) => + !v || + v + .split(',') + .map((c) => c.trim()) + .filter(Boolean) + .every((c) => + ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'].includes(c) + ), + { + message: + 'capabilities must be a comma-separated list of CAPABILITY_IAM, CAPABILITY_NAMED_IAM, CAPABILITY_AUTO_EXPAND', + } + ), + changeSetType: z.enum(['CREATE', 'UPDATE', 'IMPORT']).optional(), + description: z.string().max(1024, 'Description must be at most 1024 characters').optional(), + }) + .superRefine((data, ctx) => { + if (!data.templateBody && !data.usePreviousTemplate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Either templateBody must be provided or usePreviousTemplate must be true', + path: ['templateBody'], + }) + } + }) + +const CreateChangeSetResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + changeSetId: z.string(), + stackId: z.string(), + }), +}) + +export const awsCloudformationCreateChangeSetContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudformation/create-change-set', + body: CreateChangeSetSchema, + response: { mode: 'json', schema: CreateChangeSetResponseSchema }, +}) +export type AwsCloudformationCreateChangeSetRequest = ContractBodyInput< + typeof awsCloudformationCreateChangeSetContract +> +export type AwsCloudformationCreateChangeSetBody = ContractBody< + typeof awsCloudformationCreateChangeSetContract +> +export type AwsCloudformationCreateChangeSetResponse = ContractJsonResponse< + typeof awsCloudformationCreateChangeSetContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-stack.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-stack.ts new file mode 100644 index 00000000000..8b56f1ee24f --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-create-stack.ts @@ -0,0 +1,84 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const CreateStackSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + stackName: z.string().min(1, 'Stack name is required'), + templateBody: z.string().min(1, 'Template body is required'), + parameters: z + .array( + z.object({ + parameterKey: z.string().min(1, 'Parameter key is required'), + parameterValue: z.string().optional(), + usePreviousValue: z.boolean().optional(), + }) + ) + .optional(), + capabilities: z + .string() + .optional() + .refine( + (v) => + !v || + v + .split(',') + .map((c) => c.trim()) + .filter(Boolean) + .every((c) => + ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'].includes(c) + ), + { + message: + 'capabilities must be a comma-separated list of CAPABILITY_IAM, CAPABILITY_NAMED_IAM, CAPABILITY_AUTO_EXPAND', + } + ), + tags: z + .array( + z.object({ + key: z + .string() + .min(1, 'Tag key is required') + .max(128, 'Tag key must be at most 128 characters'), + value: z.string().max(256, 'Tag value must be at most 256 characters'), + }) + ) + .optional(), + onFailure: z.enum(['ROLLBACK', 'DELETE', 'DO_NOTHING']).optional(), + timeoutInMinutes: z.coerce.number().int().positive().optional(), +}) + +const CreateStackResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + stackId: z.string(), + }), +}) + +export const awsCloudformationCreateStackContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudformation/create-stack', + body: CreateStackSchema, + response: { mode: 'json', schema: CreateStackResponseSchema }, +}) +export type AwsCloudformationCreateStackRequest = ContractBodyInput< + typeof awsCloudformationCreateStackContract +> +export type AwsCloudformationCreateStackBody = ContractBody< + typeof awsCloudformationCreateStackContract +> +export type AwsCloudformationCreateStackResponse = ContractJsonResponse< + typeof awsCloudformationCreateStackContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-delete-stack.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-delete-stack.ts new file mode 100644 index 00000000000..9dc4194dcfb --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-delete-stack.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const DeleteStackSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + stackName: z.string().min(1, 'Stack name is required'), + retainResources: z.string().optional(), +}) + +const DeleteStackResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + message: z.string(), + }), +}) + +export const awsCloudformationDeleteStackContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudformation/delete-stack', + body: DeleteStackSchema, + response: { mode: 'json', schema: DeleteStackResponseSchema }, +}) +export type AwsCloudformationDeleteStackRequest = ContractBodyInput< + typeof awsCloudformationDeleteStackContract +> +export type AwsCloudformationDeleteStackBody = ContractBody< + typeof awsCloudformationDeleteStackContract +> +export type AwsCloudformationDeleteStackResponse = ContractJsonResponse< + typeof awsCloudformationDeleteStackContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-change-set.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-change-set.ts new file mode 100644 index 00000000000..d3353c05d4a --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-describe-change-set.ts @@ -0,0 +1,62 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const DescribeChangeSetSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + changeSetName: z.string().min(1, 'Change set name is required'), + stackName: z.string().optional(), +}) + +const DescribeChangeSetResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + changeSetName: z.string().optional(), + changeSetId: z.string().optional(), + stackId: z.string().optional(), + stackName: z.string().optional(), + description: z.string().optional(), + executionStatus: z.string().optional(), + status: z.string().optional(), + statusReason: z.string().optional(), + creationTime: z.number().optional(), + capabilities: z.array(z.string()), + changes: z.array( + z.object({ + action: z.string().optional(), + logicalResourceId: z.string().optional(), + physicalResourceId: z.string().optional(), + resourceType: z.string().optional(), + replacement: z.string().optional(), + }) + ), + }), +}) + +export const awsCloudformationDescribeChangeSetContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudformation/describe-change-set', + body: DescribeChangeSetSchema, + response: { mode: 'json', schema: DescribeChangeSetResponseSchema }, +}) +export type AwsCloudformationDescribeChangeSetRequest = ContractBodyInput< + typeof awsCloudformationDescribeChangeSetContract +> +export type AwsCloudformationDescribeChangeSetBody = ContractBody< + typeof awsCloudformationDescribeChangeSetContract +> +export type AwsCloudformationDescribeChangeSetResponse = ContractJsonResponse< + typeof awsCloudformationDescribeChangeSetContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-execute-change-set.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-execute-change-set.ts new file mode 100644 index 00000000000..036093ddda4 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-execute-change-set.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const ExecuteChangeSetSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + changeSetName: z.string().min(1, 'Change set name is required'), + stackName: z.string().optional(), +}) + +const ExecuteChangeSetResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + message: z.string(), + }), +}) + +export const awsCloudformationExecuteChangeSetContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudformation/execute-change-set', + body: ExecuteChangeSetSchema, + response: { mode: 'json', schema: ExecuteChangeSetResponseSchema }, +}) +export type AwsCloudformationExecuteChangeSetRequest = ContractBodyInput< + typeof awsCloudformationExecuteChangeSetContract +> +export type AwsCloudformationExecuteChangeSetBody = ContractBody< + typeof awsCloudformationExecuteChangeSetContract +> +export type AwsCloudformationExecuteChangeSetResponse = ContractJsonResponse< + typeof awsCloudformationExecuteChangeSetContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-get-template-summary.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-get-template-summary.ts new file mode 100644 index 00000000000..ccf0cf7c067 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-get-template-summary.ts @@ -0,0 +1,68 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const GetTemplateSummarySchema = z + .object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + templateBody: z.string().optional(), + stackName: z.string().optional(), + }) + .superRefine((data, ctx) => { + if (!data.templateBody && !data.stackName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Either templateBody or stackName is required', + path: ['templateBody'], + }) + } + }) + +const GetTemplateSummaryResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + description: z.string().optional(), + parameters: z.array( + z.object({ + parameterKey: z.string().optional(), + defaultValue: z.string().optional(), + parameterType: z.string().optional(), + noEcho: z.boolean().optional(), + description: z.string().optional(), + }) + ), + capabilities: z.array(z.string()), + capabilitiesReason: z.string().optional(), + resourceTypes: z.array(z.string()), + version: z.string().optional(), + declaredTransforms: z.array(z.string()), + }), +}) + +export const awsCloudformationGetTemplateSummaryContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudformation/get-template-summary', + body: GetTemplateSummarySchema, + response: { mode: 'json', schema: GetTemplateSummaryResponseSchema }, +}) +export type AwsCloudformationGetTemplateSummaryRequest = ContractBodyInput< + typeof awsCloudformationGetTemplateSummaryContract +> +export type AwsCloudformationGetTemplateSummaryBody = ContractBody< + typeof awsCloudformationGetTemplateSummaryContract +> +export type AwsCloudformationGetTemplateSummaryResponse = ContractJsonResponse< + typeof awsCloudformationGetTemplateSummaryContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-get-template.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-get-template.ts index baa811675aa..ecfde5aa763 100644 --- a/apps/sim/lib/api/contracts/tools/aws/cloudformation-get-template.ts +++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-get-template.ts @@ -11,6 +11,7 @@ const GetTemplateSchema = z.object({ accessKeyId: z.string().min(1, 'AWS access key ID is required'), secretAccessKey: z.string().min(1, 'AWS secret access key is required'), stackName: z.string().min(1, 'Stack name is required'), + templateStage: z.enum(['Original', 'Processed']).optional(), }) const GetTemplateResponseSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudformation-update-stack.ts b/apps/sim/lib/api/contracts/tools/aws/cloudformation-update-stack.ts new file mode 100644 index 00000000000..9c9e8506570 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudformation-update-stack.ts @@ -0,0 +1,93 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const UpdateStackSchema = z + .object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + stackName: z.string().min(1, 'Stack name is required'), + templateBody: z.string().optional(), + usePreviousTemplate: z.boolean().optional(), + parameters: z + .array( + z.object({ + parameterKey: z.string().min(1, 'Parameter key is required'), + parameterValue: z.string().optional(), + usePreviousValue: z.boolean().optional(), + }) + ) + .optional(), + capabilities: z + .string() + .optional() + .refine( + (v) => + !v || + v + .split(',') + .map((c) => c.trim()) + .filter(Boolean) + .every((c) => + ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'].includes(c) + ), + { + message: + 'capabilities must be a comma-separated list of CAPABILITY_IAM, CAPABILITY_NAMED_IAM, CAPABILITY_AUTO_EXPAND', + } + ), + tags: z + .array( + z.object({ + key: z + .string() + .min(1, 'Tag key is required') + .max(128, 'Tag key must be at most 128 characters'), + value: z.string().max(256, 'Tag value must be at most 256 characters'), + }) + ) + .optional(), + }) + .superRefine((data, ctx) => { + if (!data.templateBody && !data.usePreviousTemplate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Either templateBody must be provided or usePreviousTemplate must be true', + path: ['templateBody'], + }) + } + }) + +const UpdateStackResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + stackId: z.string(), + }), +}) + +export const awsCloudformationUpdateStackContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudformation/update-stack', + body: UpdateStackSchema, + response: { mode: 'json', schema: UpdateStackResponseSchema }, +}) +export type AwsCloudformationUpdateStackRequest = ContractBodyInput< + typeof awsCloudformationUpdateStackContract +> +export type AwsCloudformationUpdateStackBody = ContractBody< + typeof awsCloudformationUpdateStackContract +> +export type AwsCloudformationUpdateStackResponse = ContractJsonResponse< + typeof awsCloudformationUpdateStackContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarm-history.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarm-history.ts new file mode 100644 index 00000000000..22e241e1ccc --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarm-history.ts @@ -0,0 +1,73 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const DescribeAlarmHistorySchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + alarmName: z.string().optional(), + historyItemType: z.preprocess( + (v) => (v === '' ? undefined : v), + z + .enum([ + 'ConfigurationUpdate', + 'StateUpdate', + 'Action', + 'AlarmContributorStateUpdate', + 'AlarmContributorAction', + ]) + .optional() + ), + startDate: z.coerce.number().int().optional(), + endDate: z.coerce.number().int().optional(), + scanBy: z.preprocess( + (v) => (v === '' ? undefined : v), + z.enum(['TimestampDescending', 'TimestampAscending']).optional() + ), + limit: z.preprocess( + (v) => (v === '' || v === undefined || v === null ? undefined : v), + z.coerce.number().int().positive().optional() + ), +}) + +const DescribeAlarmHistoryResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + alarmHistoryItems: z.array( + z.object({ + alarmName: z.string().optional(), + alarmType: z.string().optional(), + timestamp: z.number().optional(), + historyItemType: z.string().optional(), + historySummary: z.string().optional(), + }) + ), + }), +}) + +export const awsCloudwatchDescribeAlarmHistoryContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudwatch/describe-alarm-history', + body: DescribeAlarmHistorySchema, + response: { mode: 'json', schema: DescribeAlarmHistoryResponseSchema }, +}) +export type AwsCloudwatchDescribeAlarmHistoryRequest = ContractBodyInput< + typeof awsCloudwatchDescribeAlarmHistoryContract +> +export type AwsCloudwatchDescribeAlarmHistoryBody = ContractBody< + typeof awsCloudwatchDescribeAlarmHistoryContract +> +export type AwsCloudwatchDescribeAlarmHistoryResponse = ContractJsonResponse< + typeof awsCloudwatchDescribeAlarmHistoryContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarms.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarms.ts index fd0aaa989db..45322bcf4cf 100644 --- a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarms.ts +++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-describe-alarms.ts @@ -27,7 +27,12 @@ const DescribeAlarmsSchema = z.object({ ), limit: z.preprocess( (v) => (v === '' || v === undefined || v === null ? undefined : v), - z.coerce.number().int().positive().optional() + z.coerce + .number() + .int() + .positive() + .max(100, 'limit must be at most 100 (CloudWatch DescribeAlarms limit)') + .optional() ), }) diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-filter-log-events.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-filter-log-events.ts new file mode 100644 index 00000000000..3233f6202b6 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-filter-log-events.ts @@ -0,0 +1,59 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const FilterLogEventsSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + logGroupName: z.string().min(1, 'Log group name is required'), + filterPattern: z.string().max(1024).optional(), + logStreamNamePrefix: z.string().optional(), + startTime: z.coerce.number().int().optional(), + endTime: z.coerce.number().int().optional(), + startFromHead: z.boolean().optional(), + limit: z.preprocess( + (v) => (v === '' || v === undefined || v === null ? undefined : v), + z.coerce.number().int().positive().optional() + ), +}) + +const FilterLogEventsResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + events: z.array( + z.object({ + logStreamName: z.string().optional(), + timestamp: z.number().optional(), + message: z.string().optional(), + ingestionTime: z.number().optional(), + }) + ), + }), +}) + +export const awsCloudwatchFilterLogEventsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudwatch/filter-log-events', + body: FilterLogEventsSchema, + response: { mode: 'json', schema: FilterLogEventsResponseSchema }, +}) +export type AwsCloudwatchFilterLogEventsRequest = ContractBodyInput< + typeof awsCloudwatchFilterLogEventsContract +> +export type AwsCloudwatchFilterLogEventsBody = ContractBody< + typeof awsCloudwatchFilterLogEventsContract +> +export type AwsCloudwatchFilterLogEventsResponse = ContractJsonResponse< + typeof awsCloudwatchFilterLogEventsContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-log-events.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-log-events.ts index 02e5ae023aa..3bfff17b8c3 100644 --- a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-log-events.ts +++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-log-events.ts @@ -22,7 +22,12 @@ const GetLogEventsSchema = z.object({ endTime: z.coerce.number().int().optional(), limit: z.preprocess( (v) => (v === '' || v === undefined || v === null ? undefined : v), - z.coerce.number().int().positive().optional() + z.coerce + .number() + .int() + .positive() + .max(10000, 'limit must be at most 10000 (CloudWatch Logs GetLogEvents limit)') + .optional() ), }) diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-metric-statistics.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-metric-statistics.ts index 93d6bf62fef..80278dca99c 100644 --- a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-metric-statistics.ts +++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-get-metric-statistics.ts @@ -21,7 +21,10 @@ const GetMetricStatisticsSchema = z.object({ startTime: z.coerce.number().int(), endTime: z.coerce.number().int(), period: z.coerce.number().int().min(1), - statistics: z.array(z.enum(['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount'])).min(1), + statistics: z + .array(z.enum(['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount'])) + .min(1, 'At least one statistic is required') + .max(5, 'At most 5 statistics are allowed per GetMetricStatistics call'), dimensions: z.string().optional(), }) diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-put-log-group-retention.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-put-log-group-retention.ts new file mode 100644 index 00000000000..39fbd4e279c --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-put-log-group-retention.ts @@ -0,0 +1,60 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +/** Only these values are accepted by CloudWatch Logs PutRetentionPolicy. */ +const VALID_RETENTION_DAYS = [ + 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, + 3653, +] as const + +const PutLogGroupRetentionSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + logGroupName: z.string().min(1, 'Log group name is required'), + retentionInDays: z.preprocess( + (v) => (v === '' || v === undefined || v === null ? undefined : v), + z.coerce + .number() + .refine((v) => (VALID_RETENTION_DAYS as readonly number[]).includes(v), { + message: `retentionInDays must be one of ${VALID_RETENTION_DAYS.join(', ')}`, + }) + .optional() + ), +}) + +const PutLogGroupRetentionResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + success: z.literal(true), + logGroupName: z.string(), + retentionInDays: z.number().nullable(), + }), +}) + +export const awsCloudwatchPutLogGroupRetentionContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/cloudwatch/put-log-group-retention', + body: PutLogGroupRetentionSchema, + response: { mode: 'json', schema: PutLogGroupRetentionResponseSchema }, +}) +export type AwsCloudwatchPutLogGroupRetentionRequest = ContractBodyInput< + typeof awsCloudwatchPutLogGroupRetentionContract +> +export type AwsCloudwatchPutLogGroupRetentionBody = ContractBody< + typeof awsCloudwatchPutLogGroupRetentionContract +> +export type AwsCloudwatchPutLogGroupRetentionResponse = ContractJsonResponse< + typeof awsCloudwatchPutLogGroupRetentionContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/codepipeline-disable-stage-transition.ts b/apps/sim/lib/api/contracts/tools/aws/codepipeline-disable-stage-transition.ts new file mode 100644 index 00000000000..13bc918cbda --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/codepipeline-disable-stage-transition.ts @@ -0,0 +1,54 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const DisableStageTransitionSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + pipelineName: z + .string() + .min(1, 'Pipeline name is required') + .max(100, 'Pipeline name must be at most 100 characters'), + stageName: z + .string() + .min(1, 'Stage name is required') + .max(100, 'Stage name must be at most 100 characters'), + transitionType: z.enum(['Inbound', 'Outbound']), + reason: z.string().min(1, 'Reason is required').max(300, 'Reason must be at most 300 characters'), +}) + +const DisableStageTransitionResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + pipelineName: z.string(), + stageName: z.string(), + transitionType: z.enum(['Inbound', 'Outbound']), + }), +}) + +export const awsCodepipelineDisableStageTransitionContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/codepipeline/disable-stage-transition', + body: DisableStageTransitionSchema, + response: { mode: 'json', schema: DisableStageTransitionResponseSchema }, +}) +export type AwsCodepipelineDisableStageTransitionRequest = ContractBodyInput< + typeof awsCodepipelineDisableStageTransitionContract +> +export type AwsCodepipelineDisableStageTransitionBody = ContractBody< + typeof awsCodepipelineDisableStageTransitionContract +> +export type AwsCodepipelineDisableStageTransitionResponse = ContractJsonResponse< + typeof awsCodepipelineDisableStageTransitionContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/codepipeline-enable-stage-transition.ts b/apps/sim/lib/api/contracts/tools/aws/codepipeline-enable-stage-transition.ts new file mode 100644 index 00000000000..a6a5ee66262 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/codepipeline-enable-stage-transition.ts @@ -0,0 +1,53 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const EnableStageTransitionSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + pipelineName: z + .string() + .min(1, 'Pipeline name is required') + .max(100, 'Pipeline name must be at most 100 characters'), + stageName: z + .string() + .min(1, 'Stage name is required') + .max(100, 'Stage name must be at most 100 characters'), + transitionType: z.enum(['Inbound', 'Outbound']), +}) + +const EnableStageTransitionResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + pipelineName: z.string(), + stageName: z.string(), + transitionType: z.enum(['Inbound', 'Outbound']), + }), +}) + +export const awsCodepipelineEnableStageTransitionContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/codepipeline/enable-stage-transition', + body: EnableStageTransitionSchema, + response: { mode: 'json', schema: EnableStageTransitionResponseSchema }, +}) +export type AwsCodepipelineEnableStageTransitionRequest = ContractBodyInput< + typeof awsCodepipelineEnableStageTransitionContract +> +export type AwsCodepipelineEnableStageTransitionBody = ContractBody< + typeof awsCodepipelineEnableStageTransitionContract +> +export type AwsCodepipelineEnableStageTransitionResponse = ContractJsonResponse< + typeof awsCodepipelineEnableStageTransitionContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/codepipeline-get-pipeline.ts b/apps/sim/lib/api/contracts/tools/aws/codepipeline-get-pipeline.ts new file mode 100644 index 00000000000..59b4dbebbb9 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/codepipeline-get-pipeline.ts @@ -0,0 +1,82 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const GetPipelineSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + pipelineName: z + .string() + .min(1, 'Pipeline name is required') + .max(100, 'Pipeline name must be at most 100 characters'), + version: z.preprocess( + (v) => (v === '' || v === undefined || v === null ? undefined : v), + z.coerce.number().int().min(1).optional() + ), +}) + +const ActionDeclarationSchema = z.object({ + name: z.string(), + category: z.string(), + owner: z.string(), + provider: z.string(), + version: z.string(), + runOrder: z.number().optional(), + configuration: z.record(z.string(), z.string()), + inputArtifacts: z.array(z.string()), + outputArtifacts: z.array(z.string()), +}) + +const StageDeclarationSchema = z.object({ + stageName: z.string(), + actions: z.array(ActionDeclarationSchema), +}) + +const GetPipelineResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + pipelineName: z.string(), + pipelineArn: z.string().optional(), + roleArn: z.string(), + version: z.number().optional(), + pipelineType: z.string().optional(), + executionMode: z.string().optional(), + artifactStoreType: z.string().optional(), + artifactStoreLocation: z.string().optional(), + stages: z.array(StageDeclarationSchema), + variables: z.array( + z.object({ + name: z.string(), + defaultValue: z.string().optional(), + description: z.string().optional(), + }) + ), + created: z.number().optional(), + updated: z.number().optional(), + }), +}) + +export const awsCodepipelineGetPipelineContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/codepipeline/get-pipeline', + body: GetPipelineSchema, + response: { mode: 'json', schema: GetPipelineResponseSchema }, +}) +export type AwsCodepipelineGetPipelineRequest = ContractBodyInput< + typeof awsCodepipelineGetPipelineContract +> +export type AwsCodepipelineGetPipelineBody = ContractBody +export type AwsCodepipelineGetPipelineResponse = ContractJsonResponse< + typeof awsCodepipelineGetPipelineContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/codepipeline-list-action-executions.ts b/apps/sim/lib/api/contracts/tools/aws/codepipeline-list-action-executions.ts new file mode 100644 index 00000000000..ace836b7aea --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/codepipeline-list-action-executions.ts @@ -0,0 +1,70 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const ListActionExecutionsSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + pipelineName: z + .string() + .min(1, 'Pipeline name is required') + .max(100, 'Pipeline name must be at most 100 characters'), + pipelineExecutionId: z.string().min(1).optional(), + maxResults: z.preprocess( + (v) => (v === '' || v === undefined || v === null ? undefined : v), + z.coerce.number().int().min(1).max(100).optional() + ), + nextToken: z.string().min(1).max(2048).optional(), +}) + +const ActionExecutionDetailSchema = z.object({ + pipelineExecutionId: z.string().optional(), + actionExecutionId: z.string().optional(), + pipelineVersion: z.number().optional(), + stageName: z.string().optional(), + actionName: z.string().optional(), + startTime: z.number().optional(), + lastUpdateTime: z.number().optional(), + updatedBy: z.string().optional(), + status: z.string().optional(), + externalExecutionId: z.string().optional(), + externalExecutionSummary: z.string().optional(), + externalExecutionUrl: z.string().optional(), + errorCode: z.string().optional(), + errorMessage: z.string().optional(), +}) + +const ListActionExecutionsResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + actionExecutionDetails: z.array(ActionExecutionDetailSchema), + nextToken: z.string().optional(), + }), +}) + +export const awsCodepipelineListActionExecutionsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/codepipeline/list-action-executions', + body: ListActionExecutionsSchema, + response: { mode: 'json', schema: ListActionExecutionsResponseSchema }, +}) +export type AwsCodepipelineListActionExecutionsRequest = ContractBodyInput< + typeof awsCodepipelineListActionExecutionsContract +> +export type AwsCodepipelineListActionExecutionsBody = ContractBody< + typeof awsCodepipelineListActionExecutionsContract +> +export type AwsCodepipelineListActionExecutionsResponse = ContractJsonResponse< + typeof awsCodepipelineListActionExecutionsContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/codepipeline-list-pipeline-executions.ts b/apps/sim/lib/api/contracts/tools/aws/codepipeline-list-pipeline-executions.ts index be2b912da14..6a8ccad4060 100644 --- a/apps/sim/lib/api/contracts/tools/aws/codepipeline-list-pipeline-executions.ts +++ b/apps/sim/lib/api/contracts/tools/aws/codepipeline-list-pipeline-executions.ts @@ -39,6 +39,7 @@ const PipelineExecutionSummarySchema = z.object({ stopTriggerReason: z.string().optional(), triggerType: z.string().optional(), triggerDetail: z.string().optional(), + rollbackTargetPipelineExecutionId: z.string().optional(), sourceRevisions: z.array( z.object({ actionName: z.string(), diff --git a/apps/sim/tools/athena/batch_get_query_execution.ts b/apps/sim/tools/athena/batch_get_query_execution.ts new file mode 100644 index 00000000000..18173dd94a5 --- /dev/null +++ b/apps/sim/tools/athena/batch_get_query_execution.ts @@ -0,0 +1,158 @@ +import type { + AthenaBatchGetQueryExecutionParams, + AthenaBatchGetQueryExecutionResponse, +} from '@/tools/athena/types' +import type { ToolConfig } from '@/tools/types' + +export const batchGetQueryExecutionTool: ToolConfig< + AthenaBatchGetQueryExecutionParams, + AthenaBatchGetQueryExecutionResponse +> = { + id: 'athena_batch_get_query_execution', + name: 'Athena Batch Get Query Executions', + description: 'Get the status and details of up to 50 Athena query executions in one call', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + queryExecutionIds: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comma-separated query execution IDs to check (up to 50)', + }, + }, + + request: { + url: '/api/tools/athena/batch-get-query-execution', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => { + const ids = params.queryExecutionIds + .split(',') + .map((id) => id.trim()) + .filter(Boolean) + return { + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + queryExecutionIds: ids, + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || 'Failed to batch get Athena query executions') + } + return { + success: true, + output: { + queryExecutions: data.output.queryExecutions ?? [], + unprocessedQueryExecutionIds: data.output.unprocessedQueryExecutionIds ?? [], + }, + } + }, + + outputs: { + queryExecutions: { + type: 'array', + description: 'Details for each successfully retrieved query execution', + items: { + type: 'object', + properties: { + queryExecutionId: { type: 'string', description: 'Query execution ID' }, + query: { type: 'string', description: 'SQL query string', optional: true }, + state: { + type: 'string', + description: 'Query state (QUEUED, RUNNING, SUCCEEDED, FAILED, CANCELLED)', + optional: true, + }, + stateChangeReason: { + type: 'string', + description: 'Reason for state change', + optional: true, + }, + statementType: { + type: 'string', + description: 'Statement type (DDL, DML, UTILITY)', + optional: true, + }, + database: { type: 'string', description: 'Database name', optional: true }, + catalog: { type: 'string', description: 'Data catalog name', optional: true }, + workGroup: { type: 'string', description: 'Workgroup name', optional: true }, + submissionDateTime: { + type: 'number', + description: 'Query submission time (Unix epoch ms)', + optional: true, + }, + completionDateTime: { + type: 'number', + description: 'Query completion time (Unix epoch ms)', + optional: true, + }, + dataScannedInBytes: { + type: 'number', + description: 'Amount of data scanned in bytes', + optional: true, + }, + engineExecutionTimeInMillis: { + type: 'number', + description: 'Engine execution time in milliseconds', + optional: true, + }, + queryPlanningTimeInMillis: { + type: 'number', + description: 'Query planning time in milliseconds', + optional: true, + }, + queryQueueTimeInMillis: { + type: 'number', + description: 'Time the query spent in queue in milliseconds', + optional: true, + }, + totalExecutionTimeInMillis: { + type: 'number', + description: 'Total execution time in milliseconds', + optional: true, + }, + outputLocation: { + type: 'string', + description: 'S3 location of query results', + optional: true, + }, + }, + }, + }, + unprocessedQueryExecutionIds: { + type: 'array', + description: 'Query execution IDs that could not be retrieved, with error details', + items: { + type: 'object', + properties: { + queryExecutionId: { type: 'string', description: 'Query execution ID', optional: true }, + errorCode: { type: 'string', description: 'Error code', optional: true }, + errorMessage: { type: 'string', description: 'Error message', optional: true }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/athena/delete_named_query.ts b/apps/sim/tools/athena/delete_named_query.ts new file mode 100644 index 00000000000..aadc77d0761 --- /dev/null +++ b/apps/sim/tools/athena/delete_named_query.ts @@ -0,0 +1,74 @@ +import type { + AthenaDeleteNamedQueryParams, + AthenaDeleteNamedQueryResponse, +} from '@/tools/athena/types' +import type { ToolConfig } from '@/tools/types' + +export const deleteNamedQueryTool: ToolConfig< + AthenaDeleteNamedQueryParams, + AthenaDeleteNamedQueryResponse +> = { + id: 'athena_delete_named_query', + name: 'Athena Delete Named Query', + description: 'Delete a saved/named query in AWS Athena', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + namedQueryId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Named query ID to delete', + }, + }, + + request: { + url: '/api/tools/athena/delete-named-query', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + namedQueryId: params.namedQueryId, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || 'Failed to delete Athena named query') + } + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the named query was successfully deleted', + }, + }, +} diff --git a/apps/sim/tools/athena/index.ts b/apps/sim/tools/athena/index.ts index 5461e01117f..77c87a5969e 100644 --- a/apps/sim/tools/athena/index.ts +++ b/apps/sim/tools/athena/index.ts @@ -1,18 +1,26 @@ +import { batchGetQueryExecutionTool } from '@/tools/athena/batch_get_query_execution' import { createNamedQueryTool } from '@/tools/athena/create_named_query' +import { deleteNamedQueryTool } from '@/tools/athena/delete_named_query' import { getNamedQueryTool } from '@/tools/athena/get_named_query' import { getQueryExecutionTool } from '@/tools/athena/get_query_execution' import { getQueryResultsTool } from '@/tools/athena/get_query_results' +import { listDatabasesTool } from '@/tools/athena/list_databases' import { listNamedQueriesTool } from '@/tools/athena/list_named_queries' import { listQueryExecutionsTool } from '@/tools/athena/list_query_executions' +import { listTableMetadataTool } from '@/tools/athena/list_table_metadata' import { startQueryTool } from '@/tools/athena/start_query' import { stopQueryTool } from '@/tools/athena/stop_query' +export const athenaBatchGetQueryExecutionTool = batchGetQueryExecutionTool export const athenaCreateNamedQueryTool = createNamedQueryTool +export const athenaDeleteNamedQueryTool = deleteNamedQueryTool export const athenaGetNamedQueryTool = getNamedQueryTool export const athenaGetQueryExecutionTool = getQueryExecutionTool export const athenaGetQueryResultsTool = getQueryResultsTool +export const athenaListDatabasesTool = listDatabasesTool export const athenaListNamedQueriesTool = listNamedQueriesTool export const athenaListQueryExecutionsTool = listQueryExecutionsTool +export const athenaListTableMetadataTool = listTableMetadataTool export const athenaStartQueryTool = startQueryTool export const athenaStopQueryTool = stopQueryTool diff --git a/apps/sim/tools/athena/list_databases.ts b/apps/sim/tools/athena/list_databases.ts new file mode 100644 index 00000000000..d2b5d2fb708 --- /dev/null +++ b/apps/sim/tools/athena/list_databases.ts @@ -0,0 +1,104 @@ +import type { AthenaListDatabasesParams, AthenaListDatabasesResponse } from '@/tools/athena/types' +import type { ToolConfig } from '@/tools/types' + +export const listDatabasesTool: ToolConfig = + { + id: 'athena_list_databases', + name: 'Athena List Databases', + description: 'List the databases available in an Athena data catalog', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + catalogName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Data catalog name to list databases from (e.g., AwsDataCatalog)', + }, + workGroup: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Workgroup for which the metadata is being fetched (required for IAM Identity Center enabled catalogs)', + }, + maxResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results (1-50)', + }, + nextToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination token from a previous request', + }, + }, + + request: { + url: '/api/tools/athena/list-databases', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + catalogName: params.catalogName, + ...(params.workGroup && { workGroup: params.workGroup }), + ...(params.maxResults !== undefined && { maxResults: params.maxResults }), + ...(params.nextToken && { nextToken: params.nextToken }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || 'Failed to list Athena databases') + } + return { + success: true, + output: { + databases: data.output.databases ?? [], + nextToken: data.output.nextToken ?? null, + }, + } + }, + + outputs: { + databases: { + type: 'array', + description: 'List of databases (name, description)', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Database name' }, + description: { type: 'string', description: 'Database description', optional: true }, + }, + }, + }, + nextToken: { + type: 'string', + description: 'Pagination token for next page', + optional: true, + }, + }, + } diff --git a/apps/sim/tools/athena/list_table_metadata.ts b/apps/sim/tools/athena/list_table_metadata.ts new file mode 100644 index 00000000000..57e664fcd32 --- /dev/null +++ b/apps/sim/tools/athena/list_table_metadata.ts @@ -0,0 +1,157 @@ +import type { + AthenaListTableMetadataParams, + AthenaListTableMetadataResponse, +} from '@/tools/athena/types' +import type { ToolConfig } from '@/tools/types' + +export const listTableMetadataTool: ToolConfig< + AthenaListTableMetadataParams, + AthenaListTableMetadataResponse +> = { + id: 'athena_list_table_metadata', + name: 'Athena List Table Metadata', + description: 'List tables and their column/partition metadata for an Athena database', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + catalogName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Data catalog name (e.g., AwsDataCatalog)', + }, + databaseName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Database name to list tables from', + }, + expression: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Regex filter that pattern-matches table names', + }, + workGroup: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Workgroup for which the metadata is being fetched (required for IAM Identity Center enabled catalogs)', + }, + maxResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results (1-50)', + }, + nextToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination token from a previous request', + }, + }, + + request: { + url: '/api/tools/athena/list-table-metadata', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + catalogName: params.catalogName, + databaseName: params.databaseName, + ...(params.expression && { expression: params.expression }), + ...(params.workGroup && { workGroup: params.workGroup }), + ...(params.maxResults !== undefined && { maxResults: params.maxResults }), + ...(params.nextToken && { nextToken: params.nextToken }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || 'Failed to list Athena table metadata') + } + return { + success: true, + output: { + tables: data.output.tables ?? [], + nextToken: data.output.nextToken ?? null, + }, + } + }, + + outputs: { + tables: { + type: 'array', + description: 'Table metadata (name, type, columns, partition keys)', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Table name' }, + tableType: { type: 'string', description: 'Table type', optional: true }, + createTime: { + type: 'number', + description: 'Table creation time (Unix epoch ms)', + optional: true, + }, + lastAccessTime: { + type: 'number', + description: 'Table last access time (Unix epoch ms)', + optional: true, + }, + columns: { + type: 'array', + description: 'Column definitions', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'Column data type', optional: true }, + comment: { type: 'string', description: 'Column comment', optional: true }, + }, + }, + }, + partitionKeys: { + type: 'array', + description: 'Partition key definitions', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Partition key name' }, + type: { type: 'string', description: 'Partition key data type', optional: true }, + comment: { type: 'string', description: 'Partition key comment', optional: true }, + }, + }, + }, + }, + }, + }, + nextToken: { + type: 'string', + description: 'Pagination token for next page', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/athena/types.ts b/apps/sim/tools/athena/types.ts index 159eb2386e9..68951bc196e 100644 --- a/apps/sim/tools/athena/types.ts +++ b/apps/sim/tools/athena/types.ts @@ -124,3 +124,99 @@ export interface AthenaListNamedQueriesResponse extends ToolResponse { nextToken: string | null } } + +export interface AthenaDeleteNamedQueryParams extends AthenaConnectionConfig { + namedQueryId: string +} + +export interface AthenaDeleteNamedQueryResponse extends ToolResponse { + output: { + success: boolean + } +} + +export interface AthenaBatchGetQueryExecutionParams extends AthenaConnectionConfig { + queryExecutionIds: string +} + +export interface AthenaQueryExecutionSummary { + queryExecutionId: string + query: string | null + state: string | null + stateChangeReason: string | null + statementType: string | null + database: string | null + catalog: string | null + workGroup: string | null + submissionDateTime: number | null + completionDateTime: number | null + dataScannedInBytes: number | null + engineExecutionTimeInMillis: number | null + queryPlanningTimeInMillis: number | null + queryQueueTimeInMillis: number | null + totalExecutionTimeInMillis: number | null + outputLocation: string | null +} + +export interface AthenaUnprocessedQueryExecutionId { + queryExecutionId: string | null + errorCode: string | null + errorMessage: string | null +} + +export interface AthenaBatchGetQueryExecutionResponse extends ToolResponse { + output: { + queryExecutions: AthenaQueryExecutionSummary[] + unprocessedQueryExecutionIds: AthenaUnprocessedQueryExecutionId[] + } +} + +export interface AthenaListDatabasesParams extends AthenaConnectionConfig { + catalogName: string + workGroup?: string + maxResults?: number + nextToken?: string +} + +export interface AthenaDatabase { + name: string + description: string | null +} + +export interface AthenaListDatabasesResponse extends ToolResponse { + output: { + databases: AthenaDatabase[] + nextToken: string | null + } +} + +export interface AthenaListTableMetadataParams extends AthenaConnectionConfig { + catalogName: string + databaseName: string + expression?: string + workGroup?: string + maxResults?: number + nextToken?: string +} + +export interface AthenaColumn { + name: string + type: string | null + comment: string | null +} + +export interface AthenaTableMetadata { + name: string + tableType: string | null + createTime: number | null + lastAccessTime: number | null + columns: AthenaColumn[] + partitionKeys: AthenaColumn[] +} + +export interface AthenaListTableMetadataResponse extends ToolResponse { + output: { + tables: AthenaTableMetadata[] + nextToken: string | null + } +} diff --git a/apps/sim/tools/cloudformation/cancel_update_stack.ts b/apps/sim/tools/cloudformation/cancel_update_stack.ts new file mode 100644 index 00000000000..8e7ca8184de --- /dev/null +++ b/apps/sim/tools/cloudformation/cancel_update_stack.ts @@ -0,0 +1,75 @@ +import type { + CloudFormationCancelUpdateStackParams, + CloudFormationCancelUpdateStackResponse, +} from '@/tools/cloudformation/types' +import type { ToolConfig } from '@/tools/types' + +export const cancelUpdateStackTool: ToolConfig< + CloudFormationCancelUpdateStackParams, + CloudFormationCancelUpdateStackResponse +> = { + id: 'cloudformation_cancel_update_stack', + name: 'CloudFormation Cancel Update Stack', + description: 'Cancel an in-progress stack update and roll back to the last known stable state', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + stackName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name or ID of the stack whose update should be cancelled', + }, + }, + + request: { + url: '/api/tools/cloudformation/cancel-update-stack', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + stackName: params.stackName, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to cancel CloudFormation stack update') + } + + return { + success: true, + output: { + message: data.output.message, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + }, +} diff --git a/apps/sim/tools/cloudformation/create_change_set.ts b/apps/sim/tools/cloudformation/create_change_set.ts new file mode 100644 index 00000000000..b2b144acacd --- /dev/null +++ b/apps/sim/tools/cloudformation/create_change_set.ts @@ -0,0 +1,134 @@ +import type { + CloudFormationCreateChangeSetParams, + CloudFormationCreateChangeSetResponse, +} from '@/tools/cloudformation/types' +import type { ToolConfig } from '@/tools/types' + +export const createChangeSetTool: ToolConfig< + CloudFormationCreateChangeSetParams, + CloudFormationCreateChangeSetResponse +> = { + id: 'cloudformation_create_change_set', + name: 'CloudFormation Create Change Set', + description: 'Preview the changes a stack create or update would make before applying them', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + stackName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Name of the stack to create or update (new name for CREATE type, existing name for UPDATE)', + }, + changeSetName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name for the new change set', + }, + templateBody: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The CloudFormation template body (JSON or YAML). Required unless usePreviousTemplate is true', + }, + usePreviousTemplate: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Reuse the template currently associated with the stack (UPDATE change sets only)', + }, + parameters: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Template input parameters (e.g., [{"parameterKey": "InstanceType", "parameterValue": "t3.micro"}])', + }, + capabilities: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated capabilities to acknowledge (CAPABILITY_IAM, CAPABILITY_NAMED_IAM, CAPABILITY_AUTO_EXPAND)', + }, + changeSetType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'CREATE (default, new stack), UPDATE (existing stack), or IMPORT (import existing resources)', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Description of the change set for reference', + }, + }, + + request: { + url: '/api/tools/cloudformation/create-change-set', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + stackName: params.stackName, + changeSetName: params.changeSetName, + ...(params.templateBody && { templateBody: params.templateBody }), + ...(params.usePreviousTemplate !== undefined && { + usePreviousTemplate: params.usePreviousTemplate, + }), + ...(params.parameters && { parameters: params.parameters }), + ...(params.capabilities && { capabilities: params.capabilities }), + ...(params.changeSetType && { changeSetType: params.changeSetType }), + ...(params.description && { description: params.description }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to create CloudFormation change set') + } + + return { + success: true, + output: { + changeSetId: data.output.changeSetId, + stackId: data.output.stackId, + }, + } + }, + + outputs: { + changeSetId: { type: 'string', description: 'The unique ID of the created change set' }, + stackId: { type: 'string', description: 'The unique ID of the target stack' }, + }, +} diff --git a/apps/sim/tools/cloudformation/create_stack.ts b/apps/sim/tools/cloudformation/create_stack.ts new file mode 100644 index 00000000000..94beecafe8f --- /dev/null +++ b/apps/sim/tools/cloudformation/create_stack.ts @@ -0,0 +1,120 @@ +import type { + CloudFormationCreateStackParams, + CloudFormationCreateStackResponse, +} from '@/tools/cloudformation/types' +import type { ToolConfig } from '@/tools/types' + +export const createStackTool: ToolConfig< + CloudFormationCreateStackParams, + CloudFormationCreateStackResponse +> = { + id: 'cloudformation_create_stack', + name: 'CloudFormation Create Stack', + description: 'Create a new CloudFormation stack from a template', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + stackName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name for the new stack (must be unique in the Region)', + }, + templateBody: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The CloudFormation template body (JSON or YAML)', + }, + parameters: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Template input parameters (e.g., [{"parameterKey": "InstanceType", "parameterValue": "t3.micro"}])', + }, + capabilities: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated capabilities to acknowledge (CAPABILITY_IAM, CAPABILITY_NAMED_IAM, CAPABILITY_AUTO_EXPAND) required when the template creates IAM resources or uses macros', + }, + tags: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Tags to apply to the stack and its resources (e.g., [{"key": "env", "value": "prod"}])', + }, + onFailure: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Action to take on creation failure: ROLLBACK (default), DELETE, or DO_NOTHING', + }, + timeoutInMinutes: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Amount of time before the stack creation times out and rolls back', + }, + }, + + request: { + url: '/api/tools/cloudformation/create-stack', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + stackName: params.stackName, + templateBody: params.templateBody, + ...(params.parameters && { parameters: params.parameters }), + ...(params.capabilities && { capabilities: params.capabilities }), + ...(params.tags && { tags: params.tags }), + ...(params.onFailure && { onFailure: params.onFailure }), + ...(params.timeoutInMinutes !== undefined && { timeoutInMinutes: params.timeoutInMinutes }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to create CloudFormation stack') + } + + return { + success: true, + output: { + stackId: data.output.stackId, + }, + } + }, + + outputs: { + stackId: { type: 'string', description: 'The unique ID of the created stack' }, + }, +} diff --git a/apps/sim/tools/cloudformation/delete_stack.ts b/apps/sim/tools/cloudformation/delete_stack.ts new file mode 100644 index 00000000000..f532cfc0cf5 --- /dev/null +++ b/apps/sim/tools/cloudformation/delete_stack.ts @@ -0,0 +1,83 @@ +import type { + CloudFormationDeleteStackParams, + CloudFormationDeleteStackResponse, +} from '@/tools/cloudformation/types' +import type { ToolConfig } from '@/tools/types' + +export const deleteStackTool: ToolConfig< + CloudFormationDeleteStackParams, + CloudFormationDeleteStackResponse +> = { + id: 'cloudformation_delete_stack', + name: 'CloudFormation Delete Stack', + description: 'Delete a CloudFormation stack and its resources', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + stackName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name or ID of the stack to delete', + }, + retainResources: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated logical resource IDs to retain instead of deleting (only applies to stacks in DELETE_FAILED state)', + }, + }, + + request: { + url: '/api/tools/cloudformation/delete-stack', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + stackName: params.stackName, + ...(params.retainResources && { retainResources: params.retainResources }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to delete CloudFormation stack') + } + + return { + success: true, + output: { + message: data.output.message, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + }, +} diff --git a/apps/sim/tools/cloudformation/describe_change_set.ts b/apps/sim/tools/cloudformation/describe_change_set.ts new file mode 100644 index 00000000000..a542fb3f5ad --- /dev/null +++ b/apps/sim/tools/cloudformation/describe_change_set.ts @@ -0,0 +1,118 @@ +import type { + CloudFormationDescribeChangeSetParams, + CloudFormationDescribeChangeSetResponse, +} from '@/tools/cloudformation/types' +import type { ToolConfig } from '@/tools/types' + +export const describeChangeSetTool: ToolConfig< + CloudFormationDescribeChangeSetParams, + CloudFormationDescribeChangeSetResponse +> = { + id: 'cloudformation_describe_change_set', + name: 'CloudFormation Describe Change Set', + description: 'View the resource changes a change set would make and its execution status', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + changeSetName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name or ARN of the change set to describe', + }, + stackName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Name or ID of the stack the change set belongs to (required if changeSetName is not an ARN)', + }, + }, + + request: { + url: '/api/tools/cloudformation/describe-change-set', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + changeSetName: params.changeSetName, + ...(params.stackName && { stackName: params.stackName }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to describe CloudFormation change set') + } + + return { + success: true, + output: { + changeSetName: data.output.changeSetName, + changeSetId: data.output.changeSetId, + stackId: data.output.stackId, + stackName: data.output.stackName, + description: data.output.description, + executionStatus: data.output.executionStatus, + status: data.output.status, + statusReason: data.output.statusReason, + creationTime: data.output.creationTime, + capabilities: data.output.capabilities, + changes: data.output.changes, + }, + } + }, + + outputs: { + changeSetName: { type: 'string', description: 'Name of the change set' }, + changeSetId: { type: 'string', description: 'The unique ID of the change set' }, + stackId: { type: 'string', description: 'The unique ID of the target stack' }, + stackName: { type: 'string', description: 'Name of the target stack' }, + description: { type: 'string', description: 'Description of the change set' }, + executionStatus: { + type: 'string', + description: + 'Whether the change set can be executed (AVAILABLE, UNAVAILABLE, EXECUTE_IN_PROGRESS, EXECUTE_COMPLETE, EXECUTE_FAILED, OBSOLETE)', + }, + status: { + type: 'string', + description: + 'Current status of the change set (CREATE_PENDING, CREATE_IN_PROGRESS, CREATE_COMPLETE, DELETE_COMPLETE, FAILED)', + }, + statusReason: { + type: 'string', + description: 'Reason for the current status, particularly if failed', + }, + creationTime: { type: 'number', description: 'Timestamp the change set was created' }, + capabilities: { type: 'array', description: 'Capabilities required to execute the change set' }, + changes: { + type: 'array', + description: + 'List of resource changes (action, logical/physical resource ID, resource type, replacement)', + }, + }, +} diff --git a/apps/sim/tools/cloudformation/execute_change_set.ts b/apps/sim/tools/cloudformation/execute_change_set.ts new file mode 100644 index 00000000000..cc689715cb0 --- /dev/null +++ b/apps/sim/tools/cloudformation/execute_change_set.ts @@ -0,0 +1,83 @@ +import type { + CloudFormationExecuteChangeSetParams, + CloudFormationExecuteChangeSetResponse, +} from '@/tools/cloudformation/types' +import type { ToolConfig } from '@/tools/types' + +export const executeChangeSetTool: ToolConfig< + CloudFormationExecuteChangeSetParams, + CloudFormationExecuteChangeSetResponse +> = { + id: 'cloudformation_execute_change_set', + name: 'CloudFormation Execute Change Set', + description: 'Apply a previously created and reviewed change set to its stack', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + changeSetName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name or ARN of the change set to execute', + }, + stackName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Name or ID of the stack the change set belongs to (required if changeSetName is not an ARN)', + }, + }, + + request: { + url: '/api/tools/cloudformation/execute-change-set', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + changeSetName: params.changeSetName, + ...(params.stackName && { stackName: params.stackName }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to execute CloudFormation change set') + } + + return { + success: true, + output: { + message: data.output.message, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + }, +} diff --git a/apps/sim/tools/cloudformation/get_template.ts b/apps/sim/tools/cloudformation/get_template.ts index 6a5fdd1c54a..08f6f39afe8 100644 --- a/apps/sim/tools/cloudformation/get_template.ts +++ b/apps/sim/tools/cloudformation/get_template.ts @@ -38,6 +38,13 @@ export const getTemplateTool: ToolConfig< visibility: 'user-or-llm', description: 'Stack name or ID', }, + templateStage: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Which template version to retrieve: Processed (default, with transforms applied) or Original (as submitted)', + }, }, request: { @@ -51,6 +58,7 @@ export const getTemplateTool: ToolConfig< accessKeyId: params.awsAccessKeyId, secretAccessKey: params.awsSecretAccessKey, stackName: params.stackName, + ...(params.templateStage && { templateStage: params.templateStage }), }), }, diff --git a/apps/sim/tools/cloudformation/get_template_summary.ts b/apps/sim/tools/cloudformation/get_template_summary.ts new file mode 100644 index 00000000000..3ba2a5b37ae --- /dev/null +++ b/apps/sim/tools/cloudformation/get_template_summary.ts @@ -0,0 +1,105 @@ +import type { + CloudFormationGetTemplateSummaryParams, + CloudFormationGetTemplateSummaryResponse, +} from '@/tools/cloudformation/types' +import type { ToolConfig } from '@/tools/types' + +export const getTemplateSummaryTool: ToolConfig< + CloudFormationGetTemplateSummaryParams, + CloudFormationGetTemplateSummaryResponse +> = { + id: 'cloudformation_get_template_summary', + name: 'CloudFormation Get Template Summary', + description: + 'Get a summary of a template or deployed stack: resource types, required capabilities, and parameters, without full validation', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + templateBody: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The CloudFormation template body (JSON or YAML). Required if stackName is not provided', + }, + stackName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Name or ID of a deployed stack to summarize instead of a template body', + }, + }, + + request: { + url: '/api/tools/cloudformation/get-template-summary', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + ...(params.templateBody && { templateBody: params.templateBody }), + ...(params.stackName && { stackName: params.stackName }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to get CloudFormation template summary') + } + + return { + success: true, + output: { + description: data.output.description, + parameters: data.output.parameters, + capabilities: data.output.capabilities, + capabilitiesReason: data.output.capabilitiesReason, + resourceTypes: data.output.resourceTypes, + version: data.output.version, + declaredTransforms: data.output.declaredTransforms, + }, + } + }, + + outputs: { + description: { type: 'string', description: 'Template description' }, + parameters: { + type: 'array', + description: 'Template parameters with types, defaults, and descriptions', + }, + capabilities: { type: 'array', description: 'Required capabilities (e.g., CAPABILITY_IAM)' }, + capabilitiesReason: { type: 'string', description: 'Reason capabilities are required' }, + resourceTypes: { + type: 'array', + description: 'AWS resource types declared in the template (e.g., AWS::S3::Bucket)', + }, + version: { type: 'string', description: 'Template format version' }, + declaredTransforms: { + type: 'array', + description: 'Transforms used in the template (e.g., AWS::Serverless-2016-10-31)', + }, + }, +} diff --git a/apps/sim/tools/cloudformation/index.ts b/apps/sim/tools/cloudformation/index.ts index 1d51aa2d959..eaca0ecd226 100644 --- a/apps/sim/tools/cloudformation/index.ts +++ b/apps/sim/tools/cloudformation/index.ts @@ -1,11 +1,19 @@ export * from './types' +import { cancelUpdateStackTool } from '@/tools/cloudformation/cancel_update_stack' +import { createChangeSetTool } from '@/tools/cloudformation/create_change_set' +import { createStackTool } from '@/tools/cloudformation/create_stack' +import { deleteStackTool } from '@/tools/cloudformation/delete_stack' +import { describeChangeSetTool } from '@/tools/cloudformation/describe_change_set' import { describeStackDriftDetectionStatusTool } from '@/tools/cloudformation/describe_stack_drift_detection_status' import { describeStackEventsTool } from '@/tools/cloudformation/describe_stack_events' import { describeStacksTool } from '@/tools/cloudformation/describe_stacks' import { detectStackDriftTool } from '@/tools/cloudformation/detect_stack_drift' +import { executeChangeSetTool } from '@/tools/cloudformation/execute_change_set' import { getTemplateTool } from '@/tools/cloudformation/get_template' +import { getTemplateSummaryTool } from '@/tools/cloudformation/get_template_summary' import { listStackResourcesTool } from '@/tools/cloudformation/list_stack_resources' +import { updateStackTool } from '@/tools/cloudformation/update_stack' import { validateTemplateTool } from '@/tools/cloudformation/validate_template' export const cloudformationDescribeStacksTool = describeStacksTool @@ -16,3 +24,11 @@ export const cloudformationDescribeStackDriftDetectionStatusTool = export const cloudformationDescribeStackEventsTool = describeStackEventsTool export const cloudformationGetTemplateTool = getTemplateTool export const cloudformationValidateTemplateTool = validateTemplateTool +export const cloudformationCreateStackTool = createStackTool +export const cloudformationUpdateStackTool = updateStackTool +export const cloudformationDeleteStackTool = deleteStackTool +export const cloudformationCancelUpdateStackTool = cancelUpdateStackTool +export const cloudformationCreateChangeSetTool = createChangeSetTool +export const cloudformationDescribeChangeSetTool = describeChangeSetTool +export const cloudformationExecuteChangeSetTool = executeChangeSetTool +export const cloudformationGetTemplateSummaryTool = getTemplateSummaryTool diff --git a/apps/sim/tools/cloudformation/types.ts b/apps/sim/tools/cloudformation/types.ts index c04f0cc2c79..af7a782df9a 100644 --- a/apps/sim/tools/cloudformation/types.ts +++ b/apps/sim/tools/cloudformation/types.ts @@ -30,12 +30,78 @@ export interface CloudFormationDescribeStackEventsParams extends CloudFormationC export interface CloudFormationGetTemplateParams extends CloudFormationConnectionConfig { stackName: string + templateStage?: 'Original' | 'Processed' } export interface CloudFormationValidateTemplateParams extends CloudFormationConnectionConfig { templateBody: string } +interface CloudFormationParameterInput { + parameterKey: string + parameterValue?: string + usePreviousValue?: boolean +} + +interface CloudFormationTagInput { + key: string + value: string +} + +export interface CloudFormationCreateStackParams extends CloudFormationConnectionConfig { + stackName: string + templateBody: string + parameters?: CloudFormationParameterInput[] + capabilities?: string + tags?: CloudFormationTagInput[] + onFailure?: 'ROLLBACK' | 'DELETE' | 'DO_NOTHING' + timeoutInMinutes?: number +} + +export interface CloudFormationUpdateStackParams extends CloudFormationConnectionConfig { + stackName: string + templateBody?: string + usePreviousTemplate?: boolean + parameters?: CloudFormationParameterInput[] + capabilities?: string + tags?: CloudFormationTagInput[] +} + +export interface CloudFormationDeleteStackParams extends CloudFormationConnectionConfig { + stackName: string + retainResources?: string +} + +export interface CloudFormationCancelUpdateStackParams extends CloudFormationConnectionConfig { + stackName: string +} + +export interface CloudFormationCreateChangeSetParams extends CloudFormationConnectionConfig { + stackName: string + changeSetName: string + templateBody?: string + usePreviousTemplate?: boolean + parameters?: CloudFormationParameterInput[] + capabilities?: string + changeSetType?: 'CREATE' | 'UPDATE' | 'IMPORT' + description?: string +} + +export interface CloudFormationDescribeChangeSetParams extends CloudFormationConnectionConfig { + changeSetName: string + stackName?: string +} + +export interface CloudFormationExecuteChangeSetParams extends CloudFormationConnectionConfig { + changeSetName: string + stackName?: string +} + +export interface CloudFormationGetTemplateSummaryParams extends CloudFormationConnectionConfig { + templateBody?: string + stackName?: string +} + export interface CloudFormationDescribeStacksResponse extends ToolResponse { output: { stacks: { @@ -129,3 +195,80 @@ export interface CloudFormationValidateTemplateResponse extends ToolResponse { declaredTransforms: string[] } } + +export interface CloudFormationCreateStackResponse extends ToolResponse { + output: { + stackId: string + } +} + +export interface CloudFormationUpdateStackResponse extends ToolResponse { + output: { + stackId: string + } +} + +export interface CloudFormationDeleteStackResponse extends ToolResponse { + output: { + message: string + } +} + +export interface CloudFormationCancelUpdateStackResponse extends ToolResponse { + output: { + message: string + } +} + +export interface CloudFormationCreateChangeSetResponse extends ToolResponse { + output: { + changeSetId: string + stackId: string + } +} + +export interface CloudFormationDescribeChangeSetResponse extends ToolResponse { + output: { + changeSetName: string | undefined + changeSetId: string | undefined + stackId: string | undefined + stackName: string | undefined + description: string | undefined + executionStatus: string | undefined + status: string | undefined + statusReason: string | undefined + creationTime: number | undefined + capabilities: string[] + changes: { + action: string | undefined + logicalResourceId: string | undefined + physicalResourceId: string | undefined + resourceType: string | undefined + replacement: string | undefined + }[] + } +} + +export interface CloudFormationExecuteChangeSetResponse extends ToolResponse { + output: { + message: string + } +} + +export interface CloudFormationGetTemplateSummaryResponse extends ToolResponse { + output: { + description: string | undefined + parameters: { + parameterKey: string | undefined + defaultValue: string | undefined + parameterType: string | undefined + noEcho: boolean | undefined + description: string | undefined + }[] + capabilities: string[] + capabilitiesReason: string | undefined + resourceTypes: string[] + version: string | undefined + declaredTransforms: string[] + } +} diff --git a/apps/sim/tools/cloudformation/update_stack.ts b/apps/sim/tools/cloudformation/update_stack.ts new file mode 100644 index 00000000000..ddd953e226c --- /dev/null +++ b/apps/sim/tools/cloudformation/update_stack.ts @@ -0,0 +1,117 @@ +import type { + CloudFormationUpdateStackParams, + CloudFormationUpdateStackResponse, +} from '@/tools/cloudformation/types' +import type { ToolConfig } from '@/tools/types' + +export const updateStackTool: ToolConfig< + CloudFormationUpdateStackParams, + CloudFormationUpdateStackResponse +> = { + id: 'cloudformation_update_stack', + name: 'CloudFormation Update Stack', + description: 'Update an existing CloudFormation stack with a new or previous template', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + stackName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name or ID of the stack to update', + }, + templateBody: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The new CloudFormation template body (JSON or YAML). Required unless usePreviousTemplate is true', + }, + usePreviousTemplate: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Reuse the template currently associated with the stack instead of providing templateBody', + }, + parameters: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Template input parameters (e.g., [{"parameterKey": "InstanceType", "parameterValue": "t3.micro"}])', + }, + capabilities: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated capabilities to acknowledge (CAPABILITY_IAM, CAPABILITY_NAMED_IAM, CAPABILITY_AUTO_EXPAND)', + }, + tags: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Tags to apply to the stack and its resources (e.g., [{"key": "env", "value": "prod"}])', + }, + }, + + request: { + url: '/api/tools/cloudformation/update-stack', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + stackName: params.stackName, + ...(params.templateBody && { templateBody: params.templateBody }), + ...(params.usePreviousTemplate !== undefined && { + usePreviousTemplate: params.usePreviousTemplate, + }), + ...(params.parameters && { parameters: params.parameters }), + ...(params.capabilities && { capabilities: params.capabilities }), + ...(params.tags && { tags: params.tags }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to update CloudFormation stack') + } + + return { + success: true, + output: { + stackId: data.output.stackId, + }, + } + }, + + outputs: { + stackId: { type: 'string', description: 'The unique ID of the updated stack' }, + }, +} diff --git a/apps/sim/tools/cloudwatch/describe_alarm_history.ts b/apps/sim/tools/cloudwatch/describe_alarm_history.ts new file mode 100644 index 00000000000..f4a2fa41f05 --- /dev/null +++ b/apps/sim/tools/cloudwatch/describe_alarm_history.ts @@ -0,0 +1,130 @@ +import type { + CloudWatchDescribeAlarmHistoryParams, + CloudWatchDescribeAlarmHistoryResponse, +} from '@/tools/cloudwatch/types' +import type { ToolConfig } from '@/tools/types' + +export const describeAlarmHistoryTool: ToolConfig< + CloudWatchDescribeAlarmHistoryParams, + CloudWatchDescribeAlarmHistoryResponse +> = { + id: 'cloudwatch_describe_alarm_history', + name: 'CloudWatch Describe Alarm History', + description: 'Retrieve state-change and configuration history for CloudWatch alarms', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + alarmName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Name of a specific alarm to retrieve history for. Omit for all alarms.', + }, + historyItemType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Filter by history item type (ConfigurationUpdate, StateUpdate, Action, AlarmContributorStateUpdate, AlarmContributorAction)', + }, + startDate: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Start of the history window as Unix epoch seconds', + }, + endDate: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'End of the history window as Unix epoch seconds', + }, + scanBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort order: TimestampDescending (newest first) or TimestampAscending', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of history records to return', + }, + }, + + request: { + url: '/api/tools/cloudwatch/describe-alarm-history', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + ...(params.alarmName && { alarmName: params.alarmName }), + ...(params.historyItemType && { historyItemType: params.historyItemType }), + ...(params.startDate !== undefined && { startDate: params.startDate }), + ...(params.endDate !== undefined && { endDate: params.endDate }), + ...(params.scanBy && { scanBy: params.scanBy }), + ...(params.limit !== undefined && { limit: params.limit }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to describe CloudWatch alarm history') + } + + return { + success: true, + output: { + alarmHistoryItems: data.output.alarmHistoryItems, + }, + } + }, + + outputs: { + alarmHistoryItems: { + type: 'array', + description: 'Alarm history items sorted per scanBy, newest first by default', + items: { + type: 'object', + properties: { + alarmName: { + type: 'string', + description: 'Name of the alarm this history item belongs to', + }, + alarmType: { type: 'string', description: 'MetricAlarm or CompositeAlarm' }, + timestamp: { type: 'number', description: 'Epoch ms when the history item occurred' }, + historyItemType: { + type: 'string', + description: 'ConfigurationUpdate, StateUpdate, Action, or contributor variants', + }, + historySummary: { type: 'string', description: 'Human-readable summary of the event' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/cloudwatch/filter_log_events.ts b/apps/sim/tools/cloudwatch/filter_log_events.ts new file mode 100644 index 00000000000..88bba02e144 --- /dev/null +++ b/apps/sim/tools/cloudwatch/filter_log_events.ts @@ -0,0 +1,131 @@ +import type { + CloudWatchFilterLogEventsParams, + CloudWatchFilterLogEventsResponse, +} from '@/tools/cloudwatch/types' +import type { ToolConfig } from '@/tools/types' + +export const filterLogEventsTool: ToolConfig< + CloudWatchFilterLogEventsParams, + CloudWatchFilterLogEventsResponse +> = { + id: 'cloudwatch_filter_log_events', + name: 'CloudWatch Filter Log Events', + description: + 'Search log events across all streams in a log group by filter pattern and time range, without writing a Log Insights query', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + logGroupName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'CloudWatch log group name to search', + }, + filterPattern: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'CloudWatch Logs filter pattern (e.g., "ERROR", "?ERROR ?Exception"). Matches all events if omitted.', + }, + logStreamNamePrefix: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only search log streams whose name starts with this prefix', + }, + startTime: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Start time as Unix epoch seconds', + }, + endTime: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'End time as Unix epoch seconds', + }, + startFromHead: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return the earliest matching events first instead of the latest', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of events to return', + }, + }, + + request: { + url: '/api/tools/cloudwatch/filter-log-events', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + logGroupName: params.logGroupName, + ...(params.filterPattern && { filterPattern: params.filterPattern }), + ...(params.logStreamNamePrefix && { logStreamNamePrefix: params.logStreamNamePrefix }), + ...(params.startTime !== undefined && { startTime: params.startTime }), + ...(params.endTime !== undefined && { endTime: params.endTime }), + ...(params.startFromHead !== undefined && { startFromHead: params.startFromHead }), + ...(params.limit !== undefined && { limit: params.limit }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to filter CloudWatch log events') + } + + return { + success: true, + output: { + events: data.output.events, + }, + } + }, + + outputs: { + events: { + type: 'array', + description: 'Matching log events across all searched streams, sorted by timestamp', + items: { + type: 'object', + properties: { + logStreamName: { type: 'string', description: 'Log stream the event belongs to' }, + timestamp: { type: 'number', description: 'Event timestamp in epoch milliseconds' }, + message: { type: 'string', description: 'Log event message' }, + ingestionTime: { type: 'number', description: 'Ingestion time in epoch milliseconds' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/cloudwatch/index.ts b/apps/sim/tools/cloudwatch/index.ts index 0d92b5662fe..5983645ea3f 100644 --- a/apps/sim/tools/cloudwatch/index.ts +++ b/apps/sim/tools/cloudwatch/index.ts @@ -1,23 +1,29 @@ +import { describeAlarmHistoryTool } from '@/tools/cloudwatch/describe_alarm_history' import { describeAlarmsTool } from '@/tools/cloudwatch/describe_alarms' import { describeLogGroupsTool } from '@/tools/cloudwatch/describe_log_groups' import { describeLogStreamsTool } from '@/tools/cloudwatch/describe_log_streams' +import { filterLogEventsTool } from '@/tools/cloudwatch/filter_log_events' import { getLogEventsTool } from '@/tools/cloudwatch/get_log_events' import { getMetricStatisticsTool } from '@/tools/cloudwatch/get_metric_statistics' import { listMetricsTool } from '@/tools/cloudwatch/list_metrics' import { muteAlarmTool } from '@/tools/cloudwatch/mute_alarm' +import { putLogGroupRetentionTool } from '@/tools/cloudwatch/put_log_group_retention' import { putMetricDataTool } from '@/tools/cloudwatch/put_metric_data' import { queryLogsTool } from '@/tools/cloudwatch/query_logs' import { unmuteAlarmTool } from '@/tools/cloudwatch/unmute_alarm' export * from './types' +export const cloudwatchDescribeAlarmHistoryTool = describeAlarmHistoryTool export const cloudwatchDescribeAlarmsTool = describeAlarmsTool export const cloudwatchDescribeLogGroupsTool = describeLogGroupsTool export const cloudwatchDescribeLogStreamsTool = describeLogStreamsTool +export const cloudwatchFilterLogEventsTool = filterLogEventsTool export const cloudwatchGetLogEventsTool = getLogEventsTool export const cloudwatchGetMetricStatisticsTool = getMetricStatisticsTool export const cloudwatchListMetricsTool = listMetricsTool export const cloudwatchMuteAlarmTool = muteAlarmTool +export const cloudwatchPutLogGroupRetentionTool = putLogGroupRetentionTool export const cloudwatchPutMetricDataTool = putMetricDataTool export const cloudwatchQueryLogsTool = queryLogsTool export const cloudwatchUnmuteAlarmTool = unmuteAlarmTool diff --git a/apps/sim/tools/cloudwatch/put_log_group_retention.ts b/apps/sim/tools/cloudwatch/put_log_group_retention.ts new file mode 100644 index 00000000000..de6aeb44acd --- /dev/null +++ b/apps/sim/tools/cloudwatch/put_log_group_retention.ts @@ -0,0 +1,87 @@ +import type { + CloudWatchPutLogGroupRetentionParams, + CloudWatchPutLogGroupRetentionResponse, +} from '@/tools/cloudwatch/types' +import type { ToolConfig } from '@/tools/types' + +export const putLogGroupRetentionTool: ToolConfig< + CloudWatchPutLogGroupRetentionParams, + CloudWatchPutLogGroupRetentionResponse +> = { + id: 'cloudwatch_put_log_group_retention', + name: 'CloudWatch Set Log Group Retention', + description: 'Set (or clear, for never-expire) the retention period for a CloudWatch log group', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + logGroupName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'CloudWatch log group name', + }, + retentionInDays: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'Days to retain log events (one of 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653). Omit to make events never expire.', + }, + }, + + request: { + url: '/api/tools/cloudwatch/put-log-group-retention', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + logGroupName: params.logGroupName, + ...(params.retentionInDays !== undefined && { retentionInDays: params.retentionInDays }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to set CloudWatch log group retention') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the retention policy was updated' }, + logGroupName: { type: 'string', description: 'Log group the policy applies to' }, + retentionInDays: { + type: 'number', + description: 'Retention period in days, or null if events never expire', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/cloudwatch/types.ts b/apps/sim/tools/cloudwatch/types.ts index 9782a9a0dd0..59e8700cda8 100644 --- a/apps/sim/tools/cloudwatch/types.ts +++ b/apps/sim/tools/cloudwatch/types.ts @@ -195,3 +195,67 @@ export interface CloudWatchUnmuteAlarmResponse extends ToolResponse { muteRuleName: string } } + +export type CloudWatchAlarmHistoryItemType = + | 'ConfigurationUpdate' + | 'StateUpdate' + | 'Action' + | 'AlarmContributorStateUpdate' + | 'AlarmContributorAction' + +export type CloudWatchAlarmHistoryScanBy = 'TimestampDescending' | 'TimestampAscending' + +export interface CloudWatchDescribeAlarmHistoryParams extends CloudWatchConnectionConfig { + alarmName?: string + historyItemType?: CloudWatchAlarmHistoryItemType + startDate?: number + endDate?: number + scanBy?: CloudWatchAlarmHistoryScanBy + limit?: number +} + +export interface CloudWatchDescribeAlarmHistoryResponse extends ToolResponse { + output: { + alarmHistoryItems: { + alarmName: string | undefined + alarmType: string | undefined + timestamp: number | undefined + historyItemType: string | undefined + historySummary: string | undefined + }[] + } +} + +export interface CloudWatchFilterLogEventsParams extends CloudWatchConnectionConfig { + logGroupName: string + filterPattern?: string + logStreamNamePrefix?: string + startTime?: number + endTime?: number + startFromHead?: boolean + limit?: number +} + +export interface CloudWatchFilterLogEventsResponse extends ToolResponse { + output: { + events: { + logStreamName: string | undefined + timestamp: number | undefined + message: string | undefined + ingestionTime: number | undefined + }[] + } +} + +export interface CloudWatchPutLogGroupRetentionParams extends CloudWatchConnectionConfig { + logGroupName: string + retentionInDays?: number +} + +export interface CloudWatchPutLogGroupRetentionResponse extends ToolResponse { + output: { + success: boolean + logGroupName: string + retentionInDays: number | null + } +} diff --git a/apps/sim/tools/codepipeline/disable_stage_transition.ts b/apps/sim/tools/codepipeline/disable_stage_transition.ts new file mode 100644 index 00000000000..fb53d419f37 --- /dev/null +++ b/apps/sim/tools/codepipeline/disable_stage_transition.ts @@ -0,0 +1,106 @@ +import type { + CodePipelineDisableStageTransitionParams, + CodePipelineDisableStageTransitionResponse, +} from '@/tools/codepipeline/types' +import type { ToolConfig } from '@/tools/types' + +export const disableStageTransitionTool: ToolConfig< + CodePipelineDisableStageTransitionParams, + CodePipelineDisableStageTransitionResponse +> = { + id: 'codepipeline_disable_stage_transition', + name: 'CodePipeline Disable Stage Transition', + description: + 'Prevent artifacts from transitioning into or out of a CodePipeline stage, freezing the pipeline at that point', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + pipelineName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the pipeline', + }, + stageName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the stage to disable the transition for', + }, + transitionType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Inbound to block artifacts entering the stage, Outbound to block artifacts leaving it', + }, + reason: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Reason the transition is disabled, shown in the pipeline console (max 300 characters)', + }, + }, + + request: { + url: '/api/tools/codepipeline/disable-stage-transition', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + pipelineName: params.pipelineName, + stageName: params.stageName, + transitionType: params.transitionType, + reason: params.reason, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to disable CodePipeline stage transition') + } + + return { + success: true, + output: { + pipelineName: data.output.pipelineName, + stageName: data.output.stageName, + transitionType: data.output.transitionType, + }, + } + }, + + outputs: { + pipelineName: { type: 'string', description: 'Pipeline name' }, + stageName: { type: 'string', description: 'Stage whose transition was disabled' }, + transitionType: { + type: 'string', + description: 'Transition type that was disabled (Inbound or Outbound)', + }, + }, +} diff --git a/apps/sim/tools/codepipeline/enable_stage_transition.ts b/apps/sim/tools/codepipeline/enable_stage_transition.ts new file mode 100644 index 00000000000..e6579f85e79 --- /dev/null +++ b/apps/sim/tools/codepipeline/enable_stage_transition.ts @@ -0,0 +1,98 @@ +import type { + CodePipelineEnableStageTransitionParams, + CodePipelineEnableStageTransitionResponse, +} from '@/tools/codepipeline/types' +import type { ToolConfig } from '@/tools/types' + +export const enableStageTransitionTool: ToolConfig< + CodePipelineEnableStageTransitionParams, + CodePipelineEnableStageTransitionResponse +> = { + id: 'codepipeline_enable_stage_transition', + name: 'CodePipeline Enable Stage Transition', + description: + 'Re-enable artifacts transitioning into or out of a CodePipeline stage after it was disabled', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + pipelineName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the pipeline', + }, + stageName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the stage to enable the transition for', + }, + transitionType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Inbound to allow artifacts entering the stage, Outbound to allow artifacts leaving it', + }, + }, + + request: { + url: '/api/tools/codepipeline/enable-stage-transition', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + pipelineName: params.pipelineName, + stageName: params.stageName, + transitionType: params.transitionType, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to enable CodePipeline stage transition') + } + + return { + success: true, + output: { + pipelineName: data.output.pipelineName, + stageName: data.output.stageName, + transitionType: data.output.transitionType, + }, + } + }, + + outputs: { + pipelineName: { type: 'string', description: 'Pipeline name' }, + stageName: { type: 'string', description: 'Stage whose transition was enabled' }, + transitionType: { + type: 'string', + description: 'Transition type that was enabled (Inbound or Outbound)', + }, + }, +} diff --git a/apps/sim/tools/codepipeline/get_pipeline.ts b/apps/sim/tools/codepipeline/get_pipeline.ts new file mode 100644 index 00000000000..4611cab710c --- /dev/null +++ b/apps/sim/tools/codepipeline/get_pipeline.ts @@ -0,0 +1,149 @@ +import type { + CodePipelineGetPipelineParams, + CodePipelineGetPipelineResponse, +} from '@/tools/codepipeline/types' +import type { ToolConfig } from '@/tools/types' + +export const getPipelineTool: ToolConfig< + CodePipelineGetPipelineParams, + CodePipelineGetPipelineResponse +> = { + id: 'codepipeline_get_pipeline', + name: 'CodePipeline Get Pipeline', + description: + 'Get the structure of a CodePipeline pipeline, including its stages, actions, and variables', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + pipelineName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the pipeline', + }, + version: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Pipeline version to retrieve (defaults to the current version)', + }, + }, + + request: { + url: '/api/tools/codepipeline/get-pipeline', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + pipelineName: params.pipelineName, + ...(params.version !== undefined && { version: params.version }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to get CodePipeline pipeline') + } + + return { + success: true, + output: { + pipelineName: data.output.pipelineName, + pipelineArn: data.output.pipelineArn, + roleArn: data.output.roleArn, + version: data.output.version, + pipelineType: data.output.pipelineType, + executionMode: data.output.executionMode, + artifactStoreType: data.output.artifactStoreType, + artifactStoreLocation: data.output.artifactStoreLocation, + stages: data.output.stages, + variables: data.output.variables, + created: data.output.created, + updated: data.output.updated, + }, + } + }, + + outputs: { + pipelineName: { type: 'string', description: 'Pipeline name' }, + pipelineArn: { type: 'string', description: 'Pipeline ARN', optional: true }, + roleArn: { type: 'string', description: 'IAM role ARN the pipeline assumes' }, + version: { type: 'number', description: 'Pipeline version number', optional: true }, + pipelineType: { type: 'string', description: 'Pipeline type (V1 or V2)', optional: true }, + executionMode: { + type: 'string', + description: 'Execution mode (QUEUED, SUPERSEDED, PARALLEL)', + optional: true, + }, + artifactStoreType: { + type: 'string', + description: 'Artifact store type (S3)', + optional: true, + }, + artifactStoreLocation: { + type: 'string', + description: 'Artifact store bucket location', + optional: true, + }, + stages: { + type: 'array', + description: 'Pipeline stages with their actions (name, category, provider, configuration)', + items: { + type: 'object', + properties: { + stageName: { type: 'string', description: 'Stage name' }, + actions: { + type: 'array', + description: 'Actions in the stage, in run order', + }, + }, + }, + }, + variables: { + type: 'array', + description: 'Pipeline variable declarations with default values', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Variable name' }, + defaultValue: { type: 'string', description: 'Default value' }, + description: { type: 'string', description: 'Variable description' }, + }, + }, + }, + created: { + type: 'number', + description: 'Epoch ms when the pipeline was created', + optional: true, + }, + updated: { + type: 'number', + description: 'Epoch ms when the pipeline was last updated', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/codepipeline/index.ts b/apps/sim/tools/codepipeline/index.ts index 9ceeb9baf18..e7cff2f3137 100644 --- a/apps/sim/tools/codepipeline/index.ts +++ b/apps/sim/tools/codepipeline/index.ts @@ -1,5 +1,9 @@ +import { disableStageTransitionTool } from '@/tools/codepipeline/disable_stage_transition' +import { enableStageTransitionTool } from '@/tools/codepipeline/enable_stage_transition' +import { getPipelineTool } from '@/tools/codepipeline/get_pipeline' import { getPipelineExecutionTool } from '@/tools/codepipeline/get_pipeline_execution' import { getPipelineStateTool } from '@/tools/codepipeline/get_pipeline_state' +import { listActionExecutionsTool } from '@/tools/codepipeline/list_action_executions' import { listPipelineExecutionsTool } from '@/tools/codepipeline/list_pipeline_executions' import { listPipelinesTool } from '@/tools/codepipeline/list_pipelines' import { putApprovalResultTool } from '@/tools/codepipeline/put_approval_result' @@ -9,8 +13,12 @@ import { stopExecutionTool } from '@/tools/codepipeline/stop_execution' export * from './types' +export const codepipelineDisableStageTransitionTool = disableStageTransitionTool +export const codepipelineEnableStageTransitionTool = enableStageTransitionTool +export const codepipelineGetPipelineTool = getPipelineTool export const codepipelineGetPipelineExecutionTool = getPipelineExecutionTool export const codepipelineGetPipelineStateTool = getPipelineStateTool +export const codepipelineListActionExecutionsTool = listActionExecutionsTool export const codepipelineListPipelineExecutionsTool = listPipelineExecutionsTool export const codepipelineListPipelinesTool = listPipelinesTool export const codepipelinePutApprovalResultTool = putApprovalResultTool diff --git a/apps/sim/tools/codepipeline/list_action_executions.ts b/apps/sim/tools/codepipeline/list_action_executions.ts new file mode 100644 index 00000000000..3b5187a0571 --- /dev/null +++ b/apps/sim/tools/codepipeline/list_action_executions.ts @@ -0,0 +1,144 @@ +import type { + CodePipelineListActionExecutionsParams, + CodePipelineListActionExecutionsResponse, +} from '@/tools/codepipeline/types' +import type { ToolConfig } from '@/tools/types' + +export const listActionExecutionsTool: ToolConfig< + CodePipelineListActionExecutionsParams, + CodePipelineListActionExecutionsResponse +> = { + id: 'codepipeline_list_action_executions', + name: 'CodePipeline List Action Executions', + description: + 'List action-level execution history for a CodePipeline pipeline, including per-action status, timing, and error details', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + pipelineName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the pipeline', + }, + pipelineExecutionId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return action executions for this pipeline execution', + }, + maxResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of action executions to return (1-100, default 100)', + }, + nextToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination token from a previous call', + }, + }, + + request: { + url: '/api/tools/codepipeline/list-action-executions', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + pipelineName: params.pipelineName, + ...(params.pipelineExecutionId && { pipelineExecutionId: params.pipelineExecutionId }), + ...(params.maxResults !== undefined && { maxResults: params.maxResults }), + ...(params.nextToken && { nextToken: params.nextToken }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to list CodePipeline action executions') + } + + return { + success: true, + output: { + actionExecutionDetails: data.output.actionExecutionDetails, + nextToken: data.output.nextToken, + }, + } + }, + + outputs: { + actionExecutionDetails: { + type: 'array', + description: 'Action execution history, most recent first', + items: { + type: 'object', + properties: { + pipelineExecutionId: { type: 'string', description: 'Pipeline execution ID' }, + actionExecutionId: { + type: 'string', + description: + 'Action execution ID (use as the approval token for PARALLEL execution-mode pipelines)', + }, + pipelineVersion: { type: 'number', description: 'Pipeline version number' }, + stageName: { type: 'string', description: 'Stage the action belongs to' }, + actionName: { type: 'string', description: 'Action name' }, + startTime: { type: 'number', description: 'Epoch ms when the action started' }, + lastUpdateTime: { + type: 'number', + description: 'Epoch ms when the action was last updated', + }, + updatedBy: { type: 'string', description: 'Who or what last updated the action' }, + status: { + type: 'string', + description: 'Action execution status (InProgress, Abandoned, Succeeded, Failed)', + }, + externalExecutionId: { + type: 'string', + description: 'ID of the external system execution (e.g., CodeBuild build ID)', + }, + externalExecutionSummary: { + type: 'string', + description: 'Summary from the external system execution', + }, + externalExecutionUrl: { + type: 'string', + description: 'URL of the external system execution', + }, + errorCode: { type: 'string', description: 'Error code if the action failed' }, + errorMessage: { type: 'string', description: 'Error message if the action failed' }, + }, + }, + }, + nextToken: { + type: 'string', + description: 'Pagination token for the next page of results', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/codepipeline/list_pipeline_executions.ts b/apps/sim/tools/codepipeline/list_pipeline_executions.ts index 7b8c2d977a1..2a4c027b31d 100644 --- a/apps/sim/tools/codepipeline/list_pipeline_executions.ts +++ b/apps/sim/tools/codepipeline/list_pipeline_executions.ts @@ -124,6 +124,10 @@ export const listPipelineExecutionsTool: ToolConfig< }, triggerType: { type: 'string', description: 'What triggered the execution' }, triggerDetail: { type: 'string', description: 'Detail about the trigger' }, + rollbackTargetPipelineExecutionId: { + type: 'string', + description: 'Execution ID this run rolled back to, if it was a rollback', + }, sourceRevisions: { type: 'array', description: 'Source revisions (commit IDs, summaries, URLs) for the execution', diff --git a/apps/sim/tools/codepipeline/types.ts b/apps/sim/tools/codepipeline/types.ts index ae185d3b56e..6b9bc935146 100644 --- a/apps/sim/tools/codepipeline/types.ts +++ b/apps/sim/tools/codepipeline/types.ts @@ -112,6 +112,7 @@ export interface CodePipelineListPipelineExecutionsResponse extends ToolResponse stopTriggerReason: string | undefined triggerType: string | undefined triggerDetail: string | undefined + rollbackTargetPipelineExecutionId: string | undefined sourceRevisions: { actionName: string revisionId: string | undefined @@ -180,3 +181,108 @@ export interface CodePipelinePutApprovalResultResponse extends ToolResponse { status: string } } + +export interface CodePipelineGetPipelineParams extends CodePipelineConnectionConfig { + pipelineName: string + version?: number +} + +export interface CodePipelineActionDeclaration { + name: string + category: string + owner: string + provider: string + version: string + runOrder: number | undefined + configuration: Record + inputArtifacts: string[] + outputArtifacts: string[] +} + +export interface CodePipelineStageDeclaration { + stageName: string + actions: CodePipelineActionDeclaration[] +} + +export interface CodePipelineGetPipelineResponse extends ToolResponse { + output: { + pipelineName: string + pipelineArn: string | undefined + roleArn: string + version: number | undefined + pipelineType: string | undefined + executionMode: string | undefined + artifactStoreType: string | undefined + artifactStoreLocation: string | undefined + stages: CodePipelineStageDeclaration[] + variables: { + name: string + defaultValue: string | undefined + description: string | undefined + }[] + created: number | undefined + updated: number | undefined + } +} + +export interface CodePipelineListActionExecutionsParams extends CodePipelineConnectionConfig { + pipelineName: string + pipelineExecutionId?: string + maxResults?: number + nextToken?: string +} + +export interface CodePipelineActionExecutionDetail { + pipelineExecutionId: string | undefined + actionExecutionId: string | undefined + pipelineVersion: number | undefined + stageName: string | undefined + actionName: string | undefined + startTime: number | undefined + lastUpdateTime: number | undefined + updatedBy: string | undefined + status: string | undefined + externalExecutionId: string | undefined + externalExecutionSummary: string | undefined + externalExecutionUrl: string | undefined + errorCode: string | undefined + errorMessage: string | undefined +} + +export interface CodePipelineListActionExecutionsResponse extends ToolResponse { + output: { + actionExecutionDetails: CodePipelineActionExecutionDetail[] + nextToken?: string + } +} + +export type CodePipelineTransitionType = 'Inbound' | 'Outbound' + +export interface CodePipelineDisableStageTransitionParams extends CodePipelineConnectionConfig { + pipelineName: string + stageName: string + transitionType: CodePipelineTransitionType + reason: string +} + +export interface CodePipelineDisableStageTransitionResponse extends ToolResponse { + output: { + pipelineName: string + stageName: string + transitionType: CodePipelineTransitionType + } +} + +export interface CodePipelineEnableStageTransitionParams extends CodePipelineConnectionConfig { + pipelineName: string + stageName: string + transitionType: CodePipelineTransitionType +} + +export interface CodePipelineEnableStageTransitionResponse extends ToolResponse { + output: { + pipelineName: string + stageName: string + transitionType: CodePipelineTransitionType + } +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 02c6f7c8e7b..c681356e306 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -246,12 +246,16 @@ import { ashbyUpdateCandidateTool, } from '@/tools/ashby' import { + athenaBatchGetQueryExecutionTool, athenaCreateNamedQueryTool, + athenaDeleteNamedQueryTool, athenaGetNamedQueryTool, athenaGetQueryExecutionTool, athenaGetQueryResultsTool, + athenaListDatabasesTool, athenaListNamedQueriesTool, athenaListQueryExecutionsTool, + athenaListTableMetadataTool, athenaStartQueryTool, athenaStopQueryTool, } from '@/tools/athena' @@ -495,29 +499,44 @@ import { cloudflareUpdateZoneSettingTool, } from '@/tools/cloudflare' import { + cloudformationCancelUpdateStackTool, + cloudformationCreateChangeSetTool, + cloudformationCreateStackTool, + cloudformationDeleteStackTool, + cloudformationDescribeChangeSetTool, cloudformationDescribeStackDriftDetectionStatusTool, cloudformationDescribeStackEventsTool, cloudformationDescribeStacksTool, cloudformationDetectStackDriftTool, + cloudformationExecuteChangeSetTool, + cloudformationGetTemplateSummaryTool, cloudformationGetTemplateTool, cloudformationListStackResourcesTool, + cloudformationUpdateStackTool, cloudformationValidateTemplateTool, } from '@/tools/cloudformation' import { + cloudwatchDescribeAlarmHistoryTool, cloudwatchDescribeAlarmsTool, cloudwatchDescribeLogGroupsTool, cloudwatchDescribeLogStreamsTool, + cloudwatchFilterLogEventsTool, cloudwatchGetLogEventsTool, cloudwatchGetMetricStatisticsTool, cloudwatchListMetricsTool, cloudwatchMuteAlarmTool, + cloudwatchPutLogGroupRetentionTool, cloudwatchPutMetricDataTool, cloudwatchQueryLogsTool, cloudwatchUnmuteAlarmTool, } from '@/tools/cloudwatch' import { + codepipelineDisableStageTransitionTool, + codepipelineEnableStageTransitionTool, codepipelineGetPipelineExecutionTool, codepipelineGetPipelineStateTool, + codepipelineGetPipelineTool, + codepipelineListActionExecutionsTool, codepipelineListPipelineExecutionsTool, codepipelineListPipelinesTool, codepipelinePutApprovalResultTool, @@ -4539,12 +4558,16 @@ export const tools: Record = { ashby_remove_candidate_tag: ashbyRemoveCandidateTagTool, ashby_search_candidates: ashbySearchCandidatesTool, ashby_update_candidate: ashbyUpdateCandidateTool, + athena_batch_get_query_execution: athenaBatchGetQueryExecutionTool, athena_create_named_query: athenaCreateNamedQueryTool, + athena_delete_named_query: athenaDeleteNamedQueryTool, athena_get_named_query: athenaGetNamedQueryTool, athena_get_query_execution: athenaGetQueryExecutionTool, athena_get_query_results: athenaGetQueryResultsTool, + athena_list_databases: athenaListDatabasesTool, athena_list_named_queries: athenaListNamedQueriesTool, athena_list_query_executions: athenaListQueryExecutionsTool, + athena_list_table_metadata: athenaListTableMetadataTool, athena_start_query: athenaStartQueryTool, athena_stop_query: athenaStopQueryTool, brandfetch_get_brand: brandfetchGetBrandTool, @@ -5647,26 +5670,41 @@ export const tools: Record = { rds_delete: rdsDeleteTool, rds_execute: rdsExecuteTool, rds_introspect: rdsIntrospectTool, + cloudformation_cancel_update_stack: cloudformationCancelUpdateStackTool, + cloudformation_create_change_set: cloudformationCreateChangeSetTool, + cloudformation_create_stack: cloudformationCreateStackTool, + cloudformation_delete_stack: cloudformationDeleteStackTool, + cloudformation_describe_change_set: cloudformationDescribeChangeSetTool, cloudformation_describe_stack_drift_detection_status: cloudformationDescribeStackDriftDetectionStatusTool, cloudformation_describe_stack_events: cloudformationDescribeStackEventsTool, cloudformation_describe_stacks: cloudformationDescribeStacksTool, cloudformation_detect_stack_drift: cloudformationDetectStackDriftTool, + cloudformation_execute_change_set: cloudformationExecuteChangeSetTool, cloudformation_get_template: cloudformationGetTemplateTool, + cloudformation_get_template_summary: cloudformationGetTemplateSummaryTool, cloudformation_list_stack_resources: cloudformationListStackResourcesTool, + cloudformation_update_stack: cloudformationUpdateStackTool, cloudformation_validate_template: cloudformationValidateTemplateTool, + cloudwatch_describe_alarm_history: cloudwatchDescribeAlarmHistoryTool, cloudwatch_describe_alarms: cloudwatchDescribeAlarmsTool, cloudwatch_describe_log_groups: cloudwatchDescribeLogGroupsTool, cloudwatch_describe_log_streams: cloudwatchDescribeLogStreamsTool, + cloudwatch_filter_log_events: cloudwatchFilterLogEventsTool, cloudwatch_get_log_events: cloudwatchGetLogEventsTool, cloudwatch_get_metric_statistics: cloudwatchGetMetricStatisticsTool, cloudwatch_list_metrics: cloudwatchListMetricsTool, cloudwatch_mute_alarm: cloudwatchMuteAlarmTool, + cloudwatch_put_log_group_retention: cloudwatchPutLogGroupRetentionTool, cloudwatch_put_metric_data: cloudwatchPutMetricDataTool, cloudwatch_query_logs: cloudwatchQueryLogsTool, cloudwatch_unmute_alarm: cloudwatchUnmuteAlarmTool, + codepipeline_disable_stage_transition: codepipelineDisableStageTransitionTool, + codepipeline_enable_stage_transition: codepipelineEnableStageTransitionTool, + codepipeline_get_pipeline: codepipelineGetPipelineTool, codepipeline_get_pipeline_execution: codepipelineGetPipelineExecutionTool, codepipeline_get_pipeline_state: codepipelineGetPipelineStateTool, + codepipeline_list_action_executions: codepipelineListActionExecutionsTool, codepipeline_list_pipeline_executions: codepipelineListPipelineExecutionsTool, codepipeline_list_pipelines: codepipelineListPipelinesTool, codepipeline_put_approval_result: codepipelinePutApprovalResultTool, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 334f8ff2170..436d4081dec 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 906, - zodRoutes: 906, + totalRoutes: 917, + zodRoutes: 917, nonZodRoutes: 0, } as const