CI/CD - CodeBuild + CodeDeploy + CodePipeline

This commit is contained in:
Zakaria
2026-10-01 09:18:04 -04:00
parent aa02cdb7f6
commit 7f0d707578
41 changed files with 1817 additions and 78 deletions
+2
View File
@@ -56,6 +56,7 @@ cd packages/api && bun run scripts/service-matrix.ts
| Compute | EKS / AKS / GKE | Yes (list, inspect) | Yes (list, inspect) | Yes (list, create, inspect, delete) | | Compute | EKS / AKS / GKE | Yes (list, inspect) | Yes (list, inspect) | Yes (list, create, inspect, delete) |
| Compute | Serverless | Yes (list, create, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) | | Compute | Serverless | Yes (list, create, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) |
| Compute | Containers / Cloud Run | No | No | Yes (list, create, delete, inspect) | | Compute | Containers / Cloud Run | No | No | Yes (list, create, delete, inspect) |
| Compute | CodeBuild | Yes (list, create, delete, inspect) | No | No |
| Storage | Storage | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | | Storage | Storage | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) |
| Databases | Database | Yes (list, create, update, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | | Databases | Database | Yes (list, create, update, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
| Databases | DynamoDB / Cosmos DB NoSQL / NoSQL | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | No | | Databases | DynamoDB / Cosmos DB NoSQL / NoSQL | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | No |
@@ -68,6 +69,7 @@ cd packages/api && bun run scripts/service-matrix.ts
| Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No | | Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No |
| Integration | SES Mailbox / Email | Yes (list, inspect) | No | No | | Integration | SES Mailbox / Email | Yes (list, inspect) | No | No |
| Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) | | Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) |
| Integration | CodePipeline | Yes (list, create, delete, inspect) | No | No |
| Integration | Step Functions / Workflows | Yes (list, create, delete, inspect) | No | No | | Integration | Step Functions / Workflows | Yes (list, create, delete, inspect) | No | No |
| Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, delete, inspect) | No | No | | Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, delete, inspect) | No | No |
| Provisioning | AppConfig / Configuration | Yes (list, create, delete, inspect) | No | No | | Provisioning | AppConfig / Configuration | Yes (list, create, delete, inspect) | No | No |
+1
View File
@@ -7,6 +7,7 @@ COPY packages/api/package.json packages/api/package.json
COPY packages/frontend/package.json packages/frontend/package.json COPY packages/frontend/package.json packages/frontend/package.json
RUN pnpm install --frozen-lockfile --filter @floci/frontend RUN pnpm install --frozen-lockfile --filter @floci/frontend
COPY packages/frontend/ packages/frontend/ COPY packages/frontend/ packages/frontend/
COPY aws-icons-svg/ aws-icons-svg/
COPY OS_img/ OS_img/ COPY OS_img/ OS_img/
RUN pnpm --filter @floci/frontend run build RUN pnpm --filter @floci/frontend run build
+5 -1
View File
@@ -14,9 +14,12 @@
"@aws-sdk/client-appconfig": "^3.1076.0", "@aws-sdk/client-appconfig": "^3.1076.0",
"@aws-sdk/client-cloudformation": "^3.1111.0", "@aws-sdk/client-cloudformation": "^3.1111.0",
"@aws-sdk/client-cloudwatch-logs": "^3.1096.0", "@aws-sdk/client-cloudwatch-logs": "^3.1096.0",
"@aws-sdk/client-codebuild": "^3.1076.0",
"@aws-sdk/client-codedeploy": "^3.1144.0",
"@aws-sdk/client-codepipeline": "^3.1076.0",
"@aws-sdk/client-dynamodb": "^3.1088.0", "@aws-sdk/client-dynamodb": "^3.1088.0",
"@aws-sdk/client-ecr": "^3.1076.0",
"@aws-sdk/client-ec2": "^3.1076.0", "@aws-sdk/client-ec2": "^3.1076.0",
"@aws-sdk/client-ecr": "^3.1076.0",
"@aws-sdk/client-eks": "^3.1076.0", "@aws-sdk/client-eks": "^3.1076.0",
"@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0", "@aws-sdk/client-elastic-load-balancing-v2": "^3.1096.0",
"@aws-sdk/client-eventbridge": "^3.1076.0", "@aws-sdk/client-eventbridge": "^3.1076.0",
@@ -24,6 +27,7 @@
"@aws-sdk/client-kms": "^3.1096.0", "@aws-sdk/client-kms": "^3.1096.0",
"@aws-sdk/client-lambda": "^3.1076.0", "@aws-sdk/client-lambda": "^3.1076.0",
"@aws-sdk/client-rds": "^3.1076.0", "@aws-sdk/client-rds": "^3.1076.0",
"@aws-sdk/client-rds-data": "^3.1076.0",
"@aws-sdk/client-s3": "^3.1076.0", "@aws-sdk/client-s3": "^3.1076.0",
"@aws-sdk/client-secrets-manager": "^3.1076.0", "@aws-sdk/client-secrets-manager": "^3.1076.0",
"@aws-sdk/client-sfn": "^3.1096.0", "@aws-sdk/client-sfn": "^3.1096.0",
@@ -0,0 +1,50 @@
import {describe, expect, test} from 'bun:test'
import {BatchGetBuildsCommand, BatchGetProjectsCommand, CreateProjectCommand, DeleteProjectCommand, ListBuildsForProjectCommand, ListProjectsCommand, StartBuildCommand, type CodeBuildClient} from '@aws-sdk/client-codebuild'
import {AwsCodeBuildAdapter} from './AwsCodeBuildAdapter'
function stub(handler: (command: object) => Record<string, unknown>) {
const sent: object[] = []
const client = {async send(command: object) { sent.push(command); return handler(command) }} as unknown as CodeBuildClient
return {client, sent}
}
const project = {name: 'orders', arn: 'arn:aws:codebuild:us-east-1:000000000000:project/orders', created: new Date('2026-10-01T10:00:00.000Z'), serviceRole: 'arn:aws:iam::000000000000:role/codebuild', source: {type: 'NO_SOURCE'}, artifacts: {type: 'NO_ARTIFACTS'}, environment: {type: 'LINUX_CONTAINER', image: 'aws/codebuild/standard:7.0', computeType: 'BUILD_GENERAL1_SMALL'}}
const build = {id: 'orders:build-id', arn: 'arn:aws:codebuild:us-east-1:000000000000:build/orders:build-id', projectName: 'orders', buildNumber: 3, buildStatus: 'SUCCEEDED', phases: [{phaseType: 'BUILD', phaseStatus: 'SUCCEEDED', contexts: []}], logs: {groupName: '/aws/codebuild/orders', streamName: 'stream'}}
describe('AwsCodeBuildAdapter', () => {
test('uses list and batch-get project contracts for the project table', async () => {
const {client, sent} = stub((command) => command instanceof ListProjectsCommand ? {projects: ['orders']} : command instanceof BatchGetProjectsCommand ? {projects: [project]} : {})
const resources = await new AwsCodeBuildAdapter(client).list()
expect(sent[0]).toBeInstanceOf(ListProjectsCommand)
expect((sent[1] as BatchGetProjectsCommand).input.names).toEqual(['orders'])
expect(resources[0]).toMatchObject({id: 'orders', name: 'orders', service: 'codebuild', region: 'us-east-1'})
expect(resources[0].metadata).toMatchObject({sourceType: 'NO_SOURCE', artifactsType: 'NO_ARTIFACTS'})
})
test('creates only the supported no-source, no-artifacts project contract', async () => {
const {client, sent} = stub((command) => command instanceof CreateProjectCommand ? {project} : {})
await new AwsCodeBuildAdapter(client).create({values: {name: 'orders', serviceRole: project.serviceRole}})
expect((sent[0] as CreateProjectCommand).input).toMatchObject({name: 'orders', serviceRole: project.serviceRole, source: {type: 'NO_SOURCE'}, artifacts: {type: 'NO_ARTIFACTS'}, environment: {type: 'LINUX_CONTAINER', image: 'aws/codebuild/standard:7.0', computeType: 'BUILD_GENERAL1_SMALL'}})
})
test('starts builds with AWS environment-variable overrides and exposes log metadata', async () => {
const {client, sent} = stub((command) => command instanceof StartBuildCommand ? {build} : {})
const result = await new AwsCodeBuildAdapter(client).startBuild('orders', {environmentVariablesOverride: [{name: 'MODE', value: 'test'}]})
expect((sent[0] as StartBuildCommand).input).toEqual({projectName: 'orders', environmentVariablesOverride: [{name: 'MODE', value: 'test'}]})
expect(result).toMatchObject({id: 'orders:build-id', logGroupName: '/aws/codebuild/orders', logStreamName: 'stream'})
})
test('lists build ids then batch-gets detailed phases', async () => {
const {client, sent} = stub((command) => command instanceof ListBuildsForProjectCommand ? {ids: [build.id]} : command instanceof BatchGetBuildsCommand ? {builds: [build]} : {})
const builds = await new AwsCodeBuildAdapter(client).listBuilds('orders')
expect((sent[0] as ListBuildsForProjectCommand).input.projectName).toBe('orders')
expect((sent[1] as BatchGetBuildsCommand).input.ids).toEqual([build.id])
expect(builds[0].phases[0]).toMatchObject({phaseType: 'BUILD', phaseStatus: 'SUCCEEDED'})
})
test('deletes projects by their CodeBuild name', async () => {
const {client, sent} = stub(() => ({}))
await new AwsCodeBuildAdapter(client).delete('orders')
expect((sent[0] as DeleteProjectCommand).input.name).toBe('orders')
})
})
@@ -0,0 +1,152 @@
import {
BatchGetBuildsCommand,
BatchGetProjectsCommand,
CodeBuildClient,
ComputeType,
CreateProjectCommand,
DeleteProjectCommand,
ListBuildsForProjectCommand,
ListProjectsCommand,
StartBuildCommand,
type Build,
type Project,
} from '@aws-sdk/client-codebuild'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {awsCodeBuildSchema} from '../cloud-spi/codebuildSchema'
import type {CodeBuildBuild, StartCodeBuildInput} from '../cloud-spi/codebuild'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
export class AwsCodeBuildAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'codebuild' as const
constructor(private readonly client: CodeBuildClient) {}
schema(): ServiceSchema {
return awsCodeBuildSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const names: string[] = []
let nextToken: string | undefined
do {
const response = await this.client.send(new ListProjectsCommand(nextToken ? {nextToken} : {}))
names.push(...(response.projects ?? []))
nextToken = response.nextToken
} while (nextToken)
const projects = await this.projectsByName(names)
const normalized = query.search?.trim().toLowerCase()
return projects.map(toResource).filter((project) => !normalized || project.name.toLowerCase().includes(normalized))
}
async get(id: string): Promise<CloudResource | null> {
const [project] = await this.projectsByName([id])
return project ? toResource(project) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const name = required(input.values.name, 'name')
const serviceRole = required(input.values.serviceRole, 'serviceRole')
const image = optional(input.values.environmentImage) ?? 'aws/codebuild/standard:7.0'
const computeType = optional(input.values.computeType) ?? ComputeType.BUILD_GENERAL1_SMALL
if (computeType !== ComputeType.BUILD_GENERAL1_SMALL) throw new ValidationError('computeType must be BUILD_GENERAL1_SMALL')
const response = await this.client.send(new CreateProjectCommand({
name,
serviceRole,
source: {type: 'NO_SOURCE'},
artifacts: {type: 'NO_ARTIFACTS'},
environment: {type: 'LINUX_CONTAINER', image, computeType},
}))
if (!response.project) throw new RuntimeError(`CodeBuild did not return project ${name}`)
return toResource(response.project)
}
async delete(id: string): Promise<void> {
await this.client.send(new DeleteProjectCommand({name: id}))
}
async startBuild(projectName: string, input: StartCodeBuildInput): Promise<CodeBuildBuild> {
const response = await this.client.send(new StartBuildCommand({
projectName,
environmentVariablesOverride: input.environmentVariablesOverride,
}))
if (!response.build) throw new RuntimeError(`CodeBuild did not return a build for ${projectName}`)
return toBuild(response.build)
}
async listBuilds(projectName: string): Promise<CodeBuildBuild[]> {
const ids: string[] = []
let nextToken: string | undefined
do {
const response = await this.client.send(new ListBuildsForProjectCommand(nextToken ? {projectName, nextToken} : {projectName}))
ids.push(...(response.ids ?? []))
nextToken = response.nextToken
} while (nextToken)
return this.buildsById(ids)
}
async getBuild(id: string): Promise<CodeBuildBuild | null> {
const [build] = await this.buildsById([id])
return build ?? null
}
private async projectsByName(names: string[]): Promise<Project[]> {
const projects: Project[] = []
for (let index = 0; index < names.length; index += 100) {
const response = await this.client.send(new BatchGetProjectsCommand({names: names.slice(index, index + 100)}))
projects.push(...(response.projects ?? []))
}
return projects
}
private async buildsById(ids: string[]): Promise<CodeBuildBuild[]> {
const builds: CodeBuildBuild[] = []
for (let index = 0; index < ids.length; index += 100) {
const response = await this.client.send(new BatchGetBuildsCommand({ids: ids.slice(index, index + 100)}))
builds.push(...(response.builds ?? []).map(toBuild))
}
return builds
}
}
function toResource(project: Project): CloudResource {
const name = project.name ?? ''
return {
id: name, name, cloud: 'aws', service: 'codebuild', type: 'codebuild-project', region: regionFromArn(project.arn),
createdAt: project.created ? project.created.toISOString() : null,
metadata: {
arn: project.arn,
serviceRole: project.serviceRole,
sourceType: project.source?.type,
artifactsType: project.artifacts?.type,
environmentType: project.environment?.type,
environmentImage: project.environment?.image,
computeType: project.environment?.computeType,
},
}
}
function toBuild(build: Build): CodeBuildBuild {
const id = build.id ?? build.arn
if (!id || !build.projectName) throw new RuntimeError('CodeBuild returned a build without an id or project name')
return {
id, arn: build.arn, projectName: build.projectName, buildNumber: build.buildNumber, buildStatus: build.buildStatus,
currentPhase: build.currentPhase, startTime: build.startTime, endTime: build.endTime,
phases: (build.phases ?? []).map((phase) => ({phaseType: phase.phaseType ?? 'UNKNOWN', phaseStatus: phase.phaseStatus, startTime: phase.startTime, endTime: phase.endTime, contexts: phase.contexts ?? []})),
logGroupName: build.logs?.groupName, logStreamName: build.logs?.streamName,
}
}
function required(value: unknown, field: string): string {
const result = optional(value)
if (!result) throw new ValidationError(`${field} is required`)
return result
}
function optional(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
function regionFromArn(arn?: string): string | null {
return arn?.split(':')[3] || null
}
@@ -0,0 +1,30 @@
import {describe, expect, test} from 'bun:test'
import {CreateDeploymentCommand, GetDeploymentCommand, StopDeploymentCommand, type CodeDeployClient} from '@aws-sdk/client-codedeploy'
import type {LambdaClient} from '@aws-sdk/client-lambda'
import {AwsCodeDeployAdapter} from './AwsCodeDeployAdapter'
describe('AwsCodeDeployAdapter', () => {
test('creates a native Lambda AppSpec revision with traffic hooks', async () => {
const sent: object[] = []
const client = {async send(command: object) {
sent.push(command)
if (command instanceof CreateDeploymentCommand) return {deploymentId: 'd-1'}
if (command instanceof GetDeploymentCommand) return {deploymentInfo: {deploymentId: 'd-1', applicationName: 'orders', deploymentGroupName: 'production', status: 'Created'}}
return {}
}} as unknown as CodeDeployClient
const lambda = {async send() { return {FunctionVersion: '5'} }} as unknown as LambdaClient
const adapter = new AwsCodeDeployAdapter(client, lambda)
await adapter.startCodeDeployDeployment('orders', 'production', {functionName: 'orders-api', alias: 'live', currentVersion: '4', targetVersion: '5', beforeAllowTrafficHook: 'verify-before', afterAllowTrafficHook: 'verify-after'})
const command = sent.find((item) => item instanceof CreateDeploymentCommand) as CreateDeploymentCommand
const appSpec = JSON.parse(command.input.revision?.appSpecContent?.content ?? '{}')
expect(appSpec.Resources[0]['orders-api'].Properties).toEqual({Name: 'orders-api', Alias: 'live', CurrentVersion: '4', TargetVersion: '5'})
expect(appSpec.Hooks).toEqual([{BeforeAllowTraffic: 'verify-before'}, {AfterAllowTraffic: 'verify-after'}])
})
test('stops a deployment with the requested rollback behavior', async () => {
const sent: object[] = []
const client = {async send(command: object) { sent.push(command); return {} }} as unknown as CodeDeployClient
await new AwsCodeDeployAdapter(client, {} as LambdaClient).stopCodeDeployDeployment('d-1', true)
expect((sent[0] as StopDeploymentCommand).input).toEqual({deploymentId: 'd-1', autoRollbackEnabled: true})
})
})
@@ -0,0 +1,131 @@
import {
CreateApplicationCommand,
CreateDeploymentCommand,
CreateDeploymentGroupCommand,
DeleteApplicationCommand,
DeleteDeploymentGroupCommand,
GetDeploymentCommand,
GetDeploymentGroupCommand,
ListApplicationsCommand,
ListDeploymentGroupsCommand,
ListDeploymentsCommand,
StopDeploymentCommand,
type CodeDeployClient,
} from '@aws-sdk/client-codedeploy'
import {GetAliasCommand, ListAliasesCommand, ListFunctionsCommand, ListVersionsByFunctionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {awsCodeDeploySchema} from '../cloud-spi/codedeploySchema'
import type {CodeDeployDeployment, StartCodeDeployDeploymentInput} from '../cloud-spi/codedeploy'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
export class AwsCodeDeployAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'codedeploy' as const
constructor(private readonly client: CodeDeployClient, private readonly lambda: LambdaClient) {}
schema(): ServiceSchema { return awsCodeDeploySchema() }
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const resources: CloudResource[] = []
const applications = await this.client.send(new ListApplicationsCommand({}))
for (const applicationName of applications.applications ?? []) {
const groups = await this.client.send(new ListDeploymentGroupsCommand({applicationName}))
for (const deploymentGroupName of groups.deploymentGroups ?? []) {
resources.push(await this.groupResource(applicationName, deploymentGroupName))
}
}
const search = query.search?.trim().toLowerCase()
return resources.filter((resource) => !search || resource.name.toLowerCase().includes(search) || String(resource.metadata.applicationName).toLowerCase().includes(search))
}
async get(id: string): Promise<CloudResource | null> {
const [applicationName, deploymentGroupName] = splitId(id)
return this.groupResource(applicationName, deploymentGroupName)
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const applicationName = required(input.values.applicationName, 'applicationName')
const deploymentGroupName = required(input.values.deploymentGroupName, 'deploymentGroupName')
const applications = await this.client.send(new ListApplicationsCommand({}))
if (!(applications.applications ?? []).includes(applicationName)) await this.client.send(new CreateApplicationCommand({applicationName, computePlatform: 'Lambda'}))
await this.client.send(new CreateDeploymentGroupCommand({
applicationName,
deploymentGroupName,
serviceRoleArn: required(input.values.serviceRoleArn, 'serviceRoleArn'),
deploymentConfigName: deploymentConfig(input.values.deploymentConfig),
deploymentStyle: {deploymentType: 'BLUE_GREEN', deploymentOption: 'WITH_TRAFFIC_CONTROL'},
autoRollbackConfiguration: {enabled: input.values.rollbackOnFailure !== false, events: ['DEPLOYMENT_FAILURE']},
}))
return this.groupResource(applicationName, deploymentGroupName)
}
async delete(id: string): Promise<void> {
const [applicationName, deploymentGroupName] = splitId(id)
await this.client.send(new DeleteDeploymentGroupCommand({applicationName, deploymentGroupName}))
const groups = await this.client.send(new ListDeploymentGroupsCommand({applicationName}))
if ((groups.deploymentGroups ?? []).length === 0) await this.client.send(new DeleteApplicationCommand({applicationName}))
}
async dependencies(): Promise<Record<string, unknown>> {
const functions = await this.lambda.send(new ListFunctionsCommand({}))
const lambdaFunctions = await Promise.all((functions.Functions ?? []).flatMap(async (functionConfiguration) => {
const name = functionConfiguration.FunctionName
if (!name) return []
const [aliases, versions] = await Promise.all([
this.lambda.send(new ListAliasesCommand({FunctionName: name})),
this.lambda.send(new ListVersionsByFunctionCommand({FunctionName: name})),
])
return [{name, aliases: (aliases.Aliases ?? []).map((alias) => alias.Name).filter((alias): alias is string => Boolean(alias)), versions: (versions.Versions ?? []).map((version) => version.Version).filter((version): version is string => Boolean(version) && version !== '$LATEST')}]
}))
return {lambdaFunctions}
}
async startCodeDeployDeployment(applicationName: string, deploymentGroupName: string, input: StartCodeDeployDeploymentInput): Promise<CodeDeployDeployment> {
const hooks = hooksFor(input)
const response = await this.client.send(new CreateDeploymentCommand({
applicationName, deploymentGroupName,
revision: {revisionType: 'AppSpecContent', appSpecContent: {content: JSON.stringify({version: '0.0', Resources: [{[input.functionName]: {Type: 'AWS::Lambda::Function', Properties: {Name: input.functionName, Alias: input.alias, CurrentVersion: input.currentVersion, TargetVersion: input.targetVersion}}}], ...(hooks.length ? {Hooks: hooks} : {})})}},
}))
if (!response.deploymentId) throw new RuntimeError('CodeDeploy did not return a deployment id')
return this.getCodeDeployDeployment(response.deploymentId)
}
async listCodeDeployDeployments(applicationName: string, deploymentGroupName: string): Promise<CodeDeployDeployment[]> {
const response = await this.client.send(new ListDeploymentsCommand({applicationName, deploymentGroupName, includeOnlyStatuses: ['Created', 'Queued', 'InProgress', 'Succeeded', 'Failed', 'Stopped']}))
return Promise.all((response.deployments ?? []).map((id) => this.getCodeDeployDeployment(id)))
}
async getCodeDeployDeployment(id: string): Promise<CodeDeployDeployment> {
const response = await this.client.send(new GetDeploymentCommand({deploymentId: id}))
const deployment = response.deploymentInfo
if (!deployment?.deploymentId || !deployment.applicationName || !deployment.deploymentGroupName) throw new RuntimeError(`CodeDeploy deployment ${id} was not found`)
const revision = deployment.revision?.appSpecContent?.content
const revisionInfo = appSpecInfo(revision)
let actualTrafficPercent: number | null = null
if (revisionInfo.functionName && revisionInfo.alias && revisionInfo.targetVersion) {
const alias = await this.lambda.send(new GetAliasCommand({FunctionName: revisionInfo.functionName, Name: revisionInfo.alias}))
actualTrafficPercent = alias.FunctionVersion === revisionInfo.targetVersion ? 100 : Math.round((alias.RoutingConfig?.AdditionalVersionWeights?.[revisionInfo.targetVersion] ?? 0) * 100)
}
return {id: deployment.deploymentId, applicationName: deployment.applicationName, deploymentGroupName: deployment.deploymentGroupName, status: deployment.status ?? null, creator: deployment.creator ?? null, deploymentConfigName: deployment.deploymentConfigName ?? null, createTime: deployment.createTime, completeTime: deployment.completeTime, errorInformation: deployment.errorInformation ?? null, currentVersion: revisionInfo.currentVersion, targetVersion: revisionInfo.targetVersion, trafficPercent: actualTrafficPercent ?? trafficPercent(deployment.deploymentOverview)}
}
async stopCodeDeployDeployment(id: string, rollback: boolean): Promise<void> { await this.client.send(new StopDeploymentCommand({deploymentId: id, autoRollbackEnabled: rollback})) }
private async groupResource(applicationName: string, deploymentGroupName: string): Promise<CloudResource> {
const response = await this.client.send(new GetDeploymentGroupCommand({applicationName, deploymentGroupName}))
const group = response.deploymentGroupInfo
if (!group?.deploymentGroupName) throw new RuntimeError(`CodeDeploy deployment group ${deploymentGroupName} was not found`)
const configuration = group.deploymentConfigName
return {id: `${applicationName}/${deploymentGroupName}`, name: deploymentGroupName, cloud: 'aws', service: 'codedeploy', type: 'codedeploy-deployment-group', region: regionFromArn(group.deploymentGroupId), createdAt: null, status: null, metadata: {applicationName, deploymentGroupName, deploymentGroupArn: group.deploymentGroupId, serviceRoleArn: group.serviceRoleArn, deploymentConfigName: configuration, rollbackOnFailure: group.autoRollbackConfiguration?.enabled ?? false, updatedAt: group.lastSuccessfulDeployment?.createTime?.toISOString()}}
}
}
function required(value: unknown, field: string): string { const result = optional(value); if (!result) throw new ValidationError(`${field} is required`); return result }
function optional(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined }
function splitId(id: string): [string, string] { const separator = id.indexOf('/'); if (separator <= 0 || separator === id.length - 1) throw new ValidationError('Deployment group id must be applicationName/deploymentGroupName'); return [id.slice(0, separator), id.slice(separator + 1)] }
function deploymentConfig(value: unknown): string { const choice = optional(value) ?? 'AllAtOnce'; return `CodeDeployDefault.Lambda${choice}` }
function regionFromArn(arn?: string): string | null { return arn?.split(':')[3] || null }
function hooksFor(input: {beforeAllowTrafficHook?: string; afterAllowTrafficHook?: string}): Array<Record<string, string>> { const hooks: Array<Record<string, string>> = []; if (input.beforeAllowTrafficHook) hooks.push({BeforeAllowTraffic: input.beforeAllowTrafficHook}); if (input.afterAllowTrafficHook) hooks.push({AfterAllowTraffic: input.afterAllowTrafficHook}); return hooks }
function appSpecInfo(content?: string): {functionName: string | null; alias: string | null; currentVersion: string | null; targetVersion: string | null} { try { const parsed = JSON.parse(content ?? '{}') as {Resources?: Array<Record<string, {Properties?: {Name?: string; Alias?: string; CurrentVersion?: string; TargetVersion?: string}}>>}; const properties = parsed.Resources?.[0] && Object.values(parsed.Resources[0])[0]?.Properties; return {functionName: properties?.Name ?? null, alias: properties?.Alias ?? null, currentVersion: properties?.CurrentVersion ?? null, targetVersion: properties?.TargetVersion ?? null} } catch { return {functionName: null, alias: null, currentVersion: null, targetVersion: null} } }
function trafficPercent(overview?: {InProgress?: number; Succeeded?: number; Failed?: number; Skipped?: number; Ready?: number}): number | null { if (!overview) return null; const total = Object.values(overview).reduce((sum, value) => sum + (value ?? 0), 0); return total ? Math.round(((overview.Succeeded ?? 0) / total) * 100) : 0 }
@@ -0,0 +1,46 @@
import {describe, expect, test} from 'bun:test'
import {CreatePipelineCommand, GetPipelineCommand, GetPipelineStateCommand, ListPipelineExecutionsCommand, ListPipelinesCommand, StartPipelineExecutionCommand, type CodePipelineClient} from '@aws-sdk/client-codepipeline'
import {AwsCodePipelineAdapter} from './AwsCodePipelineAdapter'
function stub(handler: (command: object) => Record<string, unknown>) {
const sent: object[] = []
const client = {async send(command: object) { sent.push(command); return handler(command) }} as unknown as CodePipelineClient
return {client, sent}
}
describe('AwsCodePipelineAdapter', () => {
test('creates the documented S3, CodeBuild, S3 local pipeline definition', async () => {
const pipeline = {name: 'release', roleArn: 'arn:aws:iam::000000000000:role/pipeline', artifactStore: {type: 'S3' as const, location: 'artifacts'}, stages: []}
const {client, sent} = stub((command) => command instanceof CreatePipelineCommand ? {pipeline} : {})
await new AwsCodePipelineAdapter(client).create({values: {name: 'release', roleArn: pipeline.roleArn, artifactBucket: 'artifacts', sourceBucket: 'source', sourceKey: 'app.zip', codeBuildProject: 'build-app', deployBucket: 'deploy', deployKey: 'releases/app.zip'}})
const stages = (sent[0] as CreatePipelineCommand).input.pipeline?.stages
expect(stages?.map((stage) => stage.name)).toEqual(['Source', 'Build', 'Deploy'])
expect(stages?.map((stage) => stage.actions?.[0].actionTypeId?.provider)).toEqual(['S3', 'CodeBuild', 'S3'])
expect(stages?.[1].actions?.[0].configuration).toEqual({ProjectName: 'build-app'})
expect(stages?.[2].actions?.[0].configuration).toEqual({BucketName: 'deploy', ObjectKey: 'releases/app.zip', Extract: 'true'})
})
test('uses native execution and state APIs and retains CodeBuild links', async () => {
const pipeline = {name: 'release', roleArn: 'arn:aws:iam::000000000000:role/pipeline', artifactStore: {type: 'S3' as const, location: 'artifacts'}, stages: [{name: 'Build', actions: [{name: 'Build', actionTypeId: {category: 'Build' as const, owner: 'AWS' as const, provider: 'CodeBuild', version: '1'}, configuration: {ProjectName: 'build-app'}}]}]}
const {client, sent} = stub((command) => {
if (command instanceof StartPipelineExecutionCommand) return {pipelineExecutionId: 'execution-1'}
if (command instanceof ListPipelineExecutionsCommand) return {pipelineExecutionSummaries: [{pipelineExecutionId: 'execution-1', status: 'InProgress', startTime: new Date('2026-10-01T10:00:00.000Z')}]}
if (command instanceof GetPipelineCommand) return {pipeline}
if (command instanceof GetPipelineStateCommand) return {stageStates: [{stageName: 'Build', latestExecution: {status: 'InProgress'}, actionStates: [{actionName: 'Build', latestExecution: {status: 'InProgress', externalExecutionId: 'build-app:1'}}]}]}
return {}
})
const adapter = new AwsCodePipelineAdapter(client)
expect(await adapter.startPipeline('release')).toEqual({id: 'execution-1'})
expect((await adapter.listPipelineExecutions('release'))[0]).toMatchObject({id: 'execution-1', status: 'InProgress'})
expect((await adapter.getPipelineState('release')).stages[0].actions[0]).toMatchObject({name: 'Build', codeBuildProject: 'build-app', externalExecutionId: 'build-app:1'})
expect(sent.some((command) => command instanceof StartPipelineExecutionCommand)).toBe(true)
expect(sent.some((command) => command instanceof ListPipelineExecutionsCommand)).toBe(true)
expect(sent.some((command) => command instanceof GetPipelineStateCommand)).toBe(true)
})
test('lists native pipeline summaries', async () => {
const {client} = stub((command) => command instanceof ListPipelinesCommand ? {pipelines: [{name: 'release', version: 2, executionMode: 'SUPERSEDED'}]} : {})
const pipelines = await new AwsCodePipelineAdapter(client).list()
expect(pipelines[0]).toMatchObject({id: 'release', version: '2', metadata: {executionMode: 'SUPERSEDED'}})
})
})
@@ -0,0 +1,108 @@
import {
CreatePipelineCommand,
DeletePipelineCommand,
GetPipelineCommand,
GetPipelineStateCommand,
ListPipelinesCommand,
ListPipelineExecutionsCommand,
StartPipelineExecutionCommand,
type ActionState,
type CodePipelineClient,
type PipelineDeclaration,
} from '@aws-sdk/client-codepipeline'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {awsCodePipelineSchema} from '../cloud-spi/codepipelineSchema'
import type {PipelineExecutionSummary, PipelineState} from '../cloud-spi/codepipeline'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
export class AwsCodePipelineAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'codepipeline' as const
constructor(private readonly client: CodePipelineClient) {}
schema(): ServiceSchema { return awsCodePipelineSchema() }
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const pipelines: CloudResource[] = []
let nextToken: string | undefined
do {
const response = await this.client.send(new ListPipelinesCommand(nextToken ? {nextToken} : {}))
pipelines.push(...(response.pipelines ?? []).map((pipeline) => toSummaryResource(pipeline)))
nextToken = response.nextToken
} while (nextToken)
const search = query.search?.trim().toLowerCase()
return pipelines.filter((pipeline) => !search || pipeline.name.toLowerCase().includes(search))
}
async get(id: string): Promise<CloudResource | null> {
const response = await this.client.send(new GetPipelineCommand({name: id}))
return response.pipeline ? toResource(response.pipeline, response.metadata) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const name = required(input.values.name, 'name')
const declaration: PipelineDeclaration = {
name,
roleArn: required(input.values.roleArn, 'roleArn'),
artifactStore: {type: 'S3', location: required(input.values.artifactBucket, 'artifactBucket')},
stages: [
stage('Source', action('Source', 'Source', 'S3', {S3Bucket: required(input.values.sourceBucket, 'sourceBucket'), S3ObjectKey: required(input.values.sourceKey, 'sourceKey'), PollForSourceChanges: 'false'}, [], ['SourceOutput'])),
stage('Build', action('Build', 'Build', 'CodeBuild', {ProjectName: required(input.values.codeBuildProject, 'codeBuildProject')}, ['SourceOutput'], ['BuildOutput'])),
stage('Deploy', action('Deploy', 'Deploy', 'S3', {BucketName: required(input.values.deployBucket, 'deployBucket'), ObjectKey: required(input.values.deployKey, 'deployKey'), Extract: 'true'}, ['BuildOutput'], [])),
],
}
const response = await this.client.send(new CreatePipelineCommand({pipeline: declaration}))
if (!response.pipeline) throw new RuntimeError(`CodePipeline did not return pipeline ${name}`)
return toResource(response.pipeline)
}
async delete(id: string): Promise<void> { await this.client.send(new DeletePipelineCommand({name: id})) }
async startPipeline(id: string): Promise<{id: string}> {
const response = await this.client.send(new StartPipelineExecutionCommand({name: id}))
if (!response.pipelineExecutionId) throw new RuntimeError(`CodePipeline did not return an execution for ${id}`)
return {id: response.pipelineExecutionId}
}
async listPipelineExecutions(id: string): Promise<PipelineExecutionSummary[]> {
const executions: PipelineExecutionSummary[] = []
let nextToken: string | undefined
do {
const response = await this.client.send(new ListPipelineExecutionsCommand(nextToken ? {pipelineName: id, nextToken} : {pipelineName: id}))
for (const execution of response.pipelineExecutionSummaries ?? []) {
if (execution.pipelineExecutionId) executions.push({id: execution.pipelineExecutionId, status: execution.status, statusSummary: execution.statusSummary, startTime: execution.startTime, lastUpdateTime: execution.lastUpdateTime})
}
nextToken = response.nextToken
} while (nextToken)
return executions
}
async getPipelineState(id: string): Promise<PipelineState> {
const [pipeline, state] = await Promise.all([this.client.send(new GetPipelineCommand({name: id})), this.client.send(new GetPipelineStateCommand({name: id}))])
const actions = (pipeline.pipeline?.stages ?? []).flatMap((stage) => stage.actions ?? [])
const codeBuildProjects = new Map(actions.filter((item) => item.actionTypeId?.provider === 'CodeBuild').map((item) => [item.name, item.configuration?.ProjectName] as const))
const codeDeployActions = new Set(actions.filter((item) => item.actionTypeId?.provider === 'CodeDeploy').map((item) => item.name))
return {stages: (state.stageStates ?? []).map((stage) => ({name: stage.stageName ?? 'Unknown', status: stage.latestExecution?.status, actions: (stage.actionStates ?? []).map((actionState) => toActionState(actionState, codeBuildProjects, codeDeployActions))}))}
}
}
function toResource(pipeline: PipelineDeclaration, metadata?: {pipelineArn?: string; created?: Date; updated?: Date}): CloudResource {
const name = pipeline.name ?? ''
return {id: name, name, cloud: 'aws', service: 'codepipeline', type: 'codepipeline-pipeline', region: regionFromArn(metadata?.pipelineArn), createdAt: metadata?.created?.toISOString() ?? null, version: pipeline.version?.toString(), metadata: {arn: metadata?.pipelineArn, roleArn: pipeline.roleArn, executionMode: pipeline.executionMode, pipelineType: pipeline.pipelineType, artifactBucket: pipeline.artifactStore?.location, updatedAt: metadata?.updated?.toISOString()}}
}
function toSummaryResource(pipeline: {name?: string; version?: number; created?: Date; updated?: Date; executionMode?: string; pipelineType?: string}): CloudResource {
const name = pipeline.name ?? ''
return {id: name, name, cloud: 'aws', service: 'codepipeline', type: 'codepipeline-pipeline', region: null, createdAt: pipeline.created?.toISOString() ?? null, version: pipeline.version?.toString(), metadata: {executionMode: pipeline.executionMode, pipelineType: pipeline.pipelineType, updatedAt: pipeline.updated?.toISOString()}}
}
function toActionState(action: ActionState, codeBuildProjects: Map<string | undefined, string | undefined>, codeDeployActions: Set<string | undefined>) {
const execution = action.latestExecution
return {name: action.actionName ?? 'Unknown', status: execution?.status, summary: execution?.summary, lastStatusChange: execution?.lastStatusChange, externalExecutionId: execution?.externalExecutionId, externalExecutionUrl: execution?.externalExecutionUrl, entityUrl: action.entityUrl, revisionUrl: action.revisionUrl, codeBuildProject: codeBuildProjects.get(action.actionName), codeDeployDeploymentId: codeDeployActions.has(action.actionName) ? execution?.externalExecutionId : undefined}
}
function stage(name: string, item: ReturnType<typeof action>) { return {name, actions: [item]} }
function action(name: string, category: 'Source' | 'Build' | 'Deploy', provider: 'S3' | 'CodeBuild', configuration: Record<string, string>, inputArtifacts: string[], outputArtifacts: string[]) { return {name, actionTypeId: {category, owner: 'AWS' as const, provider, version: '1'}, configuration, inputArtifacts: inputArtifacts.map((name) => ({name})), outputArtifacts: outputArtifacts.map((name) => ({name}))} }
function required(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim()) throw new ValidationError(`${field} is required`); return value.trim() }
function regionFromArn(arn?: string): string | null { return arn?.split(':')[3] || null }
@@ -8,8 +8,12 @@ import {
DescribeOrderableDBInstanceOptionsCommand, DescribeOrderableDBInstanceOptionsCommand,
ListTagsForResourceCommand, ListTagsForResourceCommand,
ModifyDBInstanceCommand, ModifyDBInstanceCommand,
RebootDBInstanceCommand,
StartDBInstanceCommand,
StopDBInstanceCommand,
type RDSClient, type RDSClient,
} from '@aws-sdk/client-rds' } from '@aws-sdk/client-rds'
import {ExecuteStatementCommand, type RDSDataClient} from '@aws-sdk/client-rds-data'
import {ConflictError, ValidationError} from '../cloud-spi/errors' import {ConflictError, ValidationError} from '../cloud-spi/errors'
import {createRdsService} from '../services/rds' import {createRdsService} from '../services/rds'
import {mapAwsSdkError} from './awsErrors' import {mapAwsSdkError} from './awsErrors'
@@ -24,6 +28,9 @@ type RdsCommand =
| DescribeOrderableDBInstanceOptionsCommand | DescribeOrderableDBInstanceOptionsCommand
| ListTagsForResourceCommand | ListTagsForResourceCommand
| ModifyDBInstanceCommand | ModifyDBInstanceCommand
| RebootDBInstanceCommand
| StartDBInstanceCommand
| StopDBInstanceCommand
type Responder = (command: RdsCommand) => unknown | Promise<unknown> type Responder = (command: RdsCommand) => unknown | Promise<unknown>
@@ -47,6 +54,17 @@ function adapterWith(responder: Responder) {
} }
} }
function recordingRdsData(responder: (command: ExecuteStatementCommand) => unknown | Promise<unknown>) {
const commands: ExecuteStatementCommand[] = []
const client = {
async send(command: ExecuteStatementCommand) {
commands.push(command)
return responder(command)
},
} as unknown as RDSDataClient
return {client, commands}
}
function instanceResponse(command: CreateDBInstanceCommand) { function instanceResponse(command: CreateDBInstanceCommand) {
return { return {
DBInstance: { DBInstance: {
@@ -69,6 +87,47 @@ const validCreateValues = {
} }
describe('AwsDatabaseAdapter', () => { describe('AwsDatabaseAdapter', () => {
test('explores PostgreSQL databases through the RDS Data API without console credentials', async () => {
const {client, commands} = recordingRdsData((command) => {
expect(command.input.resourceArn).toBe('arn:aws:rds:us-east-1:000000000000:db:orders-db')
expect(command.input.database).toBe('orders')
expect(command.input.sql).toContain('FROM pg_database')
return {
columnMetadata: [{name: 'name', typeName: 'varchar'}, {name: 'state', typeName: 'varchar'}, {name: 'isSystem', typeName: 'bool'}],
records: [[{stringValue: 'orders'}, {stringValue: 'ONLINE'}, {booleanValue: false}]],
}
})
const {client: rds, commands: rdsCommands} = recordingRds((command) => {
expect(command).toBeInstanceOf(DescribeDBInstancesCommand)
return {DBInstances: [{
DBInstanceIdentifier: 'orders-db',
DBInstanceArn: 'arn:aws:rds:us-east-1:000000000000:db:orders-db',
Engine: 'postgres',
DBName: 'orders',
VpcSecurityGroups: [],
}]}
})
const adapter = new AwsDatabaseAdapter(createRdsService(rds), rds, client)
await expect(adapter.listSqlDatabases('orders-db', {username: '', password: ''})).resolves.toEqual([{
name: 'orders', state: 'ONLINE', createdAt: null, isSystem: false,
}])
expect(rdsCommands).toHaveLength(1)
expect(commands).toHaveLength(1)
expect(commands[0].input.secretArn).toContain('floci-ui-rds-data')
})
test('rejects SQL exploration for unsupported RDS engines', async () => {
const {client: rds} = recordingRds(() => ({DBInstances: [{
DBInstanceIdentifier: 'maria-db', Engine: 'mariadb', VpcSecurityGroups: [],
}]}))
const {client} = recordingRdsData(() => ({}))
const adapter = new AwsDatabaseAdapter(createRdsService(rds), rds, client)
await expect(adapter.querySql('maria-db', {username: '', password: '', database: 'app'}, 'SELECT 1'))
.rejects.toBeInstanceOf(ValidationError)
})
test('keeps list search behavior intact', async () => { test('keeps list search behavior intact', async () => {
const {adapter} = adapterWith(async (command) => { const {adapter} = adapterWith(async (command) => {
expect(command).toBeInstanceOf(DescribeDBInstancesCommand) expect(command).toBeInstanceOf(DescribeDBInstancesCommand)
@@ -285,6 +344,25 @@ describe('AwsDatabaseAdapter', () => {
}) })
}) })
test('sends RDS lifecycle commands for an instance', async () => {
const {adapter, commands} = adapterWith(async () => ({}))
await adapter.start('orders-db')
await adapter.stop('orders-db')
await adapter.reboot('orders-db')
expect(commands.map((command) => command.constructor)).toEqual([
StartDBInstanceCommand,
StopDBInstanceCommand,
RebootDBInstanceCommand,
])
expect(commands.map((command) => command.input)).toEqual([
{DBInstanceIdentifier: 'orders-db'},
{DBInstanceIdentifier: 'orders-db'},
{DBInstanceIdentifier: 'orders-db'},
])
})
test('lists and normalizes database snapshots', async () => { test('lists and normalizes database snapshots', async () => {
const {adapter, commands} = adapterWith(async (command) => { const {adapter, commands} = adapterWith(async (command) => {
expect(command).toBeInstanceOf(DescribeDBSnapshotsCommand) expect(command).toBeInstanceOf(DescribeDBSnapshotsCommand)
@@ -1,6 +1,7 @@
import {ValidationError} from '../cloud-spi/errors' import {ValidationError} from '../cloud-spi/errors'
import {ListTagsForResourceCommand, type ModifyDBInstanceCommandInput, type RDSClient} from '@aws-sdk/client-rds' import {ListTagsForResourceCommand, type ModifyDBInstanceCommandInput, type RDSClient} from '@aws-sdk/client-rds'
import {rds as defaultRds} from '../aws' import {ExecuteStatementCommand, type Field, type RDSDataClient} from '@aws-sdk/client-rds-data'
import {rds as defaultRds, awsClients} from '../aws'
import {awsDatabaseSchema} from '../cloud-spi/databaseSchema' import {awsDatabaseSchema} from '../cloud-spi/databaseSchema'
import type { import type {
CloudResource, CloudResource,
@@ -10,13 +11,17 @@ import type {
DatabaseSnapshot, DatabaseSnapshot,
ResourceQuery, ResourceQuery,
ServiceSchema, ServiceSchema,
SqlConnectionInput,
SqlDatabase,
SqlQueryResult,
SqlTable,
UpdateResourceInput, UpdateResourceInput,
} from '../cloud-spi/types' } from '../cloud-spi/types'
import {rdsService, type RdsInstance, type RdsSnapshot} from '../services/rds' import {rdsService, type RdsInstance, type RdsSnapshot} from '../services/rds'
type RdsServiceShape = Pick< type RdsServiceShape = Pick<
typeof rdsService, typeof rdsService,
'listInstances' | 'describeInstance' | 'createInstance' | 'modifyInstance' | 'deleteInstance' | 'listSnapshots' | 'createSnapshot' | 'listOrderableInstanceClasses' 'listInstances' | 'describeInstance' | 'createInstance' | 'modifyInstance' | 'deleteInstance' | 'startInstance' | 'stopInstance' | 'rebootInstance' | 'listSnapshots' | 'createSnapshot' | 'listOrderableInstanceClasses'
> >
export class AwsDatabaseAdapter implements CloudServiceAdapter { export class AwsDatabaseAdapter implements CloudServiceAdapter {
@@ -26,6 +31,7 @@ export class AwsDatabaseAdapter implements CloudServiceAdapter {
constructor( constructor(
private readonly rdsService_: RdsServiceShape = rdsService, private readonly rdsService_: RdsServiceShape = rdsService,
private readonly rds: RDSClient = defaultRds, private readonly rds: RDSClient = defaultRds,
private readonly rdsData: RDSDataClient = awsClients.rdsData,
) {} ) {}
schema(): ServiceSchema { schema(): ServiceSchema {
@@ -118,6 +124,18 @@ export class AwsDatabaseAdapter implements CloudServiceAdapter {
await this.rdsService_.deleteInstance(id) await this.rdsService_.deleteInstance(id)
} }
async start(id: string): Promise<void> {
await this.rdsService_.startInstance(id)
}
async stop(id: string): Promise<void> {
await this.rdsService_.stopInstance(id)
}
async reboot(id: string): Promise<void> {
await this.rdsService_.rebootInstance(id)
}
async listDatabaseSnapshots(instanceIdentifier?: string): Promise<DatabaseSnapshot[]> { async listDatabaseSnapshots(instanceIdentifier?: string): Promise<DatabaseSnapshot[]> {
const normalizedIdentifier = stringValue(instanceIdentifier) || undefined const normalizedIdentifier = stringValue(instanceIdentifier) || undefined
return (await this.rdsService_.listSnapshots(normalizedIdentifier)).map(snapshotToDatabaseSnapshot) return (await this.rdsService_.listSnapshots(normalizedIdentifier)).map(snapshotToDatabaseSnapshot)
@@ -136,6 +154,73 @@ export class AwsDatabaseAdapter implements CloudServiceAdapter {
return await this.rdsService_.listOrderableInstanceClasses(normalizedEngine) return await this.rdsService_.listOrderableInstanceClasses(normalizedEngine)
} }
async listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise<SqlDatabase[]> {
const instance = await this.rdsService_.describeInstance(serverId)
const engine = sqlEngine(instance.engine)
const result = await this.execute(instance, defaultDatabase(instance, engine), databaseListQuery(engine))
return (result.resultSets[0]?.rows ?? []).flatMap((row) => {
const name = stringValue(row.name)
if (!name) return []
return [{
name,
state: stringValue(row.state) || 'ONLINE',
createdAt: null,
isSystem: row.isSystem === true,
}]
})
}
async listSqlTables(serverId: string, connection: SqlConnectionInput): Promise<SqlTable[]> {
const instance = await this.rdsService_.describeInstance(serverId)
const engine = sqlEngine(instance.engine)
const database = sqlDatabase(connection, instance, engine)
const result = await this.execute(instance, database, tableListQuery(engine))
return (result.resultSets[0]?.rows ?? []).flatMap((row) => {
const schema = stringValue(row.schemaName)
const name = stringValue(row.name)
if (!schema || !name) return []
return [{
schema,
name,
type: row.objectType === 'view' ? 'view' as const : 'table' as const,
rowCount: integerValue(row.rowCount),
}]
})
}
async querySql(serverId: string, connection: SqlConnectionInput, query: string): Promise<SqlQueryResult> {
if (!query.trim()) throw new ValidationError('SQL query is required')
const instance = await this.rdsService_.describeInstance(serverId)
const engine = sqlEngine(instance.engine)
return this.execute(instance, sqlDatabase(connection, instance, engine), query)
}
private async execute(instance: RdsInstance, database: string, sql: string): Promise<SqlQueryResult> {
if (!instance.arn) throw new ValidationError('DB instance ARN is not available')
const startedAt = performance.now()
const response = await this.rdsData.send(new ExecuteStatementCommand({
resourceArn: instance.arn,
// Floci falls back to the RDS instance credentials when this local
// placeholder does not resolve to a Secrets Manager secret.
secretArn: 'arn:aws:secretsmanager:us-east-1:000000000000:secret:floci-ui-rds-data',
database,
sql,
includeResultMetadata: true,
}))
const columns = (response.columnMetadata ?? []).map((column, index) => ({
name: column.name ?? `column_${index + 1}`,
type: String(column.typeName ?? column.type ?? 'unknown'),
}))
const rows = (response.records ?? []).map((record) => Object.fromEntries(
record.map((field, index) => [columns[index]?.name ?? `column_${index + 1}`, fieldValue(field)]),
))
return {
resultSets: columns.length > 0 ? [{columns, rows, truncated: false}] : [],
rowsAffected: [response.numberOfRecordsUpdated ?? 0],
durationMs: Math.round(performance.now() - startedAt),
}
}
private async toResource(instance: RdsInstance): Promise<CloudResource> { private async toResource(instance: RdsInstance): Promise<CloudResource> {
const tags = instance.arn ? await this.getTags(instance.arn) : [] const tags = instance.arn ? await this.getTags(instance.arn) : []
const vpcSecurityGroupIds = (instance.vpcSecurityGroups ?? []) const vpcSecurityGroupIds = (instance.vpcSecurityGroups ?? [])
@@ -192,6 +277,75 @@ export class AwsDatabaseAdapter implements CloudServiceAdapter {
} }
} }
const POSTGRES_DATABASES_QUERY = `
SELECT datname AS name,
CASE WHEN datallowconn THEN 'ONLINE' ELSE 'OFFLINE' END AS state,
datname IN ('postgres', 'template0', 'template1') AS "isSystem"
FROM pg_database
WHERE datallowconn
ORDER BY "isSystem" DESC, datname`
const POSTGRES_TABLES_QUERY = `
SELECT namespace.nspname AS "schemaName", relation.relname AS name,
CASE WHEN relation.relkind IN ('v', 'm') THEN 'view' ELSE 'table' END AS "objectType",
CASE WHEN relation.relkind IN ('r', 'p') THEN relation.reltuples::bigint ELSE NULL END AS "rowCount"
FROM pg_class AS relation
INNER JOIN pg_namespace AS namespace ON namespace.oid = relation.relnamespace
WHERE namespace.nspname NOT IN ('pg_catalog', 'information_schema')
AND namespace.nspname NOT LIKE 'pg_toast%'
AND relation.relkind IN ('r', 'p', 'v', 'm')
ORDER BY namespace.nspname, relation.relname`
const MYSQL_DATABASES_QUERY = `
SELECT schema_name AS name, 'ONLINE' AS state,
schema_name IN ('information_schema', 'mysql', 'performance_schema', 'sys') AS isSystem
FROM information_schema.schemata
ORDER BY isSystem DESC, schema_name`
const MYSQL_TABLES_QUERY = `
SELECT table_schema AS schemaName, table_name AS name,
CASE WHEN table_type = 'VIEW' THEN 'view' ELSE 'table' END AS objectType,
table_rows AS rowCount
FROM information_schema.tables
WHERE table_schema = DATABASE()
ORDER BY table_name`
function sqlEngine(engine: string | undefined): 'postgresql' | 'mysql' {
if (engine === 'postgres') return 'postgresql'
if (engine === 'mysql') return 'mysql'
throw new ValidationError('SQL exploration is available only for PostgreSQL and MySQL RDS instances')
}
function defaultDatabase(instance: RdsInstance, engine: 'postgresql' | 'mysql'): string {
return instance.dbName || (engine === 'postgresql' ? 'postgres' : 'mysql')
}
function sqlDatabase(connection: SqlConnectionInput, instance: RdsInstance, engine: 'postgresql' | 'mysql'): string {
return stringValue(connection.database) || defaultDatabase(instance, engine)
}
function databaseListQuery(engine: 'postgresql' | 'mysql'): string {
return engine === 'postgresql' ? POSTGRES_DATABASES_QUERY : MYSQL_DATABASES_QUERY
}
function tableListQuery(engine: 'postgresql' | 'mysql'): string {
return engine === 'postgresql' ? POSTGRES_TABLES_QUERY : MYSQL_TABLES_QUERY
}
function integerValue(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value)
if (typeof value === 'string' && /^-?\d+$/.test(value)) return Number(value)
return null
}
function fieldValue(field: Field): unknown {
if (field.isNull) return null
if (field.stringValue !== undefined) return field.stringValue
if (field.longValue !== undefined) return Number(field.longValue)
if (field.doubleValue !== undefined) return field.doubleValue
if (field.booleanValue !== undefined) return field.booleanValue
if (field.blobValue !== undefined) return Buffer.from(field.blobValue).toString('base64')
if (field.arrayValue !== undefined) return field.arrayValue
return null
}
const INSTANCE_IDENTIFIER_PATTERN = /^(?!.*--)[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/ const INSTANCE_IDENTIFIER_PATTERN = /^(?!.*--)[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/
const SNAPSHOT_IDENTIFIER_PATTERN = /^(?!.*--)[A-Za-z](?:[A-Za-z0-9-]{0,253}[A-Za-z0-9])?$/ const SNAPSHOT_IDENTIFIER_PATTERN = /^(?!.*--)[A-Za-z](?:[A-Za-z0-9-]{0,253}[A-Za-z0-9])?$/
+12
View File
@@ -3,6 +3,7 @@ import { LambdaClient } from "@aws-sdk/client-lambda";
import { EKSClient } from "@aws-sdk/client-eks"; import { EKSClient } from "@aws-sdk/client-eks";
import { EC2Client } from "@aws-sdk/client-ec2"; import { EC2Client } from "@aws-sdk/client-ec2";
import { RDSClient } from "@aws-sdk/client-rds"; import { RDSClient } from "@aws-sdk/client-rds";
import { RDSDataClient } from "@aws-sdk/client-rds-data";
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs";
import { SSMClient } from "@aws-sdk/client-ssm"; import { SSMClient } from "@aws-sdk/client-ssm";
@@ -18,6 +19,9 @@ import { CloudFormationClient } from "@aws-sdk/client-cloudformation";
import { AppConfigClient } from "@aws-sdk/client-appconfig"; import { AppConfigClient } from "@aws-sdk/client-appconfig";
import { SNSClient } from "@aws-sdk/client-sns"; import { SNSClient } from "@aws-sdk/client-sns";
import { ECRClient } from "@aws-sdk/client-ecr"; import { ECRClient } from "@aws-sdk/client-ecr";
import { CodeBuildClient } from "@aws-sdk/client-codebuild";
import { CodePipelineClient } from "@aws-sdk/client-codepipeline";
import { CodeDeployClient } from "@aws-sdk/client-codedeploy";
const endpoint = process.env.FLOCI_ENDPOINT; const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1"; const region = process.env.AWS_REGION || "us-east-1";
@@ -52,6 +56,7 @@ export type AwsClients = {
eks: EKSClient; eks: EKSClient;
ec2: EC2Client; ec2: EC2Client;
rds: RDSClient; rds: RDSClient;
rdsData: RDSDataClient;
secretsManager: SecretsManagerClient; secretsManager: SecretsManagerClient;
logs: CloudWatchLogsClient; logs: CloudWatchLogsClient;
ssm: SSMClient; ssm: SSMClient;
@@ -67,6 +72,9 @@ export type AwsClients = {
appConfig: AppConfigClient; appConfig: AppConfigClient;
sns: SNSClient; sns: SNSClient;
ecr: ECRClient; ecr: ECRClient;
codebuild: CodeBuildClient;
codepipeline: CodePipelineClient;
codedeploy: CodeDeployClient;
}; };
export type AwsClientName = keyof AwsClients; export type AwsClientName = keyof AwsClients;
@@ -85,6 +93,7 @@ function buildClients(accountId: string): AwsClients {
eks: new EKSClient(base), eks: new EKSClient(base),
ec2: new EC2Client(base), ec2: new EC2Client(base),
rds: new RDSClient(base), rds: new RDSClient(base),
rdsData: new RDSDataClient(base),
secretsManager: new SecretsManagerClient(base), secretsManager: new SecretsManagerClient(base),
logs: new CloudWatchLogsClient(base), logs: new CloudWatchLogsClient(base),
ssm: new SSMClient(base), ssm: new SSMClient(base),
@@ -100,6 +109,9 @@ function buildClients(accountId: string): AwsClients {
appConfig: new AppConfigClient(base), appConfig: new AppConfigClient(base),
sns: new SNSClient(base), sns: new SNSClient(base),
ecr: new ECRClient(base), ecr: new ECRClient(base),
codebuild: new CodeBuildClient(base),
codepipeline: new CodePipelineClient(base),
codedeploy: new CodeDeployClient(base),
}; };
} }
+31
View File
@@ -0,0 +1,31 @@
export interface CodeBuildEnvironmentVariableOverride {
name: string
value: string
type?: 'PLAINTEXT' | 'PARAMETER_STORE' | 'SECRETS_MANAGER'
}
export interface StartCodeBuildInput {
environmentVariablesOverride?: CodeBuildEnvironmentVariableOverride[]
}
export interface CodeBuildPhase {
phaseType: string
phaseStatus?: string
startTime?: Date
endTime?: Date
contexts: Array<{statusCode?: string; message?: string}>
}
export interface CodeBuildBuild {
id: string
arn?: string
projectName: string
buildNumber?: number
buildStatus?: string
currentPhase?: string
startTime?: Date
endTime?: Date
phases: CodeBuildPhase[]
logGroupName?: string
logStreamName?: string
}
@@ -0,0 +1,43 @@
import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const projectColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'environmentType', label: 'Environment', path: 'metadata.environmentType', format: 'badge'},
{name: 'computeType', label: 'Compute', path: 'metadata.computeType', format: 'badge'},
{name: 'createdAt', label: 'Created', format: 'datetime'},
]
const projectFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
/** The console exposes only source and artifact modes the local runtime can execute. */
export function awsCodeBuildSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'codebuild',
displayName: 'AWS CodeBuild',
fields: [
{name: 'name', label: 'Project name', type: 'text', required: true, group: 'Required'},
{name: 'serviceRole', label: 'Service role ARN', type: 'text', required: true, group: 'Required'},
{
name: 'environmentImage', label: 'Environment image', type: 'text', required: false, group: 'Environment',
defaultValue: 'aws/codebuild/standard:7.0',
description: 'AWS CodeBuild managed image identifier.',
},
{
name: 'computeType', label: 'Compute type', type: 'select', required: true, group: 'Environment', defaultValue: 'BUILD_GENERAL1_SMALL',
options: [{label: 'General 1 small', value: 'BUILD_GENERAL1_SMALL'}],
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List projects', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect project', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create project', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete project', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: projectFilters,
columns: projectColumns,
}
}
+23
View File
@@ -0,0 +1,23 @@
export interface CodeDeployDeployment {
id: string
applicationName: string
deploymentGroupName: string
status: string | null
creator: string | null
deploymentConfigName: string | null
createTime: Date | undefined
completeTime: Date | undefined
errorInformation: {code?: string; message?: string} | null
targetVersion: string | null
currentVersion: string | null
trafficPercent: number | null
}
export interface StartCodeDeployDeploymentInput {
functionName: string
alias: string
currentVersion: string
targetVersion: string
beforeAllowTrafficHook?: string
afterAllowTrafficHook?: string
}
@@ -0,0 +1,23 @@
import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const columns: TableColumnSchema[] = [
{name: 'name', label: 'Deployment group'},
{name: 'applicationName', label: 'Application', path: 'metadata.applicationName'},
{name: 'deploymentConfigName', label: 'Configuration', path: 'metadata.deploymentConfigName', format: 'badge'},
{name: 'updatedAt', label: 'Updated', path: 'metadata.updatedAt', format: 'datetime'},
]
const filters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
export function awsCodeDeploySchema(): ServiceSchema {
return {
cloud: 'aws', service: 'codedeploy', displayName: 'AWS CodeDeploy', fields: [], actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {resourceActions: [
{name: 'list', label: 'List deployment groups', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect deployment group', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create application and group', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete deployment group', enabled: true, status: 'available', runtimeRequired: true},
]},
filters, columns,
}
}
@@ -0,0 +1,31 @@
export interface PipelineExecutionSummary {
id: string
status?: string
statusSummary?: string
startTime?: Date
lastUpdateTime?: Date
}
export interface PipelineActionState {
name: string
status?: string
summary?: string
lastStatusChange?: Date
externalExecutionId?: string
externalExecutionUrl?: string
entityUrl?: string
revisionUrl?: string
codeBuildProject?: string
codeDeployDeploymentId?: string
}
export interface PipelineStageState {
name: string
status?: string
lastStatusChange?: Date
actions: PipelineActionState[]
}
export interface PipelineState {
stages: PipelineStageState[]
}
@@ -0,0 +1,33 @@
import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
const columns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'executionMode', label: 'Execution mode', path: 'metadata.executionMode', format: 'badge'},
{name: 'version', label: 'Version', format: 'code'},
{name: 'updatedAt', label: 'Updated', path: 'metadata.updatedAt', format: 'datetime'},
]
const filters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
export function awsCodePipelineSchema(): ServiceSchema {
return {
cloud: 'aws', service: 'codepipeline', displayName: 'AWS CodePipeline', fields: [
{name: 'name', label: 'Pipeline name', type: 'text', required: true, group: 'Pipeline'},
{name: 'roleArn', label: 'Service role ARN', type: 'text', required: true, group: 'Pipeline'},
{name: 'artifactBucket', label: 'Artifact S3 bucket', type: 'text', required: true, group: 'Artifacts'},
{name: 'sourceBucket', label: 'Source S3 bucket', type: 'text', required: true, group: 'Source'},
{name: 'sourceKey', label: 'Source object key', type: 'text', required: true, group: 'Source'},
{name: 'codeBuildProject', label: 'CodeBuild project', type: 'text', required: true, group: 'Build'},
{name: 'deployBucket', label: 'Deploy S3 bucket', type: 'text', required: true, group: 'Deploy'},
{name: 'deployKey', label: 'Deploy object key', type: 'text', required: true, group: 'Deploy'},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {resourceActions: [
{name: 'list', label: 'List pipelines', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect pipeline', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create pipeline', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete pipeline', enabled: true, status: 'available', runtimeRequired: true},
]},
filters, columns,
}
}
@@ -114,6 +114,9 @@ export function awsDatabaseSchema(): ServiceSchema {
{name: 'update', label: 'Update DB instance', enabled: true, status: 'available', runtimeRequired: true}, {name: 'update', label: 'Update DB instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete DB instance', enabled: true, status: 'available', runtimeRequired: true}, {name: 'delete', label: 'Delete DB instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect DB instance', enabled: true, status: 'available', runtimeRequired: true}, {name: 'inspect', label: 'Inspect DB instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'start', label: 'Start DB instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'stop', label: 'Stop DB instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'reboot', label: 'Reboot DB instance', enabled: true, status: 'available', runtimeRequired: true},
], ],
databaseActions: [ databaseActions: [
{ {
@@ -77,6 +77,24 @@ export const SERVICE_CATALOG = {
group: 'Compute', group: 'Compute',
order: 40, order: 40,
}, },
codebuild: {
displayName: 'CodeBuild',
iconKey: 'codebuild',
group: 'Compute',
order: 50,
},
codepipeline: {
displayName: 'CodePipeline',
iconKey: 'codepipeline',
group: 'Integration',
order: 25,
},
codedeploy: {
displayName: 'CodeDeploy',
iconKey: 'codedeploy',
group: 'Integration',
order: 26,
},
storage: {displayName: 'Storage', iconKey: 'storage', group: 'Storage', order: 10}, storage: {displayName: 'Storage', iconKey: 'storage', group: 'Storage', order: 10},
ecr: { ecr: {
displayName: 'Elastic Container Registry', displayName: 'Elastic Container Registry',
+16 -4
View File
@@ -1,6 +1,9 @@
// Derived from SERVICE_CATALOG so a new catalog row is a new service type. // Derived from SERVICE_CATALOG so a new catalog row is a new service type.
// This type-only cycle with serviceCatalog.ts is erased at compile time. // This type-only cycle with serviceCatalog.ts is erased at compile time.
import type {CloudServiceType, ServiceGroup} from './serviceCatalog' import type {CloudServiceType, ServiceGroup} from './serviceCatalog'
import type {CodeBuildBuild, StartCodeBuildInput} from './codebuild'
import type {PipelineExecutionSummary, PipelineState} from './codepipeline'
import type {CodeDeployDeployment, StartCodeDeployDeploymentInput} from './codedeploy'
import type { import type {
ChildItem, ChildItem,
CollectionActionName, CollectionActionName,
@@ -103,6 +106,7 @@ export interface FieldSchema {
* that a capability block can describe but that are not table-level controls. * that a capability block can describe but that are not table-level controls.
*/ */
export type ActionSchema = 'list' | 'create' | 'update' | 'delete' | 'inspect' export type ActionSchema = 'list' | 'create' | 'update' | 'delete' | 'inspect'
export type ResourceLifecycleAction = 'start' | 'stop' | 'reboot'
export type ResourceActionName = export type ResourceActionName =
| 'list' | 'list'
| 'create' | 'create'
@@ -112,9 +116,7 @@ export type ResourceActionName =
| 'encrypt' | 'encrypt'
| 'decrypt' | 'decrypt'
| 'invoke' | 'invoke'
| 'start' | ResourceLifecycleAction
| 'stop'
| 'reboot'
| 'updateTags' | 'updateTags'
export type ObjectActionName = 'list' | 'upload' | 'download' | 'delete' | 'createFolder' | 'copy' export type ObjectActionName = 'list' | 'upload' | 'download' | 'delete' | 'createFolder' | 'copy'
export type DatabaseActionName = 'listSnapshots' | 'createSnapshot' export type DatabaseActionName = 'listSnapshots' | 'createSnapshot'
@@ -266,7 +268,7 @@ export interface SqlConnectionInput {
username: string username: string
password: string password: string
database?: string database?: string
engine?: 'azure-sql' | 'postgresql' engine?: 'azure-sql' | 'postgresql' | 'mysql'
} }
export interface SqlDatabase { export interface SqlDatabase {
@@ -854,4 +856,14 @@ export interface CloudServiceAdapter {
deleteAppConfigDeploymentStrategy?(strategyId: string): Promise<void> deleteAppConfigDeploymentStrategy?(strategyId: string): Promise<void>
startAppConfigDeployment?(applicationId: string, environmentId: string, input: CreateResourceInput): Promise<AppConfigDeployment> startAppConfigDeployment?(applicationId: string, environmentId: string, input: CreateResourceInput): Promise<AppConfigDeployment>
getAppConfigDeployment?(applicationId: string, environmentId: string, deploymentNumber: number): Promise<AppConfigDeployment | null> getAppConfigDeployment?(applicationId: string, environmentId: string, deploymentNumber: number): Promise<AppConfigDeployment | null>
startBuild?(projectName: string, input: StartCodeBuildInput): Promise<CodeBuildBuild>
listBuilds?(projectName: string): Promise<CodeBuildBuild[]>
getBuild?(id: string): Promise<CodeBuildBuild | null>
startPipeline?(id: string): Promise<{id: string}>
listPipelineExecutions?(id: string): Promise<PipelineExecutionSummary[]>
getPipelineState?(id: string): Promise<PipelineState>
startCodeDeployDeployment?(applicationName: string, deploymentGroupName: string, input: StartCodeDeployDeploymentInput): Promise<CodeDeployDeployment>
listCodeDeployDeployments?(applicationName: string, deploymentGroupName: string): Promise<CodeDeployDeployment[]>
getCodeDeployDeployment?(id: string): Promise<CodeDeployDeployment>
stopCodeDeployDeployment?(id: string, rollback: boolean): Promise<void>
} }
+3
View File
@@ -178,6 +178,9 @@ describe('adapter capability advertisements match the runtime reality', () => {
{name: 'update', enabled: true, status: 'available', runtimeRequired: true}, {name: 'update', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', enabled: true, status: 'available', runtimeRequired: true}, {name: 'delete', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', enabled: true, status: 'available', runtimeRequired: true}, {name: 'inspect', enabled: true, status: 'available', runtimeRequired: true},
{name: 'start', enabled: true, status: 'available', runtimeRequired: true},
{name: 'stop', enabled: true, status: 'available', runtimeRequired: true},
{name: 'reboot', enabled: true, status: 'available', runtimeRequired: true},
]) ])
expect(schema.capabilities?.resourceActions?.find(({name}) => name === 'create')?.label).toBe('Create DB instance') expect(schema.capabilities?.resourceActions?.find(({name}) => name === 'create')?.label).toBe('Create DB instance')
expect(schema.capabilities?.resourceActions?.find(({name}) => name === 'update')?.label).toBe('Update DB instance') expect(schema.capabilities?.resourceActions?.find(({name}) => name === 'update')?.label).toBe('Update DB instance')
+7 -1
View File
@@ -39,6 +39,9 @@ import {AwsSesAdapter} from './adapter-aws/AwsSesAdapter'
import {AwsAppConfigAdapter} from './adapter-aws/AwsAppConfigAdapter' import {AwsAppConfigAdapter} from './adapter-aws/AwsAppConfigAdapter'
import {AwsSnsAdapter} from './adapter-aws/AwsSnsAdapter' import {AwsSnsAdapter} from './adapter-aws/AwsSnsAdapter'
import {AwsEcrAdapter} from './adapter-aws/AwsEcrAdapter' import {AwsEcrAdapter} from './adapter-aws/AwsEcrAdapter'
import {AwsCodeBuildAdapter} from './adapter-aws/AwsCodeBuildAdapter'
import {AwsCodePipelineAdapter} from './adapter-aws/AwsCodePipelineAdapter'
import {AwsCodeDeployAdapter} from './adapter-aws/AwsCodeDeployAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws' import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2' import {createEc2Service} from './services/ec2'
import {createEksService} from './services/eks' import {createEksService} from './services/eks'
@@ -61,7 +64,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsStorageAdapter(clients.s3, clients.lambda, resolveAccountId(accountId)), new AwsStorageAdapter(clients.s3, clients.lambda, resolveAccountId(accountId)),
new AwsDynamoDbAdapter(clients.dynamodb), new AwsDynamoDbAdapter(clients.dynamodb),
new AwsEksAdapter(createEksService(clients.eks)), new AwsEksAdapter(createEksService(clients.eks)),
new AwsDatabaseAdapter(createRdsService(clients.rds), clients.rds), new AwsDatabaseAdapter(createRdsService(clients.rds), clients.rds, clients.rdsData),
new AwsComputeAdapter(ec2Service), new AwsComputeAdapter(ec2Service),
new AwsNetworkingAdapter(ec2Service), new AwsNetworkingAdapter(ec2Service),
new AwsServerlessAdapter(clients.lambda, clients.eventbridge), new AwsServerlessAdapter(clients.lambda, clients.eventbridge),
@@ -80,6 +83,9 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsAppConfigAdapter(clients.appConfig), new AwsAppConfigAdapter(clients.appConfig),
new AwsSnsAdapter(clients.sns), new AwsSnsAdapter(clients.sns),
new AwsEcrAdapter(clients.ecr), new AwsEcrAdapter(clients.ecr),
new AwsCodeBuildAdapter(clients.codebuild),
new AwsCodePipelineAdapter(clients.codepipeline),
new AwsCodeDeployAdapter(clients.codedeploy, clients.lambda),
new AzureStorageAdapter(), new AzureStorageAdapter(),
new AzureServiceBusAdapter(), new AzureServiceBusAdapter(),
new AzureDatabaseAdapter(), new AzureDatabaseAdapter(),
+21
View File
@@ -448,6 +448,27 @@ describe('database snapshot routes', () => {
}) })
}) })
describe('resource lifecycle routes', () => {
test('routes start, stop, and reboot through the generic adapter lifecycle operations', async () => {
const calls: string[] = []
const app = appWithRoutes([mockAdapter('aws', {
service: 'database',
schema: awsDatabaseSchema,
start: async (id) => { calls.push(`start:${id}`) },
stop: async (id) => { calls.push(`stop:${id}`) },
reboot: async (id) => { calls.push(`reboot:${id}`) },
})])
for (const action of ['start', 'stop', 'reboot']) {
const response = await app.request(`/api/clouds/aws/services/database/resources/orders-db/${action}`, {method: 'POST'})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ok: true})
}
expect(calls).toEqual(['start:orders-db', 'stop:orders-db', 'reboot:orders-db'])
})
})
describe('cloud schema routes', () => { describe('cloud schema routes', () => {
test('returns AWS storage schema', async () => { test('returns AWS storage schema', async () => {
const res = await appWithRoutes().request('/api/clouds/aws/services/storage/schema') const res = await appWithRoutes().request('/api/clouds/aws/services/storage/schema')
+101
View File
@@ -863,6 +863,71 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
}) })
}) })
app.post('/:cloud/services/codebuild/resources/:id/builds', async (c) => {
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const body = await jsonBody<{environmentVariablesOverride?: unknown}>(c)
const overrides = body.environmentVariablesOverride
if (overrides !== undefined && (!Array.isArray(overrides) || overrides.some((item) => !item || typeof item !== 'object' || typeof (item as {name?: unknown}).name !== 'string' || typeof (item as {value?: unknown}).value !== 'string' || ((item as {type?: unknown}).type !== undefined && !['PLAINTEXT', 'PARAMETER_STORE', 'SECRETS_MANAGER'].includes((item as {type: string}).type))))) {
throw new ValidationError('environmentVariablesOverride must contain name, value, and an optional supported type')
}
return c.json(await svc(c).startCodeBuild('aws', c.req.param('id'), {environmentVariablesOverride: overrides as never}), 201)
})
})
app.get('/:cloud/services/codebuild/resources/:id/builds', async (c) => {
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).listCodeBuilds('aws', c.req.param('id'))))
})
app.get('/:cloud/services/codebuild/resources/:id/builds/:buildId', async (c) => {
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const build = await svc(c).getCodeBuild('aws', c.req.param('buildId'))
return build ? c.json(build) : c.json({error: 'Build not found'}, 404)
})
})
app.post('/:cloud/services/codepipeline/resources/:id/executions', async (c) => {
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).startPipeline('aws', c.req.param('id')), 201))
})
app.post('/:cloud/services/codedeploy/resources/:id/deployments', async (c) => {
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const body = await jsonBody<{functionName?: unknown; alias?: unknown; currentVersion?: unknown; targetVersion?: unknown; beforeAllowTrafficHook?: unknown; afterAllowTrafficHook?: unknown}>(c)
const [applicationName, deploymentGroupName] = codeDeployGroupId(c.req.param('id'))
if (typeof body.functionName !== 'string' || typeof body.alias !== 'string' || typeof body.currentVersion !== 'string' || typeof body.targetVersion !== 'string') throw new ValidationError('functionName, alias, currentVersion, and targetVersion are required')
return c.json(await svc(c).startCodeDeployDeployment('aws', applicationName, deploymentGroupName, {functionName: body.functionName, alias: body.alias, currentVersion: body.currentVersion, targetVersion: body.targetVersion, beforeAllowTrafficHook: typeof body.beforeAllowTrafficHook === 'string' ? body.beforeAllowTrafficHook : undefined, afterAllowTrafficHook: typeof body.afterAllowTrafficHook === 'string' ? body.afterAllowTrafficHook : undefined}), 201)
})
})
app.get('/:cloud/services/codedeploy/resources/:id/deployments', async (c) => {
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => { const [applicationName, deploymentGroupName] = codeDeployGroupId(c.req.param('id')); return c.json(await svc(c).listCodeDeployDeployments('aws', applicationName, deploymentGroupName)) })
})
app.get('/:cloud/services/codedeploy/deployments/:deploymentId', async (c) => {
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).getCodeDeployDeployment('aws', c.req.param('deploymentId'))))
})
app.post('/:cloud/services/codedeploy/deployments/:deploymentId/stop', async (c) => {
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => { const body = await jsonBody<{rollback?: unknown}>(c); await svc(c).stopCodeDeployDeployment('aws', c.req.param('deploymentId'), body.rollback === true); return c.json({ok: true}) })
})
app.get('/:cloud/services/codepipeline/resources/:id/executions', async (c) => {
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).listPipelineExecutions('aws', c.req.param('id'))))
})
app.get('/:cloud/services/codepipeline/resources/:id/state', async (c) => {
if (c.req.param('cloud') !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).getPipelineState('aws', c.req.param('id'))))
})
app.get('/:cloud/services/serverless/resources/:id/sqs-event-source-mappings', async (c) => { app.get('/:cloud/services/serverless/resources/:id/sqs-event-source-mappings', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
@@ -1110,6 +1175,36 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
}) })
}) })
app.post('/:cloud/services/:service/resources/:id/start', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
const serviceType = c.req.param('service') as CloudServiceType
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
await svc(c).startResource(cloud, serviceType, c.req.param('id'))
return c.json({ok: true})
})
})
app.post('/:cloud/services/:service/resources/:id/stop', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
const serviceType = c.req.param('service') as CloudServiceType
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
await svc(c).stopResource(cloud, serviceType, c.req.param('id'))
return c.json({ok: true})
})
})
app.post('/:cloud/services/:service/resources/:id/reboot', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
const serviceType = c.req.param('service') as CloudServiceType
if (!isCloudProvider(cloud) || !isServiceType(serviceType)) return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
await svc(c).rebootResource(cloud, serviceType, c.req.param('id'))
return c.json({ok: true})
})
})
app.post('/:cloud/services/notifications/resources/:id/publish', async (c) => { app.post('/:cloud/services/notifications/resources/:id/publish', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
@@ -1366,6 +1461,12 @@ function pageQuery(c: Context): PageQuery {
return {cursor: c.req.query('cursor'), limit: clampLimit(c.req.query('limit'))} return {cursor: c.req.query('cursor'), limit: clampLimit(c.req.query('limit'))}
} }
function codeDeployGroupId(id: string): [string, string] {
const separator = id.indexOf('/')
if (separator <= 0 || separator === id.length - 1) throw new ValidationError('Deployment group id must be applicationName/deploymentGroupName')
return [id.slice(0, separator), id.slice(separator + 1)]
}
async function withRuntime(c: Context, handler: () => Promise<Response>): Promise<Response> { async function withRuntime(c: Context, handler: () => Promise<Response>): Promise<Response> {
try { try {
return await handler() return await handler()
@@ -27,3 +27,25 @@ describe('CloudProxyService API Gateway CORS', () => {
}}]) }}])
}) })
}) })
describe('CloudProxyService resource lifecycle', () => {
test('forwards lifecycle actions to the registered adapter', async () => {
const calls: string[] = []
const adapter: CloudServiceAdapter = {
cloud: 'aws', service: 'apigateway', schema: awsApiGatewaySchema,
list: async () => [], get: async () => null, create: async () => ({
id: 'api', name: 'api', cloud: 'aws', service: 'apigateway', type: 'rest-api', region: null, createdAt: null, metadata: {},
}), delete: async () => {},
start: async (id) => { calls.push(`start:${id}`) },
stop: async (id) => { calls.push(`stop:${id}`) },
reboot: async (id) => { calls.push(`reboot:${id}`) },
}
const service = new CloudProxyService(new CloudAdapterRegistry([adapter]))
await service.startResource('aws', 'apigateway', 'api-123')
await service.stopResource('aws', 'apigateway', 'api-123')
await service.rebootResource('aws', 'apigateway', 'api-123')
expect(calls).toEqual(['start:api-123', 'stop:api-123', 'reboot:api-123'])
})
})
@@ -86,6 +86,9 @@ import type {
import {NotSupportedError} from '../cloud-spi/errors' import {NotSupportedError} from '../cloud-spi/errors'
import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry'
import type {StartWorkflowExecutionInput, WorkflowExecutionDetail, WorkflowExecutionHistoryPage, WorkflowExecutionSummary} from '../cloud-spi/workflowExecutions' import type {StartWorkflowExecutionInput, WorkflowExecutionDetail, WorkflowExecutionHistoryPage, WorkflowExecutionSummary} from '../cloud-spi/workflowExecutions'
import type {CodeBuildBuild, StartCodeBuildInput} from '../cloud-spi/codebuild'
import type {PipelineExecutionSummary, PipelineState} from '../cloud-spi/codepipeline'
import type {CodeDeployDeployment, StartCodeDeployDeploymentInput} from '../cloud-spi/codedeploy'
import {SERVICE_CATALOG_ENTRIES, displayNameFor, routeFor} from '../cloud-spi/serviceCatalog' import {SERVICE_CATALOG_ENTRIES, displayNameFor, routeFor} from '../cloud-spi/serviceCatalog'
import {toHttpError} from '../cloud-spi/errors' import {toHttpError} from '../cloud-spi/errors'
import {mapAwsSdkError} from '../adapter-aws/awsErrors' import {mapAwsSdkError} from '../adapter-aws/awsErrors'
@@ -482,6 +485,24 @@ export class CloudProxyService {
await this.requireAdapter(cloud, service).delete(id) await this.requireAdapter(cloud, service).delete(id)
} }
async startResource(cloud: CloudProvider, service: CloudServiceType, id: string): Promise<void> {
const adapter = this.requireAdapter(cloud, service)
if (!adapter.start) throw new NotSupportedError(`Resource start is not supported for ${cloud}/${service}`)
await adapter.start(id)
}
async stopResource(cloud: CloudProvider, service: CloudServiceType, id: string): Promise<void> {
const adapter = this.requireAdapter(cloud, service)
if (!adapter.stop) throw new NotSupportedError(`Resource stop is not supported for ${cloud}/${service}`)
await adapter.stop(id)
}
async rebootResource(cloud: CloudProvider, service: CloudServiceType, id: string): Promise<void> {
const adapter = this.requireAdapter(cloud, service)
if (!adapter.reboot) throw new NotSupportedError(`Resource reboot is not supported for ${cloud}/${service}`)
await adapter.reboot(id)
}
async encryptKms(cloud: CloudProvider, keyId: string, input: KmsEncryptInput): Promise<KmsEncryptResult> { async encryptKms(cloud: CloudProvider, keyId: string, input: KmsEncryptInput): Promise<KmsEncryptResult> {
const adapter = this.requireAdapter(cloud, 'kms') const adapter = this.requireAdapter(cloud, 'kms')
if (!adapter.encrypt) throw new NotSupportedError(`${cloud}/kms encrypt is not supported`) if (!adapter.encrypt) throw new NotSupportedError(`${cloud}/kms encrypt is not supported`)
@@ -852,6 +873,46 @@ export class CloudProxyService {
await this.workflowAdapter(cloud).stopExecution(executionArn) await this.workflowAdapter(cloud).stopExecution(executionArn)
} }
async startCodeBuild(cloud: CloudProvider, projectName: string, input: StartCodeBuildInput): Promise<CodeBuildBuild> {
return this.codeBuildAdapter(cloud).startBuild!(projectName, input)
}
async listCodeBuilds(cloud: CloudProvider, projectName: string): Promise<CodeBuildBuild[]> {
return this.codeBuildAdapter(cloud).listBuilds!(projectName)
}
async getCodeBuild(cloud: CloudProvider, id: string): Promise<CodeBuildBuild | null> {
return this.codeBuildAdapter(cloud).getBuild!(id)
}
async startPipeline(cloud: CloudProvider, id: string): Promise<{id: string}> {
return this.codePipelineAdapter(cloud).startPipeline!(id)
}
async listPipelineExecutions(cloud: CloudProvider, id: string): Promise<PipelineExecutionSummary[]> {
return this.codePipelineAdapter(cloud).listPipelineExecutions!(id)
}
async getPipelineState(cloud: CloudProvider, id: string): Promise<PipelineState> {
return this.codePipelineAdapter(cloud).getPipelineState!(id)
}
async startCodeDeployDeployment(cloud: CloudProvider, applicationName: string, deploymentGroupName: string, input: StartCodeDeployDeploymentInput): Promise<CodeDeployDeployment> {
return this.codeDeployAdapter(cloud).startCodeDeployDeployment!(applicationName, deploymentGroupName, input)
}
async listCodeDeployDeployments(cloud: CloudProvider, applicationName: string, deploymentGroupName: string): Promise<CodeDeployDeployment[]> {
return this.codeDeployAdapter(cloud).listCodeDeployDeployments!(applicationName, deploymentGroupName)
}
async getCodeDeployDeployment(cloud: CloudProvider, id: string): Promise<CodeDeployDeployment> {
return this.codeDeployAdapter(cloud).getCodeDeployDeployment!(id)
}
async stopCodeDeployDeployment(cloud: CloudProvider, id: string, rollback: boolean): Promise<void> {
await this.codeDeployAdapter(cloud).stopCodeDeployDeployment!(id, rollback)
}
private workflowAdapter(cloud: CloudProvider) { private workflowAdapter(cloud: CloudProvider) {
const adapter = this.requireAdapter(cloud, 'workflows') const adapter = this.requireAdapter(cloud, 'workflows')
if (!('startExecution' in adapter) || !('listExecutions' in adapter) || !('describeExecution' in adapter) || !('getExecutionHistory' in adapter) || !('stopExecution' in adapter)) { if (!('startExecution' in adapter) || !('listExecutions' in adapter) || !('describeExecution' in adapter) || !('getExecutionHistory' in adapter) || !('stopExecution' in adapter)) {
@@ -860,6 +921,30 @@ export class CloudProxyService {
return adapter as import('../adapter-aws/AwsStepFunctionsAdapter').AwsStepFunctionsAdapter return adapter as import('../adapter-aws/AwsStepFunctionsAdapter').AwsStepFunctionsAdapter
} }
private codeBuildAdapter(cloud: CloudProvider) {
const adapter = this.requireAdapter(cloud, 'codebuild')
if (!adapter.startBuild || !adapter.listBuilds || !adapter.getBuild) {
throw new NotSupportedError(`CodeBuild builds are not supported for ${cloud}`)
}
return adapter
}
private codePipelineAdapter(cloud: CloudProvider) {
const adapter = this.requireAdapter(cloud, 'codepipeline')
if (!adapter.startPipeline || !adapter.listPipelineExecutions || !adapter.getPipelineState) {
throw new NotSupportedError(`CodePipeline executions are not supported for ${cloud}`)
}
return adapter
}
private codeDeployAdapter(cloud: CloudProvider) {
const adapter = this.requireAdapter(cloud, 'codedeploy')
if (!adapter.startCodeDeployDeployment || !adapter.listCodeDeployDeployments || !adapter.getCodeDeployDeployment || !adapter.stopCodeDeployDeployment) {
throw new NotSupportedError(`CodeDeploy deployments are not supported for ${cloud}`)
}
return adapter
}
private requireAdapter(cloud: CloudProvider, service: CloudServiceType) { private requireAdapter(cloud: CloudProvider, service: CloudServiceType) {
const adapter = this.registry.get(cloud, service) const adapter = this.registry.get(cloud, service)
if (!adapter) throw new NotSupportedError(`No adapter registered for ${cloud}/${service}`) if (!adapter) throw new NotSupportedError(`No adapter registered for ${cloud}/${service}`)
+21
View File
@@ -6,6 +6,9 @@ import {
DescribeDBSnapshotsCommand, DescribeDBSnapshotsCommand,
DescribeOrderableDBInstanceOptionsCommand, DescribeOrderableDBInstanceOptionsCommand,
ModifyDBInstanceCommand, ModifyDBInstanceCommand,
RebootDBInstanceCommand,
StartDBInstanceCommand,
StopDBInstanceCommand,
type CreateDBInstanceCommandInput, type CreateDBInstanceCommandInput,
type DBInstance, type DBInstance,
type DBSnapshot, type DBSnapshot,
@@ -194,6 +197,24 @@ export function createRdsService(client: RDSClient = awsClients.rds) {
); );
}, },
async startInstance(identifier: string): Promise<void> {
await client.send(
new StartDBInstanceCommand({ DBInstanceIdentifier: identifier }),
);
},
async stopInstance(identifier: string): Promise<void> {
await client.send(
new StopDBInstanceCommand({ DBInstanceIdentifier: identifier }),
);
},
async rebootInstance(identifier: string): Promise<void> {
await client.send(
new RebootDBInstanceCommand({ DBInstanceIdentifier: identifier }),
);
},
async listSnapshots(instanceIdentifier?: string): Promise<RdsSnapshot[]> { async listSnapshots(instanceIdentifier?: string): Promise<RdsSnapshot[]> {
const snapshots: RdsSnapshot[] = []; const snapshots: RdsSnapshot[] = [];
let marker: string | undefined; let marker: string | undefined;
+53
View File
@@ -23,6 +23,9 @@ export const apiEndpointKeys = {
update: "clouds.services.resources.update", update: "clouds.services.resources.update",
delete: "clouds.services.resources.delete", delete: "clouds.services.resources.delete",
invoke: "clouds.services.resources.invoke", invoke: "clouds.services.resources.invoke",
start: "clouds.services.resources.start",
stop: "clouds.services.resources.stop",
reboot: "clouds.services.resources.reboot",
publish: "clouds.services.resources.notifications.publish", publish: "clouds.services.resources.notifications.publish",
createSnsSubscription: "clouds.services.resources.notifications.subscription.create", createSnsSubscription: "clouds.services.resources.notifications.subscription.create",
listSnsSubscriptions: "clouds.services.resources.notifications.subscriptions.list", listSnsSubscriptions: "clouds.services.resources.notifications.subscriptions.list",
@@ -61,6 +64,16 @@ export const apiEndpointKeys = {
describeWorkflowExecution: "clouds.services.resources.workflows.execution.describe", describeWorkflowExecution: "clouds.services.resources.workflows.execution.describe",
getWorkflowExecutionHistory: "clouds.services.resources.workflows.execution.history.get", getWorkflowExecutionHistory: "clouds.services.resources.workflows.execution.history.get",
stopWorkflowExecution: "clouds.services.resources.workflows.execution.stop", stopWorkflowExecution: "clouds.services.resources.workflows.execution.stop",
startCodeBuild: "clouds.services.resources.codebuild.build.start",
listCodeBuilds: "clouds.services.resources.codebuild.builds.list",
getCodeBuild: "clouds.services.resources.codebuild.build.get",
startCodePipeline: "clouds.services.resources.codepipeline.execution.start",
listCodePipelineExecutions: "clouds.services.resources.codepipeline.executions.list",
getCodePipelineState: "clouds.services.resources.codepipeline.state.get",
startCodeDeployDeployment: "clouds.services.resources.codedeploy.deployment.start",
listCodeDeployDeployments: "clouds.services.resources.codedeploy.deployments.list",
getCodeDeployDeployment: "clouds.services.resources.codedeploy.deployment.get",
stopCodeDeployDeployment: "clouds.services.resources.codedeploy.deployment.stop",
}, },
storage: { storage: {
objects: { objects: {
@@ -342,6 +355,18 @@ export const endpointRegistry: EndpointRegistry = new Map([
method: "POST", method: "POST",
}, },
], ],
[
apiEndpointKeys.clouds.resources.start,
{ path: "/clouds/:cloud/services/:service/resources/:id/start", method: "POST" },
],
[
apiEndpointKeys.clouds.resources.stop,
{ path: "/clouds/:cloud/services/:service/resources/:id/stop", method: "POST" },
],
[
apiEndpointKeys.clouds.resources.reboot,
{ path: "/clouds/:cloud/services/:service/resources/:id/reboot", method: "POST" },
],
[ [
apiEndpointKeys.clouds.resources.publish, apiEndpointKeys.clouds.resources.publish,
{ {
@@ -542,6 +567,34 @@ export const endpointRegistry: EndpointRegistry = new Map([
apiEndpointKeys.clouds.resources.stopWorkflowExecution, apiEndpointKeys.clouds.resources.stopWorkflowExecution,
{ path: "/clouds/:cloud/services/workflows/resources/:id/executions/:executionArn/stop", method: "POST" }, { path: "/clouds/:cloud/services/workflows/resources/:id/executions/:executionArn/stop", method: "POST" },
], ],
[
apiEndpointKeys.clouds.resources.startCodeBuild,
{ path: "/clouds/:cloud/services/codebuild/resources/:id/builds", method: "POST" },
],
[
apiEndpointKeys.clouds.resources.listCodeBuilds,
{ path: "/clouds/:cloud/services/codebuild/resources/:id/builds", method: "GET" },
],
[
apiEndpointKeys.clouds.resources.getCodeBuild,
{ path: "/clouds/:cloud/services/codebuild/resources/:id/builds/:buildId", method: "GET" },
],
[
apiEndpointKeys.clouds.resources.startCodePipeline,
{ path: "/clouds/:cloud/services/codepipeline/resources/:id/executions", method: "POST" },
],
[
apiEndpointKeys.clouds.resources.listCodePipelineExecutions,
{ path: "/clouds/:cloud/services/codepipeline/resources/:id/executions", method: "GET" },
],
[
apiEndpointKeys.clouds.resources.getCodePipelineState,
{ path: "/clouds/:cloud/services/codepipeline/resources/:id/state", method: "GET" },
],
[apiEndpointKeys.clouds.resources.startCodeDeployDeployment, {path: "/clouds/:cloud/services/codedeploy/resources/:id/deployments", method: "POST"}],
[apiEndpointKeys.clouds.resources.listCodeDeployDeployments, {path: "/clouds/:cloud/services/codedeploy/resources/:id/deployments", method: "GET"}],
[apiEndpointKeys.clouds.resources.getCodeDeployDeployment, {path: "/clouds/:cloud/services/codedeploy/deployments/:deploymentId", method: "GET"}],
[apiEndpointKeys.clouds.resources.stopCodeDeployDeployment, {path: "/clouds/:cloud/services/codedeploy/deployments/:deploymentId/stop", method: "POST"}],
[ [
apiEndpointKeys.clouds.resources.get, apiEndpointKeys.clouds.resources.get,
{ {
@@ -208,6 +208,42 @@ export async function deleteCloudResource(
); );
} }
export async function startCloudResource(
cloud: CloudProvider,
service: CloudServiceType,
id: string,
): Promise<void> {
await apiClient.call<void>(
apiEndpointKeys.clouds.resources.start,
requestOptions(cloud, service),
{ cloud, service, id },
);
}
export async function stopCloudResource(
cloud: CloudProvider,
service: CloudServiceType,
id: string,
): Promise<void> {
await apiClient.call<void>(
apiEndpointKeys.clouds.resources.stop,
requestOptions(cloud, service),
{ cloud, service, id },
);
}
export async function rebootCloudResource(
cloud: CloudProvider,
service: CloudServiceType,
id: string,
): Promise<void> {
await apiClient.call<void>(
apiEndpointKeys.clouds.resources.reboot,
requestOptions(cloud, service),
{ cloud, service, id },
);
}
export async function clearEmailInbox( export async function clearEmailInbox(
cloud: CloudProvider, cloud: CloudProvider,
signal?: AbortSignal, signal?: AbortSignal,
@@ -257,6 +293,47 @@ export interface WorkflowExecutionHistoryPage {
nextToken?: string; nextToken?: string;
} }
export interface CodeBuildEnvironmentVariableOverride {
name: string;
value: string;
type?: "PLAINTEXT" | "PARAMETER_STORE" | "SECRETS_MANAGER";
}
export interface CodeBuildBuild {
id: string;
arn?: string;
projectName: string;
buildNumber?: number;
buildStatus?: string;
currentPhase?: string;
startTime?: string;
endTime?: string;
phases: Array<{phaseType: string; phaseStatus?: string; startTime?: string; endTime?: string; contexts: Array<{statusCode?: string; message?: string}>}>;
logGroupName?: string;
logStreamName?: string;
}
export interface PipelineExecutionSummary {
id: string;
status?: string;
statusSummary?: string;
startTime?: string;
lastUpdateTime?: string;
}
export interface PipelineState {
stages: Array<{
name: string;
status?: string;
lastStatusChange?: string;
actions: Array<{name: string; status?: string; summary?: string; lastStatusChange?: string; externalExecutionId?: string; externalExecutionUrl?: string; entityUrl?: string; revisionUrl?: string; codeBuildProject?: string; codeDeployDeploymentId?: string}>;
}>;
}
export interface CodeDeployDeployment { id: string; applicationName: string; deploymentGroupName: string; status: string | null; creator: string | null; deploymentConfigName: string | null; createTime?: string; completeTime?: string; errorInformation: {code?: string; message?: string} | null; currentVersion: string | null; targetVersion: string | null; trafficPercent: number | null }
export interface StartCodeDeployDeploymentInput { functionName: string; alias: string; currentVersion: string; targetVersion: string; beforeAllowTrafficHook?: string; afterAllowTrafficHook?: string }
export interface CodeDeployDependencies { lambdaFunctions: Array<{name: string; aliases: string[]; versions: string[]}> }
export interface CreateApiGatewayLambdaRouteInput { export interface CreateApiGatewayLambdaRouteInput {
path: string; path: string;
parentResourceId?: string; parentResourceId?: string;
@@ -883,6 +960,42 @@ export async function stopWorkflowExecution(stateMachineArn: string, executionAr
); );
} }
export async function startCodeBuild(projectName: string, input: {environmentVariablesOverride?: CodeBuildEnvironmentVariableOverride[]}): Promise<CodeBuildBuild> {
const res = await apiClient.call<CodeBuildBuild, typeof input>(apiEndpointKeys.clouds.resources.startCodeBuild, requestOptions("aws", "codebuild", {body: input}), {cloud: "aws", id: projectName});
return res.data;
}
export async function listCodeBuilds(projectName: string, signal?: AbortSignal): Promise<CodeBuildBuild[]> {
const res = await apiClient.call<CodeBuildBuild[]>(apiEndpointKeys.clouds.resources.listCodeBuilds, requestOptions("aws", "codebuild", {signal}), {cloud: "aws", id: projectName});
return res.data;
}
export async function getCodeBuild(projectName: string, buildId: string, signal?: AbortSignal): Promise<CodeBuildBuild> {
const res = await apiClient.call<CodeBuildBuild>(apiEndpointKeys.clouds.resources.getCodeBuild, requestOptions("aws", "codebuild", {signal}), {cloud: "aws", id: projectName, buildId});
return res.data;
}
export async function startCodePipeline(name: string): Promise<{id: string}> {
const res = await apiClient.call<{id: string}>(apiEndpointKeys.clouds.resources.startCodePipeline, requestOptions('aws', 'codepipeline'), {cloud: 'aws', id: name});
return res.data;
}
export async function listCodePipelineExecutions(name: string, signal?: AbortSignal): Promise<PipelineExecutionSummary[]> {
const res = await apiClient.call<PipelineExecutionSummary[]>(apiEndpointKeys.clouds.resources.listCodePipelineExecutions, requestOptions('aws', 'codepipeline', {signal}), {cloud: 'aws', id: name});
return res.data;
}
export async function getCodePipelineState(name: string, signal?: AbortSignal): Promise<PipelineState> {
const res = await apiClient.call<PipelineState>(apiEndpointKeys.clouds.resources.getCodePipelineState, requestOptions('aws', 'codepipeline', {signal}), {cloud: 'aws', id: name});
return res.data;
}
export async function getCodeDeployDependencies(signal?: AbortSignal): Promise<CodeDeployDependencies> { const res = await apiClient.call<CodeDeployDependencies>(apiEndpointKeys.clouds.dependencies, requestOptions('aws', 'codedeploy', {signal}), {cloud: 'aws', service: 'codedeploy'}); return res.data }
export async function startCodeDeployDeployment(groupId: string, input: StartCodeDeployDeploymentInput): Promise<CodeDeployDeployment> { const res = await apiClient.call<CodeDeployDeployment, StartCodeDeployDeploymentInput>(apiEndpointKeys.clouds.resources.startCodeDeployDeployment, requestOptions('aws', 'codedeploy', {body: input}), {cloud: 'aws', id: groupId}); return res.data }
export async function listCodeDeployDeployments(groupId: string, signal?: AbortSignal): Promise<CodeDeployDeployment[]> { const res = await apiClient.call<CodeDeployDeployment[]>(apiEndpointKeys.clouds.resources.listCodeDeployDeployments, requestOptions('aws', 'codedeploy', {signal}), {cloud: 'aws', id: groupId}); return res.data }
export async function getCodeDeployDeployment(id: string, signal?: AbortSignal): Promise<CodeDeployDeployment> { const res = await apiClient.call<CodeDeployDeployment>(apiEndpointKeys.clouds.resources.getCodeDeployDeployment, requestOptions('aws', 'codedeploy', {signal}), {cloud: 'aws', deploymentId: id}); return res.data }
export async function stopCodeDeployDeployment(id: string, rollback: boolean): Promise<void> { await apiClient.call<void, {rollback: boolean}>(apiEndpointKeys.clouds.resources.stopCodeDeployDeployment, requestOptions('aws', 'codedeploy', {body: {rollback}}), {cloud: 'aws', deploymentId: id}) }
export async function publishNotification( export async function publishNotification(
cloud: CloudProvider, cloud: CloudProvider,
topicArn: string, topicArn: string,
@@ -26,6 +26,10 @@ const POSTGRES_DEFAULT_QUERY = `SELECT
current_database() AS database_name, current_database() AS database_name,
current_user AS username, current_user AS username,
version();`; version();`;
const MYSQL_DEFAULT_QUERY = `SELECT
DATABASE() AS database_name,
CURRENT_USER() AS username,
VERSION() AS version;`;
interface AzureSqlPanelProps { interface AzureSqlPanelProps {
cloud: CloudProvider; cloud: CloudProvider;
@@ -40,11 +44,13 @@ export function AzureSqlPanel({
}: AzureSqlPanelProps) { }: AzureSqlPanelProps) {
const accountId = useAccountId(); const accountId = useAccountId();
const serverId = resource?.id; const serverId = resource?.id;
const engine: SqlEngine = const isAwsRds = cloud === "aws" && resource?.type === "db-instance";
resource?.type === "postgres-flexible-server" ? "postgresql" : "azure-sql"; const engine: SqlEngine = isAwsRds
const defaultDatabase = engine === "postgresql" ? "postgres" : "master"; ? resource?.engine === "mysql" ? "mysql" : "postgresql"
: resource?.type === "postgres-flexible-server" ? "postgresql" : "azure-sql";
const defaultDatabase = engine === "postgresql" ? "postgres" : engine === "mysql" ? "mysql" : "master";
const defaultQuery = const defaultQuery =
engine === "postgresql" ? POSTGRES_DEFAULT_QUERY : SQL_SERVER_DEFAULT_QUERY; engine === "postgresql" ? POSTGRES_DEFAULT_QUERY : engine === "mysql" ? MYSQL_DEFAULT_QUERY : SQL_SERVER_DEFAULT_QUERY;
const administratorLogin = metadataString( const administratorLogin = metadataString(
resource?.metadata.administratorLogin, resource?.metadata.administratorLogin,
); );
@@ -100,7 +106,7 @@ export function AzureSqlPanel({
setQueryText(defaultQuery); setQueryText(defaultQuery);
}, [administratorLogin, cloud, defaultDatabase, defaultQuery, serverId]); }, [administratorLogin, cloud, defaultDatabase, defaultQuery, serverId]);
if (cloud !== "azure") return null; if (cloud !== "azure" && !isAwsRds) return null;
if (!serverId) { if (!serverId) {
return ( return (
@@ -115,7 +121,7 @@ export function AzureSqlPanel({
function connect(event: FormEvent<HTMLFormElement>) { function connect(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!username || !password) return; if (!isAwsRds && (!username || !password)) return;
queryMut.reset(); queryMut.reset();
loadDatabases(); loadDatabases();
} }
@@ -181,12 +187,12 @@ export function AzureSqlPanel({
<div className="sql-connection-title"> <div className="sql-connection-title">
<Plug size={16} /> <Plug size={16} />
<span> <span>
<small>{engine === "postgresql" ? "POSTGRESQL DATA PLANE" : "SQL DATA PLANE"}</small> <small>{isAwsRds ? "RDS DATA API" : engine === "postgresql" ? "POSTGRESQL DATA PLANE" : "SQL DATA PLANE"}</small>
<strong>{resource.name}</strong> <strong>{resource.name}</strong>
<em>{metadataString(resource.metadata.fullyQualifiedDomainName)}</em> <em>{isAwsRds ? `${resource.engine} · ${resource.status ?? "unknown"}` : metadataString(resource.metadata.fullyQualifiedDomainName)}</em>
</span> </span>
</div> </div>
<label> {!isAwsRds && <label>
<span>Username</span> <span>Username</span>
<input <input
className="input" className="input"
@@ -195,8 +201,8 @@ export function AzureSqlPanel({
disabled={connected || !runtimeReachable} disabled={connected || !runtimeReachable}
autoComplete="username" autoComplete="username"
/> />
</label> </label>}
<label> {!isAwsRds && <label>
<span>Password</span> <span>Password</span>
<input <input
className="input" className="input"
@@ -206,7 +212,7 @@ export function AzureSqlPanel({
disabled={connected || !runtimeReachable} disabled={connected || !runtimeReachable}
autoComplete="current-password" autoComplete="current-password"
/> />
</label> </label>}
{connected ? ( {connected ? (
<button className="button" type="button" onClick={disconnect}> <button className="button" type="button" onClick={disconnect}>
<Unplug size={14} /> <Unplug size={14} />
@@ -219,8 +225,7 @@ export function AzureSqlPanel({
disabled={ disabled={
!runtimeReachable || !runtimeReachable ||
databasesMut.isPending || databasesMut.isPending ||
!username || (!isAwsRds && (!username || !password))
!password
} }
> >
<Plug size={14} /> <Plug size={14} />
@@ -228,7 +233,9 @@ export function AzureSqlPanel({
</button> </button>
)} )}
<small className="sql-credential-note"> <small className="sql-credential-note">
Credentials remain in this page only and are sent to the local API per request. {isAwsRds
? "Queries use Floci's local RDS Data API credentials. Database passwords are not sent by the console."
: "Credentials remain in this page only and are sent to the local API per request."}
</small> </small>
{connectionError && <div className="form-error">{connectionError}</div>} {connectionError && <div className="form-error">{connectionError}</div>}
</form> </form>
@@ -236,7 +243,7 @@ export function AzureSqlPanel({
{!connected ? ( {!connected ? (
<div className="empty compact sql-connect-empty"> <div className="empty compact sql-connect-empty">
<h3>Connect to browse SQL data</h3> <h3>Connect to browse SQL data</h3>
<p>Use the administrator login configured for this local engine.</p> <p>{isAwsRds ? "Connect through Floci's local RDS Data API to browse databases and run SQL." : "Use the administrator login configured for this local engine."}</p>
</div> </div>
) : ( ) : (
<div className="sql-explorer"> <div className="sql-explorer">
@@ -329,6 +336,8 @@ export function AzureSqlPanel({
title={selectedDatabase} title={selectedDatabase}
detail={engine === "postgresql" detail={engine === "postgresql"
? "PostgreSQL SQL against selected database" ? "PostgreSQL SQL against selected database"
: engine === "mysql"
? "MySQL SQL against selected database"
: "T-SQL against selected database"} : "T-SQL against selected database"}
/> />
<div className="sql-query-editor"> <div className="sql-query-editor">
@@ -454,12 +463,16 @@ function metadataString(value: unknown): string {
function tablePreviewQuery(table: SqlTable, engine: SqlEngine): string { function tablePreviewQuery(table: SqlTable, engine: SqlEngine): string {
const tableName = `${quoteIdentifier(table.schema, engine)}.${quoteIdentifier(table.name, engine)}`; const tableName = `${quoteIdentifier(table.schema, engine)}.${quoteIdentifier(table.name, engine)}`;
return engine === 'postgresql' return engine === 'postgresql'
? `SELECT * FROM ${tableName} LIMIT 100;`
: engine === 'mysql'
? `SELECT * FROM ${tableName} LIMIT 100;` ? `SELECT * FROM ${tableName} LIMIT 100;`
: `SELECT TOP (100) * FROM ${tableName};`; : `SELECT TOP (100) * FROM ${tableName};`;
} }
function quoteIdentifier(value: string, engine: SqlEngine): string { function quoteIdentifier(value: string, engine: SqlEngine): string {
return engine === "postgresql" return engine === "mysql"
? `\`${value.replace(/`/g, "``")}\``
: engine === "postgresql"
? `"${value.replace(/"/g, '""')}"` ? `"${value.replace(/"/g, '""')}"`
: `[${value.replace(/]/g, "]]")}]`; : `[${value.replace(/]/g, "]]")}]`;
} }
@@ -0,0 +1,46 @@
import {useEffect, useState} from 'react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {ExternalLink, Play, Plus, RefreshCw, X} from 'lucide-react'
import {getCodeBuild, listCodeBuilds, startCodeBuild, type CodeBuildBuild, type CodeBuildEnvironmentVariableOverride} from '@/api/cloudProxyClient'
import {formatDateTime} from '@/lib/format'
import type {CloudResource} from '@/types/resource'
interface CodeBuildPanelProps { resource?: CloudResource; runtimeReachable: boolean }
export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps) {
const client = useQueryClient()
const [startOpen, setStartOpen] = useState(false)
const [overrides, setOverrides] = useState<CodeBuildEnvironmentVariableOverride[]>([])
const [selectedId, setSelectedId] = useState<string>()
const enabled = Boolean(resource && runtimeReachable)
const buildsKey = ['codebuild-builds', resource?.id]
const builds = useQuery({queryKey: buildsKey, queryFn: ({signal}) => listCodeBuilds(resource!.id, signal), enabled, refetchInterval: (query) => query.state.data?.some((build) => active(build.buildStatus)) ? 2_000 : false})
const detail = useQuery({queryKey: ['codebuild-build', resource?.id, selectedId], queryFn: ({signal}) => getCodeBuild(resource!.id, selectedId!, signal), enabled: enabled && Boolean(selectedId), refetchInterval: (query) => active(query.state.data?.buildStatus) ? 2_000 : false})
const start = useMutation({mutationFn: () => startCodeBuild(resource!.id, {environmentVariablesOverride: overrides.length ? overrides : undefined}), onSuccess: (build) => { setSelectedId(build.id); setStartOpen(false); setOverrides([]); void client.invalidateQueries({queryKey: buildsKey}) }})
useEffect(() => { setSelectedId(undefined); setStartOpen(false); setOverrides([]) }, [resource?.id])
if (!resource) return null
const selected = detail.data ?? builds.data?.find((build) => build.id === selectedId)
return <section className="table-panel">
<div className="dynamic-stage-header"><div><p className="eyebrow">Builds</p><h3>Run and inspect builds</h3></div><div className="header-actions"><button className="button" type="button" disabled={!enabled || builds.isFetching} onClick={() => builds.refetch()}><RefreshCw size={13}/>{builds.isFetching ? 'Refreshing' : 'Refresh'}</button><button className="button primary" type="button" disabled={!enabled} onClick={() => setStartOpen(true)}><Play size={13}/>Start build</button></div></div>
{startOpen && <StartBuildDialog overrides={overrides} pending={start.isPending} error={start.error} onChange={setOverrides} onCancel={() => setStartOpen(false)} onStart={() => start.mutate()}/>}
{builds.isError && <p className="error-text compact-text">{message(builds.error, 'Unable to load builds.')}</p>}
<div className="workflow-execution-list"><div className="workflow-execution-list-heading"><h4>Build history</h4><span>{builds.data?.length ?? 0}</span></div><div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Build</th><th>Status</th><th>Phase</th><th>Started</th></tr></thead><tbody>{(builds.data ?? []).map((build) => <tr key={build.id} onClick={() => setSelectedId(build.id)} style={{cursor: 'pointer'}}><td><code>#{build.buildNumber ?? build.id}</code></td><td>{build.buildStatus ?? '-'}</td><td>{build.currentPhase ?? '-'}</td><td>{formatDateTime(build.startTime) ?? '-'}</td></tr>)}</tbody></table></div>{!builds.isLoading && builds.data?.length === 0 && <p className="muted compact-text">No builds yet.</p>}</div>
{selectedId && <BuildDetail build={selected} loading={detail.isLoading} error={detail.error}/>}
</section>
}
function StartBuildDialog({overrides, pending, error, onChange, onCancel, onStart}: {overrides: CodeBuildEnvironmentVariableOverride[]; pending: boolean; error: unknown; onChange: (values: CodeBuildEnvironmentVariableOverride[]) => void; onCancel: () => void; onStart: () => void}) {
return <div className="workflow-confirmation" role="dialog" aria-labelledby="start-build-title"><div className="dynamic-stage-header"><div><strong id="start-build-title">Start build</strong><p className="muted compact-text">Environment variable overrides apply only to this build.</p></div><button className="icon-btn" type="button" aria-label="Close" disabled={pending} onClick={onCancel}><X size={14}/></button></div>{overrides.map((override, index) => <div className="input-row" key={index}><input className="input" aria-label={`Variable name ${index + 1}`} placeholder="Name" value={override.name} onChange={(event) => onChange(replace(overrides, index, {...override, name: event.target.value}))}/><input className="input" aria-label={`Variable value ${index + 1}`} placeholder="Value" value={override.value} onChange={(event) => onChange(replace(overrides, index, {...override, value: event.target.value}))}/><button className="button compact" type="button" disabled={pending} onClick={() => onChange(overrides.filter((_, item) => item !== index))}>Remove</button></div>)}<button className="button compact" type="button" disabled={pending} onClick={() => onChange([...overrides, {name: '', value: '', type: 'PLAINTEXT'}])}><Plus size={13}/>Add override</button>{Boolean(error) && <p className="error-text compact-text">{message(error, 'Unable to start build.')}</p>}<div><button className="button primary compact" type="button" disabled={pending || overrides.some((item) => !item.name.trim())} onClick={onStart}>{pending ? 'Starting' : 'Start build'}</button><button className="button compact" type="button" disabled={pending} onClick={onCancel}>Cancel</button></div></div>
}
function BuildDetail({build, loading, error}: {build?: CodeBuildBuild; loading: boolean; error: unknown}) {
if (loading) return <p className="muted compact-text">Loading build details...</p>
if (error) return <p className="error-text compact-text">{message(error, 'Unable to load build details.')}</p>
if (!build) return null
const logsHref = build.logGroupName ? `/cloud-explorer/aws/logs?resource=${encodeURIComponent(build.logGroupName)}` : undefined
return <section className="workflow-execution-detail"><div className="dynamic-stage-header"><div><p className="eyebrow">Build detail</p><h3>#{build.buildNumber ?? build.id}</h3><p className="muted compact-text">{build.buildStatus ?? 'Unknown'}{build.endTime ? `, finished ${formatDateTime(build.endTime)}` : ''}</p></div>{logsHref && <a className="button compact" href={logsHref}><ExternalLink size={12}/>Open logs{build.logStreamName ? `: ${build.logStreamName}` : ''}</a>}</div><ol className="workflow-execution-history">{build.phases.map((phase, index) => <li key={`${phase.phaseType}-${index}`}><div><strong>{phase.phaseType}</strong><span>{phase.phaseStatus ?? 'Unknown'}</span></div><time>{formatDateTime(phase.startTime) ?? '-'}{phase.endTime ? ` to ${formatDateTime(phase.endTime)}` : ''}</time>{phase.contexts.map((context, contextIndex) => <p className="muted compact-text" key={contextIndex}>{context.statusCode}: {context.message}</p>)}</li>)}</ol></section>
}
function replace<T>(items: T[], index: number, value: T): T[] { return items.map((item, itemIndex) => itemIndex === index ? value : item) }
function active(status?: string): boolean { return status === 'IN_PROGRESS' }
function message(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback }
@@ -0,0 +1,63 @@
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {Ban, Play, RefreshCw, RotateCcw} from 'lucide-react'
import {useState} from 'react'
import {getCodeDeployDependencies, listCodeDeployDeployments, startCodeDeployDeployment, stopCodeDeployDeployment, type CodeDeployDependencies} from '@/api/cloudProxyClient'
import {formatDateTime} from '@/lib/format'
import type {CloudResource} from '@/types/resource'
interface CreateProps { isSubmitting: boolean; submitError: string | null; onCancel: () => void; onSubmit: (values: Record<string, unknown>) => void }
export function CodeDeployCreatePanel({isSubmitting, submitError, onCancel, onSubmit}: CreateProps) {
const [applicationName, setApplicationName] = useState('')
const [deploymentGroupName, setDeploymentGroupName] = useState('')
const [serviceRoleArn, setServiceRoleArn] = useState('')
const [deploymentConfig, setDeploymentConfig] = useState('AllAtOnce')
const [rollbackOnFailure, setRollbackOnFailure] = useState(true)
return <form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); onSubmit({applicationName, deploymentGroupName, serviceRoleArn, deploymentConfig, rollbackOnFailure}) }}>
<div className="dynamic-stage-header"><div><p className="eyebrow">Lambda deployment</p><h3>Create application and deployment group</h3><p className="muted compact-text">The group controls the traffic-shift policy and rollback on deployment failure.</p></div></div>
<label>Application name<input className="input" required value={applicationName} onChange={(event) => setApplicationName(event.target.value)}/></label>
<label>Deployment group name<input className="input" required value={deploymentGroupName} onChange={(event) => setDeploymentGroupName(event.target.value)}/></label>
<label>CodeDeploy service role ARN<input className="input" required value={serviceRoleArn} onChange={(event) => setServiceRoleArn(event.target.value)} placeholder="arn:aws:iam::000000000000:role/CodeDeployRole"/></label>
<label>Traffic configuration<select className="input" value={deploymentConfig} onChange={(event) => setDeploymentConfig(event.target.value)}><option value="AllAtOnce">All at once</option><option value="Canary10Percent5Minutes">Canary 10% then 5 minutes</option><option value="Canary10Percent30Minutes">Canary 10% then 30 minutes</option><option value="Linear10PercentEvery1Minute">Linear 10% every minute</option><option value="Linear10PercentEvery10Minutes">Linear 10% every 10 minutes</option></select></label>
<label className="checkbox-row"><input type="checkbox" checked={rollbackOnFailure} onChange={(event) => setRollbackOnFailure(event.target.checked)}/> Roll back automatically on deployment failure</label>
{submitError && <p className="error-text compact-text">{submitError}</p>}
<div className="form-actions"><button className="button" type="button" disabled={isSubmitting} onClick={onCancel}>Cancel</button><button className="button primary" type="submit" disabled={isSubmitting}>{isSubmitting ? 'Creating' : 'Create deployment group'}</button></div>
</form>
}
export function CodeDeployPanel({resource, runtimeReachable}: {resource?: CloudResource; runtimeReachable: boolean}) {
const client = useQueryClient()
const [functionName, setFunctionName] = useState('')
const [alias, setAlias] = useState('')
const [currentVersion, setCurrentVersion] = useState('')
const [targetVersion, setTargetVersion] = useState('')
const [beforeHook, setBeforeHook] = useState('')
const [afterHook, setAfterHook] = useState('')
const enabled = Boolean(resource && runtimeReachable)
const dependencies = useQuery({queryKey: ['codedeploy-dependencies'], queryFn: ({signal}) => getCodeDeployDependencies(signal), enabled})
const deploymentsKey = ['codedeploy-deployments', resource?.id]
const deployments = useQuery({queryKey: deploymentsKey, queryFn: ({signal}) => listCodeDeployDeployments(resource!.id, signal), enabled, refetchInterval: (query) => query.state.data?.some((deployment) => active(deployment.status)) ? 2_000 : false})
const start = useMutation({mutationFn: () => startCodeDeployDeployment(resource!.id, {functionName, alias, currentVersion, targetVersion, ...(beforeHook ? {beforeAllowTrafficHook: beforeHook} : {}), ...(afterHook ? {afterAllowTrafficHook: afterHook} : {})}), onSuccess: () => void client.invalidateQueries({queryKey: deploymentsKey})})
const stop = useMutation({mutationFn: ({id, rollback}: {id: string; rollback: boolean}) => stopCodeDeployDeployment(id, rollback), onSuccess: () => void client.invalidateQueries({queryKey: deploymentsKey})})
const selected = dependencies.data?.lambdaFunctions.find((item) => item.name === functionName)
if (!resource) return null
return <section className="table-panel">
<div className="dynamic-stage-header"><div><p className="eyebrow">Lambda release</p><h3>Deploy a version to an alias</h3><p className="muted compact-text">{String(resource.metadata.applicationName)} / {resource.name}</p></div><button className="button" type="button" disabled={!enabled || deployments.isFetching} onClick={() => void deployments.refetch()}><RefreshCw size={13}/>Refresh</button></div>
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); start.mutate() }}>
<label>Function<select className="input" required value={functionName} onChange={(event) => { setFunctionName(event.target.value); setAlias(''); setCurrentVersion(''); setTargetVersion('') }}><option value="">Select Lambda function</option>{(dependencies.data?.lambdaFunctions ?? []).map((item) => <option key={item.name} value={item.name}>{item.name}</option>)}</select></label>
<label>Alias<select className="input" required value={alias} onChange={(event) => setAlias(event.target.value)} disabled={!selected}><option value="">Select alias</option>{selected?.aliases.map((item) => <option key={item} value={item}>{item}</option>)}</select></label>
<label>Current version<select className="input" required value={currentVersion} onChange={(event) => setCurrentVersion(event.target.value)} disabled={!selected}><option value="">Select current version</option>{selected?.versions.map((item) => <option key={item} value={item}>{item}</option>)}</select></label>
<label>Target version<select className="input" required value={targetVersion} onChange={(event) => setTargetVersion(event.target.value)} disabled={!selected}><option value="">Select target version</option>{selected?.versions.map((item) => <option key={item} value={item}>{item}</option>)}</select></label>
<label>BeforeAllowTraffic hook (optional)<HookPicker dependencies={dependencies.data} value={beforeHook} onChange={setBeforeHook}/></label>
<label>AfterAllowTraffic hook (optional)<HookPicker dependencies={dependencies.data} value={afterHook} onChange={setAfterHook}/></label>
{start.isError && <p className="error-text compact-text">{message(start.error)}</p>}
<div className="form-actions"><button className="button primary" type="submit" disabled={!enabled || start.isPending}><Play size={13}/>{start.isPending ? 'Starting' : 'Start deployment'}</button></div>
</form>
{deployments.isError && <p className="error-text compact-text">{message(deployments.error)}</p>}
<div className="workflow-execution-list"><div className="workflow-execution-list-heading"><h4>Deployment group history</h4><span>{deployments.data?.length ?? 0}</span></div><div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Deployment</th><th>Status</th><th>Traffic</th><th>Versions</th><th>Started</th><th>Actions</th></tr></thead><tbody>{(deployments.data ?? []).map((deployment) => <tr key={deployment.id}><td><code>{deployment.id}</code>{deployment.errorInformation?.message && <p className="error-text compact-text">{deployment.errorInformation.message}</p>}</td><td>{deployment.status ?? '-'}</td><td>{deployment.trafficPercent === null ? '-' : `${deployment.trafficPercent}%`}</td><td>{deployment.currentVersion ?? '-'} to {deployment.targetVersion ?? '-'}</td><td>{formatDateTime(deployment.createTime) ?? '-'}</td><td>{active(deployment.status) && <><button className="button compact danger" type="button" disabled={stop.isPending} onClick={() => stop.mutate({id: deployment.id, rollback: false})}><Ban size={12}/>Stop</button><button className="button compact" type="button" disabled={stop.isPending} onClick={() => stop.mutate({id: deployment.id, rollback: true})}><RotateCcw size={12}/>Stop and roll back</button></>}</td></tr>)}</tbody></table></div>{!deployments.isLoading && deployments.data?.length === 0 && <p className="muted compact-text">No deployments yet.</p>}</div>
</section>
}
function HookPicker({dependencies, value, onChange}: {dependencies?: CodeDeployDependencies; value: string; onChange: (value: string) => void}) { return <select className="input" value={value} onChange={(event) => onChange(event.target.value)}><option value="">No hook</option>{(dependencies?.lambdaFunctions ?? []).map((item) => <option key={item.name} value={item.name}>{item.name}</option>)}</select> }
function active(status: string | null): boolean { return status === 'Created' || status === 'Queued' || status === 'InProgress' }
function message(error: unknown): string { return error instanceof Error ? error.message : 'Unable to complete the CodeDeploy operation.' }
@@ -0,0 +1,44 @@
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {ExternalLink, Play, RefreshCw} from 'lucide-react'
import {getCodePipelineState, listCodePipelineExecutions, startCodePipeline, type PipelineState} from '@/api/cloudProxyClient'
import {formatDateTime} from '@/lib/format'
import type {CloudResource} from '@/types/resource'
interface CodePipelinePanelProps { resource?: CloudResource; runtimeReachable: boolean }
export function CodePipelinePanel({resource, runtimeReachable}: CodePipelinePanelProps) {
const client = useQueryClient()
const enabled = Boolean(resource && runtimeReachable)
const executionsKey = ['codepipeline-executions', resource?.id]
const executions = useQuery({queryKey: executionsKey, queryFn: ({signal}) => listCodePipelineExecutions(resource!.id, signal), enabled, refetchInterval: (query) => query.state.data?.some((execution) => active(execution.status)) ? 2_000 : false})
const state = useQuery({queryKey: ['codepipeline-state', resource?.id], queryFn: ({signal}) => getCodePipelineState(resource!.id, signal), enabled, refetchInterval: (query) => query.state.data && activeState(query.state.data) ? 2_000 : false})
const start = useMutation({mutationFn: () => startCodePipeline(resource!.id), onSuccess: () => { void client.invalidateQueries({queryKey: executionsKey}); void client.invalidateQueries({queryKey: ['codepipeline-state', resource?.id]}) }})
if (!resource) return null
return <section className="table-panel">
<div className="dynamic-stage-header"><div><p className="eyebrow">Pipeline execution</p><h3>Run and inspect stages</h3><p className="muted compact-text">Local actions: S3 source, CodeBuild, and S3 deploy.</p></div><div className="header-actions"><button className="button" type="button" disabled={!enabled || executions.isFetching || state.isFetching} onClick={() => { void executions.refetch(); void state.refetch() }}><RefreshCw size={13}/>Refresh</button><button className="button primary" type="button" disabled={!enabled || start.isPending} onClick={() => start.mutate()}><Play size={13}/>{start.isPending ? 'Starting' : 'Release change'}</button></div></div>
{start.isError && <p className="error-text compact-text">{message(start.error, 'Unable to start pipeline execution.')}</p>}
{(executions.isError || state.isError) && <p className="error-text compact-text">{message(executions.error ?? state.error, 'Unable to load pipeline execution state.')}</p>}
<div className="workflow-execution-list"><div className="workflow-execution-list-heading"><h4>Execution history</h4><span>{executions.data?.length ?? 0}</span></div><div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Execution</th><th>Status</th><th>Started</th><th>Updated</th></tr></thead><tbody>{(executions.data ?? []).map((execution) => <tr key={execution.id}><td><code>{execution.id}</code>{execution.statusSummary && <p className="muted compact-text">{execution.statusSummary}</p>}</td><td>{execution.status ?? '-'}</td><td>{formatDateTime(execution.startTime) ?? '-'}</td><td>{formatDateTime(execution.lastUpdateTime) ?? '-'}</td></tr>)}</tbody></table></div>{!executions.isLoading && executions.data?.length === 0 && <p className="muted compact-text">No executions yet.</p>}</div>
<PipelineTimeline state={state.data} loading={state.isLoading}/>
</section>
}
function PipelineTimeline({state, loading}: {state?: PipelineState; loading: boolean}) {
if (loading) return <p className="muted compact-text">Loading stage state...</p>
if (!state) return null
return <section className="workflow-execution-detail"><div className="dynamic-stage-header"><div><p className="eyebrow">Current pipeline state</p><h3>Stages and actions</h3></div></div><ol className="workflow-execution-history">{state.stages.map((stage) => <li key={stage.name}><div><strong>{stage.name}</strong><span>{stage.status ?? 'Unknown'}</span></div><time>{formatDateTime(stage.lastStatusChange) ?? '-'}</time>{stage.actions.map((action) => <div className="workflow-action" key={action.name}><strong>{action.name}</strong><span>{action.status ?? 'Unknown'}</span>{action.summary && <p className="muted compact-text">{action.summary}</p>}<ActionLinks action={action}/></div>)}</li>)}</ol></section>
}
function ActionLinks({action}: {action: PipelineState['stages'][number]['actions'][number]}) {
const links = [
action.codeBuildProject ? {href: `/cloud-explorer/aws/codebuild?resource=${encodeURIComponent(action.codeBuildProject)}`, label: 'Open CodeBuild'} : undefined,
action.codeDeployDeploymentId ? {href: `/cloud-explorer/aws/codedeploy?deployment=${encodeURIComponent(action.codeDeployDeploymentId)}`, label: 'Open CodeDeploy'} : undefined,
action.externalExecutionUrl ? {href: action.externalExecutionUrl, label: 'Execution details'} : undefined,
action.entityUrl ? {href: action.entityUrl, label: 'Action details'} : undefined,
].filter((item): item is {href: string; label: string} => Boolean(item))
return <>{links.map((link) => <a className="button compact" key={`${link.label}:${link.href}`} href={link.href}><ExternalLink size={12}/>{link.label}</a>)}</>
}
function active(status?: string): boolean { return status === 'InProgress' || status === 'Stopping' }
function activeState(state: PipelineState): boolean { return state.stages.some((stage) => active(stage.status) || stage.actions.some((action) => active(action.status))) }
function message(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback }
@@ -63,6 +63,9 @@ import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
import { TargetGroupPanel } from "@/components/TargetGroupPanel"; import { TargetGroupPanel } from "@/components/TargetGroupPanel";
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel"; import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger"; import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
import {CodeDeployCreatePanel, CodeDeployPanel} from "@/components/CodeDeployPanel";
import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel"; import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel";
import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate"; import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate";
import {workflowStudioPath} from "@/lib/workflowStudio"; import {workflowStudioPath} from "@/lib/workflowStudio";
@@ -831,6 +834,8 @@ export function DynamicResourceView({
onCancel={() => setCreateOpen(false)} onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate({values})} onSubmit={(values) => createMut.mutate({values})}
/> />
) : service === "codedeploy" && cloud === "aws" ? (
<CodeDeployCreatePanel isSubmitting={createMut.isPending} submitError={createMut.error instanceof Error ? createMut.error.message : null} onCancel={() => setCreateOpen(false)} onSubmit={(values) => createMut.mutate({values})}/>
) : ( ) : (
<DynamicFormRenderer <DynamicFormRenderer
key={`${cloud}:${service}:create`} key={`${cloud}:${service}:create`}
@@ -1004,6 +1009,15 @@ export function DynamicResourceView({
{service === "workflows" && cloud === "aws" && ( {service === "workflows" && cloud === "aws" && (
<WorkflowExecutionDebugger resource={activeSelected} runtimeReachable={canUseRuntime} /> <WorkflowExecutionDebugger resource={activeSelected} runtimeReachable={canUseRuntime} />
)} )}
{service === "codebuild" && cloud === "aws" && (
<CodeBuildPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "codepipeline" && cloud === "aws" && (
<CodePipelinePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "codedeploy" && cloud === "aws" && (
<CodeDeployPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{editingResource && schema.updateFields && ( {editingResource && schema.updateFields && (
<div <div
className="modal-overlay" className="modal-overlay"
@@ -1,4 +1,5 @@
import {useState} from 'react' import {useState} from 'react'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import { K8sEngineDetails } from "@/features/k8s/K8sEngineDetails"; import { K8sEngineDetails } from "@/features/k8s/K8sEngineDetails";
import { LogsExplorerPanel } from "@/components/LogsExplorerPanel"; import { LogsExplorerPanel } from "@/components/LogsExplorerPanel";
import { LogsQueryPanel } from "@/components/LogsQueryPanel"; import { LogsQueryPanel } from "@/components/LogsQueryPanel";
@@ -6,6 +7,8 @@ import type { CloudProvider } from "@/types/cloud";
import type { CloudResource, StorageObject } from "@/types/resource"; import type { CloudResource, StorageObject } from "@/types/resource";
import {formatBytes} from "@/lib/format"; import {formatBytes} from "@/lib/format";
import {CopyValueButton} from "@/components/CopyValueButton"; import {CopyValueButton} from "@/components/CopyValueButton";
import {rebootCloudResource, startCloudResource, stopCloudResource} from "@/api/cloudProxyClient";
import {cloudQueryKeys} from "@/api/queries/cloudQueries";
interface ResourceInspectorProps { interface ResourceInspectorProps {
resource?: CloudResource; resource?: CloudResource;
@@ -149,9 +152,9 @@ export function ResourceInspector({
)} )}
</section> </section>
{isDatabase && ( {isDatabase && (
<DatabaseConnectionsSection metadata={resource.metadata} engine={resource.engine} /> <DatabaseConnectionsSection metadata={resource.metadata} engine={resource.engine} status={resource.status} />
)} )}
{isAwsDatabase && <DatabaseLifecycleSection status={resource.status} />} {isAwsDatabase && <DatabaseLifecycleSection resource={resource} />}
{isDatabase && !isAwsDatabase && ( {isDatabase && !isAwsDatabase && (
<ProviderDatabaseSection cloud={resource.cloud} /> <ProviderDatabaseSection cloud={resource.cloud} />
)} )}
@@ -283,9 +286,11 @@ function ProviderDatabaseSection({ cloud }: { cloud: string }) {
function DatabaseConnectionsSection({ function DatabaseConnectionsSection({
metadata, metadata,
engine, engine,
status,
}: { }: {
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
engine?: string | null; engine?: string | null;
status?: string | null;
}) { }) {
const endpoint = getRecordMetadata(metadata.endpoint); const endpoint = getRecordMetadata(metadata.endpoint);
const subnetGroup = getRecordMetadata(metadata.subnetGroup); const subnetGroup = getRecordMetadata(metadata.subnetGroup);
@@ -299,6 +304,7 @@ function DatabaseConnectionsSection({
return ( return (
<section className="inspector-section"> <section className="inspector-section">
<p className="metric-label">Connections</p> <p className="metric-label">Connections</p>
<DatabaseConnectionHealth status={status} hasEndpoint={Boolean(address && port !== null)} />
<div className="inspector-grid compact-grid"> <div className="inspector-grid compact-grid">
<InspectorItem label="Endpoint" value={address ?? "-"} /> <InspectorItem label="Endpoint" value={address ?? "-"} />
<InspectorItem <InspectorItem
@@ -328,6 +334,19 @@ function DatabaseConnectionsSection({
); );
} }
function DatabaseConnectionHealth({status, hasEndpoint}: {status?: string | null; hasEndpoint: boolean}) {
const normalized = status?.toLowerCase()
const state = normalized === 'available' && hasEndpoint
? {label: 'Ready', detail: 'The provider reports this instance as available with a connection endpoint.'}
: normalized === 'failed'
? {label: 'Unavailable', detail: 'The provider reports the backing database as failed.'}
: !hasEndpoint
? {label: 'Endpoint unavailable', detail: 'The provider has not returned a usable database endpoint.'}
: {label: status ?? 'Unknown', detail: 'Connection readiness follows the provider-reported instance status.'}
return <p className="muted compact-text" role="status"><strong>Connection health: {state.label}.</strong> {state.detail}</p>
}
function DatabaseConnectionCommand({ engine, address, port, database, username }: { engine: string; address: string; port: number; database: string; username: string }) { function DatabaseConnectionCommand({ engine, address, port, database, username }: { engine: string; address: string; port: number; database: string; username: string }) {
const host = address.startsWith('172.') ? 'localhost' : address const host = address.startsWith('172.') ? 'localhost' : address
const command = engine.includes('postgres') const command = engine.includes('postgres')
@@ -343,22 +362,40 @@ function DatabaseConnectionCommand({ engine, address, port, database, username }
return <div className="metadata-block"><p className="metric-label">Terminal connection</p><p className="copy-value mono">{command}<CopyValueButton value={command} label="Connection command" /></p><p className="metric-label">Client connection URI</p><p className="copy-value mono">{uri}<CopyValueButton value={uri} label="Connection URI" /></p><p className="muted compact-text">Paste the URI into a compatible database client, then replace the password placeholder. The password is never retained by the console.</p></div> return <div className="metadata-block"><p className="metric-label">Terminal connection</p><p className="copy-value mono">{command}<CopyValueButton value={command} label="Connection command" /></p><p className="metric-label">Client connection URI</p><p className="copy-value mono">{uri}<CopyValueButton value={uri} label="Connection URI" /></p><p className="muted compact-text">Paste the URI into a compatible database client, then replace the password placeholder. The password is never retained by the console.</p></div>
} }
function DatabaseLifecycleSection({ status }: { status?: string | null }) { function DatabaseLifecycleSection({resource}: {resource: CloudResource}) {
const queryClient = useQueryClient()
const lifecycle = useMutation({
mutationFn: async (action: "start" | "stop" | "reboot") => {
if (action === "start") return startCloudResource(resource.cloud, resource.service, resource.id)
if (action === "stop") return stopCloudResource(resource.cloud, resource.service, resource.id)
return rebootCloudResource(resource.cloud, resource.service, resource.id)
},
onSuccess: () => {
void queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources(resource.cloud, resource.service)})
void queryClient.invalidateQueries({queryKey: cloudQueryKeys.resource(resource.cloud, resource.service, resource.id)})
},
})
const status = resource.status?.toLowerCase()
const isPending = lifecycle.isPending
return ( return (
<section className="inspector-section"> <section className="inspector-section">
<p className="metric-label">Lifecycle</p> <p className="metric-label">Lifecycle</p>
<div className="lifecycle-actions"> <div className="lifecycle-actions">
<button className="button compact" type="button" disabled> <button className="button compact" type="button" disabled={isPending || status !== "stopped"} onClick={() => lifecycle.mutate("start")}>
Start Start
</button> </button>
<button className="button compact" type="button" disabled> <button className="button compact" type="button" disabled={isPending || status !== "available"} onClick={() => lifecycle.mutate("stop")}>
Stop Stop
</button> </button>
<button className="button compact" type="button" disabled={isPending || status !== "available"} onClick={() => lifecycle.mutate("reboot")}>
Reboot
</button>
</div> </div>
<p className="muted compact-text"> <p className="muted compact-text">
Current status: {status ?? "unknown"}. Start and stop are not supported Current status: {resource.status ?? "unknown"}.
by the local Floci RDS runtime.
</p> </p>
{lifecycle.error && <p className="muted compact-text">{lifecycle.error instanceof Error ? lifecycle.error.message : "Unable to update DB instance lifecycle."}</p>}
</section> </section>
); );
} }
@@ -5,6 +5,7 @@ import {
Container, Container,
Database, Database,
HardDrive, HardDrive,
Hammer,
KeyRound, KeyRound,
Layers, Layers,
Lock, Lock,
@@ -25,6 +26,9 @@ import {
import type {CloudProvider} from '@/types/cloud' import type {CloudProvider} from '@/types/cloud'
import apiGatewayIcon from '@/assets/aws-services/api-gateway.svg' import apiGatewayIcon from '@/assets/aws-services/api-gateway.svg'
import appConfigIcon from '@/assets/aws-services/appconfig.svg' import appConfigIcon from '@/assets/aws-services/appconfig.svg'
import codeBuildIcon from '../../../../aws-icons-svg/Developer-Tools/CodeBuild.svg'
import codePipelineIcon from '../../../../aws-icons-svg/Developer-Tools/CodePipeline.svg'
import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy.svg'
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg' import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg' import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg' import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
@@ -80,11 +84,17 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
configuration: SlidersHorizontal, configuration: SlidersHorizontal,
scheduler: Clock, scheduler: Clock,
workflows: Workflow, workflows: Workflow,
codebuild: Hammer,
codepipeline: Workflow,
codedeploy: Workflow,
} }
const AWS_SERVICE_ICON_ASSETS: Record<string, string> = { const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
apigateway: apiGatewayIcon, apigateway: apiGatewayIcon,
compute: ec2Icon, compute: ec2Icon,
codebuild: codeBuildIcon,
codepipeline: codePipelineIcon,
codedeploy: codeDeployIcon,
configuration: appConfigIcon, configuration: appConfigIcon,
containers: ecsIcon, containers: ecsIcon,
database: rdsIcon, database: rdsIcon,
@@ -0,0 +1,28 @@
import {describe, expect, test} from 'vitest'
import {dataExplorerKind, dataExplorerPath} from './dataExplorer'
import type {CloudResource} from '@/types/resource'
function rdsInstance(engine: string): CloudResource {
return {
id: 'orders-db',
name: 'orders-db',
cloud: 'aws',
service: 'database',
type: 'db-instance',
region: 'us-east-1',
createdAt: null,
engine,
metadata: {},
}
}
describe('dataExplorerPath', () => {
test('enables the SQL explorer for PostgreSQL and MySQL RDS instances', () => {
expect(dataExplorerKind(rdsInstance('postgres'))).toBe('sql')
expect(dataExplorerPath(rdsInstance('mysql'))).toBe('/cloud-explorer/aws/database/orders-db/data')
})
test('does not expose unsupported RDS engines as SQL explorers', () => {
expect(dataExplorerKind(rdsInstance('mariadb'))).toBeUndefined()
})
})
@@ -5,6 +5,8 @@ export function dataExplorerKind(resource: CloudResource): 'dynamodb' | 'cosmos'
if (resource.cloud === 'azure' && resource.service === 'nosql' && resource.type === 'cosmos-database') return 'cosmos' if (resource.cloud === 'azure' && resource.service === 'nosql' && resource.type === 'cosmos-database') return 'cosmos'
if (resource.cloud === 'azure' && resource.service === 'database' && if (resource.cloud === 'azure' && resource.service === 'database' &&
(resource.type === 'sql-server' || resource.type === 'postgres-flexible-server')) return 'sql' (resource.type === 'sql-server' || resource.type === 'postgres-flexible-server')) return 'sql'
if (resource.cloud === 'aws' && resource.service === 'database' && resource.type === 'db-instance' &&
(resource.engine === 'postgres' || resource.engine === 'mysql')) return 'sql'
} }
export function dataExplorerPath(resource: CloudResource): string | undefined { export function dataExplorerPath(resource: CloudResource): string | undefined {
+1 -1
View File
@@ -112,7 +112,7 @@ export interface SqlCredentials {
password: string password: string
} }
export type SqlEngine = 'azure-sql' | 'postgresql' export type SqlEngine = 'azure-sql' | 'postgresql' | 'mysql'
export interface SqlDatabase { export interface SqlDatabase {
name: string name: string
+114 -42
View File
@@ -22,6 +22,15 @@ importers:
'@aws-sdk/client-cloudwatch-logs': '@aws-sdk/client-cloudwatch-logs':
specifier: ^3.1096.0 specifier: ^3.1096.0
version: 3.1131.0 version: 3.1131.0
'@aws-sdk/client-codebuild':
specifier: ^3.1076.0
version: 3.1144.0
'@aws-sdk/client-codedeploy':
specifier: ^3.1144.0
version: 3.1144.0
'@aws-sdk/client-codepipeline':
specifier: ^3.1076.0
version: 3.1144.0
'@aws-sdk/client-dynamodb': '@aws-sdk/client-dynamodb':
specifier: ^3.1088.0 specifier: ^3.1088.0
version: 3.1112.0 version: 3.1112.0
@@ -52,6 +61,9 @@ importers:
'@aws-sdk/client-rds': '@aws-sdk/client-rds':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1076.0 version: 3.1076.0
'@aws-sdk/client-rds-data':
specifier: ^3.1076.0
version: 3.1144.0
'@aws-sdk/client-s3': '@aws-sdk/client-s3':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1076.0 version: 3.1076.0
@@ -257,6 +269,18 @@ packages:
resolution: {integrity: sha512-FmV57mQjXtze2y/TCepl0E+4vvHqXCkiAMn24E5ogYC/xEGAYDPupTkzCQdSUmbyW6/tmPtmcOD+6LbUP1AKog==} resolution: {integrity: sha512-FmV57mQjXtze2y/TCepl0E+4vvHqXCkiAMn24E5ogYC/xEGAYDPupTkzCQdSUmbyW6/tmPtmcOD+6LbUP1AKog==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/client-codebuild@3.1144.0':
resolution: {integrity: sha512-vLy5q+QNeP1mH6jfIATnVCJb+hTwN8RrZR9syYhyDvH6nu+PiGCSfYpf9z7lqHFiGBGsZbP0DJYfYXVRMrq8ag==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-codedeploy@3.1144.0':
resolution: {integrity: sha512-+Qks8xIBempDELtZxWgvij89Wp+M+bjQDLf3JI5g6eB96FdHMXH6bcH/erny/PhzHvLF3/zpf6VIgK3jOOgNMQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-codepipeline@3.1144.0':
resolution: {integrity: sha512-IhmU0tlv959L/1DjetFai1JcYfm1TrR+ZshokjrYe1zYk5Vt51nv07upMNywYH9BcArEPAv6pHUZXYFQITVEIA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-dynamodb@3.1112.0': '@aws-sdk/client-dynamodb@3.1112.0':
resolution: {integrity: sha512-Dborj6oKxgV9htN0jpdqGA9mTVOAPtYrrNI9ro67JNojtqIp1qHkdTkz1RkVi5Um2meV0qKVvhxLXRsI/S9RYQ==} resolution: {integrity: sha512-Dborj6oKxgV9htN0jpdqGA9mTVOAPtYrrNI9ro67JNojtqIp1qHkdTkz1RkVi5Um2meV0qKVvhxLXRsI/S9RYQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -293,6 +317,10 @@ packages:
resolution: {integrity: sha512-uueu0Do1dy3cm42AdjL3zivHoc5b55vOL3g6BjxUv2xMifSLKZi5Ilh5FhXVDdS/Uwxak/JH+vVQDDnEuQ52gA==} resolution: {integrity: sha512-uueu0Do1dy3cm42AdjL3zivHoc5b55vOL3g6BjxUv2xMifSLKZi5Ilh5FhXVDdS/Uwxak/JH+vVQDDnEuQ52gA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/client-rds-data@3.1144.0':
resolution: {integrity: sha512-6WIjf0rUcT2G//bjsHET1BbQ4sBJ/oR5CEon7ehOI86tr1zQj8XggGqJBuRHvZ+jePabWkTmQcrkpra58Jn2gQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-rds@3.1076.0': '@aws-sdk/client-rds@3.1076.0':
resolution: {integrity: sha512-GqkUvUd1KFltgqNJ1yibN4HvDoMevRNmC4CU+pm+bAVIUusj9Lk3D22JYeIXsO5yVInpWQMx5F8l810UnSZXKQ==} resolution: {integrity: sha512-GqkUvUd1KFltgqNJ1yibN4HvDoMevRNmC4CU+pm+bAVIUusj9Lk3D22JYeIXsO5yVInpWQMx5F8l810UnSZXKQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -2710,6 +2738,39 @@ snapshots:
'@smithy/types': 4.17.2 '@smithy/types': 4.17.2
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/client-codebuild@3.1144.0':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/credential-provider-node': 3.972.84
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-codedeploy@3.1144.0':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/credential-provider-node': 3.972.84
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-codepipeline@3.1144.0':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/credential-provider-node': 3.972.84
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-dynamodb@3.1112.0': '@aws-sdk/client-dynamodb@3.1112.0':
dependencies: dependencies:
'@aws-sdk/core': 3.977.8 '@aws-sdk/core': 3.977.8
@@ -2821,6 +2882,17 @@ snapshots:
'@smithy/types': 4.15.1 '@smithy/types': 4.15.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/client-rds-data@3.1144.0':
dependencies:
'@aws-sdk/core': 3.978.1
'@aws-sdk/credential-provider-node': 3.972.84
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.35.0
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-rds@3.1076.0': '@aws-sdk/client-rds@3.1076.0':
dependencies: dependencies:
'@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0
@@ -3182,20 +3254,20 @@ snapshots:
'@aws-sdk/credential-provider-login@3.972.60': '@aws-sdk/credential-provider-login@3.972.60':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.1
'@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.33.3 '@smithy/core': 3.35.0
'@smithy/types': 4.17.2 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.69': '@aws-sdk/credential-provider-login@3.972.69':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.1
'@aws-sdk/nested-clients': 3.997.36 '@aws-sdk/nested-clients': 3.997.36
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.33.3 '@smithy/core': 3.35.0
'@smithy/types': 4.17.2 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.76': '@aws-sdk/credential-provider-login@3.972.76':
@@ -3218,11 +3290,11 @@ snapshots:
'@aws-sdk/credential-provider-login@3.972.78': '@aws-sdk/credential-provider-login@3.972.78':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.1
'@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.33.3 '@smithy/core': 3.35.0
'@smithy/types': 4.17.2 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.79': '@aws-sdk/credential-provider-login@3.972.79':
@@ -3562,24 +3634,24 @@ snapshots:
'@aws-sdk/nested-clients@3.997.28': '@aws-sdk/nested-clients@3.997.28':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.1
'@aws-sdk/signature-v4-multi-region': 3.996.38 '@aws-sdk/signature-v4-multi-region': 3.996.38
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.33.3 '@smithy/core': 3.35.0
'@smithy/fetch-http-handler': 5.7.2 '@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.11.3 '@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.17.2 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.36': '@aws-sdk/nested-clients@3.997.36':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.1
'@aws-sdk/signature-v4-multi-region': 3.996.42 '@aws-sdk/signature-v4-multi-region': 3.996.42
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.33.3 '@smithy/core': 3.35.0
'@smithy/fetch-http-handler': 5.7.2 '@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.11.3 '@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.17.2 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.43': '@aws-sdk/nested-clients@3.997.43':
@@ -3606,13 +3678,13 @@ snapshots:
'@aws-sdk/nested-clients@3.997.45': '@aws-sdk/nested-clients@3.997.45':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.1
'@aws-sdk/signature-v4-multi-region': 3.996.46 '@aws-sdk/signature-v4-multi-region': 3.996.46
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.33.3 '@smithy/core': 3.35.0
'@smithy/fetch-http-handler': 5.7.2 '@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.11.3 '@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.17.2 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.46': '@aws-sdk/nested-clients@3.997.46':
@@ -3663,20 +3735,20 @@ snapshots:
'@aws-sdk/token-providers@3.1080.0': '@aws-sdk/token-providers@3.1080.0':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.1
'@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.33.3 '@smithy/core': 3.35.0
'@smithy/types': 4.17.2 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/token-providers@3.1096.0': '@aws-sdk/token-providers@3.1096.0':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.1
'@aws-sdk/nested-clients': 3.997.36 '@aws-sdk/nested-clients': 3.997.36
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.33.3 '@smithy/core': 3.35.0
'@smithy/types': 4.17.2 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/token-providers@3.1111.0': '@aws-sdk/token-providers@3.1111.0':
@@ -3699,11 +3771,11 @@ snapshots:
'@aws-sdk/token-providers@3.1129.0': '@aws-sdk/token-providers@3.1129.0':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.1
'@aws-sdk/nested-clients': 3.997.45 '@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.33.3 '@smithy/core': 3.35.0
'@smithy/types': 4.17.2 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/token-providers@3.1138.0': '@aws-sdk/token-providers@3.1138.0':