CI/CD - CodeBuild + CodeDeploy + CodePipeline
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {BatchGetBuildsCommand, BatchGetProjectsCommand, CreateProjectCommand, DeleteProjectCommand, ListBuildsForProjectCommand, ListProjectsCommand, StartBuildCommand, type CodeBuildClient} from '@aws-sdk/client-codebuild'
|
||||
import {AwsCodeBuildAdapter} from './AwsCodeBuildAdapter'
|
||||
|
||||
function stub(handler: (command: object) => Record<string, unknown>) {
|
||||
const sent: object[] = []
|
||||
const client = {async send(command: object) { sent.push(command); return handler(command) }} as unknown as CodeBuildClient
|
||||
return {client, sent}
|
||||
}
|
||||
|
||||
const project = {name: 'orders', arn: 'arn:aws:codebuild:us-east-1:000000000000:project/orders', created: new Date('2026-10-01T10:00:00.000Z'), serviceRole: 'arn:aws:iam::000000000000:role/codebuild', source: {type: 'NO_SOURCE'}, artifacts: {type: 'NO_ARTIFACTS'}, environment: {type: 'LINUX_CONTAINER', image: 'aws/codebuild/standard:7.0', computeType: 'BUILD_GENERAL1_SMALL'}}
|
||||
const build = {id: 'orders:build-id', arn: 'arn:aws:codebuild:us-east-1:000000000000:build/orders:build-id', projectName: 'orders', buildNumber: 3, buildStatus: 'SUCCEEDED', phases: [{phaseType: 'BUILD', phaseStatus: 'SUCCEEDED', contexts: []}], logs: {groupName: '/aws/codebuild/orders', streamName: 'stream'}}
|
||||
|
||||
describe('AwsCodeBuildAdapter', () => {
|
||||
test('uses list and batch-get project contracts for the project table', async () => {
|
||||
const {client, sent} = stub((command) => command instanceof ListProjectsCommand ? {projects: ['orders']} : command instanceof BatchGetProjectsCommand ? {projects: [project]} : {})
|
||||
const resources = await new AwsCodeBuildAdapter(client).list()
|
||||
expect(sent[0]).toBeInstanceOf(ListProjectsCommand)
|
||||
expect((sent[1] as BatchGetProjectsCommand).input.names).toEqual(['orders'])
|
||||
expect(resources[0]).toMatchObject({id: 'orders', name: 'orders', service: 'codebuild', region: 'us-east-1'})
|
||||
expect(resources[0].metadata).toMatchObject({sourceType: 'NO_SOURCE', artifactsType: 'NO_ARTIFACTS'})
|
||||
})
|
||||
|
||||
test('creates only the supported no-source, no-artifacts project contract', async () => {
|
||||
const {client, sent} = stub((command) => command instanceof CreateProjectCommand ? {project} : {})
|
||||
await new AwsCodeBuildAdapter(client).create({values: {name: 'orders', serviceRole: project.serviceRole}})
|
||||
expect((sent[0] as CreateProjectCommand).input).toMatchObject({name: 'orders', serviceRole: project.serviceRole, source: {type: 'NO_SOURCE'}, artifacts: {type: 'NO_ARTIFACTS'}, environment: {type: 'LINUX_CONTAINER', image: 'aws/codebuild/standard:7.0', computeType: 'BUILD_GENERAL1_SMALL'}})
|
||||
})
|
||||
|
||||
test('starts builds with AWS environment-variable overrides and exposes log metadata', async () => {
|
||||
const {client, sent} = stub((command) => command instanceof StartBuildCommand ? {build} : {})
|
||||
const result = await new AwsCodeBuildAdapter(client).startBuild('orders', {environmentVariablesOverride: [{name: 'MODE', value: 'test'}]})
|
||||
expect((sent[0] as StartBuildCommand).input).toEqual({projectName: 'orders', environmentVariablesOverride: [{name: 'MODE', value: 'test'}]})
|
||||
expect(result).toMatchObject({id: 'orders:build-id', logGroupName: '/aws/codebuild/orders', logStreamName: 'stream'})
|
||||
})
|
||||
|
||||
test('lists build ids then batch-gets detailed phases', async () => {
|
||||
const {client, sent} = stub((command) => command instanceof ListBuildsForProjectCommand ? {ids: [build.id]} : command instanceof BatchGetBuildsCommand ? {builds: [build]} : {})
|
||||
const builds = await new AwsCodeBuildAdapter(client).listBuilds('orders')
|
||||
expect((sent[0] as ListBuildsForProjectCommand).input.projectName).toBe('orders')
|
||||
expect((sent[1] as BatchGetBuildsCommand).input.ids).toEqual([build.id])
|
||||
expect(builds[0].phases[0]).toMatchObject({phaseType: 'BUILD', phaseStatus: 'SUCCEEDED'})
|
||||
})
|
||||
|
||||
test('deletes projects by their CodeBuild name', async () => {
|
||||
const {client, sent} = stub(() => ({}))
|
||||
await new AwsCodeBuildAdapter(client).delete('orders')
|
||||
expect((sent[0] as DeleteProjectCommand).input.name).toBe('orders')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
BatchGetBuildsCommand,
|
||||
BatchGetProjectsCommand,
|
||||
CodeBuildClient,
|
||||
ComputeType,
|
||||
CreateProjectCommand,
|
||||
DeleteProjectCommand,
|
||||
ListBuildsForProjectCommand,
|
||||
ListProjectsCommand,
|
||||
StartBuildCommand,
|
||||
type Build,
|
||||
type Project,
|
||||
} from '@aws-sdk/client-codebuild'
|
||||
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import {awsCodeBuildSchema} from '../cloud-spi/codebuildSchema'
|
||||
import type {CodeBuildBuild, StartCodeBuildInput} from '../cloud-spi/codebuild'
|
||||
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||
|
||||
export class AwsCodeBuildAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'codebuild' as const
|
||||
|
||||
constructor(private readonly client: CodeBuildClient) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsCodeBuildSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const names: string[] = []
|
||||
let nextToken: string | undefined
|
||||
do {
|
||||
const response = await this.client.send(new ListProjectsCommand(nextToken ? {nextToken} : {}))
|
||||
names.push(...(response.projects ?? []))
|
||||
nextToken = response.nextToken
|
||||
} while (nextToken)
|
||||
const projects = await this.projectsByName(names)
|
||||
const normalized = query.search?.trim().toLowerCase()
|
||||
return projects.map(toResource).filter((project) => !normalized || project.name.toLowerCase().includes(normalized))
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const [project] = await this.projectsByName([id])
|
||||
return project ? toResource(project) : null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const name = required(input.values.name, 'name')
|
||||
const serviceRole = required(input.values.serviceRole, 'serviceRole')
|
||||
const image = optional(input.values.environmentImage) ?? 'aws/codebuild/standard:7.0'
|
||||
const computeType = optional(input.values.computeType) ?? ComputeType.BUILD_GENERAL1_SMALL
|
||||
if (computeType !== ComputeType.BUILD_GENERAL1_SMALL) throw new ValidationError('computeType must be BUILD_GENERAL1_SMALL')
|
||||
const response = await this.client.send(new CreateProjectCommand({
|
||||
name,
|
||||
serviceRole,
|
||||
source: {type: 'NO_SOURCE'},
|
||||
artifacts: {type: 'NO_ARTIFACTS'},
|
||||
environment: {type: 'LINUX_CONTAINER', image, computeType},
|
||||
}))
|
||||
if (!response.project) throw new RuntimeError(`CodeBuild did not return project ${name}`)
|
||||
return toResource(response.project)
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.client.send(new DeleteProjectCommand({name: id}))
|
||||
}
|
||||
|
||||
async startBuild(projectName: string, input: StartCodeBuildInput): Promise<CodeBuildBuild> {
|
||||
const response = await this.client.send(new StartBuildCommand({
|
||||
projectName,
|
||||
environmentVariablesOverride: input.environmentVariablesOverride,
|
||||
}))
|
||||
if (!response.build) throw new RuntimeError(`CodeBuild did not return a build for ${projectName}`)
|
||||
return toBuild(response.build)
|
||||
}
|
||||
|
||||
async listBuilds(projectName: string): Promise<CodeBuildBuild[]> {
|
||||
const ids: string[] = []
|
||||
let nextToken: string | undefined
|
||||
do {
|
||||
const response = await this.client.send(new ListBuildsForProjectCommand(nextToken ? {projectName, nextToken} : {projectName}))
|
||||
ids.push(...(response.ids ?? []))
|
||||
nextToken = response.nextToken
|
||||
} while (nextToken)
|
||||
return this.buildsById(ids)
|
||||
}
|
||||
|
||||
async getBuild(id: string): Promise<CodeBuildBuild | null> {
|
||||
const [build] = await this.buildsById([id])
|
||||
return build ?? null
|
||||
}
|
||||
|
||||
private async projectsByName(names: string[]): Promise<Project[]> {
|
||||
const projects: Project[] = []
|
||||
for (let index = 0; index < names.length; index += 100) {
|
||||
const response = await this.client.send(new BatchGetProjectsCommand({names: names.slice(index, index + 100)}))
|
||||
projects.push(...(response.projects ?? []))
|
||||
}
|
||||
return projects
|
||||
}
|
||||
|
||||
private async buildsById(ids: string[]): Promise<CodeBuildBuild[]> {
|
||||
const builds: CodeBuildBuild[] = []
|
||||
for (let index = 0; index < ids.length; index += 100) {
|
||||
const response = await this.client.send(new BatchGetBuildsCommand({ids: ids.slice(index, index + 100)}))
|
||||
builds.push(...(response.builds ?? []).map(toBuild))
|
||||
}
|
||||
return builds
|
||||
}
|
||||
}
|
||||
|
||||
function toResource(project: Project): CloudResource {
|
||||
const name = project.name ?? ''
|
||||
return {
|
||||
id: name, name, cloud: 'aws', service: 'codebuild', type: 'codebuild-project', region: regionFromArn(project.arn),
|
||||
createdAt: project.created ? project.created.toISOString() : null,
|
||||
metadata: {
|
||||
arn: project.arn,
|
||||
serviceRole: project.serviceRole,
|
||||
sourceType: project.source?.type,
|
||||
artifactsType: project.artifacts?.type,
|
||||
environmentType: project.environment?.type,
|
||||
environmentImage: project.environment?.image,
|
||||
computeType: project.environment?.computeType,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toBuild(build: Build): CodeBuildBuild {
|
||||
const id = build.id ?? build.arn
|
||||
if (!id || !build.projectName) throw new RuntimeError('CodeBuild returned a build without an id or project name')
|
||||
return {
|
||||
id, arn: build.arn, projectName: build.projectName, buildNumber: build.buildNumber, buildStatus: build.buildStatus,
|
||||
currentPhase: build.currentPhase, startTime: build.startTime, endTime: build.endTime,
|
||||
phases: (build.phases ?? []).map((phase) => ({phaseType: phase.phaseType ?? 'UNKNOWN', phaseStatus: phase.phaseStatus, startTime: phase.startTime, endTime: phase.endTime, contexts: phase.contexts ?? []})),
|
||||
logGroupName: build.logs?.groupName, logStreamName: build.logs?.streamName,
|
||||
}
|
||||
}
|
||||
|
||||
function required(value: unknown, field: string): string {
|
||||
const result = optional(value)
|
||||
if (!result) throw new ValidationError(`${field} is required`)
|
||||
return result
|
||||
}
|
||||
|
||||
function optional(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
||||
}
|
||||
|
||||
function regionFromArn(arn?: string): string | null {
|
||||
return arn?.split(':')[3] || null
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {CreateDeploymentCommand, GetDeploymentCommand, StopDeploymentCommand, type CodeDeployClient} from '@aws-sdk/client-codedeploy'
|
||||
import type {LambdaClient} from '@aws-sdk/client-lambda'
|
||||
import {AwsCodeDeployAdapter} from './AwsCodeDeployAdapter'
|
||||
|
||||
describe('AwsCodeDeployAdapter', () => {
|
||||
test('creates a native Lambda AppSpec revision with traffic hooks', async () => {
|
||||
const sent: object[] = []
|
||||
const client = {async send(command: object) {
|
||||
sent.push(command)
|
||||
if (command instanceof CreateDeploymentCommand) return {deploymentId: 'd-1'}
|
||||
if (command instanceof GetDeploymentCommand) return {deploymentInfo: {deploymentId: 'd-1', applicationName: 'orders', deploymentGroupName: 'production', status: 'Created'}}
|
||||
return {}
|
||||
}} as unknown as CodeDeployClient
|
||||
const lambda = {async send() { return {FunctionVersion: '5'} }} as unknown as LambdaClient
|
||||
const adapter = new AwsCodeDeployAdapter(client, lambda)
|
||||
await adapter.startCodeDeployDeployment('orders', 'production', {functionName: 'orders-api', alias: 'live', currentVersion: '4', targetVersion: '5', beforeAllowTrafficHook: 'verify-before', afterAllowTrafficHook: 'verify-after'})
|
||||
const command = sent.find((item) => item instanceof CreateDeploymentCommand) as CreateDeploymentCommand
|
||||
const appSpec = JSON.parse(command.input.revision?.appSpecContent?.content ?? '{}')
|
||||
expect(appSpec.Resources[0]['orders-api'].Properties).toEqual({Name: 'orders-api', Alias: 'live', CurrentVersion: '4', TargetVersion: '5'})
|
||||
expect(appSpec.Hooks).toEqual([{BeforeAllowTraffic: 'verify-before'}, {AfterAllowTraffic: 'verify-after'}])
|
||||
})
|
||||
|
||||
test('stops a deployment with the requested rollback behavior', async () => {
|
||||
const sent: object[] = []
|
||||
const client = {async send(command: object) { sent.push(command); return {} }} as unknown as CodeDeployClient
|
||||
await new AwsCodeDeployAdapter(client, {} as LambdaClient).stopCodeDeployDeployment('d-1', true)
|
||||
expect((sent[0] as StopDeploymentCommand).input).toEqual({deploymentId: 'd-1', autoRollbackEnabled: true})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
CreateApplicationCommand,
|
||||
CreateDeploymentCommand,
|
||||
CreateDeploymentGroupCommand,
|
||||
DeleteApplicationCommand,
|
||||
DeleteDeploymentGroupCommand,
|
||||
GetDeploymentCommand,
|
||||
GetDeploymentGroupCommand,
|
||||
ListApplicationsCommand,
|
||||
ListDeploymentGroupsCommand,
|
||||
ListDeploymentsCommand,
|
||||
StopDeploymentCommand,
|
||||
type CodeDeployClient,
|
||||
} from '@aws-sdk/client-codedeploy'
|
||||
import {GetAliasCommand, ListAliasesCommand, ListFunctionsCommand, ListVersionsByFunctionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import {awsCodeDeploySchema} from '../cloud-spi/codedeploySchema'
|
||||
import type {CodeDeployDeployment, StartCodeDeployDeploymentInput} from '../cloud-spi/codedeploy'
|
||||
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||
|
||||
export class AwsCodeDeployAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'codedeploy' as const
|
||||
|
||||
constructor(private readonly client: CodeDeployClient, private readonly lambda: LambdaClient) {}
|
||||
|
||||
schema(): ServiceSchema { return awsCodeDeploySchema() }
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const resources: CloudResource[] = []
|
||||
const applications = await this.client.send(new ListApplicationsCommand({}))
|
||||
for (const applicationName of applications.applications ?? []) {
|
||||
const groups = await this.client.send(new ListDeploymentGroupsCommand({applicationName}))
|
||||
for (const deploymentGroupName of groups.deploymentGroups ?? []) {
|
||||
resources.push(await this.groupResource(applicationName, deploymentGroupName))
|
||||
}
|
||||
}
|
||||
const search = query.search?.trim().toLowerCase()
|
||||
return resources.filter((resource) => !search || resource.name.toLowerCase().includes(search) || String(resource.metadata.applicationName).toLowerCase().includes(search))
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const [applicationName, deploymentGroupName] = splitId(id)
|
||||
return this.groupResource(applicationName, deploymentGroupName)
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const applicationName = required(input.values.applicationName, 'applicationName')
|
||||
const deploymentGroupName = required(input.values.deploymentGroupName, 'deploymentGroupName')
|
||||
const applications = await this.client.send(new ListApplicationsCommand({}))
|
||||
if (!(applications.applications ?? []).includes(applicationName)) await this.client.send(new CreateApplicationCommand({applicationName, computePlatform: 'Lambda'}))
|
||||
await this.client.send(new CreateDeploymentGroupCommand({
|
||||
applicationName,
|
||||
deploymentGroupName,
|
||||
serviceRoleArn: required(input.values.serviceRoleArn, 'serviceRoleArn'),
|
||||
deploymentConfigName: deploymentConfig(input.values.deploymentConfig),
|
||||
deploymentStyle: {deploymentType: 'BLUE_GREEN', deploymentOption: 'WITH_TRAFFIC_CONTROL'},
|
||||
autoRollbackConfiguration: {enabled: input.values.rollbackOnFailure !== false, events: ['DEPLOYMENT_FAILURE']},
|
||||
}))
|
||||
return this.groupResource(applicationName, deploymentGroupName)
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const [applicationName, deploymentGroupName] = splitId(id)
|
||||
await this.client.send(new DeleteDeploymentGroupCommand({applicationName, deploymentGroupName}))
|
||||
const groups = await this.client.send(new ListDeploymentGroupsCommand({applicationName}))
|
||||
if ((groups.deploymentGroups ?? []).length === 0) await this.client.send(new DeleteApplicationCommand({applicationName}))
|
||||
}
|
||||
|
||||
async dependencies(): Promise<Record<string, unknown>> {
|
||||
const functions = await this.lambda.send(new ListFunctionsCommand({}))
|
||||
const lambdaFunctions = await Promise.all((functions.Functions ?? []).flatMap(async (functionConfiguration) => {
|
||||
const name = functionConfiguration.FunctionName
|
||||
if (!name) return []
|
||||
const [aliases, versions] = await Promise.all([
|
||||
this.lambda.send(new ListAliasesCommand({FunctionName: name})),
|
||||
this.lambda.send(new ListVersionsByFunctionCommand({FunctionName: name})),
|
||||
])
|
||||
return [{name, aliases: (aliases.Aliases ?? []).map((alias) => alias.Name).filter((alias): alias is string => Boolean(alias)), versions: (versions.Versions ?? []).map((version) => version.Version).filter((version): version is string => Boolean(version) && version !== '$LATEST')}]
|
||||
}))
|
||||
return {lambdaFunctions}
|
||||
}
|
||||
|
||||
async startCodeDeployDeployment(applicationName: string, deploymentGroupName: string, input: StartCodeDeployDeploymentInput): Promise<CodeDeployDeployment> {
|
||||
const hooks = hooksFor(input)
|
||||
const response = await this.client.send(new CreateDeploymentCommand({
|
||||
applicationName, deploymentGroupName,
|
||||
revision: {revisionType: 'AppSpecContent', appSpecContent: {content: JSON.stringify({version: '0.0', Resources: [{[input.functionName]: {Type: 'AWS::Lambda::Function', Properties: {Name: input.functionName, Alias: input.alias, CurrentVersion: input.currentVersion, TargetVersion: input.targetVersion}}}], ...(hooks.length ? {Hooks: hooks} : {})})}},
|
||||
}))
|
||||
if (!response.deploymentId) throw new RuntimeError('CodeDeploy did not return a deployment id')
|
||||
return this.getCodeDeployDeployment(response.deploymentId)
|
||||
}
|
||||
|
||||
async listCodeDeployDeployments(applicationName: string, deploymentGroupName: string): Promise<CodeDeployDeployment[]> {
|
||||
const response = await this.client.send(new ListDeploymentsCommand({applicationName, deploymentGroupName, includeOnlyStatuses: ['Created', 'Queued', 'InProgress', 'Succeeded', 'Failed', 'Stopped']}))
|
||||
return Promise.all((response.deployments ?? []).map((id) => this.getCodeDeployDeployment(id)))
|
||||
}
|
||||
|
||||
async getCodeDeployDeployment(id: string): Promise<CodeDeployDeployment> {
|
||||
const response = await this.client.send(new GetDeploymentCommand({deploymentId: id}))
|
||||
const deployment = response.deploymentInfo
|
||||
if (!deployment?.deploymentId || !deployment.applicationName || !deployment.deploymentGroupName) throw new RuntimeError(`CodeDeploy deployment ${id} was not found`)
|
||||
const revision = deployment.revision?.appSpecContent?.content
|
||||
const revisionInfo = appSpecInfo(revision)
|
||||
let actualTrafficPercent: number | null = null
|
||||
if (revisionInfo.functionName && revisionInfo.alias && revisionInfo.targetVersion) {
|
||||
const alias = await this.lambda.send(new GetAliasCommand({FunctionName: revisionInfo.functionName, Name: revisionInfo.alias}))
|
||||
actualTrafficPercent = alias.FunctionVersion === revisionInfo.targetVersion ? 100 : Math.round((alias.RoutingConfig?.AdditionalVersionWeights?.[revisionInfo.targetVersion] ?? 0) * 100)
|
||||
}
|
||||
return {id: deployment.deploymentId, applicationName: deployment.applicationName, deploymentGroupName: deployment.deploymentGroupName, status: deployment.status ?? null, creator: deployment.creator ?? null, deploymentConfigName: deployment.deploymentConfigName ?? null, createTime: deployment.createTime, completeTime: deployment.completeTime, errorInformation: deployment.errorInformation ?? null, currentVersion: revisionInfo.currentVersion, targetVersion: revisionInfo.targetVersion, trafficPercent: actualTrafficPercent ?? trafficPercent(deployment.deploymentOverview)}
|
||||
}
|
||||
|
||||
async stopCodeDeployDeployment(id: string, rollback: boolean): Promise<void> { await this.client.send(new StopDeploymentCommand({deploymentId: id, autoRollbackEnabled: rollback})) }
|
||||
|
||||
private async groupResource(applicationName: string, deploymentGroupName: string): Promise<CloudResource> {
|
||||
const response = await this.client.send(new GetDeploymentGroupCommand({applicationName, deploymentGroupName}))
|
||||
const group = response.deploymentGroupInfo
|
||||
if (!group?.deploymentGroupName) throw new RuntimeError(`CodeDeploy deployment group ${deploymentGroupName} was not found`)
|
||||
const configuration = group.deploymentConfigName
|
||||
return {id: `${applicationName}/${deploymentGroupName}`, name: deploymentGroupName, cloud: 'aws', service: 'codedeploy', type: 'codedeploy-deployment-group', region: regionFromArn(group.deploymentGroupId), createdAt: null, status: null, metadata: {applicationName, deploymentGroupName, deploymentGroupArn: group.deploymentGroupId, serviceRoleArn: group.serviceRoleArn, deploymentConfigName: configuration, rollbackOnFailure: group.autoRollbackConfiguration?.enabled ?? false, updatedAt: group.lastSuccessfulDeployment?.createTime?.toISOString()}}
|
||||
}
|
||||
}
|
||||
|
||||
function required(value: unknown, field: string): string { const result = optional(value); if (!result) throw new ValidationError(`${field} is required`); return result }
|
||||
function optional(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined }
|
||||
function splitId(id: string): [string, string] { const separator = id.indexOf('/'); if (separator <= 0 || separator === id.length - 1) throw new ValidationError('Deployment group id must be applicationName/deploymentGroupName'); return [id.slice(0, separator), id.slice(separator + 1)] }
|
||||
function deploymentConfig(value: unknown): string { const choice = optional(value) ?? 'AllAtOnce'; return `CodeDeployDefault.Lambda${choice}` }
|
||||
function regionFromArn(arn?: string): string | null { return arn?.split(':')[3] || null }
|
||||
function hooksFor(input: {beforeAllowTrafficHook?: string; afterAllowTrafficHook?: string}): Array<Record<string, string>> { const hooks: Array<Record<string, string>> = []; if (input.beforeAllowTrafficHook) hooks.push({BeforeAllowTraffic: input.beforeAllowTrafficHook}); if (input.afterAllowTrafficHook) hooks.push({AfterAllowTraffic: input.afterAllowTrafficHook}); return hooks }
|
||||
function appSpecInfo(content?: string): {functionName: string | null; alias: string | null; currentVersion: string | null; targetVersion: string | null} { try { const parsed = JSON.parse(content ?? '{}') as {Resources?: Array<Record<string, {Properties?: {Name?: string; Alias?: string; CurrentVersion?: string; TargetVersion?: string}}>>}; const properties = parsed.Resources?.[0] && Object.values(parsed.Resources[0])[0]?.Properties; return {functionName: properties?.Name ?? null, alias: properties?.Alias ?? null, currentVersion: properties?.CurrentVersion ?? null, targetVersion: properties?.TargetVersion ?? null} } catch { return {functionName: null, alias: null, currentVersion: null, targetVersion: null} } }
|
||||
function trafficPercent(overview?: {InProgress?: number; Succeeded?: number; Failed?: number; Skipped?: number; Ready?: number}): number | null { if (!overview) return null; const total = Object.values(overview).reduce((sum, value) => sum + (value ?? 0), 0); return total ? Math.round(((overview.Succeeded ?? 0) / total) * 100) : 0 }
|
||||
@@ -0,0 +1,46 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {CreatePipelineCommand, GetPipelineCommand, GetPipelineStateCommand, ListPipelineExecutionsCommand, ListPipelinesCommand, StartPipelineExecutionCommand, type CodePipelineClient} from '@aws-sdk/client-codepipeline'
|
||||
import {AwsCodePipelineAdapter} from './AwsCodePipelineAdapter'
|
||||
|
||||
function stub(handler: (command: object) => Record<string, unknown>) {
|
||||
const sent: object[] = []
|
||||
const client = {async send(command: object) { sent.push(command); return handler(command) }} as unknown as CodePipelineClient
|
||||
return {client, sent}
|
||||
}
|
||||
|
||||
describe('AwsCodePipelineAdapter', () => {
|
||||
test('creates the documented S3, CodeBuild, S3 local pipeline definition', async () => {
|
||||
const pipeline = {name: 'release', roleArn: 'arn:aws:iam::000000000000:role/pipeline', artifactStore: {type: 'S3' as const, location: 'artifacts'}, stages: []}
|
||||
const {client, sent} = stub((command) => command instanceof CreatePipelineCommand ? {pipeline} : {})
|
||||
await new AwsCodePipelineAdapter(client).create({values: {name: 'release', roleArn: pipeline.roleArn, artifactBucket: 'artifacts', sourceBucket: 'source', sourceKey: 'app.zip', codeBuildProject: 'build-app', deployBucket: 'deploy', deployKey: 'releases/app.zip'}})
|
||||
const stages = (sent[0] as CreatePipelineCommand).input.pipeline?.stages
|
||||
expect(stages?.map((stage) => stage.name)).toEqual(['Source', 'Build', 'Deploy'])
|
||||
expect(stages?.map((stage) => stage.actions?.[0].actionTypeId?.provider)).toEqual(['S3', 'CodeBuild', 'S3'])
|
||||
expect(stages?.[1].actions?.[0].configuration).toEqual({ProjectName: 'build-app'})
|
||||
expect(stages?.[2].actions?.[0].configuration).toEqual({BucketName: 'deploy', ObjectKey: 'releases/app.zip', Extract: 'true'})
|
||||
})
|
||||
|
||||
test('uses native execution and state APIs and retains CodeBuild links', async () => {
|
||||
const pipeline = {name: 'release', roleArn: 'arn:aws:iam::000000000000:role/pipeline', artifactStore: {type: 'S3' as const, location: 'artifacts'}, stages: [{name: 'Build', actions: [{name: 'Build', actionTypeId: {category: 'Build' as const, owner: 'AWS' as const, provider: 'CodeBuild', version: '1'}, configuration: {ProjectName: 'build-app'}}]}]}
|
||||
const {client, sent} = stub((command) => {
|
||||
if (command instanceof StartPipelineExecutionCommand) return {pipelineExecutionId: 'execution-1'}
|
||||
if (command instanceof ListPipelineExecutionsCommand) return {pipelineExecutionSummaries: [{pipelineExecutionId: 'execution-1', status: 'InProgress', startTime: new Date('2026-10-01T10:00:00.000Z')}]}
|
||||
if (command instanceof GetPipelineCommand) return {pipeline}
|
||||
if (command instanceof GetPipelineStateCommand) return {stageStates: [{stageName: 'Build', latestExecution: {status: 'InProgress'}, actionStates: [{actionName: 'Build', latestExecution: {status: 'InProgress', externalExecutionId: 'build-app:1'}}]}]}
|
||||
return {}
|
||||
})
|
||||
const adapter = new AwsCodePipelineAdapter(client)
|
||||
expect(await adapter.startPipeline('release')).toEqual({id: 'execution-1'})
|
||||
expect((await adapter.listPipelineExecutions('release'))[0]).toMatchObject({id: 'execution-1', status: 'InProgress'})
|
||||
expect((await adapter.getPipelineState('release')).stages[0].actions[0]).toMatchObject({name: 'Build', codeBuildProject: 'build-app', externalExecutionId: 'build-app:1'})
|
||||
expect(sent.some((command) => command instanceof StartPipelineExecutionCommand)).toBe(true)
|
||||
expect(sent.some((command) => command instanceof ListPipelineExecutionsCommand)).toBe(true)
|
||||
expect(sent.some((command) => command instanceof GetPipelineStateCommand)).toBe(true)
|
||||
})
|
||||
|
||||
test('lists native pipeline summaries', async () => {
|
||||
const {client} = stub((command) => command instanceof ListPipelinesCommand ? {pipelines: [{name: 'release', version: 2, executionMode: 'SUPERSEDED'}]} : {})
|
||||
const pipelines = await new AwsCodePipelineAdapter(client).list()
|
||||
expect(pipelines[0]).toMatchObject({id: 'release', version: '2', metadata: {executionMode: 'SUPERSEDED'}})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
CreatePipelineCommand,
|
||||
DeletePipelineCommand,
|
||||
GetPipelineCommand,
|
||||
GetPipelineStateCommand,
|
||||
ListPipelinesCommand,
|
||||
ListPipelineExecutionsCommand,
|
||||
StartPipelineExecutionCommand,
|
||||
type ActionState,
|
||||
type CodePipelineClient,
|
||||
type PipelineDeclaration,
|
||||
} from '@aws-sdk/client-codepipeline'
|
||||
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import {awsCodePipelineSchema} from '../cloud-spi/codepipelineSchema'
|
||||
import type {PipelineExecutionSummary, PipelineState} from '../cloud-spi/codepipeline'
|
||||
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||
|
||||
export class AwsCodePipelineAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'codepipeline' as const
|
||||
|
||||
constructor(private readonly client: CodePipelineClient) {}
|
||||
|
||||
schema(): ServiceSchema { return awsCodePipelineSchema() }
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const pipelines: CloudResource[] = []
|
||||
let nextToken: string | undefined
|
||||
do {
|
||||
const response = await this.client.send(new ListPipelinesCommand(nextToken ? {nextToken} : {}))
|
||||
pipelines.push(...(response.pipelines ?? []).map((pipeline) => toSummaryResource(pipeline)))
|
||||
nextToken = response.nextToken
|
||||
} while (nextToken)
|
||||
const search = query.search?.trim().toLowerCase()
|
||||
return pipelines.filter((pipeline) => !search || pipeline.name.toLowerCase().includes(search))
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const response = await this.client.send(new GetPipelineCommand({name: id}))
|
||||
return response.pipeline ? toResource(response.pipeline, response.metadata) : null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const name = required(input.values.name, 'name')
|
||||
const declaration: PipelineDeclaration = {
|
||||
name,
|
||||
roleArn: required(input.values.roleArn, 'roleArn'),
|
||||
artifactStore: {type: 'S3', location: required(input.values.artifactBucket, 'artifactBucket')},
|
||||
stages: [
|
||||
stage('Source', action('Source', 'Source', 'S3', {S3Bucket: required(input.values.sourceBucket, 'sourceBucket'), S3ObjectKey: required(input.values.sourceKey, 'sourceKey'), PollForSourceChanges: 'false'}, [], ['SourceOutput'])),
|
||||
stage('Build', action('Build', 'Build', 'CodeBuild', {ProjectName: required(input.values.codeBuildProject, 'codeBuildProject')}, ['SourceOutput'], ['BuildOutput'])),
|
||||
stage('Deploy', action('Deploy', 'Deploy', 'S3', {BucketName: required(input.values.deployBucket, 'deployBucket'), ObjectKey: required(input.values.deployKey, 'deployKey'), Extract: 'true'}, ['BuildOutput'], [])),
|
||||
],
|
||||
}
|
||||
const response = await this.client.send(new CreatePipelineCommand({pipeline: declaration}))
|
||||
if (!response.pipeline) throw new RuntimeError(`CodePipeline did not return pipeline ${name}`)
|
||||
return toResource(response.pipeline)
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> { await this.client.send(new DeletePipelineCommand({name: id})) }
|
||||
|
||||
async startPipeline(id: string): Promise<{id: string}> {
|
||||
const response = await this.client.send(new StartPipelineExecutionCommand({name: id}))
|
||||
if (!response.pipelineExecutionId) throw new RuntimeError(`CodePipeline did not return an execution for ${id}`)
|
||||
return {id: response.pipelineExecutionId}
|
||||
}
|
||||
|
||||
async listPipelineExecutions(id: string): Promise<PipelineExecutionSummary[]> {
|
||||
const executions: PipelineExecutionSummary[] = []
|
||||
let nextToken: string | undefined
|
||||
do {
|
||||
const response = await this.client.send(new ListPipelineExecutionsCommand(nextToken ? {pipelineName: id, nextToken} : {pipelineName: id}))
|
||||
for (const execution of response.pipelineExecutionSummaries ?? []) {
|
||||
if (execution.pipelineExecutionId) executions.push({id: execution.pipelineExecutionId, status: execution.status, statusSummary: execution.statusSummary, startTime: execution.startTime, lastUpdateTime: execution.lastUpdateTime})
|
||||
}
|
||||
nextToken = response.nextToken
|
||||
} while (nextToken)
|
||||
return executions
|
||||
}
|
||||
|
||||
async getPipelineState(id: string): Promise<PipelineState> {
|
||||
const [pipeline, state] = await Promise.all([this.client.send(new GetPipelineCommand({name: id})), this.client.send(new GetPipelineStateCommand({name: id}))])
|
||||
const actions = (pipeline.pipeline?.stages ?? []).flatMap((stage) => stage.actions ?? [])
|
||||
const codeBuildProjects = new Map(actions.filter((item) => item.actionTypeId?.provider === 'CodeBuild').map((item) => [item.name, item.configuration?.ProjectName] as const))
|
||||
const codeDeployActions = new Set(actions.filter((item) => item.actionTypeId?.provider === 'CodeDeploy').map((item) => item.name))
|
||||
return {stages: (state.stageStates ?? []).map((stage) => ({name: stage.stageName ?? 'Unknown', status: stage.latestExecution?.status, actions: (stage.actionStates ?? []).map((actionState) => toActionState(actionState, codeBuildProjects, codeDeployActions))}))}
|
||||
}
|
||||
}
|
||||
|
||||
function toResource(pipeline: PipelineDeclaration, metadata?: {pipelineArn?: string; created?: Date; updated?: Date}): CloudResource {
|
||||
const name = pipeline.name ?? ''
|
||||
return {id: name, name, cloud: 'aws', service: 'codepipeline', type: 'codepipeline-pipeline', region: regionFromArn(metadata?.pipelineArn), createdAt: metadata?.created?.toISOString() ?? null, version: pipeline.version?.toString(), metadata: {arn: metadata?.pipelineArn, roleArn: pipeline.roleArn, executionMode: pipeline.executionMode, pipelineType: pipeline.pipelineType, artifactBucket: pipeline.artifactStore?.location, updatedAt: metadata?.updated?.toISOString()}}
|
||||
}
|
||||
|
||||
function toSummaryResource(pipeline: {name?: string; version?: number; created?: Date; updated?: Date; executionMode?: string; pipelineType?: string}): CloudResource {
|
||||
const name = pipeline.name ?? ''
|
||||
return {id: name, name, cloud: 'aws', service: 'codepipeline', type: 'codepipeline-pipeline', region: null, createdAt: pipeline.created?.toISOString() ?? null, version: pipeline.version?.toString(), metadata: {executionMode: pipeline.executionMode, pipelineType: pipeline.pipelineType, updatedAt: pipeline.updated?.toISOString()}}
|
||||
}
|
||||
|
||||
function toActionState(action: ActionState, codeBuildProjects: Map<string | undefined, string | undefined>, codeDeployActions: Set<string | undefined>) {
|
||||
const execution = action.latestExecution
|
||||
return {name: action.actionName ?? 'Unknown', status: execution?.status, summary: execution?.summary, lastStatusChange: execution?.lastStatusChange, externalExecutionId: execution?.externalExecutionId, externalExecutionUrl: execution?.externalExecutionUrl, entityUrl: action.entityUrl, revisionUrl: action.revisionUrl, codeBuildProject: codeBuildProjects.get(action.actionName), codeDeployDeploymentId: codeDeployActions.has(action.actionName) ? execution?.externalExecutionId : undefined}
|
||||
}
|
||||
|
||||
function stage(name: string, item: ReturnType<typeof action>) { return {name, actions: [item]} }
|
||||
function action(name: string, category: 'Source' | 'Build' | 'Deploy', provider: 'S3' | 'CodeBuild', configuration: Record<string, string>, inputArtifacts: string[], outputArtifacts: string[]) { return {name, actionTypeId: {category, owner: 'AWS' as const, provider, version: '1'}, configuration, inputArtifacts: inputArtifacts.map((name) => ({name})), outputArtifacts: outputArtifacts.map((name) => ({name}))} }
|
||||
function required(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim()) throw new ValidationError(`${field} is required`); return value.trim() }
|
||||
function regionFromArn(arn?: string): string | null { return arn?.split(':')[3] || null }
|
||||
@@ -8,8 +8,12 @@ import {
|
||||
DescribeOrderableDBInstanceOptionsCommand,
|
||||
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<unknown>
|
||||
|
||||
@@ -47,6 +54,17 @@ function adapterWith(responder: Responder) {
|
||||
}
|
||||
}
|
||||
|
||||
function recordingRdsData(responder: (command: ExecuteStatementCommand) => unknown | Promise<unknown>) {
|
||||
const commands: ExecuteStatementCommand[] = []
|
||||
const client = {
|
||||
async send(command: ExecuteStatementCommand) {
|
||||
commands.push(command)
|
||||
return responder(command)
|
||||
},
|
||||
} as unknown as RDSDataClient
|
||||
return {client, commands}
|
||||
}
|
||||
|
||||
function instanceResponse(command: CreateDBInstanceCommand) {
|
||||
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)
|
||||
|
||||
@@ -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<void> {
|
||||
await this.rdsService_.startInstance(id)
|
||||
}
|
||||
|
||||
async stop(id: string): Promise<void> {
|
||||
await this.rdsService_.stopInstance(id)
|
||||
}
|
||||
|
||||
async reboot(id: string): Promise<void> {
|
||||
await this.rdsService_.rebootInstance(id)
|
||||
}
|
||||
|
||||
async listDatabaseSnapshots(instanceIdentifier?: string): Promise<DatabaseSnapshot[]> {
|
||||
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<SqlDatabase[]> {
|
||||
const instance = await this.rdsService_.describeInstance(serverId)
|
||||
const engine = sqlEngine(instance.engine)
|
||||
const result = await this.execute(instance, defaultDatabase(instance, engine), databaseListQuery(engine))
|
||||
return (result.resultSets[0]?.rows ?? []).flatMap((row) => {
|
||||
const name = stringValue(row.name)
|
||||
if (!name) return []
|
||||
return [{
|
||||
name,
|
||||
state: stringValue(row.state) || 'ONLINE',
|
||||
createdAt: null,
|
||||
isSystem: row.isSystem === true,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
async listSqlTables(serverId: string, connection: SqlConnectionInput): Promise<SqlTable[]> {
|
||||
const instance = await this.rdsService_.describeInstance(serverId)
|
||||
const engine = sqlEngine(instance.engine)
|
||||
const database = sqlDatabase(connection, instance, engine)
|
||||
const result = await this.execute(instance, database, tableListQuery(engine))
|
||||
return (result.resultSets[0]?.rows ?? []).flatMap((row) => {
|
||||
const schema = stringValue(row.schemaName)
|
||||
const name = stringValue(row.name)
|
||||
if (!schema || !name) return []
|
||||
return [{
|
||||
schema,
|
||||
name,
|
||||
type: row.objectType === 'view' ? 'view' as const : 'table' as const,
|
||||
rowCount: integerValue(row.rowCount),
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
async querySql(serverId: string, connection: SqlConnectionInput, query: string): Promise<SqlQueryResult> {
|
||||
if (!query.trim()) throw new ValidationError('SQL query is required')
|
||||
const instance = await this.rdsService_.describeInstance(serverId)
|
||||
const engine = sqlEngine(instance.engine)
|
||||
return this.execute(instance, sqlDatabase(connection, instance, engine), query)
|
||||
}
|
||||
|
||||
private async execute(instance: RdsInstance, database: string, sql: string): Promise<SqlQueryResult> {
|
||||
if (!instance.arn) throw new ValidationError('DB instance ARN is not available')
|
||||
const startedAt = performance.now()
|
||||
const response = await this.rdsData.send(new ExecuteStatementCommand({
|
||||
resourceArn: instance.arn,
|
||||
// Floci falls back to the RDS instance credentials when this local
|
||||
// placeholder does not resolve to a Secrets Manager secret.
|
||||
secretArn: 'arn:aws:secretsmanager:us-east-1:000000000000:secret:floci-ui-rds-data',
|
||||
database,
|
||||
sql,
|
||||
includeResultMetadata: true,
|
||||
}))
|
||||
const columns = (response.columnMetadata ?? []).map((column, index) => ({
|
||||
name: column.name ?? `column_${index + 1}`,
|
||||
type: String(column.typeName ?? column.type ?? 'unknown'),
|
||||
}))
|
||||
const rows = (response.records ?? []).map((record) => Object.fromEntries(
|
||||
record.map((field, index) => [columns[index]?.name ?? `column_${index + 1}`, fieldValue(field)]),
|
||||
))
|
||||
return {
|
||||
resultSets: columns.length > 0 ? [{columns, rows, truncated: false}] : [],
|
||||
rowsAffected: [response.numberOfRecordsUpdated ?? 0],
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
}
|
||||
}
|
||||
|
||||
private async toResource(instance: RdsInstance): Promise<CloudResource> {
|
||||
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])?$/
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface CodeBuildEnvironmentVariableOverride {
|
||||
name: string
|
||||
value: string
|
||||
type?: 'PLAINTEXT' | 'PARAMETER_STORE' | 'SECRETS_MANAGER'
|
||||
}
|
||||
|
||||
export interface StartCodeBuildInput {
|
||||
environmentVariablesOverride?: CodeBuildEnvironmentVariableOverride[]
|
||||
}
|
||||
|
||||
export interface CodeBuildPhase {
|
||||
phaseType: string
|
||||
phaseStatus?: string
|
||||
startTime?: Date
|
||||
endTime?: Date
|
||||
contexts: Array<{statusCode?: string; message?: string}>
|
||||
}
|
||||
|
||||
export interface CodeBuildBuild {
|
||||
id: string
|
||||
arn?: string
|
||||
projectName: string
|
||||
buildNumber?: number
|
||||
buildStatus?: string
|
||||
currentPhase?: string
|
||||
startTime?: Date
|
||||
endTime?: Date
|
||||
phases: CodeBuildPhase[]
|
||||
logGroupName?: string
|
||||
logStreamName?: string
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||
|
||||
const projectColumns: TableColumnSchema[] = [
|
||||
{name: 'name', label: 'Name'},
|
||||
{name: 'environmentType', label: 'Environment', path: 'metadata.environmentType', format: 'badge'},
|
||||
{name: 'computeType', label: 'Compute', path: 'metadata.computeType', format: 'badge'},
|
||||
{name: 'createdAt', label: 'Created', format: 'datetime'},
|
||||
]
|
||||
|
||||
const projectFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||
|
||||
/** The console exposes only source and artifact modes the local runtime can execute. */
|
||||
export function awsCodeBuildSchema(): ServiceSchema {
|
||||
return {
|
||||
cloud: 'aws',
|
||||
service: 'codebuild',
|
||||
displayName: 'AWS CodeBuild',
|
||||
fields: [
|
||||
{name: 'name', label: 'Project name', type: 'text', required: true, group: 'Required'},
|
||||
{name: 'serviceRole', label: 'Service role ARN', type: 'text', required: true, group: 'Required'},
|
||||
{
|
||||
name: 'environmentImage', label: 'Environment image', type: 'text', required: false, group: 'Environment',
|
||||
defaultValue: 'aws/codebuild/standard:7.0',
|
||||
description: 'AWS CodeBuild managed image identifier.',
|
||||
},
|
||||
{
|
||||
name: 'computeType', label: 'Compute type', type: 'select', required: true, group: 'Environment', defaultValue: 'BUILD_GENERAL1_SMALL',
|
||||
options: [{label: 'General 1 small', value: 'BUILD_GENERAL1_SMALL'}],
|
||||
},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {
|
||||
resourceActions: [
|
||||
{name: 'list', label: 'List projects', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'inspect', label: 'Inspect project', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'create', label: 'Create project', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete project', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
},
|
||||
filters: projectFilters,
|
||||
columns: projectColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface CodeDeployDeployment {
|
||||
id: string
|
||||
applicationName: string
|
||||
deploymentGroupName: string
|
||||
status: string | null
|
||||
creator: string | null
|
||||
deploymentConfigName: string | null
|
||||
createTime: Date | undefined
|
||||
completeTime: Date | undefined
|
||||
errorInformation: {code?: string; message?: string} | null
|
||||
targetVersion: string | null
|
||||
currentVersion: string | null
|
||||
trafficPercent: number | null
|
||||
}
|
||||
|
||||
export interface StartCodeDeployDeploymentInput {
|
||||
functionName: string
|
||||
alias: string
|
||||
currentVersion: string
|
||||
targetVersion: string
|
||||
beforeAllowTrafficHook?: string
|
||||
afterAllowTrafficHook?: string
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||
|
||||
const columns: TableColumnSchema[] = [
|
||||
{name: 'name', label: 'Deployment group'},
|
||||
{name: 'applicationName', label: 'Application', path: 'metadata.applicationName'},
|
||||
{name: 'deploymentConfigName', label: 'Configuration', path: 'metadata.deploymentConfigName', format: 'badge'},
|
||||
{name: 'updatedAt', label: 'Updated', path: 'metadata.updatedAt', format: 'datetime'},
|
||||
]
|
||||
|
||||
const filters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||
|
||||
export function awsCodeDeploySchema(): ServiceSchema {
|
||||
return {
|
||||
cloud: 'aws', service: 'codedeploy', displayName: 'AWS CodeDeploy', fields: [], actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {resourceActions: [
|
||||
{name: 'list', label: 'List deployment groups', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'inspect', label: 'Inspect deployment group', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'create', label: 'Create application and group', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete deployment group', enabled: true, status: 'available', runtimeRequired: true},
|
||||
]},
|
||||
filters, columns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface PipelineExecutionSummary {
|
||||
id: string
|
||||
status?: string
|
||||
statusSummary?: string
|
||||
startTime?: Date
|
||||
lastUpdateTime?: Date
|
||||
}
|
||||
|
||||
export interface PipelineActionState {
|
||||
name: string
|
||||
status?: string
|
||||
summary?: string
|
||||
lastStatusChange?: Date
|
||||
externalExecutionId?: string
|
||||
externalExecutionUrl?: string
|
||||
entityUrl?: string
|
||||
revisionUrl?: string
|
||||
codeBuildProject?: string
|
||||
codeDeployDeploymentId?: string
|
||||
}
|
||||
|
||||
export interface PipelineStageState {
|
||||
name: string
|
||||
status?: string
|
||||
lastStatusChange?: Date
|
||||
actions: PipelineActionState[]
|
||||
}
|
||||
|
||||
export interface PipelineState {
|
||||
stages: PipelineStageState[]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type {FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||
|
||||
const columns: TableColumnSchema[] = [
|
||||
{name: 'name', label: 'Name'},
|
||||
{name: 'executionMode', label: 'Execution mode', path: 'metadata.executionMode', format: 'badge'},
|
||||
{name: 'version', label: 'Version', format: 'code'},
|
||||
{name: 'updatedAt', label: 'Updated', path: 'metadata.updatedAt', format: 'datetime'},
|
||||
]
|
||||
|
||||
const filters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||
|
||||
export function awsCodePipelineSchema(): ServiceSchema {
|
||||
return {
|
||||
cloud: 'aws', service: 'codepipeline', displayName: 'AWS CodePipeline', fields: [
|
||||
{name: 'name', label: 'Pipeline name', type: 'text', required: true, group: 'Pipeline'},
|
||||
{name: 'roleArn', label: 'Service role ARN', type: 'text', required: true, group: 'Pipeline'},
|
||||
{name: 'artifactBucket', label: 'Artifact S3 bucket', type: 'text', required: true, group: 'Artifacts'},
|
||||
{name: 'sourceBucket', label: 'Source S3 bucket', type: 'text', required: true, group: 'Source'},
|
||||
{name: 'sourceKey', label: 'Source object key', type: 'text', required: true, group: 'Source'},
|
||||
{name: 'codeBuildProject', label: 'CodeBuild project', type: 'text', required: true, group: 'Build'},
|
||||
{name: 'deployBucket', label: 'Deploy S3 bucket', type: 'text', required: true, group: 'Deploy'},
|
||||
{name: 'deployKey', label: 'Deploy object key', type: 'text', required: true, group: 'Deploy'},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {resourceActions: [
|
||||
{name: 'list', label: 'List pipelines', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'inspect', label: 'Inspect pipeline', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'create', label: 'Create pipeline', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete pipeline', enabled: true, status: 'available', runtimeRequired: true},
|
||||
]},
|
||||
filters, columns,
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,9 @@ export function awsDatabaseSchema(): ServiceSchema {
|
||||
{name: 'update', label: 'Update DB instance', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: '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: [
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<void>
|
||||
startAppConfigDeployment?(applicationId: string, environmentId: string, input: CreateResourceInput): Promise<AppConfigDeployment>
|
||||
getAppConfigDeployment?(applicationId: string, environmentId: string, deploymentNumber: number): Promise<AppConfigDeployment | null>
|
||||
startBuild?(projectName: string, input: StartCodeBuildInput): Promise<CodeBuildBuild>
|
||||
listBuilds?(projectName: string): Promise<CodeBuildBuild[]>
|
||||
getBuild?(id: string): Promise<CodeBuildBuild | null>
|
||||
startPipeline?(id: string): Promise<{id: string}>
|
||||
listPipelineExecutions?(id: string): Promise<PipelineExecutionSummary[]>
|
||||
getPipelineState?(id: string): Promise<PipelineState>
|
||||
startCodeDeployDeployment?(applicationName: string, deploymentGroupName: string, input: StartCodeDeployDeploymentInput): Promise<CodeDeployDeployment>
|
||||
listCodeDeployDeployments?(applicationName: string, deploymentGroupName: string): Promise<CodeDeployDeployment[]>
|
||||
getCodeDeployDeployment?(id: string): Promise<CodeDeployDeployment>
|
||||
stopCodeDeployDeployment?(id: string, rollback: boolean): Promise<void>
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<Response>): Promise<Response> {
|
||||
try {
|
||||
return await handler()
|
||||
|
||||
@@ -27,3 +27,25 @@ describe('CloudProxyService API Gateway CORS', () => {
|
||||
}}])
|
||||
})
|
||||
})
|
||||
|
||||
describe('CloudProxyService resource lifecycle', () => {
|
||||
test('forwards lifecycle actions to the registered adapter', async () => {
|
||||
const calls: string[] = []
|
||||
const adapter: CloudServiceAdapter = {
|
||||
cloud: 'aws', service: 'apigateway', schema: awsApiGatewaySchema,
|
||||
list: async () => [], get: async () => null, create: async () => ({
|
||||
id: 'api', name: 'api', cloud: 'aws', service: 'apigateway', type: 'rest-api', region: null, createdAt: null, metadata: {},
|
||||
}), delete: async () => {},
|
||||
start: async (id) => { calls.push(`start:${id}`) },
|
||||
stop: async (id) => { calls.push(`stop:${id}`) },
|
||||
reboot: async (id) => { calls.push(`reboot:${id}`) },
|
||||
}
|
||||
const service = new CloudProxyService(new CloudAdapterRegistry([adapter]))
|
||||
|
||||
await service.startResource('aws', 'apigateway', 'api-123')
|
||||
await service.stopResource('aws', 'apigateway', 'api-123')
|
||||
await service.rebootResource('aws', 'apigateway', 'api-123')
|
||||
|
||||
expect(calls).toEqual(['start:api-123', 'stop:api-123', 'reboot:api-123'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -86,6 +86,9 @@ import type {
|
||||
import {NotSupportedError} from '../cloud-spi/errors'
|
||||
import {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<void> {
|
||||
const adapter = this.requireAdapter(cloud, service)
|
||||
if (!adapter.start) throw new NotSupportedError(`Resource start is not supported for ${cloud}/${service}`)
|
||||
await adapter.start(id)
|
||||
}
|
||||
|
||||
async stopResource(cloud: CloudProvider, service: CloudServiceType, id: string): Promise<void> {
|
||||
const adapter = this.requireAdapter(cloud, service)
|
||||
if (!adapter.stop) throw new NotSupportedError(`Resource stop is not supported for ${cloud}/${service}`)
|
||||
await adapter.stop(id)
|
||||
}
|
||||
|
||||
async rebootResource(cloud: CloudProvider, service: CloudServiceType, id: string): Promise<void> {
|
||||
const adapter = this.requireAdapter(cloud, service)
|
||||
if (!adapter.reboot) throw new NotSupportedError(`Resource reboot is not supported for ${cloud}/${service}`)
|
||||
await adapter.reboot(id)
|
||||
}
|
||||
|
||||
async encryptKms(cloud: CloudProvider, keyId: string, input: KmsEncryptInput): Promise<KmsEncryptResult> {
|
||||
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<CodeBuildBuild> {
|
||||
return this.codeBuildAdapter(cloud).startBuild!(projectName, input)
|
||||
}
|
||||
|
||||
async listCodeBuilds(cloud: CloudProvider, projectName: string): Promise<CodeBuildBuild[]> {
|
||||
return this.codeBuildAdapter(cloud).listBuilds!(projectName)
|
||||
}
|
||||
|
||||
async getCodeBuild(cloud: CloudProvider, id: string): Promise<CodeBuildBuild | null> {
|
||||
return this.codeBuildAdapter(cloud).getBuild!(id)
|
||||
}
|
||||
|
||||
async startPipeline(cloud: CloudProvider, id: string): Promise<{id: string}> {
|
||||
return this.codePipelineAdapter(cloud).startPipeline!(id)
|
||||
}
|
||||
|
||||
async listPipelineExecutions(cloud: CloudProvider, id: string): Promise<PipelineExecutionSummary[]> {
|
||||
return this.codePipelineAdapter(cloud).listPipelineExecutions!(id)
|
||||
}
|
||||
|
||||
async getPipelineState(cloud: CloudProvider, id: string): Promise<PipelineState> {
|
||||
return this.codePipelineAdapter(cloud).getPipelineState!(id)
|
||||
}
|
||||
|
||||
async startCodeDeployDeployment(cloud: CloudProvider, applicationName: string, deploymentGroupName: string, input: StartCodeDeployDeploymentInput): Promise<CodeDeployDeployment> {
|
||||
return this.codeDeployAdapter(cloud).startCodeDeployDeployment!(applicationName, deploymentGroupName, input)
|
||||
}
|
||||
|
||||
async listCodeDeployDeployments(cloud: CloudProvider, applicationName: string, deploymentGroupName: string): Promise<CodeDeployDeployment[]> {
|
||||
return this.codeDeployAdapter(cloud).listCodeDeployDeployments!(applicationName, deploymentGroupName)
|
||||
}
|
||||
|
||||
async getCodeDeployDeployment(cloud: CloudProvider, id: string): Promise<CodeDeployDeployment> {
|
||||
return this.codeDeployAdapter(cloud).getCodeDeployDeployment!(id)
|
||||
}
|
||||
|
||||
async stopCodeDeployDeployment(cloud: CloudProvider, id: string, rollback: boolean): Promise<void> {
|
||||
await this.codeDeployAdapter(cloud).stopCodeDeployDeployment!(id, rollback)
|
||||
}
|
||||
|
||||
private workflowAdapter(cloud: CloudProvider) {
|
||||
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}`)
|
||||
|
||||
@@ -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<void> {
|
||||
await client.send(
|
||||
new StartDBInstanceCommand({ DBInstanceIdentifier: identifier }),
|
||||
);
|
||||
},
|
||||
|
||||
async stopInstance(identifier: string): Promise<void> {
|
||||
await client.send(
|
||||
new StopDBInstanceCommand({ DBInstanceIdentifier: identifier }),
|
||||
);
|
||||
},
|
||||
|
||||
async rebootInstance(identifier: string): Promise<void> {
|
||||
await client.send(
|
||||
new RebootDBInstanceCommand({ DBInstanceIdentifier: identifier }),
|
||||
);
|
||||
},
|
||||
|
||||
async listSnapshots(instanceIdentifier?: string): Promise<RdsSnapshot[]> {
|
||||
const snapshots: RdsSnapshot[] = [];
|
||||
let marker: string | undefined;
|
||||
|
||||
@@ -23,6 +23,9 @@ export const apiEndpointKeys = {
|
||||
update: "clouds.services.resources.update",
|
||||
delete: "clouds.services.resources.delete",
|
||||
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",
|
||||
@@ -61,6 +64,16 @@ export const apiEndpointKeys = {
|
||||
describeWorkflowExecution: "clouds.services.resources.workflows.execution.describe",
|
||||
getWorkflowExecutionHistory: "clouds.services.resources.workflows.execution.history.get",
|
||||
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,
|
||||
{
|
||||
|
||||
@@ -208,6 +208,42 @@ export async function deleteCloudResource(
|
||||
);
|
||||
}
|
||||
|
||||
export async function startCloudResource(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.clouds.resources.start,
|
||||
requestOptions(cloud, service),
|
||||
{ cloud, service, id },
|
||||
);
|
||||
}
|
||||
|
||||
export async function stopCloudResource(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.clouds.resources.stop,
|
||||
requestOptions(cloud, service),
|
||||
{ cloud, service, id },
|
||||
);
|
||||
}
|
||||
|
||||
export async function rebootCloudResource(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.clouds.resources.reboot,
|
||||
requestOptions(cloud, service),
|
||||
{ cloud, service, id },
|
||||
);
|
||||
}
|
||||
|
||||
export async function clearEmailInbox(
|
||||
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<CodeBuildBuild> {
|
||||
const res = await apiClient.call<CodeBuildBuild, typeof input>(apiEndpointKeys.clouds.resources.startCodeBuild, requestOptions("aws", "codebuild", {body: input}), {cloud: "aws", id: projectName});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listCodeBuilds(projectName: string, signal?: AbortSignal): Promise<CodeBuildBuild[]> {
|
||||
const res = await apiClient.call<CodeBuildBuild[]>(apiEndpointKeys.clouds.resources.listCodeBuilds, requestOptions("aws", "codebuild", {signal}), {cloud: "aws", id: projectName});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getCodeBuild(projectName: string, buildId: string, signal?: AbortSignal): Promise<CodeBuildBuild> {
|
||||
const res = await apiClient.call<CodeBuildBuild>(apiEndpointKeys.clouds.resources.getCodeBuild, requestOptions("aws", "codebuild", {signal}), {cloud: "aws", id: projectName, buildId});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function startCodePipeline(name: string): Promise<{id: string}> {
|
||||
const res = await apiClient.call<{id: string}>(apiEndpointKeys.clouds.resources.startCodePipeline, requestOptions('aws', 'codepipeline'), {cloud: 'aws', id: name});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listCodePipelineExecutions(name: string, signal?: AbortSignal): Promise<PipelineExecutionSummary[]> {
|
||||
const res = await apiClient.call<PipelineExecutionSummary[]>(apiEndpointKeys.clouds.resources.listCodePipelineExecutions, requestOptions('aws', 'codepipeline', {signal}), {cloud: 'aws', id: name});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getCodePipelineState(name: string, signal?: AbortSignal): Promise<PipelineState> {
|
||||
const res = await apiClient.call<PipelineState>(apiEndpointKeys.clouds.resources.getCodePipelineState, requestOptions('aws', 'codepipeline', {signal}), {cloud: 'aws', id: name});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getCodeDeployDependencies(signal?: AbortSignal): Promise<CodeDeployDependencies> { const res = await apiClient.call<CodeDeployDependencies>(apiEndpointKeys.clouds.dependencies, requestOptions('aws', 'codedeploy', {signal}), {cloud: 'aws', service: 'codedeploy'}); return res.data }
|
||||
export async function startCodeDeployDeployment(groupId: string, input: StartCodeDeployDeploymentInput): Promise<CodeDeployDeployment> { const res = await apiClient.call<CodeDeployDeployment, StartCodeDeployDeploymentInput>(apiEndpointKeys.clouds.resources.startCodeDeployDeployment, requestOptions('aws', 'codedeploy', {body: input}), {cloud: 'aws', id: groupId}); return res.data }
|
||||
export async function listCodeDeployDeployments(groupId: string, signal?: AbortSignal): Promise<CodeDeployDeployment[]> { const res = await apiClient.call<CodeDeployDeployment[]>(apiEndpointKeys.clouds.resources.listCodeDeployDeployments, requestOptions('aws', 'codedeploy', {signal}), {cloud: 'aws', id: groupId}); return res.data }
|
||||
export async function getCodeDeployDeployment(id: string, signal?: AbortSignal): Promise<CodeDeployDeployment> { const res = await apiClient.call<CodeDeployDeployment>(apiEndpointKeys.clouds.resources.getCodeDeployDeployment, requestOptions('aws', 'codedeploy', {signal}), {cloud: 'aws', deploymentId: id}); return res.data }
|
||||
export async function stopCodeDeployDeployment(id: string, rollback: boolean): Promise<void> { await apiClient.call<void, {rollback: boolean}>(apiEndpointKeys.clouds.resources.stopCodeDeployDeployment, requestOptions('aws', 'codedeploy', {body: {rollback}}), {cloud: 'aws', deploymentId: id}) }
|
||||
|
||||
export async function publishNotification(
|
||||
cloud: CloudProvider,
|
||||
topicArn: string,
|
||||
|
||||
@@ -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<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!username || !password) return;
|
||||
if (!isAwsRds && (!username || !password)) return;
|
||||
queryMut.reset();
|
||||
loadDatabases();
|
||||
}
|
||||
@@ -181,12 +187,12 @@ export function AzureSqlPanel({
|
||||
<div className="sql-connection-title">
|
||||
<Plug size={16} />
|
||||
<span>
|
||||
<small>{engine === "postgresql" ? "POSTGRESQL DATA PLANE" : "SQL DATA PLANE"}</small>
|
||||
<small>{isAwsRds ? "RDS DATA API" : engine === "postgresql" ? "POSTGRESQL DATA PLANE" : "SQL DATA PLANE"}</small>
|
||||
<strong>{resource.name}</strong>
|
||||
<em>{metadataString(resource.metadata.fullyQualifiedDomainName)}</em>
|
||||
<em>{isAwsRds ? `${resource.engine} · ${resource.status ?? "unknown"}` : metadataString(resource.metadata.fullyQualifiedDomainName)}</em>
|
||||
</span>
|
||||
</div>
|
||||
<label>
|
||||
{!isAwsRds && <label>
|
||||
<span>Username</span>
|
||||
<input
|
||||
className="input"
|
||||
@@ -195,8 +201,8 @@ export function AzureSqlPanel({
|
||||
disabled={connected || !runtimeReachable}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
</label>}
|
||||
{!isAwsRds && <label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
className="input"
|
||||
@@ -206,7 +212,7 @@ export function AzureSqlPanel({
|
||||
disabled={connected || !runtimeReachable}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
</label>}
|
||||
{connected ? (
|
||||
<button className="button" type="button" onClick={disconnect}>
|
||||
<Unplug size={14} />
|
||||
@@ -219,8 +225,7 @@ export function AzureSqlPanel({
|
||||
disabled={
|
||||
!runtimeReachable ||
|
||||
databasesMut.isPending ||
|
||||
!username ||
|
||||
!password
|
||||
(!isAwsRds && (!username || !password))
|
||||
}
|
||||
>
|
||||
<Plug size={14} />
|
||||
@@ -228,7 +233,9 @@ export function AzureSqlPanel({
|
||||
</button>
|
||||
)}
|
||||
<small className="sql-credential-note">
|
||||
Credentials remain in this page only and are sent to the local API per request.
|
||||
{isAwsRds
|
||||
? "Queries use Floci's local RDS Data API credentials. Database passwords are not sent by the console."
|
||||
: "Credentials remain in this page only and are sent to the local API per request."}
|
||||
</small>
|
||||
{connectionError && <div className="form-error">{connectionError}</div>}
|
||||
</form>
|
||||
@@ -236,7 +243,7 @@ export function AzureSqlPanel({
|
||||
{!connected ? (
|
||||
<div className="empty compact sql-connect-empty">
|
||||
<h3>Connect to browse SQL data</h3>
|
||||
<p>Use the administrator login configured for this local engine.</p>
|
||||
<p>{isAwsRds ? "Connect through Floci's local RDS Data API to browse databases and run SQL." : "Use the administrator login configured for this local engine."}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="sql-explorer">
|
||||
@@ -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"}
|
||||
/>
|
||||
<div className="sql-query-editor">
|
||||
@@ -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, "]]")}]`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {ExternalLink, Play, Plus, RefreshCw, X} from 'lucide-react'
|
||||
import {getCodeBuild, listCodeBuilds, startCodeBuild, type CodeBuildBuild, type CodeBuildEnvironmentVariableOverride} from '@/api/cloudProxyClient'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
interface CodeBuildPanelProps { resource?: CloudResource; runtimeReachable: boolean }
|
||||
|
||||
export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps) {
|
||||
const client = useQueryClient()
|
||||
const [startOpen, setStartOpen] = useState(false)
|
||||
const [overrides, setOverrides] = useState<CodeBuildEnvironmentVariableOverride[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string>()
|
||||
const enabled = Boolean(resource && runtimeReachable)
|
||||
const buildsKey = ['codebuild-builds', resource?.id]
|
||||
const builds = useQuery({queryKey: buildsKey, queryFn: ({signal}) => listCodeBuilds(resource!.id, signal), enabled, refetchInterval: (query) => query.state.data?.some((build) => active(build.buildStatus)) ? 2_000 : false})
|
||||
const detail = useQuery({queryKey: ['codebuild-build', resource?.id, selectedId], queryFn: ({signal}) => getCodeBuild(resource!.id, selectedId!, signal), enabled: enabled && Boolean(selectedId), refetchInterval: (query) => active(query.state.data?.buildStatus) ? 2_000 : false})
|
||||
const start = useMutation({mutationFn: () => startCodeBuild(resource!.id, {environmentVariablesOverride: overrides.length ? overrides : undefined}), onSuccess: (build) => { setSelectedId(build.id); setStartOpen(false); setOverrides([]); void client.invalidateQueries({queryKey: buildsKey}) }})
|
||||
useEffect(() => { setSelectedId(undefined); setStartOpen(false); setOverrides([]) }, [resource?.id])
|
||||
if (!resource) return null
|
||||
const selected = detail.data ?? builds.data?.find((build) => build.id === selectedId)
|
||||
return <section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Builds</p><h3>Run and inspect builds</h3></div><div className="header-actions"><button className="button" type="button" disabled={!enabled || builds.isFetching} onClick={() => builds.refetch()}><RefreshCw size={13}/>{builds.isFetching ? 'Refreshing' : 'Refresh'}</button><button className="button primary" type="button" disabled={!enabled} onClick={() => setStartOpen(true)}><Play size={13}/>Start build</button></div></div>
|
||||
{startOpen && <StartBuildDialog overrides={overrides} pending={start.isPending} error={start.error} onChange={setOverrides} onCancel={() => setStartOpen(false)} onStart={() => start.mutate()}/>}
|
||||
{builds.isError && <p className="error-text compact-text">{message(builds.error, 'Unable to load builds.')}</p>}
|
||||
<div className="workflow-execution-list"><div className="workflow-execution-list-heading"><h4>Build history</h4><span>{builds.data?.length ?? 0}</span></div><div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Build</th><th>Status</th><th>Phase</th><th>Started</th></tr></thead><tbody>{(builds.data ?? []).map((build) => <tr key={build.id} onClick={() => setSelectedId(build.id)} style={{cursor: 'pointer'}}><td><code>#{build.buildNumber ?? build.id}</code></td><td>{build.buildStatus ?? '-'}</td><td>{build.currentPhase ?? '-'}</td><td>{formatDateTime(build.startTime) ?? '-'}</td></tr>)}</tbody></table></div>{!builds.isLoading && builds.data?.length === 0 && <p className="muted compact-text">No builds yet.</p>}</div>
|
||||
{selectedId && <BuildDetail build={selected} loading={detail.isLoading} error={detail.error}/>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function StartBuildDialog({overrides, pending, error, onChange, onCancel, onStart}: {overrides: CodeBuildEnvironmentVariableOverride[]; pending: boolean; error: unknown; onChange: (values: CodeBuildEnvironmentVariableOverride[]) => void; onCancel: () => void; onStart: () => void}) {
|
||||
return <div className="workflow-confirmation" role="dialog" aria-labelledby="start-build-title"><div className="dynamic-stage-header"><div><strong id="start-build-title">Start build</strong><p className="muted compact-text">Environment variable overrides apply only to this build.</p></div><button className="icon-btn" type="button" aria-label="Close" disabled={pending} onClick={onCancel}><X size={14}/></button></div>{overrides.map((override, index) => <div className="input-row" key={index}><input className="input" aria-label={`Variable name ${index + 1}`} placeholder="Name" value={override.name} onChange={(event) => onChange(replace(overrides, index, {...override, name: event.target.value}))}/><input className="input" aria-label={`Variable value ${index + 1}`} placeholder="Value" value={override.value} onChange={(event) => onChange(replace(overrides, index, {...override, value: event.target.value}))}/><button className="button compact" type="button" disabled={pending} onClick={() => onChange(overrides.filter((_, item) => item !== index))}>Remove</button></div>)}<button className="button compact" type="button" disabled={pending} onClick={() => onChange([...overrides, {name: '', value: '', type: 'PLAINTEXT'}])}><Plus size={13}/>Add override</button>{Boolean(error) && <p className="error-text compact-text">{message(error, 'Unable to start build.')}</p>}<div><button className="button primary compact" type="button" disabled={pending || overrides.some((item) => !item.name.trim())} onClick={onStart}>{pending ? 'Starting' : 'Start build'}</button><button className="button compact" type="button" disabled={pending} onClick={onCancel}>Cancel</button></div></div>
|
||||
}
|
||||
|
||||
function BuildDetail({build, loading, error}: {build?: CodeBuildBuild; loading: boolean; error: unknown}) {
|
||||
if (loading) return <p className="muted compact-text">Loading build details...</p>
|
||||
if (error) return <p className="error-text compact-text">{message(error, 'Unable to load build details.')}</p>
|
||||
if (!build) return null
|
||||
const logsHref = build.logGroupName ? `/cloud-explorer/aws/logs?resource=${encodeURIComponent(build.logGroupName)}` : undefined
|
||||
return <section className="workflow-execution-detail"><div className="dynamic-stage-header"><div><p className="eyebrow">Build detail</p><h3>#{build.buildNumber ?? build.id}</h3><p className="muted compact-text">{build.buildStatus ?? 'Unknown'}{build.endTime ? `, finished ${formatDateTime(build.endTime)}` : ''}</p></div>{logsHref && <a className="button compact" href={logsHref}><ExternalLink size={12}/>Open logs{build.logStreamName ? `: ${build.logStreamName}` : ''}</a>}</div><ol className="workflow-execution-history">{build.phases.map((phase, index) => <li key={`${phase.phaseType}-${index}`}><div><strong>{phase.phaseType}</strong><span>{phase.phaseStatus ?? 'Unknown'}</span></div><time>{formatDateTime(phase.startTime) ?? '-'}{phase.endTime ? ` to ${formatDateTime(phase.endTime)}` : ''}</time>{phase.contexts.map((context, contextIndex) => <p className="muted compact-text" key={contextIndex}>{context.statusCode}: {context.message}</p>)}</li>)}</ol></section>
|
||||
}
|
||||
|
||||
function replace<T>(items: T[], index: number, value: T): T[] { return items.map((item, itemIndex) => itemIndex === index ? value : item) }
|
||||
function active(status?: string): boolean { return status === 'IN_PROGRESS' }
|
||||
function message(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback }
|
||||
@@ -0,0 +1,63 @@
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {Ban, Play, RefreshCw, RotateCcw} from 'lucide-react'
|
||||
import {useState} from 'react'
|
||||
import {getCodeDeployDependencies, listCodeDeployDeployments, startCodeDeployDeployment, stopCodeDeployDeployment, type CodeDeployDependencies} from '@/api/cloudProxyClient'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
interface CreateProps { isSubmitting: boolean; submitError: string | null; onCancel: () => void; onSubmit: (values: Record<string, unknown>) => void }
|
||||
|
||||
export function CodeDeployCreatePanel({isSubmitting, submitError, onCancel, onSubmit}: CreateProps) {
|
||||
const [applicationName, setApplicationName] = useState('')
|
||||
const [deploymentGroupName, setDeploymentGroupName] = useState('')
|
||||
const [serviceRoleArn, setServiceRoleArn] = useState('')
|
||||
const [deploymentConfig, setDeploymentConfig] = useState('AllAtOnce')
|
||||
const [rollbackOnFailure, setRollbackOnFailure] = useState(true)
|
||||
return <form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); onSubmit({applicationName, deploymentGroupName, serviceRoleArn, deploymentConfig, rollbackOnFailure}) }}>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Lambda deployment</p><h3>Create application and deployment group</h3><p className="muted compact-text">The group controls the traffic-shift policy and rollback on deployment failure.</p></div></div>
|
||||
<label>Application name<input className="input" required value={applicationName} onChange={(event) => setApplicationName(event.target.value)}/></label>
|
||||
<label>Deployment group name<input className="input" required value={deploymentGroupName} onChange={(event) => setDeploymentGroupName(event.target.value)}/></label>
|
||||
<label>CodeDeploy service role ARN<input className="input" required value={serviceRoleArn} onChange={(event) => setServiceRoleArn(event.target.value)} placeholder="arn:aws:iam::000000000000:role/CodeDeployRole"/></label>
|
||||
<label>Traffic configuration<select className="input" value={deploymentConfig} onChange={(event) => setDeploymentConfig(event.target.value)}><option value="AllAtOnce">All at once</option><option value="Canary10Percent5Minutes">Canary 10% then 5 minutes</option><option value="Canary10Percent30Minutes">Canary 10% then 30 minutes</option><option value="Linear10PercentEvery1Minute">Linear 10% every minute</option><option value="Linear10PercentEvery10Minutes">Linear 10% every 10 minutes</option></select></label>
|
||||
<label className="checkbox-row"><input type="checkbox" checked={rollbackOnFailure} onChange={(event) => setRollbackOnFailure(event.target.checked)}/> Roll back automatically on deployment failure</label>
|
||||
{submitError && <p className="error-text compact-text">{submitError}</p>}
|
||||
<div className="form-actions"><button className="button" type="button" disabled={isSubmitting} onClick={onCancel}>Cancel</button><button className="button primary" type="submit" disabled={isSubmitting}>{isSubmitting ? 'Creating' : 'Create deployment group'}</button></div>
|
||||
</form>
|
||||
}
|
||||
|
||||
export function CodeDeployPanel({resource, runtimeReachable}: {resource?: CloudResource; runtimeReachable: boolean}) {
|
||||
const client = useQueryClient()
|
||||
const [functionName, setFunctionName] = useState('')
|
||||
const [alias, setAlias] = useState('')
|
||||
const [currentVersion, setCurrentVersion] = useState('')
|
||||
const [targetVersion, setTargetVersion] = useState('')
|
||||
const [beforeHook, setBeforeHook] = useState('')
|
||||
const [afterHook, setAfterHook] = useState('')
|
||||
const enabled = Boolean(resource && runtimeReachable)
|
||||
const dependencies = useQuery({queryKey: ['codedeploy-dependencies'], queryFn: ({signal}) => getCodeDeployDependencies(signal), enabled})
|
||||
const deploymentsKey = ['codedeploy-deployments', resource?.id]
|
||||
const deployments = useQuery({queryKey: deploymentsKey, queryFn: ({signal}) => listCodeDeployDeployments(resource!.id, signal), enabled, refetchInterval: (query) => query.state.data?.some((deployment) => active(deployment.status)) ? 2_000 : false})
|
||||
const start = useMutation({mutationFn: () => startCodeDeployDeployment(resource!.id, {functionName, alias, currentVersion, targetVersion, ...(beforeHook ? {beforeAllowTrafficHook: beforeHook} : {}), ...(afterHook ? {afterAllowTrafficHook: afterHook} : {})}), onSuccess: () => void client.invalidateQueries({queryKey: deploymentsKey})})
|
||||
const stop = useMutation({mutationFn: ({id, rollback}: {id: string; rollback: boolean}) => stopCodeDeployDeployment(id, rollback), onSuccess: () => void client.invalidateQueries({queryKey: deploymentsKey})})
|
||||
const selected = dependencies.data?.lambdaFunctions.find((item) => item.name === functionName)
|
||||
if (!resource) return null
|
||||
return <section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Lambda release</p><h3>Deploy a version to an alias</h3><p className="muted compact-text">{String(resource.metadata.applicationName)} / {resource.name}</p></div><button className="button" type="button" disabled={!enabled || deployments.isFetching} onClick={() => void deployments.refetch()}><RefreshCw size={13}/>Refresh</button></div>
|
||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); start.mutate() }}>
|
||||
<label>Function<select className="input" required value={functionName} onChange={(event) => { setFunctionName(event.target.value); setAlias(''); setCurrentVersion(''); setTargetVersion('') }}><option value="">Select Lambda function</option>{(dependencies.data?.lambdaFunctions ?? []).map((item) => <option key={item.name} value={item.name}>{item.name}</option>)}</select></label>
|
||||
<label>Alias<select className="input" required value={alias} onChange={(event) => setAlias(event.target.value)} disabled={!selected}><option value="">Select alias</option>{selected?.aliases.map((item) => <option key={item} value={item}>{item}</option>)}</select></label>
|
||||
<label>Current version<select className="input" required value={currentVersion} onChange={(event) => setCurrentVersion(event.target.value)} disabled={!selected}><option value="">Select current version</option>{selected?.versions.map((item) => <option key={item} value={item}>{item}</option>)}</select></label>
|
||||
<label>Target version<select className="input" required value={targetVersion} onChange={(event) => setTargetVersion(event.target.value)} disabled={!selected}><option value="">Select target version</option>{selected?.versions.map((item) => <option key={item} value={item}>{item}</option>)}</select></label>
|
||||
<label>BeforeAllowTraffic hook (optional)<HookPicker dependencies={dependencies.data} value={beforeHook} onChange={setBeforeHook}/></label>
|
||||
<label>AfterAllowTraffic hook (optional)<HookPicker dependencies={dependencies.data} value={afterHook} onChange={setAfterHook}/></label>
|
||||
{start.isError && <p className="error-text compact-text">{message(start.error)}</p>}
|
||||
<div className="form-actions"><button className="button primary" type="submit" disabled={!enabled || start.isPending}><Play size={13}/>{start.isPending ? 'Starting' : 'Start deployment'}</button></div>
|
||||
</form>
|
||||
{deployments.isError && <p className="error-text compact-text">{message(deployments.error)}</p>}
|
||||
<div className="workflow-execution-list"><div className="workflow-execution-list-heading"><h4>Deployment group history</h4><span>{deployments.data?.length ?? 0}</span></div><div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Deployment</th><th>Status</th><th>Traffic</th><th>Versions</th><th>Started</th><th>Actions</th></tr></thead><tbody>{(deployments.data ?? []).map((deployment) => <tr key={deployment.id}><td><code>{deployment.id}</code>{deployment.errorInformation?.message && <p className="error-text compact-text">{deployment.errorInformation.message}</p>}</td><td>{deployment.status ?? '-'}</td><td>{deployment.trafficPercent === null ? '-' : `${deployment.trafficPercent}%`}</td><td>{deployment.currentVersion ?? '-'} to {deployment.targetVersion ?? '-'}</td><td>{formatDateTime(deployment.createTime) ?? '-'}</td><td>{active(deployment.status) && <><button className="button compact danger" type="button" disabled={stop.isPending} onClick={() => stop.mutate({id: deployment.id, rollback: false})}><Ban size={12}/>Stop</button><button className="button compact" type="button" disabled={stop.isPending} onClick={() => stop.mutate({id: deployment.id, rollback: true})}><RotateCcw size={12}/>Stop and roll back</button></>}</td></tr>)}</tbody></table></div>{!deployments.isLoading && deployments.data?.length === 0 && <p className="muted compact-text">No deployments yet.</p>}</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
function HookPicker({dependencies, value, onChange}: {dependencies?: CodeDeployDependencies; value: string; onChange: (value: string) => void}) { return <select className="input" value={value} onChange={(event) => onChange(event.target.value)}><option value="">No hook</option>{(dependencies?.lambdaFunctions ?? []).map((item) => <option key={item.name} value={item.name}>{item.name}</option>)}</select> }
|
||||
function active(status: string | null): boolean { return status === 'Created' || status === 'Queued' || status === 'InProgress' }
|
||||
function message(error: unknown): string { return error instanceof Error ? error.message : 'Unable to complete the CodeDeploy operation.' }
|
||||
@@ -0,0 +1,44 @@
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {ExternalLink, Play, RefreshCw} from 'lucide-react'
|
||||
import {getCodePipelineState, listCodePipelineExecutions, startCodePipeline, type PipelineState} from '@/api/cloudProxyClient'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
interface CodePipelinePanelProps { resource?: CloudResource; runtimeReachable: boolean }
|
||||
|
||||
export function CodePipelinePanel({resource, runtimeReachable}: CodePipelinePanelProps) {
|
||||
const client = useQueryClient()
|
||||
const enabled = Boolean(resource && runtimeReachable)
|
||||
const executionsKey = ['codepipeline-executions', resource?.id]
|
||||
const executions = useQuery({queryKey: executionsKey, queryFn: ({signal}) => listCodePipelineExecutions(resource!.id, signal), enabled, refetchInterval: (query) => query.state.data?.some((execution) => active(execution.status)) ? 2_000 : false})
|
||||
const state = useQuery({queryKey: ['codepipeline-state', resource?.id], queryFn: ({signal}) => getCodePipelineState(resource!.id, signal), enabled, refetchInterval: (query) => query.state.data && activeState(query.state.data) ? 2_000 : false})
|
||||
const start = useMutation({mutationFn: () => startCodePipeline(resource!.id), onSuccess: () => { void client.invalidateQueries({queryKey: executionsKey}); void client.invalidateQueries({queryKey: ['codepipeline-state', resource?.id]}) }})
|
||||
if (!resource) return null
|
||||
return <section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Pipeline execution</p><h3>Run and inspect stages</h3><p className="muted compact-text">Local actions: S3 source, CodeBuild, and S3 deploy.</p></div><div className="header-actions"><button className="button" type="button" disabled={!enabled || executions.isFetching || state.isFetching} onClick={() => { void executions.refetch(); void state.refetch() }}><RefreshCw size={13}/>Refresh</button><button className="button primary" type="button" disabled={!enabled || start.isPending} onClick={() => start.mutate()}><Play size={13}/>{start.isPending ? 'Starting' : 'Release change'}</button></div></div>
|
||||
{start.isError && <p className="error-text compact-text">{message(start.error, 'Unable to start pipeline execution.')}</p>}
|
||||
{(executions.isError || state.isError) && <p className="error-text compact-text">{message(executions.error ?? state.error, 'Unable to load pipeline execution state.')}</p>}
|
||||
<div className="workflow-execution-list"><div className="workflow-execution-list-heading"><h4>Execution history</h4><span>{executions.data?.length ?? 0}</span></div><div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Execution</th><th>Status</th><th>Started</th><th>Updated</th></tr></thead><tbody>{(executions.data ?? []).map((execution) => <tr key={execution.id}><td><code>{execution.id}</code>{execution.statusSummary && <p className="muted compact-text">{execution.statusSummary}</p>}</td><td>{execution.status ?? '-'}</td><td>{formatDateTime(execution.startTime) ?? '-'}</td><td>{formatDateTime(execution.lastUpdateTime) ?? '-'}</td></tr>)}</tbody></table></div>{!executions.isLoading && executions.data?.length === 0 && <p className="muted compact-text">No executions yet.</p>}</div>
|
||||
<PipelineTimeline state={state.data} loading={state.isLoading}/>
|
||||
</section>
|
||||
}
|
||||
|
||||
function PipelineTimeline({state, loading}: {state?: PipelineState; loading: boolean}) {
|
||||
if (loading) return <p className="muted compact-text">Loading stage state...</p>
|
||||
if (!state) return null
|
||||
return <section className="workflow-execution-detail"><div className="dynamic-stage-header"><div><p className="eyebrow">Current pipeline state</p><h3>Stages and actions</h3></div></div><ol className="workflow-execution-history">{state.stages.map((stage) => <li key={stage.name}><div><strong>{stage.name}</strong><span>{stage.status ?? 'Unknown'}</span></div><time>{formatDateTime(stage.lastStatusChange) ?? '-'}</time>{stage.actions.map((action) => <div className="workflow-action" key={action.name}><strong>{action.name}</strong><span>{action.status ?? 'Unknown'}</span>{action.summary && <p className="muted compact-text">{action.summary}</p>}<ActionLinks action={action}/></div>)}</li>)}</ol></section>
|
||||
}
|
||||
|
||||
function ActionLinks({action}: {action: PipelineState['stages'][number]['actions'][number]}) {
|
||||
const links = [
|
||||
action.codeBuildProject ? {href: `/cloud-explorer/aws/codebuild?resource=${encodeURIComponent(action.codeBuildProject)}`, label: 'Open CodeBuild'} : undefined,
|
||||
action.codeDeployDeploymentId ? {href: `/cloud-explorer/aws/codedeploy?deployment=${encodeURIComponent(action.codeDeployDeploymentId)}`, label: 'Open CodeDeploy'} : undefined,
|
||||
action.externalExecutionUrl ? {href: action.externalExecutionUrl, label: 'Execution details'} : undefined,
|
||||
action.entityUrl ? {href: action.entityUrl, label: 'Action details'} : undefined,
|
||||
].filter((item): item is {href: string; label: string} => Boolean(item))
|
||||
return <>{links.map((link) => <a className="button compact" key={`${link.label}:${link.href}`} href={link.href}><ExternalLink size={12}/>{link.label}</a>)}</>
|
||||
}
|
||||
|
||||
function active(status?: string): boolean { return status === 'InProgress' || status === 'Stopping' }
|
||||
function activeState(state: PipelineState): boolean { return state.stages.some((stage) => active(stage.status) || stage.actions.some((action) => active(action.status))) }
|
||||
function message(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback }
|
||||
@@ -63,6 +63,9 @@ import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
|
||||
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
|
||||
import { 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";
|
||||
@@ -831,6 +834,8 @@ export function DynamicResourceView({
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onSubmit={(values) => createMut.mutate({values})}
|
||||
/>
|
||||
) : service === "codedeploy" && cloud === "aws" ? (
|
||||
<CodeDeployCreatePanel isSubmitting={createMut.isPending} submitError={createMut.error instanceof Error ? createMut.error.message : null} onCancel={() => setCreateOpen(false)} onSubmit={(values) => createMut.mutate({values})}/>
|
||||
) : (
|
||||
<DynamicFormRenderer
|
||||
key={`${cloud}:${service}:create`}
|
||||
@@ -1004,6 +1009,15 @@ export function DynamicResourceView({
|
||||
{service === "workflows" && cloud === "aws" && (
|
||||
<WorkflowExecutionDebugger resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "codebuild" && cloud === "aws" && (
|
||||
<CodeBuildPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "codepipeline" && cloud === "aws" && (
|
||||
<CodePipelinePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "codedeploy" && cloud === "aws" && (
|
||||
<CodeDeployPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{editingResource && schema.updateFields && (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {useState} from 'react'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
import { K8sEngineDetails } from "@/features/k8s/K8sEngineDetails";
|
||||
import { LogsExplorerPanel } from "@/components/LogsExplorerPanel";
|
||||
import { LogsQueryPanel } from "@/components/LogsQueryPanel";
|
||||
@@ -6,6 +7,8 @@ import type { CloudProvider } from "@/types/cloud";
|
||||
import type { CloudResource, StorageObject } from "@/types/resource";
|
||||
import {formatBytes} from "@/lib/format";
|
||||
import {CopyValueButton} from "@/components/CopyValueButton";
|
||||
import {rebootCloudResource, startCloudResource, stopCloudResource} from "@/api/cloudProxyClient";
|
||||
import {cloudQueryKeys} from "@/api/queries/cloudQueries";
|
||||
|
||||
interface ResourceInspectorProps {
|
||||
resource?: CloudResource;
|
||||
@@ -149,9 +152,9 @@ export function ResourceInspector({
|
||||
)}
|
||||
</section>
|
||||
{isDatabase && (
|
||||
<DatabaseConnectionsSection metadata={resource.metadata} engine={resource.engine} />
|
||||
<DatabaseConnectionsSection metadata={resource.metadata} engine={resource.engine} status={resource.status} />
|
||||
)}
|
||||
{isAwsDatabase && <DatabaseLifecycleSection status={resource.status} />}
|
||||
{isAwsDatabase && <DatabaseLifecycleSection resource={resource} />}
|
||||
{isDatabase && !isAwsDatabase && (
|
||||
<ProviderDatabaseSection cloud={resource.cloud} />
|
||||
)}
|
||||
@@ -283,9 +286,11 @@ function ProviderDatabaseSection({ cloud }: { cloud: string }) {
|
||||
function DatabaseConnectionsSection({
|
||||
metadata,
|
||||
engine,
|
||||
status,
|
||||
}: {
|
||||
metadata: Record<string, unknown>;
|
||||
engine?: string | null;
|
||||
status?: string | null;
|
||||
}) {
|
||||
const endpoint = getRecordMetadata(metadata.endpoint);
|
||||
const subnetGroup = getRecordMetadata(metadata.subnetGroup);
|
||||
@@ -299,6 +304,7 @@ function DatabaseConnectionsSection({
|
||||
return (
|
||||
<section className="inspector-section">
|
||||
<p className="metric-label">Connections</p>
|
||||
<DatabaseConnectionHealth status={status} hasEndpoint={Boolean(address && port !== null)} />
|
||||
<div className="inspector-grid compact-grid">
|
||||
<InspectorItem label="Endpoint" value={address ?? "-"} />
|
||||
<InspectorItem
|
||||
@@ -328,6 +334,19 @@ function DatabaseConnectionsSection({
|
||||
);
|
||||
}
|
||||
|
||||
function DatabaseConnectionHealth({status, hasEndpoint}: {status?: string | null; hasEndpoint: boolean}) {
|
||||
const normalized = status?.toLowerCase()
|
||||
const state = normalized === 'available' && hasEndpoint
|
||||
? {label: 'Ready', detail: 'The provider reports this instance as available with a connection endpoint.'}
|
||||
: normalized === 'failed'
|
||||
? {label: 'Unavailable', detail: 'The provider reports the backing database as failed.'}
|
||||
: !hasEndpoint
|
||||
? {label: 'Endpoint unavailable', detail: 'The provider has not returned a usable database endpoint.'}
|
||||
: {label: status ?? 'Unknown', detail: 'Connection readiness follows the provider-reported instance status.'}
|
||||
|
||||
return <p className="muted compact-text" role="status"><strong>Connection health: {state.label}.</strong> {state.detail}</p>
|
||||
}
|
||||
|
||||
function DatabaseConnectionCommand({ engine, address, port, database, username }: { engine: string; address: string; port: number; database: string; username: string }) {
|
||||
const host = address.startsWith('172.') ? 'localhost' : address
|
||||
const command = engine.includes('postgres')
|
||||
@@ -343,22 +362,40 @@ function DatabaseConnectionCommand({ engine, address, port, database, username }
|
||||
return <div className="metadata-block"><p className="metric-label">Terminal connection</p><p className="copy-value mono">{command}<CopyValueButton value={command} label="Connection command" /></p><p className="metric-label">Client connection URI</p><p className="copy-value mono">{uri}<CopyValueButton value={uri} label="Connection URI" /></p><p className="muted compact-text">Paste the URI into a compatible database client, then replace the password placeholder. The password is never retained by the console.</p></div>
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="inspector-section">
|
||||
<p className="metric-label">Lifecycle</p>
|
||||
<div className="lifecycle-actions">
|
||||
<button className="button compact" type="button" disabled>
|
||||
<button className="button compact" type="button" disabled={isPending || status !== "stopped"} onClick={() => lifecycle.mutate("start")}>
|
||||
Start
|
||||
</button>
|
||||
<button className="button compact" type="button" disabled>
|
||||
<button className="button compact" type="button" disabled={isPending || status !== "available"} onClick={() => lifecycle.mutate("stop")}>
|
||||
Stop
|
||||
</button>
|
||||
<button className="button compact" type="button" disabled={isPending || status !== "available"} onClick={() => lifecycle.mutate("reboot")}>
|
||||
Reboot
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted compact-text">
|
||||
Current status: {status ?? "unknown"}. Start and stop are not supported
|
||||
by the local Floci RDS runtime.
|
||||
Current status: {resource.status ?? "unknown"}.
|
||||
</p>
|
||||
{lifecycle.error && <p className="muted compact-text">{lifecycle.error instanceof Error ? lifecycle.error.message : "Unable to update DB instance lifecycle."}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, LucideIcon> = {
|
||||
configuration: SlidersHorizontal,
|
||||
scheduler: Clock,
|
||||
workflows: Workflow,
|
||||
codebuild: Hammer,
|
||||
codepipeline: Workflow,
|
||||
codedeploy: Workflow,
|
||||
}
|
||||
|
||||
const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
||||
apigateway: apiGatewayIcon,
|
||||
compute: ec2Icon,
|
||||
codebuild: codeBuildIcon,
|
||||
codepipeline: codePipelineIcon,
|
||||
codedeploy: codeDeployIcon,
|
||||
configuration: appConfigIcon,
|
||||
containers: ecsIcon,
|
||||
database: rdsIcon,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import {describe, expect, test} from 'vitest'
|
||||
import {dataExplorerKind, dataExplorerPath} from './dataExplorer'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
function rdsInstance(engine: string): CloudResource {
|
||||
return {
|
||||
id: 'orders-db',
|
||||
name: 'orders-db',
|
||||
cloud: 'aws',
|
||||
service: 'database',
|
||||
type: 'db-instance',
|
||||
region: 'us-east-1',
|
||||
createdAt: null,
|
||||
engine,
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
|
||||
describe('dataExplorerPath', () => {
|
||||
test('enables the SQL explorer for PostgreSQL and MySQL RDS instances', () => {
|
||||
expect(dataExplorerKind(rdsInstance('postgres'))).toBe('sql')
|
||||
expect(dataExplorerPath(rdsInstance('mysql'))).toBe('/cloud-explorer/aws/database/orders-db/data')
|
||||
})
|
||||
|
||||
test('does not expose unsupported RDS engines as SQL explorers', () => {
|
||||
expect(dataExplorerKind(rdsInstance('mariadb'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,8 @@ export function dataExplorerKind(resource: CloudResource): 'dynamodb' | 'cosmos'
|
||||
if (resource.cloud === 'azure' && resource.service === 'nosql' && resource.type === 'cosmos-database') return 'cosmos'
|
||||
if (resource.cloud === 'azure' && resource.service === '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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+114
-42
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user