From 7f0d70757836ffc649079271154fd1b87a0ca74e Mon Sep 17 00:00:00 2001 From: Zakaria Date: Thu, 1 Oct 2026 09:18:04 -0400 Subject: [PATCH] CI/CD - CodeBuild + CodeDeploy + CodePipeline --- README.md | 2 + docker/Dockerfile | 1 + packages/api/package.json | 6 +- .../adapter-aws/AwsCodeBuildAdapter.test.ts | 50 ++++++ .../src/adapter-aws/AwsCodeBuildAdapter.ts | 152 +++++++++++++++++ .../adapter-aws/AwsCodeDeployAdapter.test.ts | 30 ++++ .../src/adapter-aws/AwsCodeDeployAdapter.ts | 131 +++++++++++++++ .../AwsCodePipelineAdapter.test.ts | 46 +++++ .../src/adapter-aws/AwsCodePipelineAdapter.ts | 108 ++++++++++++ .../adapter-aws/AwsDatabaseAdapter.test.ts | 78 +++++++++ .../api/src/adapter-aws/AwsDatabaseAdapter.ts | 158 +++++++++++++++++- packages/api/src/aws.ts | 12 ++ packages/api/src/cloud-spi/codebuild.ts | 31 ++++ packages/api/src/cloud-spi/codebuildSchema.ts | 43 +++++ packages/api/src/cloud-spi/codedeploy.ts | 23 +++ .../api/src/cloud-spi/codedeploySchema.ts | 23 +++ packages/api/src/cloud-spi/codepipeline.ts | 31 ++++ .../api/src/cloud-spi/codepipelineSchema.ts | 33 ++++ packages/api/src/cloud-spi/databaseSchema.ts | 3 + packages/api/src/cloud-spi/serviceCatalog.ts | 18 ++ packages/api/src/cloud-spi/types.ts | 20 ++- packages/api/src/cloudProxy.test.ts | 3 + packages/api/src/cloudProxy.ts | 8 +- packages/api/src/routes/clouds.test.ts | 21 +++ packages/api/src/routes/clouds.ts | 101 +++++++++++ .../api/src/service/CloudProxyService.test.ts | 22 +++ packages/api/src/service/CloudProxyService.ts | 85 ++++++++++ packages/api/src/services/rds.ts | 21 +++ packages/frontend/src/api/api.ts | 57 ++++++- packages/frontend/src/api/cloudProxyClient.ts | 113 +++++++++++++ .../frontend/src/components/AzureSqlPanel.tsx | 47 ++++-- .../src/components/CodeBuildPanel.tsx | 46 +++++ .../src/components/CodeDeployPanel.tsx | 63 +++++++ .../src/components/CodePipelinePanel.tsx | 44 +++++ .../src/components/DynamicResourceView.tsx | 16 +- .../src/components/ResourceInspector.tsx | 51 +++++- .../frontend/src/components/serviceIcons.tsx | 10 ++ .../frontend/src/lib/dataExplorer.test.ts | 28 ++++ packages/frontend/src/lib/dataExplorer.ts | 2 + packages/frontend/src/types/resource.ts | 2 +- pnpm-lock.yaml | 156 ++++++++++++----- 41 files changed, 1817 insertions(+), 78 deletions(-) create mode 100644 packages/api/src/adapter-aws/AwsCodeBuildAdapter.test.ts create mode 100644 packages/api/src/adapter-aws/AwsCodeBuildAdapter.ts create mode 100644 packages/api/src/adapter-aws/AwsCodeDeployAdapter.test.ts create mode 100644 packages/api/src/adapter-aws/AwsCodeDeployAdapter.ts create mode 100644 packages/api/src/adapter-aws/AwsCodePipelineAdapter.test.ts create mode 100644 packages/api/src/adapter-aws/AwsCodePipelineAdapter.ts create mode 100644 packages/api/src/cloud-spi/codebuild.ts create mode 100644 packages/api/src/cloud-spi/codebuildSchema.ts create mode 100644 packages/api/src/cloud-spi/codedeploy.ts create mode 100644 packages/api/src/cloud-spi/codedeploySchema.ts create mode 100644 packages/api/src/cloud-spi/codepipeline.ts create mode 100644 packages/api/src/cloud-spi/codepipelineSchema.ts create mode 100644 packages/frontend/src/components/CodeBuildPanel.tsx create mode 100644 packages/frontend/src/components/CodeDeployPanel.tsx create mode 100644 packages/frontend/src/components/CodePipelinePanel.tsx create mode 100644 packages/frontend/src/lib/dataExplorer.test.ts diff --git a/README.md b/README.md index 01d0ccf..93cd239 100644 --- a/README.md +++ b/README.md @@ -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 | 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 | 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) | | 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 | @@ -68,6 +69,7 @@ cd packages/api && bun run scripts/service-matrix.ts | Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No | | Integration | SES Mailbox / Email | Yes (list, inspect) | No | No | | 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 | | Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, delete, inspect) | No | No | | Provisioning | AppConfig / Configuration | Yes (list, create, delete, inspect) | No | No | diff --git a/docker/Dockerfile b/docker/Dockerfile index 11d41f9..c39cd58 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -7,6 +7,7 @@ COPY packages/api/package.json packages/api/package.json COPY packages/frontend/package.json packages/frontend/package.json RUN pnpm install --frozen-lockfile --filter @floci/frontend COPY packages/frontend/ packages/frontend/ +COPY aws-icons-svg/ aws-icons-svg/ COPY OS_img/ OS_img/ RUN pnpm --filter @floci/frontend run build diff --git a/packages/api/package.json b/packages/api/package.json index fcbc2f2..65b8951 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -14,9 +14,12 @@ "@aws-sdk/client-appconfig": "^3.1076.0", "@aws-sdk/client-cloudformation": "^3.1111.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-ecr": "^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-elastic-load-balancing-v2": "^3.1096.0", "@aws-sdk/client-eventbridge": "^3.1076.0", @@ -24,6 +27,7 @@ "@aws-sdk/client-kms": "^3.1096.0", "@aws-sdk/client-lambda": "^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-secrets-manager": "^3.1076.0", "@aws-sdk/client-sfn": "^3.1096.0", diff --git a/packages/api/src/adapter-aws/AwsCodeBuildAdapter.test.ts b/packages/api/src/adapter-aws/AwsCodeBuildAdapter.test.ts new file mode 100644 index 0000000..3ebb469 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsCodeBuildAdapter.test.ts @@ -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) { + 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') + }) +}) diff --git a/packages/api/src/adapter-aws/AwsCodeBuildAdapter.ts b/packages/api/src/adapter-aws/AwsCodeBuildAdapter.ts new file mode 100644 index 0000000..7638dfc --- /dev/null +++ b/packages/api/src/adapter-aws/AwsCodeBuildAdapter.ts @@ -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 { + 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 { + const [project] = await this.projectsByName([id]) + return project ? toResource(project) : null + } + + async create(input: CreateResourceInput): Promise { + 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 { + await this.client.send(new DeleteProjectCommand({name: id})) + } + + async startBuild(projectName: string, input: StartCodeBuildInput): Promise { + 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 { + 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 { + const [build] = await this.buildsById([id]) + return build ?? null + } + + private async projectsByName(names: string[]): Promise { + 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 { + 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 +} diff --git a/packages/api/src/adapter-aws/AwsCodeDeployAdapter.test.ts b/packages/api/src/adapter-aws/AwsCodeDeployAdapter.test.ts new file mode 100644 index 0000000..ceb6200 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsCodeDeployAdapter.test.ts @@ -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}) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsCodeDeployAdapter.ts b/packages/api/src/adapter-aws/AwsCodeDeployAdapter.ts new file mode 100644 index 0000000..2740695 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsCodeDeployAdapter.ts @@ -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 { + 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 { + const [applicationName, deploymentGroupName] = splitId(id) + return this.groupResource(applicationName, deploymentGroupName) + } + + async create(input: CreateResourceInput): Promise { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 { await this.client.send(new StopDeploymentCommand({deploymentId: id, autoRollbackEnabled: rollback})) } + + private async groupResource(applicationName: string, deploymentGroupName: string): Promise { + 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> { const hooks: Array> = []; 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>}; 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 } diff --git a/packages/api/src/adapter-aws/AwsCodePipelineAdapter.test.ts b/packages/api/src/adapter-aws/AwsCodePipelineAdapter.test.ts new file mode 100644 index 0000000..29dfd09 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsCodePipelineAdapter.test.ts @@ -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) { + 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'}}) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsCodePipelineAdapter.ts b/packages/api/src/adapter-aws/AwsCodePipelineAdapter.ts new file mode 100644 index 0000000..ea2fd66 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsCodePipelineAdapter.ts @@ -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 { + 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 { + const response = await this.client.send(new GetPipelineCommand({name: id})) + return response.pipeline ? toResource(response.pipeline, response.metadata) : null + } + + async create(input: CreateResourceInput): Promise { + 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 { 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 { + 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 { + 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, codeDeployActions: Set) { + 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) { return {name, actions: [item]} } +function action(name: string, category: 'Source' | 'Build' | 'Deploy', provider: 'S3' | 'CodeBuild', configuration: Record, 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 } diff --git a/packages/api/src/adapter-aws/AwsDatabaseAdapter.test.ts b/packages/api/src/adapter-aws/AwsDatabaseAdapter.test.ts index 6a6ca66..49fc0d9 100644 --- a/packages/api/src/adapter-aws/AwsDatabaseAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsDatabaseAdapter.test.ts @@ -8,8 +8,12 @@ import { DescribeOrderableDBInstanceOptionsCommand, ListTagsForResourceCommand, ModifyDBInstanceCommand, + RebootDBInstanceCommand, + StartDBInstanceCommand, + StopDBInstanceCommand, type RDSClient, } from '@aws-sdk/client-rds' +import {ExecuteStatementCommand, type RDSDataClient} from '@aws-sdk/client-rds-data' import {ConflictError, ValidationError} from '../cloud-spi/errors' import {createRdsService} from '../services/rds' import {mapAwsSdkError} from './awsErrors' @@ -24,6 +28,9 @@ type RdsCommand = | DescribeOrderableDBInstanceOptionsCommand | ListTagsForResourceCommand | ModifyDBInstanceCommand + | RebootDBInstanceCommand + | StartDBInstanceCommand + | StopDBInstanceCommand type Responder = (command: RdsCommand) => unknown | Promise @@ -47,6 +54,17 @@ function adapterWith(responder: Responder) { } } +function recordingRdsData(responder: (command: ExecuteStatementCommand) => unknown | Promise) { + 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) { return { DBInstance: { @@ -69,6 +87,47 @@ const validCreateValues = { } 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 () => { const {adapter} = adapterWith(async (command) => { 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 () => { const {adapter, commands} = adapterWith(async (command) => { expect(command).toBeInstanceOf(DescribeDBSnapshotsCommand) diff --git a/packages/api/src/adapter-aws/AwsDatabaseAdapter.ts b/packages/api/src/adapter-aws/AwsDatabaseAdapter.ts index 2cfd34b..1696b61 100644 --- a/packages/api/src/adapter-aws/AwsDatabaseAdapter.ts +++ b/packages/api/src/adapter-aws/AwsDatabaseAdapter.ts @@ -1,6 +1,7 @@ import {ValidationError} from '../cloud-spi/errors' 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 type { CloudResource, @@ -10,13 +11,17 @@ import type { DatabaseSnapshot, ResourceQuery, ServiceSchema, + SqlConnectionInput, + SqlDatabase, + SqlQueryResult, + SqlTable, UpdateResourceInput, } from '../cloud-spi/types' import {rdsService, type RdsInstance, type RdsSnapshot} from '../services/rds' type RdsServiceShape = Pick< 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 { @@ -26,6 +31,7 @@ export class AwsDatabaseAdapter implements CloudServiceAdapter { constructor( private readonly rdsService_: RdsServiceShape = rdsService, private readonly rds: RDSClient = defaultRds, + private readonly rdsData: RDSDataClient = awsClients.rdsData, ) {} schema(): ServiceSchema { @@ -118,6 +124,18 @@ export class AwsDatabaseAdapter implements CloudServiceAdapter { await this.rdsService_.deleteInstance(id) } + async start(id: string): Promise { + await this.rdsService_.startInstance(id) + } + + async stop(id: string): Promise { + await this.rdsService_.stopInstance(id) + } + + async reboot(id: string): Promise { + await this.rdsService_.rebootInstance(id) + } + async listDatabaseSnapshots(instanceIdentifier?: string): Promise { const normalizedIdentifier = stringValue(instanceIdentifier) || undefined return (await this.rdsService_.listSnapshots(normalizedIdentifier)).map(snapshotToDatabaseSnapshot) @@ -136,6 +154,73 @@ export class AwsDatabaseAdapter implements CloudServiceAdapter { return await this.rdsService_.listOrderableInstanceClasses(normalizedEngine) } + async listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise { + 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 { + 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 { + 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 { + 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 { const tags = instance.arn ? await this.getTags(instance.arn) : [] 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 SNAPSHOT_IDENTIFIER_PATTERN = /^(?!.*--)[A-Za-z](?:[A-Za-z0-9-]{0,253}[A-Za-z0-9])?$/ diff --git a/packages/api/src/aws.ts b/packages/api/src/aws.ts index 609847a..159b015 100644 --- a/packages/api/src/aws.ts +++ b/packages/api/src/aws.ts @@ -3,6 +3,7 @@ import { LambdaClient } from "@aws-sdk/client-lambda"; import { EKSClient } from "@aws-sdk/client-eks"; import { EC2Client } from "@aws-sdk/client-ec2"; import { RDSClient } from "@aws-sdk/client-rds"; +import { RDSDataClient } from "@aws-sdk/client-rds-data"; import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; 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 { SNSClient } from "@aws-sdk/client-sns"; 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 region = process.env.AWS_REGION || "us-east-1"; @@ -52,6 +56,7 @@ export type AwsClients = { eks: EKSClient; ec2: EC2Client; rds: RDSClient; + rdsData: RDSDataClient; secretsManager: SecretsManagerClient; logs: CloudWatchLogsClient; ssm: SSMClient; @@ -67,6 +72,9 @@ export type AwsClients = { appConfig: AppConfigClient; sns: SNSClient; ecr: ECRClient; + codebuild: CodeBuildClient; + codepipeline: CodePipelineClient; + codedeploy: CodeDeployClient; }; export type AwsClientName = keyof AwsClients; @@ -85,6 +93,7 @@ function buildClients(accountId: string): AwsClients { eks: new EKSClient(base), ec2: new EC2Client(base), rds: new RDSClient(base), + rdsData: new RDSDataClient(base), secretsManager: new SecretsManagerClient(base), logs: new CloudWatchLogsClient(base), ssm: new SSMClient(base), @@ -100,6 +109,9 @@ function buildClients(accountId: string): AwsClients { appConfig: new AppConfigClient(base), sns: new SNSClient(base), ecr: new ECRClient(base), + codebuild: new CodeBuildClient(base), + codepipeline: new CodePipelineClient(base), + codedeploy: new CodeDeployClient(base), }; } diff --git a/packages/api/src/cloud-spi/codebuild.ts b/packages/api/src/cloud-spi/codebuild.ts new file mode 100644 index 0000000..93277bf --- /dev/null +++ b/packages/api/src/cloud-spi/codebuild.ts @@ -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 +} diff --git a/packages/api/src/cloud-spi/codebuildSchema.ts b/packages/api/src/cloud-spi/codebuildSchema.ts new file mode 100644 index 0000000..1b8d7a7 --- /dev/null +++ b/packages/api/src/cloud-spi/codebuildSchema.ts @@ -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, + } +} diff --git a/packages/api/src/cloud-spi/codedeploy.ts b/packages/api/src/cloud-spi/codedeploy.ts new file mode 100644 index 0000000..f514824 --- /dev/null +++ b/packages/api/src/cloud-spi/codedeploy.ts @@ -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 +} diff --git a/packages/api/src/cloud-spi/codedeploySchema.ts b/packages/api/src/cloud-spi/codedeploySchema.ts new file mode 100644 index 0000000..fb6e0d0 --- /dev/null +++ b/packages/api/src/cloud-spi/codedeploySchema.ts @@ -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, + } +} diff --git a/packages/api/src/cloud-spi/codepipeline.ts b/packages/api/src/cloud-spi/codepipeline.ts new file mode 100644 index 0000000..ef4cf6d --- /dev/null +++ b/packages/api/src/cloud-spi/codepipeline.ts @@ -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[] +} diff --git a/packages/api/src/cloud-spi/codepipelineSchema.ts b/packages/api/src/cloud-spi/codepipelineSchema.ts new file mode 100644 index 0000000..63be1aa --- /dev/null +++ b/packages/api/src/cloud-spi/codepipelineSchema.ts @@ -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, + } +} diff --git a/packages/api/src/cloud-spi/databaseSchema.ts b/packages/api/src/cloud-spi/databaseSchema.ts index c2f2cfd..12f029d 100644 --- a/packages/api/src/cloud-spi/databaseSchema.ts +++ b/packages/api/src/cloud-spi/databaseSchema.ts @@ -114,6 +114,9 @@ export function awsDatabaseSchema(): ServiceSchema { {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: '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: [ { diff --git a/packages/api/src/cloud-spi/serviceCatalog.ts b/packages/api/src/cloud-spi/serviceCatalog.ts index b65bac0..6b05154 100644 --- a/packages/api/src/cloud-spi/serviceCatalog.ts +++ b/packages/api/src/cloud-spi/serviceCatalog.ts @@ -77,6 +77,24 @@ export const SERVICE_CATALOG = { group: 'Compute', 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}, ecr: { displayName: 'Elastic Container Registry', diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index f415bff..1f59d10 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -1,6 +1,9 @@ // 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. 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 { ChildItem, CollectionActionName, @@ -103,6 +106,7 @@ export interface FieldSchema { * that a capability block can describe but that are not table-level controls. */ export type ActionSchema = 'list' | 'create' | 'update' | 'delete' | 'inspect' +export type ResourceLifecycleAction = 'start' | 'stop' | 'reboot' export type ResourceActionName = | 'list' | 'create' @@ -112,9 +116,7 @@ export type ResourceActionName = | 'encrypt' | 'decrypt' | 'invoke' - | 'start' - | 'stop' - | 'reboot' + | ResourceLifecycleAction | 'updateTags' export type ObjectActionName = 'list' | 'upload' | 'download' | 'delete' | 'createFolder' | 'copy' export type DatabaseActionName = 'listSnapshots' | 'createSnapshot' @@ -266,7 +268,7 @@ export interface SqlConnectionInput { username: string password: string database?: string - engine?: 'azure-sql' | 'postgresql' + engine?: 'azure-sql' | 'postgresql' | 'mysql' } export interface SqlDatabase { @@ -854,4 +856,14 @@ export interface CloudServiceAdapter { deleteAppConfigDeploymentStrategy?(strategyId: string): Promise startAppConfigDeployment?(applicationId: string, environmentId: string, input: CreateResourceInput): Promise getAppConfigDeployment?(applicationId: string, environmentId: string, deploymentNumber: number): Promise + startBuild?(projectName: string, input: StartCodeBuildInput): Promise + listBuilds?(projectName: string): Promise + getBuild?(id: string): Promise + startPipeline?(id: string): Promise<{id: string}> + listPipelineExecutions?(id: string): Promise + getPipelineState?(id: string): Promise + startCodeDeployDeployment?(applicationName: string, deploymentGroupName: string, input: StartCodeDeployDeploymentInput): Promise + listCodeDeployDeployments?(applicationName: string, deploymentGroupName: string): Promise + getCodeDeployDeployment?(id: string): Promise + stopCodeDeployDeployment?(id: string, rollback: boolean): Promise } diff --git a/packages/api/src/cloudProxy.test.ts b/packages/api/src/cloudProxy.test.ts index 688ee59..5b508f4 100644 --- a/packages/api/src/cloudProxy.test.ts +++ b/packages/api/src/cloudProxy.test.ts @@ -178,6 +178,9 @@ describe('adapter capability advertisements match the runtime reality', () => { {name: 'update', enabled: true, status: 'available', runtimeRequired: true}, {name: 'delete', 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 === 'update')?.label).toBe('Update DB instance') diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index 9ebdb20..e21d720 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -39,6 +39,9 @@ import {AwsSesAdapter} from './adapter-aws/AwsSesAdapter' import {AwsAppConfigAdapter} from './adapter-aws/AwsAppConfigAdapter' import {AwsSnsAdapter} from './adapter-aws/AwsSnsAdapter' 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 {createEc2Service} from './services/ec2' 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 AwsDynamoDbAdapter(clients.dynamodb), 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 AwsNetworkingAdapter(ec2Service), new AwsServerlessAdapter(clients.lambda, clients.eventbridge), @@ -80,6 +83,9 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap new AwsAppConfigAdapter(clients.appConfig), new AwsSnsAdapter(clients.sns), new AwsEcrAdapter(clients.ecr), + new AwsCodeBuildAdapter(clients.codebuild), + new AwsCodePipelineAdapter(clients.codepipeline), + new AwsCodeDeployAdapter(clients.codedeploy, clients.lambda), new AzureStorageAdapter(), new AzureServiceBusAdapter(), new AzureDatabaseAdapter(), diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts index 3087b27..d98d7a7 100644 --- a/packages/api/src/routes/clouds.test.ts +++ b/packages/api/src/routes/clouds.test.ts @@ -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', () => { test('returns AWS storage schema', async () => { const res = await appWithRoutes().request('/api/clouds/aws/services/storage/schema') diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index f1e46c8..bf925d9 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -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) => { const cloud = c.req.param('cloud') as CloudProvider 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) => { const cloud = c.req.param('cloud') as CloudProvider 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'))} } +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): Promise { try { return await handler() diff --git a/packages/api/src/service/CloudProxyService.test.ts b/packages/api/src/service/CloudProxyService.test.ts index d481f66..c3c487d 100644 --- a/packages/api/src/service/CloudProxyService.test.ts +++ b/packages/api/src/service/CloudProxyService.test.ts @@ -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']) + }) +}) diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index 7ed4b84..d6c84e5 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -86,6 +86,9 @@ import type { import {NotSupportedError} from '../cloud-spi/errors' import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' 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 {toHttpError} from '../cloud-spi/errors' import {mapAwsSdkError} from '../adapter-aws/awsErrors' @@ -482,6 +485,24 @@ export class CloudProxyService { await this.requireAdapter(cloud, service).delete(id) } + async startResource(cloud: CloudProvider, service: CloudServiceType, id: string): Promise { + 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 { + 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 { + 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 { const adapter = this.requireAdapter(cloud, 'kms') 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) } + async startCodeBuild(cloud: CloudProvider, projectName: string, input: StartCodeBuildInput): Promise { + return this.codeBuildAdapter(cloud).startBuild!(projectName, input) + } + + async listCodeBuilds(cloud: CloudProvider, projectName: string): Promise { + return this.codeBuildAdapter(cloud).listBuilds!(projectName) + } + + async getCodeBuild(cloud: CloudProvider, id: string): Promise { + 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 { + return this.codePipelineAdapter(cloud).listPipelineExecutions!(id) + } + + async getPipelineState(cloud: CloudProvider, id: string): Promise { + return this.codePipelineAdapter(cloud).getPipelineState!(id) + } + + async startCodeDeployDeployment(cloud: CloudProvider, applicationName: string, deploymentGroupName: string, input: StartCodeDeployDeploymentInput): Promise { + return this.codeDeployAdapter(cloud).startCodeDeployDeployment!(applicationName, deploymentGroupName, input) + } + + async listCodeDeployDeployments(cloud: CloudProvider, applicationName: string, deploymentGroupName: string): Promise { + return this.codeDeployAdapter(cloud).listCodeDeployDeployments!(applicationName, deploymentGroupName) + } + + async getCodeDeployDeployment(cloud: CloudProvider, id: string): Promise { + return this.codeDeployAdapter(cloud).getCodeDeployDeployment!(id) + } + + async stopCodeDeployDeployment(cloud: CloudProvider, id: string, rollback: boolean): Promise { + await this.codeDeployAdapter(cloud).stopCodeDeployDeployment!(id, rollback) + } + private workflowAdapter(cloud: CloudProvider) { const adapter = this.requireAdapter(cloud, 'workflows') 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 } + 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) { const adapter = this.registry.get(cloud, service) if (!adapter) throw new NotSupportedError(`No adapter registered for ${cloud}/${service}`) diff --git a/packages/api/src/services/rds.ts b/packages/api/src/services/rds.ts index c6eb6b7..2558b8f 100644 --- a/packages/api/src/services/rds.ts +++ b/packages/api/src/services/rds.ts @@ -6,6 +6,9 @@ import { DescribeDBSnapshotsCommand, DescribeOrderableDBInstanceOptionsCommand, ModifyDBInstanceCommand, + RebootDBInstanceCommand, + StartDBInstanceCommand, + StopDBInstanceCommand, type CreateDBInstanceCommandInput, type DBInstance, type DBSnapshot, @@ -194,6 +197,24 @@ export function createRdsService(client: RDSClient = awsClients.rds) { ); }, + async startInstance(identifier: string): Promise { + await client.send( + new StartDBInstanceCommand({ DBInstanceIdentifier: identifier }), + ); + }, + + async stopInstance(identifier: string): Promise { + await client.send( + new StopDBInstanceCommand({ DBInstanceIdentifier: identifier }), + ); + }, + + async rebootInstance(identifier: string): Promise { + await client.send( + new RebootDBInstanceCommand({ DBInstanceIdentifier: identifier }), + ); + }, + async listSnapshots(instanceIdentifier?: string): Promise { const snapshots: RdsSnapshot[] = []; let marker: string | undefined; diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index 8a80703..b5bc4bb 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -22,7 +22,10 @@ export const apiEndpointKeys = { create: "clouds.services.resources.create", update: "clouds.services.resources.update", 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", createSnsSubscription: "clouds.services.resources.notifications.subscription.create", listSnsSubscriptions: "clouds.services.resources.notifications.subscriptions.list", @@ -60,7 +63,17 @@ export const apiEndpointKeys = { listWorkflowExecutions: "clouds.services.resources.workflows.executions.list", describeWorkflowExecution: "clouds.services.resources.workflows.execution.describe", 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: { objects: { @@ -342,6 +355,18 @@ export const endpointRegistry: EndpointRegistry = new Map([ 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, { @@ -542,6 +567,34 @@ export const endpointRegistry: EndpointRegistry = new Map([ apiEndpointKeys.clouds.resources.stopWorkflowExecution, { 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, { diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 7e5084d..5e81e9e 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -208,6 +208,42 @@ export async function deleteCloudResource( ); } +export async function startCloudResource( + cloud: CloudProvider, + service: CloudServiceType, + id: string, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.resources.start, + requestOptions(cloud, service), + { cloud, service, id }, + ); +} + +export async function stopCloudResource( + cloud: CloudProvider, + service: CloudServiceType, + id: string, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.resources.stop, + requestOptions(cloud, service), + { cloud, service, id }, + ); +} + +export async function rebootCloudResource( + cloud: CloudProvider, + service: CloudServiceType, + id: string, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.resources.reboot, + requestOptions(cloud, service), + { cloud, service, id }, + ); +} + export async function clearEmailInbox( cloud: CloudProvider, signal?: AbortSignal, @@ -257,6 +293,47 @@ export interface WorkflowExecutionHistoryPage { 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 { path: string; parentResourceId?: string; @@ -883,6 +960,42 @@ export async function stopWorkflowExecution(stateMachineArn: string, executionAr ); } +export async function startCodeBuild(projectName: string, input: {environmentVariablesOverride?: CodeBuildEnvironmentVariableOverride[]}): Promise { + const res = await apiClient.call(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 { + const res = await apiClient.call(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 { + const res = await apiClient.call(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 { + const res = await apiClient.call(apiEndpointKeys.clouds.resources.listCodePipelineExecutions, requestOptions('aws', 'codepipeline', {signal}), {cloud: 'aws', id: name}); + return res.data; +} + +export async function getCodePipelineState(name: string, signal?: AbortSignal): Promise { + const res = await apiClient.call(apiEndpointKeys.clouds.resources.getCodePipelineState, requestOptions('aws', 'codepipeline', {signal}), {cloud: 'aws', id: name}); + return res.data; +} + +export async function getCodeDeployDependencies(signal?: AbortSignal): Promise { const res = await apiClient.call(apiEndpointKeys.clouds.dependencies, requestOptions('aws', 'codedeploy', {signal}), {cloud: 'aws', service: 'codedeploy'}); return res.data } +export async function startCodeDeployDeployment(groupId: string, input: StartCodeDeployDeploymentInput): Promise { const res = await apiClient.call(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 { const res = await apiClient.call(apiEndpointKeys.clouds.resources.listCodeDeployDeployments, requestOptions('aws', 'codedeploy', {signal}), {cloud: 'aws', id: groupId}); return res.data } +export async function getCodeDeployDeployment(id: string, signal?: AbortSignal): Promise { const res = await apiClient.call(apiEndpointKeys.clouds.resources.getCodeDeployDeployment, requestOptions('aws', 'codedeploy', {signal}), {cloud: 'aws', deploymentId: id}); return res.data } +export async function stopCodeDeployDeployment(id: string, rollback: boolean): Promise { await apiClient.call(apiEndpointKeys.clouds.resources.stopCodeDeployDeployment, requestOptions('aws', 'codedeploy', {body: {rollback}}), {cloud: 'aws', deploymentId: id}) } + export async function publishNotification( cloud: CloudProvider, topicArn: string, diff --git a/packages/frontend/src/components/AzureSqlPanel.tsx b/packages/frontend/src/components/AzureSqlPanel.tsx index f53fd78..f2048f3 100644 --- a/packages/frontend/src/components/AzureSqlPanel.tsx +++ b/packages/frontend/src/components/AzureSqlPanel.tsx @@ -26,6 +26,10 @@ const POSTGRES_DEFAULT_QUERY = `SELECT current_database() AS database_name, current_user AS username, version();`; +const MYSQL_DEFAULT_QUERY = `SELECT + DATABASE() AS database_name, + CURRENT_USER() AS username, + VERSION() AS version;`; interface AzureSqlPanelProps { cloud: CloudProvider; @@ -40,11 +44,13 @@ export function AzureSqlPanel({ }: AzureSqlPanelProps) { const accountId = useAccountId(); const serverId = resource?.id; - const engine: SqlEngine = - resource?.type === "postgres-flexible-server" ? "postgresql" : "azure-sql"; - const defaultDatabase = engine === "postgresql" ? "postgres" : "master"; + const isAwsRds = cloud === "aws" && resource?.type === "db-instance"; + const engine: SqlEngine = isAwsRds + ? resource?.engine === "mysql" ? "mysql" : "postgresql" + : resource?.type === "postgres-flexible-server" ? "postgresql" : "azure-sql"; + const defaultDatabase = engine === "postgresql" ? "postgres" : engine === "mysql" ? "mysql" : "master"; 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( resource?.metadata.administratorLogin, ); @@ -100,7 +106,7 @@ export function AzureSqlPanel({ setQueryText(defaultQuery); }, [administratorLogin, cloud, defaultDatabase, defaultQuery, serverId]); - if (cloud !== "azure") return null; + if (cloud !== "azure" && !isAwsRds) return null; if (!serverId) { return ( @@ -115,7 +121,7 @@ export function AzureSqlPanel({ function connect(event: FormEvent) { event.preventDefault(); - if (!username || !password) return; + if (!isAwsRds && (!username || !password)) return; queryMut.reset(); loadDatabases(); } @@ -181,12 +187,12 @@ export function AzureSqlPanel({
- {engine === "postgresql" ? "POSTGRESQL DATA PLANE" : "SQL DATA PLANE"} + {isAwsRds ? "RDS DATA API" : engine === "postgresql" ? "POSTGRESQL DATA PLANE" : "SQL DATA PLANE"} {resource.name} - {metadataString(resource.metadata.fullyQualifiedDomainName)} + {isAwsRds ? `${resource.engine} ยท ${resource.status ?? "unknown"}` : metadataString(resource.metadata.fullyQualifiedDomainName)}
- } {connected ? ( )} - 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."} {connectionError &&
{connectionError}
} @@ -236,7 +243,7 @@ export function AzureSqlPanel({ {!connected ? (

Connect to browse SQL data

-

Use the administrator login configured for this local engine.

+

{isAwsRds ? "Connect through Floci's local RDS Data API to browse databases and run SQL." : "Use the administrator login configured for this local engine."}

) : (
@@ -329,6 +336,8 @@ export function AzureSqlPanel({ title={selectedDatabase} detail={engine === "postgresql" ? "PostgreSQL SQL against selected database" + : engine === "mysql" + ? "MySQL SQL against selected database" : "T-SQL against selected database"} />
@@ -454,12 +463,16 @@ function metadataString(value: unknown): string { function tablePreviewQuery(table: SqlTable, engine: SqlEngine): string { const tableName = `${quoteIdentifier(table.schema, engine)}.${quoteIdentifier(table.name, engine)}`; return engine === 'postgresql' + ? `SELECT * FROM ${tableName} LIMIT 100;` + : engine === 'mysql' ? `SELECT * FROM ${tableName} LIMIT 100;` : `SELECT TOP (100) * FROM ${tableName};`; } function quoteIdentifier(value: string, engine: SqlEngine): string { - return engine === "postgresql" + return engine === "mysql" + ? `\`${value.replace(/`/g, "``")}\`` + : engine === "postgresql" ? `"${value.replace(/"/g, '""')}"` : `[${value.replace(/]/g, "]]")}]`; } diff --git a/packages/frontend/src/components/CodeBuildPanel.tsx b/packages/frontend/src/components/CodeBuildPanel.tsx new file mode 100644 index 0000000..43c1300 --- /dev/null +++ b/packages/frontend/src/components/CodeBuildPanel.tsx @@ -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([]) + const [selectedId, setSelectedId] = useState() + 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
+

Builds

Run and inspect builds

+ {startOpen && setStartOpen(false)} onStart={() => start.mutate()}/>} + {builds.isError &&

{message(builds.error, 'Unable to load builds.')}

} +

Build history

{builds.data?.length ?? 0}
{(builds.data ?? []).map((build) => setSelectedId(build.id)} style={{cursor: 'pointer'}}>)}
BuildStatusPhaseStarted
#{build.buildNumber ?? build.id}{build.buildStatus ?? '-'}{build.currentPhase ?? '-'}{formatDateTime(build.startTime) ?? '-'}
{!builds.isLoading && builds.data?.length === 0 &&

No builds yet.

}
+ {selectedId && } +
+} + +function StartBuildDialog({overrides, pending, error, onChange, onCancel, onStart}: {overrides: CodeBuildEnvironmentVariableOverride[]; pending: boolean; error: unknown; onChange: (values: CodeBuildEnvironmentVariableOverride[]) => void; onCancel: () => void; onStart: () => void}) { + return
Start build

Environment variable overrides apply only to this build.

{overrides.map((override, index) =>
onChange(replace(overrides, index, {...override, name: event.target.value}))}/> onChange(replace(overrides, index, {...override, value: event.target.value}))}/>
)}{Boolean(error) &&

{message(error, 'Unable to start build.')}

}
+} + +function BuildDetail({build, loading, error}: {build?: CodeBuildBuild; loading: boolean; error: unknown}) { + if (loading) return

Loading build details...

+ if (error) return

{message(error, 'Unable to load build details.')}

+ if (!build) return null + const logsHref = build.logGroupName ? `/cloud-explorer/aws/logs?resource=${encodeURIComponent(build.logGroupName)}` : undefined + return

Build detail

#{build.buildNumber ?? build.id}

{build.buildStatus ?? 'Unknown'}{build.endTime ? `, finished ${formatDateTime(build.endTime)}` : ''}

{logsHref && Open logs{build.logStreamName ? `: ${build.logStreamName}` : ''}}
    {build.phases.map((phase, index) =>
  1. {phase.phaseType}{phase.phaseStatus ?? 'Unknown'}
    {phase.contexts.map((context, contextIndex) =>

    {context.statusCode}: {context.message}

    )}
  2. )}
+} + +function replace(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 } diff --git a/packages/frontend/src/components/CodeDeployPanel.tsx b/packages/frontend/src/components/CodeDeployPanel.tsx new file mode 100644 index 0000000..f9817a0 --- /dev/null +++ b/packages/frontend/src/components/CodeDeployPanel.tsx @@ -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) => 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
{ event.preventDefault(); onSubmit({applicationName, deploymentGroupName, serviceRoleArn, deploymentConfig, rollbackOnFailure}) }}> +

Lambda deployment

Create application and deployment group

The group controls the traffic-shift policy and rollback on deployment failure.

+ + + + + + {submitError &&

{submitError}

} +
+
+} + +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
+

Lambda release

Deploy a version to an alias

{String(resource.metadata.applicationName)} / {resource.name}

+
{ event.preventDefault(); start.mutate() }}> + + + + + + + {start.isError &&

{message(start.error)}

} +
+
+ {deployments.isError &&

{message(deployments.error)}

} +

Deployment group history

{deployments.data?.length ?? 0}
{(deployments.data ?? []).map((deployment) => )}
DeploymentStatusTrafficVersionsStartedActions
{deployment.id}{deployment.errorInformation?.message &&

{deployment.errorInformation.message}

}
{deployment.status ?? '-'}{deployment.trafficPercent === null ? '-' : `${deployment.trafficPercent}%`}{deployment.currentVersion ?? '-'} to {deployment.targetVersion ?? '-'}{formatDateTime(deployment.createTime) ?? '-'}{active(deployment.status) && <>}
{!deployments.isLoading && deployments.data?.length === 0 &&

No deployments yet.

}
+
+} + +function HookPicker({dependencies, value, onChange}: {dependencies?: CodeDeployDependencies; value: string; onChange: (value: string) => void}) { return } +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.' } diff --git a/packages/frontend/src/components/CodePipelinePanel.tsx b/packages/frontend/src/components/CodePipelinePanel.tsx new file mode 100644 index 0000000..ed41ca5 --- /dev/null +++ b/packages/frontend/src/components/CodePipelinePanel.tsx @@ -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
+

Pipeline execution

Run and inspect stages

Local actions: S3 source, CodeBuild, and S3 deploy.

+ {start.isError &&

{message(start.error, 'Unable to start pipeline execution.')}

} + {(executions.isError || state.isError) &&

{message(executions.error ?? state.error, 'Unable to load pipeline execution state.')}

} +

Execution history

{executions.data?.length ?? 0}
{(executions.data ?? []).map((execution) => )}
ExecutionStatusStartedUpdated
{execution.id}{execution.statusSummary &&

{execution.statusSummary}

}
{execution.status ?? '-'}{formatDateTime(execution.startTime) ?? '-'}{formatDateTime(execution.lastUpdateTime) ?? '-'}
{!executions.isLoading && executions.data?.length === 0 &&

No executions yet.

}
+ +
+} + +function PipelineTimeline({state, loading}: {state?: PipelineState; loading: boolean}) { + if (loading) return

Loading stage state...

+ if (!state) return null + return

Current pipeline state

Stages and actions

    {state.stages.map((stage) =>
  1. {stage.name}{stage.status ?? 'Unknown'}
    {stage.actions.map((action) =>
    {action.name}{action.status ?? 'Unknown'}{action.summary &&

    {action.summary}

    }
    )}
  2. )}
+} + +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) => {link.label})} +} + +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 } diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index 50c9887..32af68f 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -63,6 +63,9 @@ import { LoadBalancerPanel } from "@/components/LoadBalancerPanel"; import { TargetGroupPanel } from "@/components/TargetGroupPanel"; import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel"; 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 {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate"; import {workflowStudioPath} from "@/lib/workflowStudio"; @@ -824,13 +827,15 @@ export function DynamicResourceView({ onCancel={() => setCreateOpen(false)} onSubmit={(submission) => createMut.mutate(submission)} /> - ) : service === "iac" && cloud === "aws" ? ( + ) : service === "iac" && cloud === "aws" ? ( setCreateOpen(false)} onSubmit={(values) => createMut.mutate({values})} /> + ) : service === "codedeploy" && cloud === "aws" ? ( + setCreateOpen(false)} onSubmit={(values) => createMut.mutate({values})}/> ) : ( )} + {service === "codebuild" && cloud === "aws" && ( + + )} + {service === "codepipeline" && cloud === "aws" && ( + + )} + {service === "codedeploy" && cloud === "aws" && ( + + )} {editingResource && schema.updateFields && (
{isDatabase && ( - + )} - {isAwsDatabase && } + {isAwsDatabase && } {isDatabase && !isAwsDatabase && ( )} @@ -283,9 +286,11 @@ function ProviderDatabaseSection({ cloud }: { cloud: string }) { function DatabaseConnectionsSection({ metadata, engine, + status, }: { metadata: Record; engine?: string | null; + status?: string | null; }) { const endpoint = getRecordMetadata(metadata.endpoint); const subnetGroup = getRecordMetadata(metadata.subnetGroup); @@ -299,6 +304,7 @@ function DatabaseConnectionsSection({ return (

Connections

+
Connection health: {state.label}. {state.detail}

+} + 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 command = engine.includes('postgres') @@ -343,22 +362,40 @@ function DatabaseConnectionCommand({ engine, address, port, database, username } return

Terminal connection

{command}

Client connection URI

{uri}

Paste the URI into a compatible database client, then replace the password placeholder. The password is never retained by the console.

} -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 (

Lifecycle

- - +

- Current status: {status ?? "unknown"}. Start and stop are not supported - by the local Floci RDS runtime. + Current status: {resource.status ?? "unknown"}.

+ {lifecycle.error &&

{lifecycle.error instanceof Error ? lifecycle.error.message : "Unable to update DB instance lifecycle."}

}
); } diff --git a/packages/frontend/src/components/serviceIcons.tsx b/packages/frontend/src/components/serviceIcons.tsx index 6e492b8..f2ef08f 100644 --- a/packages/frontend/src/components/serviceIcons.tsx +++ b/packages/frontend/src/components/serviceIcons.tsx @@ -5,6 +5,7 @@ import { Container, Database, HardDrive, + Hammer, KeyRound, Layers, Lock, @@ -25,6 +26,9 @@ import { import type {CloudProvider} from '@/types/cloud' import apiGatewayIcon from '@/assets/aws-services/api-gateway.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 cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg' import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg' @@ -80,11 +84,17 @@ const SERVICE_ICONS: Record = { configuration: SlidersHorizontal, scheduler: Clock, workflows: Workflow, + codebuild: Hammer, + codepipeline: Workflow, + codedeploy: Workflow, } const AWS_SERVICE_ICON_ASSETS: Record = { apigateway: apiGatewayIcon, compute: ec2Icon, + codebuild: codeBuildIcon, + codepipeline: codePipelineIcon, + codedeploy: codeDeployIcon, configuration: appConfigIcon, containers: ecsIcon, database: rdsIcon, diff --git a/packages/frontend/src/lib/dataExplorer.test.ts b/packages/frontend/src/lib/dataExplorer.test.ts new file mode 100644 index 0000000..344a150 --- /dev/null +++ b/packages/frontend/src/lib/dataExplorer.test.ts @@ -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() + }) +}) diff --git a/packages/frontend/src/lib/dataExplorer.ts b/packages/frontend/src/lib/dataExplorer.ts index 7a2d4b8..22e53ec 100644 --- a/packages/frontend/src/lib/dataExplorer.ts +++ b/packages/frontend/src/lib/dataExplorer.ts @@ -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 === 'database' && (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 { diff --git a/packages/frontend/src/types/resource.ts b/packages/frontend/src/types/resource.ts index 864fbe7..737a36b 100644 --- a/packages/frontend/src/types/resource.ts +++ b/packages/frontend/src/types/resource.ts @@ -112,7 +112,7 @@ export interface SqlCredentials { password: string } -export type SqlEngine = 'azure-sql' | 'postgresql' +export type SqlEngine = 'azure-sql' | 'postgresql' | 'mysql' export interface SqlDatabase { name: string diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb99750..206808a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,15 @@ importers: '@aws-sdk/client-cloudwatch-logs': specifier: ^3.1096.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': specifier: ^3.1088.0 version: 3.1112.0 @@ -52,6 +61,9 @@ importers: '@aws-sdk/client-rds': specifier: ^3.1076.0 version: 3.1076.0 + '@aws-sdk/client-rds-data': + specifier: ^3.1076.0 + version: 3.1144.0 '@aws-sdk/client-s3': specifier: ^3.1076.0 version: 3.1076.0 @@ -257,6 +269,18 @@ packages: resolution: {integrity: sha512-FmV57mQjXtze2y/TCepl0E+4vvHqXCkiAMn24E5ogYC/xEGAYDPupTkzCQdSUmbyW6/tmPtmcOD+6LbUP1AKog==} 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': resolution: {integrity: sha512-Dborj6oKxgV9htN0jpdqGA9mTVOAPtYrrNI9ro67JNojtqIp1qHkdTkz1RkVi5Um2meV0qKVvhxLXRsI/S9RYQ==} engines: {node: '>=20.0.0'} @@ -293,6 +317,10 @@ packages: resolution: {integrity: sha512-uueu0Do1dy3cm42AdjL3zivHoc5b55vOL3g6BjxUv2xMifSLKZi5Ilh5FhXVDdS/Uwxak/JH+vVQDDnEuQ52gA==} 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': resolution: {integrity: sha512-GqkUvUd1KFltgqNJ1yibN4HvDoMevRNmC4CU+pm+bAVIUusj9Lk3D22JYeIXsO5yVInpWQMx5F8l810UnSZXKQ==} engines: {node: '>=20.0.0'} @@ -2710,6 +2738,39 @@ snapshots: '@smithy/types': 4.17.2 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': dependencies: '@aws-sdk/core': 3.977.8 @@ -2821,6 +2882,17 @@ snapshots: '@smithy/types': 4.15.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': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -3182,20 +3254,20 @@ snapshots: '@aws-sdk/credential-provider-login@3.972.60': dependencies: - '@aws-sdk/core': 3.978.0 + '@aws-sdk/core': 3.978.1 '@aws-sdk/nested-clients': 3.997.45 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/credential-provider-login@3.972.69': dependencies: - '@aws-sdk/core': 3.978.0 + '@aws-sdk/core': 3.978.1 '@aws-sdk/nested-clients': 3.997.36 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/credential-provider-login@3.972.76': @@ -3218,11 +3290,11 @@ snapshots: '@aws-sdk/credential-provider-login@3.972.78': dependencies: - '@aws-sdk/core': 3.978.0 + '@aws-sdk/core': 3.978.1 '@aws-sdk/nested-clients': 3.997.45 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/credential-provider-login@3.972.79': @@ -3562,24 +3634,24 @@ snapshots: '@aws-sdk/nested-clients@3.997.28': dependencies: - '@aws-sdk/core': 3.978.0 + '@aws-sdk/core': 3.978.1 '@aws-sdk/signature-v4-multi-region': 3.996.38 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/fetch-http-handler': 5.7.2 - '@smithy/node-http-handler': 4.11.3 - '@smithy/types': 4.17.2 + '@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/nested-clients@3.997.36': dependencies: - '@aws-sdk/core': 3.978.0 + '@aws-sdk/core': 3.978.1 '@aws-sdk/signature-v4-multi-region': 3.996.42 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/fetch-http-handler': 5.7.2 - '@smithy/node-http-handler': 4.11.3 - '@smithy/types': 4.17.2 + '@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/nested-clients@3.997.43': @@ -3606,13 +3678,13 @@ snapshots: '@aws-sdk/nested-clients@3.997.45': dependencies: - '@aws-sdk/core': 3.978.0 + '@aws-sdk/core': 3.978.1 '@aws-sdk/signature-v4-multi-region': 3.996.46 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/fetch-http-handler': 5.7.2 - '@smithy/node-http-handler': 4.11.3 - '@smithy/types': 4.17.2 + '@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/nested-clients@3.997.46': @@ -3663,20 +3735,20 @@ snapshots: '@aws-sdk/token-providers@3.1080.0': dependencies: - '@aws-sdk/core': 3.978.0 + '@aws-sdk/core': 3.978.1 '@aws-sdk/nested-clients': 3.997.45 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/token-providers@3.1096.0': dependencies: - '@aws-sdk/core': 3.978.0 + '@aws-sdk/core': 3.978.1 '@aws-sdk/nested-clients': 3.997.36 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/token-providers@3.1111.0': @@ -3699,11 +3771,11 @@ snapshots: '@aws-sdk/token-providers@3.1129.0': dependencies: - '@aws-sdk/core': 3.978.0 + '@aws-sdk/core': 3.978.1 '@aws-sdk/nested-clients': 3.997.45 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws-sdk/token-providers@3.1138.0':