@friggframework/devtools 2.0.0-next.8 → 2.0.0-next.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frigg-cli/README.md +1289 -0
- package/frigg-cli/__tests__/unit/commands/build.test.js +279 -0
- package/frigg-cli/__tests__/unit/commands/db-setup.test.js +649 -0
- package/frigg-cli/__tests__/unit/commands/deploy.test.js +320 -0
- package/frigg-cli/__tests__/unit/commands/doctor.test.js +309 -0
- package/frigg-cli/__tests__/unit/commands/install.test.js +400 -0
- package/frigg-cli/__tests__/unit/commands/ui.test.js +346 -0
- package/frigg-cli/__tests__/unit/dependencies.test.js +74 -0
- package/frigg-cli/__tests__/unit/utils/database-validator.test.js +397 -0
- package/frigg-cli/__tests__/unit/utils/error-messages.test.js +345 -0
- package/frigg-cli/__tests__/unit/version-detection.test.js +171 -0
- package/frigg-cli/__tests__/utils/mock-factory.js +270 -0
- package/frigg-cli/__tests__/utils/prisma-mock.js +194 -0
- package/frigg-cli/__tests__/utils/test-fixtures.js +463 -0
- package/frigg-cli/__tests__/utils/test-setup.js +287 -0
- package/frigg-cli/auth-command/CLAUDE.md +293 -0
- package/frigg-cli/auth-command/README.md +450 -0
- package/frigg-cli/auth-command/api-key-flow.js +153 -0
- package/frigg-cli/auth-command/auth-tester.js +344 -0
- package/frigg-cli/auth-command/credential-storage.js +182 -0
- package/frigg-cli/auth-command/index.js +256 -0
- package/frigg-cli/auth-command/json-schema-form.js +67 -0
- package/frigg-cli/auth-command/module-loader.js +172 -0
- package/frigg-cli/auth-command/oauth-callback-server.js +431 -0
- package/frigg-cli/auth-command/oauth-flow.js +195 -0
- package/frigg-cli/auth-command/utils/browser.js +30 -0
- package/frigg-cli/build-command/index.js +45 -12
- package/frigg-cli/db-setup-command/index.js +246 -0
- package/frigg-cli/deploy-command/SPEC-DEPLOY-DRY-RUN.md +981 -0
- package/frigg-cli/deploy-command/index.js +295 -23
- package/frigg-cli/doctor-command/index.js +335 -0
- package/frigg-cli/generate-command/__tests__/generate-command.test.js +301 -0
- package/frigg-cli/generate-command/azure-generator.js +43 -0
- package/frigg-cli/generate-command/gcp-generator.js +47 -0
- package/frigg-cli/generate-command/index.js +332 -0
- package/frigg-cli/generate-command/terraform-generator.js +555 -0
- package/frigg-cli/generate-iam-command.js +118 -0
- package/frigg-cli/index.js +174 -1
- package/frigg-cli/index.test.js +1 -4
- package/frigg-cli/init-command/backend-first-handler.js +756 -0
- package/frigg-cli/init-command/index.js +93 -0
- package/frigg-cli/init-command/template-handler.js +143 -0
- package/frigg-cli/install-command/index.js +1 -4
- package/frigg-cli/jest.config.js +124 -0
- package/frigg-cli/package.json +63 -0
- package/frigg-cli/repair-command/index.js +564 -0
- package/frigg-cli/start-command/index.js +118 -5
- package/frigg-cli/start-command/start-command.test.js +297 -0
- package/frigg-cli/test/init-command.test.js +180 -0
- package/frigg-cli/test/npm-registry.test.js +319 -0
- package/frigg-cli/ui-command/index.js +154 -0
- package/frigg-cli/utils/app-resolver.js +319 -0
- package/frigg-cli/utils/backend-path.js +16 -17
- package/frigg-cli/utils/database-validator.js +167 -0
- package/frigg-cli/utils/error-messages.js +329 -0
- package/frigg-cli/utils/npm-registry.js +167 -0
- package/frigg-cli/utils/process-manager.js +199 -0
- package/frigg-cli/utils/repo-detection.js +405 -0
- package/infrastructure/ARCHITECTURE.md +487 -0
- package/infrastructure/CLAUDE.md +481 -0
- package/infrastructure/HEALTH.md +468 -0
- package/infrastructure/README.md +522 -0
- package/infrastructure/__tests__/fixtures/mock-aws-resources.js +391 -0
- package/infrastructure/__tests__/helpers/test-utils.js +277 -0
- package/infrastructure/__tests__/postgres-config.test.js +914 -0
- package/infrastructure/__tests__/template-generation.test.js +687 -0
- package/infrastructure/create-frigg-infrastructure.js +129 -20
- package/infrastructure/docs/POSTGRES-CONFIGURATION.md +630 -0
- package/infrastructure/docs/PRE-DEPLOYMENT-HEALTH-CHECK-SPEC.md +1317 -0
- package/infrastructure/docs/WEBSOCKET-CONFIGURATION.md +105 -0
- package/infrastructure/docs/deployment-instructions.md +268 -0
- package/infrastructure/docs/generate-iam-command.md +278 -0
- package/infrastructure/docs/iam-policy-templates.md +193 -0
- package/infrastructure/domains/database/aurora-builder.js +857 -0
- package/infrastructure/domains/database/aurora-builder.test.js +960 -0
- package/infrastructure/domains/database/aurora-discovery.js +87 -0
- package/infrastructure/domains/database/aurora-discovery.test.js +188 -0
- package/infrastructure/domains/database/aurora-resolver.js +210 -0
- package/infrastructure/domains/database/aurora-resolver.test.js +347 -0
- package/infrastructure/domains/database/migration-builder.js +701 -0
- package/infrastructure/domains/database/migration-builder.test.js +321 -0
- package/infrastructure/domains/database/migration-resolver.js +163 -0
- package/infrastructure/domains/database/migration-resolver.test.js +337 -0
- package/infrastructure/domains/health/application/ports/IPropertyReconciler.js +164 -0
- package/infrastructure/domains/health/application/ports/IResourceDetector.js +129 -0
- package/infrastructure/domains/health/application/ports/IResourceImporter.js +142 -0
- package/infrastructure/domains/health/application/ports/IStackRepository.js +131 -0
- package/infrastructure/domains/health/application/ports/index.js +26 -0
- package/infrastructure/domains/health/application/use-cases/__tests__/execute-resource-import-use-case.test.js +679 -0
- package/infrastructure/domains/health/application/use-cases/__tests__/mismatch-analyzer-method-name.test.js +167 -0
- package/infrastructure/domains/health/application/use-cases/__tests__/repair-via-import-use-case.test.js +1130 -0
- package/infrastructure/domains/health/application/use-cases/execute-resource-import-use-case.js +221 -0
- package/infrastructure/domains/health/application/use-cases/reconcile-properties-use-case.js +152 -0
- package/infrastructure/domains/health/application/use-cases/reconcile-properties-use-case.test.js +343 -0
- package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.js +535 -0
- package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.test.js +376 -0
- package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.js +213 -0
- package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.test.js +441 -0
- package/infrastructure/domains/health/docs/ACME-DEV-DRIFT-ANALYSIS.md +267 -0
- package/infrastructure/domains/health/docs/BUILD-VS-DEPLOYED-TEMPLATE-ANALYSIS.md +324 -0
- package/infrastructure/domains/health/docs/ORPHAN-DETECTION-ANALYSIS.md +386 -0
- package/infrastructure/domains/health/docs/SPEC-CLEANUP-COMMAND.md +1419 -0
- package/infrastructure/domains/health/docs/TDD-IMPLEMENTATION-SUMMARY.md +391 -0
- package/infrastructure/domains/health/docs/TEMPLATE-COMPARISON-IMPLEMENTATION.md +551 -0
- package/infrastructure/domains/health/domain/entities/issue.js +299 -0
- package/infrastructure/domains/health/domain/entities/issue.test.js +528 -0
- package/infrastructure/domains/health/domain/entities/property-mismatch.js +108 -0
- package/infrastructure/domains/health/domain/entities/property-mismatch.test.js +275 -0
- package/infrastructure/domains/health/domain/entities/resource.js +159 -0
- package/infrastructure/domains/health/domain/entities/resource.test.js +432 -0
- package/infrastructure/domains/health/domain/entities/stack-health-report.js +306 -0
- package/infrastructure/domains/health/domain/entities/stack-health-report.test.js +601 -0
- package/infrastructure/domains/health/domain/services/__tests__/health-score-percentage-based.test.js +380 -0
- package/infrastructure/domains/health/domain/services/__tests__/import-progress-monitor.test.js +971 -0
- package/infrastructure/domains/health/domain/services/__tests__/import-template-generator.test.js +1150 -0
- package/infrastructure/domains/health/domain/services/__tests__/logical-id-mapper.test.js +672 -0
- package/infrastructure/domains/health/domain/services/__tests__/template-parser.test.js +496 -0
- package/infrastructure/domains/health/domain/services/__tests__/update-progress-monitor.test.js +419 -0
- package/infrastructure/domains/health/domain/services/health-score-calculator.js +248 -0
- package/infrastructure/domains/health/domain/services/health-score-calculator.test.js +504 -0
- package/infrastructure/domains/health/domain/services/import-progress-monitor.js +195 -0
- package/infrastructure/domains/health/domain/services/import-template-generator.js +435 -0
- package/infrastructure/domains/health/domain/services/logical-id-mapper.js +345 -0
- package/infrastructure/domains/health/domain/services/mismatch-analyzer.js +234 -0
- package/infrastructure/domains/health/domain/services/mismatch-analyzer.test.js +431 -0
- package/infrastructure/domains/health/domain/services/property-mutability-config.js +382 -0
- package/infrastructure/domains/health/domain/services/template-parser.js +245 -0
- package/infrastructure/domains/health/domain/services/update-progress-monitor.js +192 -0
- package/infrastructure/domains/health/domain/value-objects/health-score.js +138 -0
- package/infrastructure/domains/health/domain/value-objects/health-score.test.js +267 -0
- package/infrastructure/domains/health/domain/value-objects/property-mutability.js +161 -0
- package/infrastructure/domains/health/domain/value-objects/property-mutability.test.js +198 -0
- package/infrastructure/domains/health/domain/value-objects/resource-state.js +167 -0
- package/infrastructure/domains/health/domain/value-objects/resource-state.test.js +196 -0
- package/infrastructure/domains/health/domain/value-objects/stack-identifier.js +192 -0
- package/infrastructure/domains/health/domain/value-objects/stack-identifier.test.js +262 -0
- package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-cfn-tagged.test.js +312 -0
- package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-multi-stack.test.js +367 -0
- package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-relationship-analysis.test.js +432 -0
- package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.js +784 -0
- package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.test.js +1133 -0
- package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.js +565 -0
- package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.test.js +554 -0
- package/infrastructure/domains/health/infrastructure/adapters/aws-resource-importer.js +318 -0
- package/infrastructure/domains/health/infrastructure/adapters/aws-resource-importer.test.js +398 -0
- package/infrastructure/domains/health/infrastructure/adapters/aws-stack-repository.js +777 -0
- package/infrastructure/domains/health/infrastructure/adapters/aws-stack-repository.test.js +580 -0
- package/infrastructure/domains/integration/integration-builder.js +547 -0
- package/infrastructure/domains/integration/integration-builder.test.js +798 -0
- package/infrastructure/domains/integration/integration-resolver.js +170 -0
- package/infrastructure/domains/integration/integration-resolver.test.js +369 -0
- package/infrastructure/domains/integration/websocket-builder.js +69 -0
- package/infrastructure/domains/integration/websocket-builder.test.js +195 -0
- package/infrastructure/domains/networking/vpc-builder.js +2051 -0
- package/infrastructure/domains/networking/vpc-builder.test.js +1960 -0
- package/infrastructure/domains/networking/vpc-discovery.js +177 -0
- package/infrastructure/domains/networking/vpc-discovery.test.js +350 -0
- package/infrastructure/domains/networking/vpc-resolver.js +505 -0
- package/infrastructure/domains/networking/vpc-resolver.test.js +801 -0
- package/infrastructure/domains/parameters/ssm-builder.js +79 -0
- package/infrastructure/domains/parameters/ssm-builder.test.js +189 -0
- package/infrastructure/domains/parameters/ssm-discovery.js +84 -0
- package/infrastructure/domains/parameters/ssm-discovery.test.js +210 -0
- package/infrastructure/domains/scheduler/scheduler-builder.js +211 -0
- package/infrastructure/domains/security/iam-generator.js +816 -0
- package/infrastructure/domains/security/iam-generator.test.js +204 -0
- package/infrastructure/domains/security/kms-builder.js +415 -0
- package/infrastructure/domains/security/kms-builder.test.js +392 -0
- package/infrastructure/domains/security/kms-discovery.js +80 -0
- package/infrastructure/domains/security/kms-discovery.test.js +177 -0
- package/infrastructure/domains/security/kms-resolver.js +96 -0
- package/infrastructure/domains/security/kms-resolver.test.js +216 -0
- package/infrastructure/domains/security/templates/frigg-deployment-iam-stack.yaml +401 -0
- package/infrastructure/domains/security/templates/iam-policy-basic.json +218 -0
- package/infrastructure/domains/security/templates/iam-policy-full.json +288 -0
- package/infrastructure/domains/shared/base-builder.js +112 -0
- package/infrastructure/domains/shared/base-resolver.js +186 -0
- package/infrastructure/domains/shared/base-resolver.test.js +305 -0
- package/infrastructure/domains/shared/builder-orchestrator.js +212 -0
- package/infrastructure/domains/shared/builder-orchestrator.test.js +213 -0
- package/infrastructure/domains/shared/cloudformation-discovery-v2.js +334 -0
- package/infrastructure/domains/shared/cloudformation-discovery.js +681 -0
- package/infrastructure/domains/shared/cloudformation-discovery.test.js +1320 -0
- package/infrastructure/domains/shared/environment-builder.js +119 -0
- package/infrastructure/domains/shared/environment-builder.test.js +247 -0
- package/infrastructure/domains/shared/providers/aws-provider-adapter.js +579 -0
- package/infrastructure/domains/shared/providers/aws-provider-adapter.test.js +416 -0
- package/infrastructure/domains/shared/providers/azure-provider-adapter.stub.js +93 -0
- package/infrastructure/domains/shared/providers/cloud-provider-adapter.js +136 -0
- package/infrastructure/domains/shared/providers/gcp-provider-adapter.stub.js +82 -0
- package/infrastructure/domains/shared/providers/provider-factory.js +108 -0
- package/infrastructure/domains/shared/providers/provider-factory.test.js +170 -0
- package/infrastructure/domains/shared/resource-discovery.enhanced.test.js +306 -0
- package/infrastructure/domains/shared/resource-discovery.js +256 -0
- package/infrastructure/domains/shared/resource-discovery.test.js +757 -0
- package/infrastructure/domains/shared/types/app-definition.js +205 -0
- package/infrastructure/domains/shared/types/discovery-result.js +106 -0
- package/infrastructure/domains/shared/types/discovery-result.test.js +258 -0
- package/infrastructure/domains/shared/types/index.js +46 -0
- package/infrastructure/domains/shared/types/resource-ownership.js +108 -0
- package/infrastructure/domains/shared/types/resource-ownership.test.js +101 -0
- package/infrastructure/domains/shared/utilities/base-definition-factory.js +408 -0
- package/infrastructure/domains/shared/utilities/base-definition-factory.js.bak +338 -0
- package/infrastructure/domains/shared/utilities/base-definition-factory.test.js +291 -0
- package/infrastructure/domains/shared/utilities/handler-path-resolver.js +134 -0
- package/infrastructure/domains/shared/utilities/handler-path-resolver.test.js +268 -0
- package/infrastructure/domains/shared/utilities/prisma-layer-manager.js +159 -0
- package/infrastructure/domains/shared/utilities/prisma-layer-manager.test.js +444 -0
- package/infrastructure/domains/shared/validation/env-validator.js +78 -0
- package/infrastructure/domains/shared/validation/env-validator.test.js +173 -0
- package/infrastructure/domains/shared/validation/plugin-validator.js +187 -0
- package/infrastructure/domains/shared/validation/plugin-validator.test.js +323 -0
- package/infrastructure/esbuild.config.js +53 -0
- package/infrastructure/infrastructure-composer.js +119 -0
- package/infrastructure/infrastructure-composer.test.js +1896 -0
- package/infrastructure/integration.test.js +383 -0
- package/infrastructure/scripts/build-prisma-layer.js +701 -0
- package/infrastructure/scripts/build-prisma-layer.test.js +170 -0
- package/infrastructure/scripts/build-time-discovery.js +238 -0
- package/infrastructure/scripts/build-time-discovery.test.js +379 -0
- package/infrastructure/scripts/run-discovery.js +110 -0
- package/infrastructure/scripts/verify-prisma-layer.js +72 -0
- package/management-ui/README.md +203 -0
- package/package.json +44 -14
- package/test/index.js +2 -4
- package/test/mock-api.js +1 -3
- package/test/mock-integration.js +4 -14
- package/.eslintrc.json +0 -3
- package/CHANGELOG.md +0 -132
- package/infrastructure/app-handler-helpers.js +0 -57
- package/infrastructure/backend-utils.js +0 -87
- package/infrastructure/routers/auth.js +0 -26
- package/infrastructure/routers/integration-defined-routers.js +0 -42
- package/infrastructure/routers/middleware/loadUser.js +0 -15
- package/infrastructure/routers/middleware/requireLoggedInUser.js +0 -12
- package/infrastructure/routers/user.js +0 -41
- package/infrastructure/routers/websocket.js +0 -55
- package/infrastructure/serverless-template.js +0 -291
- package/infrastructure/workers/integration-defined-workers.js +0 -24
- package/test/auther-definition-tester.js +0 -125
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aurora PostgreSQL Builder
|
|
3
|
+
*
|
|
4
|
+
* Domain Layer - Hexagonal Architecture
|
|
5
|
+
*
|
|
6
|
+
* Responsible for:
|
|
7
|
+
* - Aurora Serverless v2 cluster creation or discovery
|
|
8
|
+
* - Database subnet groups
|
|
9
|
+
* - Database security groups
|
|
10
|
+
* - Secrets Manager integration for credentials
|
|
11
|
+
* - Database connection environment variables
|
|
12
|
+
*
|
|
13
|
+
* Uses ownership-based architecture:
|
|
14
|
+
* - STACK: Resources in our CloudFormation template (definitions + Refs)
|
|
15
|
+
* - EXTERNAL: Resources outside our stack (reference by physical ID)
|
|
16
|
+
* - AUTO: System decides based on discovery
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { InfrastructureBuilder, ValidationResult } = require('../shared/base-builder');
|
|
20
|
+
const AuroraResourceResolver = require('./aurora-resolver');
|
|
21
|
+
const { createEmptyDiscoveryResult } = require('../shared/types/discovery-result');
|
|
22
|
+
const { ResourceOwnership } = require('../shared/types/resource-ownership');
|
|
23
|
+
|
|
24
|
+
// Pool + timeout query params appended to DATABASE_URL for Lambda-to-Aurora
|
|
25
|
+
// connections. Chosen to make worker Lambdas fail loud and fast on any DB
|
|
26
|
+
// contention rather than silently hanging for Lambda's 900s timeout.
|
|
27
|
+
//
|
|
28
|
+
// connection_limit=2 — two pg connections per Lambda container. One is too
|
|
29
|
+
// tight: several core use cases (get-process.executeMany,
|
|
30
|
+
// field-encryption-service batches) issue in-handler
|
|
31
|
+
// Promise.all against Prisma, and would serialize
|
|
32
|
+
// behind a single slot. Two removes that cliff while
|
|
33
|
+
// still being safe against max_connections (at 4 ACU
|
|
34
|
+
// Aurora pg 15 allows ~400 connections; 200 concurrent
|
|
35
|
+
// Lambdas × 2 = 400, leaves cluster room for maint).
|
|
36
|
+
// pool_timeout=20 — wait up to 20s for a pool slot, then throw P2024.
|
|
37
|
+
// Still fail-fast relative to 900s Lambda cap; gives
|
|
38
|
+
// in-handler fan-outs headroom.
|
|
39
|
+
// connect_timeout=10 — bound TCP/TLS handshake.
|
|
40
|
+
// socket_timeout=60 — kill dead client sockets (server never responds).
|
|
41
|
+
// options=-c statement_timeout=30000 -c lock_timeout=10000
|
|
42
|
+
// — Postgres-side hard caps. A query stuck >30s aborts
|
|
43
|
+
// with SQLSTATE 57014; a lock wait >10s aborts with
|
|
44
|
+
// SQLSTATE 55P03. URL encoding per libpq URI rules
|
|
45
|
+
// (space→%20, `=`→%3D inside the options value).
|
|
46
|
+
const LAMBDA_DATABASE_URL_QUERY_PARAMS = [
|
|
47
|
+
'connection_limit=2',
|
|
48
|
+
'pool_timeout=20',
|
|
49
|
+
'connect_timeout=10',
|
|
50
|
+
'socket_timeout=60',
|
|
51
|
+
'options=-c%20statement_timeout%3D30000%20-c%20lock_timeout%3D10000',
|
|
52
|
+
].join('&');
|
|
53
|
+
|
|
54
|
+
class AuroraBuilder extends InfrastructureBuilder {
|
|
55
|
+
constructor() {
|
|
56
|
+
super();
|
|
57
|
+
this.name = 'AuroraBuilder';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
shouldExecute(appDefinition) {
|
|
61
|
+
// Skip Aurora in local mode (when FRIGG_SKIP_AWS_DISCOVERY is set)
|
|
62
|
+
// Aurora is an AWS-specific service that should only be created in production
|
|
63
|
+
if (process.env.FRIGG_SKIP_AWS_DISCOVERY === 'true') {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return appDefinition.database?.postgres?.enable === true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getDependencies() {
|
|
71
|
+
return ['VpcBuilder']; // Aurora requires VPC to be configured first
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
validate(appDefinition) {
|
|
75
|
+
const result = new ValidationResult();
|
|
76
|
+
|
|
77
|
+
if (!appDefinition.database?.postgres) {
|
|
78
|
+
result.addError('PostgreSQL database configuration is missing');
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const dbConfig = appDefinition.database.postgres;
|
|
83
|
+
|
|
84
|
+
// Validate management mode
|
|
85
|
+
const validModes = ['discover', 'managed', 'use-existing'];
|
|
86
|
+
const management = dbConfig.management || 'discover';
|
|
87
|
+
if (!validModes.includes(management)) {
|
|
88
|
+
result.addError(`Invalid database.postgres.management: "${management}"`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Validate use-existing requirements
|
|
92
|
+
if (management === 'use-existing' && !dbConfig.endpoint) {
|
|
93
|
+
result.addError('database.postgres.endpoint is required when management="use-existing"');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Validate capacity settings
|
|
97
|
+
if (dbConfig.minCapacity !== undefined && (dbConfig.minCapacity < 0.5 || dbConfig.minCapacity > 128)) {
|
|
98
|
+
result.addError('database.postgres.minCapacity must be between 0.5 and 128');
|
|
99
|
+
}
|
|
100
|
+
if (dbConfig.maxCapacity !== undefined && (dbConfig.maxCapacity < 0.5 || dbConfig.maxCapacity > 128)) {
|
|
101
|
+
result.addError('database.postgres.maxCapacity must be between 0.5 and 128');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Warn about public accessibility in production
|
|
105
|
+
if (dbConfig.publiclyAccessible === true) {
|
|
106
|
+
result.addWarning('database.postgres.publiclyAccessible=true is not recommended for production');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Build Aurora infrastructure using ownership-based architecture
|
|
114
|
+
*/
|
|
115
|
+
async build(appDefinition, discoveredResources) {
|
|
116
|
+
console.log(`\n[${this.name}] Configuring Aurora PostgreSQL...`);
|
|
117
|
+
|
|
118
|
+
// Backwards compatibility: Translate old schema to new ownership schema
|
|
119
|
+
appDefinition = this.translateLegacyConfig(appDefinition, discoveredResources);
|
|
120
|
+
|
|
121
|
+
// Initialize result
|
|
122
|
+
const result = {
|
|
123
|
+
resources: {},
|
|
124
|
+
iamStatements: [],
|
|
125
|
+
environment: {},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Special case: use-existing with endpoint (bypass resolver)
|
|
129
|
+
if (appDefinition.database?.postgres?._useExistingEndpoint) {
|
|
130
|
+
console.log(' Using provided database endpoint (use-existing mode)');
|
|
131
|
+
await this.useExistingAurora(appDefinition, discoveredResources, result);
|
|
132
|
+
console.log(`\n[${this.name}] ✅ Aurora PostgreSQL configuration completed`);
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Get structured discovery result
|
|
137
|
+
const discovery = discoveredResources._structured || this.convertFlatDiscoveryToStructured(discoveredResources, appDefinition);
|
|
138
|
+
|
|
139
|
+
// Use AuroraResourceResolver to make ownership decisions
|
|
140
|
+
const resolver = new AuroraResourceResolver();
|
|
141
|
+
const decisions = resolver.resolveAll(appDefinition, discovery);
|
|
142
|
+
|
|
143
|
+
console.log('\n 📋 Resource Ownership Decisions:');
|
|
144
|
+
console.log(` Cluster: ${decisions.cluster.ownership} - ${decisions.cluster.reason}`);
|
|
145
|
+
console.log(` Instance: ${decisions.instance.ownership} - ${decisions.instance.reason}`);
|
|
146
|
+
console.log(` Subnet Group: ${decisions.subnetGroup.ownership} - ${decisions.subnetGroup.reason}`);
|
|
147
|
+
console.log(` Secret: ${decisions.secret.ownership} - ${decisions.secret.reason}`);
|
|
148
|
+
|
|
149
|
+
// Build resources based on ownership decisions
|
|
150
|
+
await this.buildFromDecisions(decisions, appDefinition, discoveredResources, result);
|
|
151
|
+
|
|
152
|
+
console.log(`\n[${this.name}] ✅ Aurora PostgreSQL configuration completed`);
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Convert flat discovery to structured discovery
|
|
158
|
+
* Provides backwards compatibility for tests
|
|
159
|
+
*/
|
|
160
|
+
convertFlatDiscoveryToStructured(flatDiscovery, appDefinition = {}) {
|
|
161
|
+
const discovery = createEmptyDiscoveryResult();
|
|
162
|
+
|
|
163
|
+
if (!flatDiscovery) {
|
|
164
|
+
return discovery;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Check if resources are from CloudFormation stack
|
|
168
|
+
const isManagedIsolated = appDefinition.managementMode === 'managed' &&
|
|
169
|
+
(appDefinition.vpcIsolation === 'isolated' || !appDefinition.vpcIsolation);
|
|
170
|
+
const hasExistingStackResources = isManagedIsolated && flatDiscovery.auroraClusterId &&
|
|
171
|
+
typeof flatDiscovery.auroraClusterId === 'string';
|
|
172
|
+
|
|
173
|
+
if (flatDiscovery.fromCloudFormationStack || hasExistingStackResources) {
|
|
174
|
+
discovery.fromCloudFormation = true;
|
|
175
|
+
discovery.stackName = flatDiscovery.stackName || 'assumed-stack';
|
|
176
|
+
|
|
177
|
+
// Add stack-managed resources
|
|
178
|
+
let existingLogicalIds = flatDiscovery.existingLogicalIds || [];
|
|
179
|
+
|
|
180
|
+
// Infer logical IDs from physical IDs if needed
|
|
181
|
+
if (hasExistingStackResources && existingLogicalIds.length === 0) {
|
|
182
|
+
if (flatDiscovery.auroraClusterId) existingLogicalIds.push('FriggAuroraCluster');
|
|
183
|
+
if (flatDiscovery.auroraInstanceId) existingLogicalIds.push('FriggAuroraInstance');
|
|
184
|
+
if (flatDiscovery.dbSubnetGroupName) existingLogicalIds.push('FriggDBSubnetGroup');
|
|
185
|
+
if (flatDiscovery.dbSecretArn) existingLogicalIds.push('FriggDBSecret');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
existingLogicalIds.forEach(logicalId => {
|
|
189
|
+
let resourceType = '';
|
|
190
|
+
let physicalId = '';
|
|
191
|
+
|
|
192
|
+
if (logicalId === 'FriggAuroraCluster') {
|
|
193
|
+
resourceType = 'AWS::RDS::DBCluster';
|
|
194
|
+
physicalId = flatDiscovery.auroraClusterId;
|
|
195
|
+
} else if (logicalId === 'FriggAuroraInstance') {
|
|
196
|
+
resourceType = 'AWS::RDS::DBInstance';
|
|
197
|
+
physicalId = flatDiscovery.auroraInstanceId;
|
|
198
|
+
} else if (logicalId === 'FriggDBSubnetGroup') {
|
|
199
|
+
resourceType = 'AWS::RDS::DBSubnetGroup';
|
|
200
|
+
physicalId = flatDiscovery.dbSubnetGroupName;
|
|
201
|
+
} else if (logicalId === 'FriggDBSecret') {
|
|
202
|
+
resourceType = 'AWS::SecretsManager::Secret';
|
|
203
|
+
physicalId = flatDiscovery.dbSecretArn;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (physicalId && typeof physicalId === 'string') {
|
|
207
|
+
discovery.stackManaged.push({
|
|
208
|
+
logicalId,
|
|
209
|
+
physicalId,
|
|
210
|
+
resourceType
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
} else {
|
|
215
|
+
// Resources discovered from AWS API (external)
|
|
216
|
+
// Handle both cluster ID and endpoint
|
|
217
|
+
if (flatDiscovery.auroraClusterId && typeof flatDiscovery.auroraClusterId === 'string') {
|
|
218
|
+
discovery.external.push({
|
|
219
|
+
physicalId: flatDiscovery.auroraClusterId,
|
|
220
|
+
resourceType: 'AWS::RDS::DBCluster',
|
|
221
|
+
source: 'aws-discovery'
|
|
222
|
+
});
|
|
223
|
+
} else if (flatDiscovery.auroraClusterEndpoint && typeof flatDiscovery.auroraClusterEndpoint === 'string') {
|
|
224
|
+
// Endpoint provided (discover mode) - treat as external
|
|
225
|
+
discovery.external.push({
|
|
226
|
+
physicalId: flatDiscovery.auroraClusterEndpoint,
|
|
227
|
+
resourceType: 'AWS::RDS::DBCluster',
|
|
228
|
+
source: 'aws-discovery',
|
|
229
|
+
properties: { Endpoint: flatDiscovery.auroraClusterEndpoint }
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (flatDiscovery.auroraInstanceId && typeof flatDiscovery.auroraInstanceId === 'string') {
|
|
234
|
+
discovery.external.push({
|
|
235
|
+
physicalId: flatDiscovery.auroraInstanceId,
|
|
236
|
+
resourceType: 'AWS::RDS::DBInstance',
|
|
237
|
+
source: 'aws-discovery'
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return discovery;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Translate legacy configuration to ownership-based configuration
|
|
247
|
+
* Provides backwards compatibility
|
|
248
|
+
*/
|
|
249
|
+
translateLegacyConfig(appDefinition, discoveredResources) {
|
|
250
|
+
// If already using ownership schema, return as-is
|
|
251
|
+
if (appDefinition.database?.postgres?.ownership) {
|
|
252
|
+
return appDefinition;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const translated = JSON.parse(JSON.stringify(appDefinition));
|
|
256
|
+
|
|
257
|
+
// Initialize ownership sections
|
|
258
|
+
if (!translated.database) translated.database = {};
|
|
259
|
+
if (!translated.database.postgres) translated.database.postgres = {};
|
|
260
|
+
if (!translated.database.postgres.ownership) {
|
|
261
|
+
translated.database.postgres.ownership = {};
|
|
262
|
+
}
|
|
263
|
+
if (!translated.database.postgres.external) {
|
|
264
|
+
translated.database.postgres.external = {};
|
|
265
|
+
}
|
|
266
|
+
if (!translated.database.postgres.config) {
|
|
267
|
+
translated.database.postgres.config = {};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Handle top-level managementMode
|
|
271
|
+
const globalMode = appDefinition.managementMode || 'discover';
|
|
272
|
+
const vpcIsolation = appDefinition.vpcIsolation || 'shared';
|
|
273
|
+
|
|
274
|
+
if (globalMode === 'managed') {
|
|
275
|
+
if (appDefinition.database?.postgres?.management) {
|
|
276
|
+
console.log(` ⚠️ managementMode='managed' ignoring: database.postgres.management`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (vpcIsolation === 'isolated') {
|
|
280
|
+
const hasStackAurora = discoveredResources?.auroraClusterId &&
|
|
281
|
+
typeof discoveredResources.auroraClusterId === 'string';
|
|
282
|
+
|
|
283
|
+
if (hasStackAurora) {
|
|
284
|
+
translated.database.postgres.ownership.cluster = 'auto';
|
|
285
|
+
translated.database.postgres.ownership.instance = 'auto';
|
|
286
|
+
translated.database.postgres.ownership.subnetGroup = 'auto';
|
|
287
|
+
translated.database.postgres.ownership.secret = 'auto';
|
|
288
|
+
console.log(` managementMode='managed' + vpcIsolation='isolated' → stack has Aurora, reusing`);
|
|
289
|
+
} else {
|
|
290
|
+
translated.database.postgres.ownership.cluster = 'stack';
|
|
291
|
+
translated.database.postgres.ownership.instance = 'stack';
|
|
292
|
+
translated.database.postgres.ownership.subnetGroup = 'stack';
|
|
293
|
+
translated.database.postgres.ownership.secret = 'stack';
|
|
294
|
+
console.log(` managementMode='managed' + vpcIsolation='isolated' → no stack Aurora, creating new`);
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
translated.database.postgres.ownership.cluster = 'auto';
|
|
298
|
+
translated.database.postgres.ownership.instance = 'auto';
|
|
299
|
+
translated.database.postgres.ownership.subnetGroup = 'auto';
|
|
300
|
+
translated.database.postgres.ownership.secret = 'auto';
|
|
301
|
+
console.log(` managementMode='managed' + vpcIsolation='shared' → discovering Aurora`);
|
|
302
|
+
}
|
|
303
|
+
} else if (globalMode === 'existing') {
|
|
304
|
+
translated.database.postgres.ownership.cluster = 'external';
|
|
305
|
+
translated.database.postgres.ownership.instance = 'external';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Handle legacy database.postgres.management
|
|
309
|
+
// BUT: if managementMode (top-level) is set, it takes precedence
|
|
310
|
+
const dbManagement = appDefinition.database?.postgres?.management;
|
|
311
|
+
if (dbManagement && globalMode !== 'managed' && globalMode !== 'existing') {
|
|
312
|
+
if (dbManagement === 'managed') {
|
|
313
|
+
translated.database.postgres.ownership.cluster = 'stack';
|
|
314
|
+
translated.database.postgres.ownership.instance = 'stack';
|
|
315
|
+
translated.database.postgres.ownership.subnetGroup = 'stack';
|
|
316
|
+
translated.database.postgres.ownership.secret = 'stack';
|
|
317
|
+
} else if (dbManagement === 'use-existing') {
|
|
318
|
+
// For use-existing with endpoint, we bypass resolver entirely
|
|
319
|
+
// Mark this with a special flag
|
|
320
|
+
translated.database.postgres._useExistingEndpoint = true;
|
|
321
|
+
if (appDefinition.database.postgres.endpoint) {
|
|
322
|
+
translated.database.postgres.external.endpoint = appDefinition.database.postgres.endpoint;
|
|
323
|
+
}
|
|
324
|
+
} else if (dbManagement === 'discover') {
|
|
325
|
+
translated.database.postgres.ownership.cluster = 'auto';
|
|
326
|
+
translated.database.postgres.ownership.instance = 'auto';
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Preserve other database config
|
|
331
|
+
if (appDefinition.database?.postgres?.minCapacity) {
|
|
332
|
+
translated.database.postgres.config.minCapacity = appDefinition.database.postgres.minCapacity;
|
|
333
|
+
}
|
|
334
|
+
if (appDefinition.database?.postgres?.maxCapacity) {
|
|
335
|
+
translated.database.postgres.config.maxCapacity = appDefinition.database.postgres.maxCapacity;
|
|
336
|
+
}
|
|
337
|
+
if (appDefinition.database?.postgres?.publiclyAccessible !== undefined) {
|
|
338
|
+
translated.database.postgres.config.publiclyAccessible = appDefinition.database.postgres.publiclyAccessible;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return translated;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Build all Aurora resources based on ownership decisions
|
|
346
|
+
*/
|
|
347
|
+
async buildFromDecisions(decisions, appDefinition, discoveredResources, result) {
|
|
348
|
+
// Determine build strategy from ownership decisions
|
|
349
|
+
|
|
350
|
+
if (decisions.cluster.ownership === ResourceOwnership.EXTERNAL) {
|
|
351
|
+
// External cluster discovered - reference it without creating infrastructure
|
|
352
|
+
console.log(' → Discovering and referencing external Aurora cluster');
|
|
353
|
+
await this.discoverAurora(appDefinition, discoveredResources, result);
|
|
354
|
+
} else if (decisions.cluster.ownership === ResourceOwnership.STACK && decisions.cluster.physicalId) {
|
|
355
|
+
// Cluster exists in stack - add definitions (CloudFormation idempotency)
|
|
356
|
+
console.log(' → Adding Aurora definitions to template (existing in stack)');
|
|
357
|
+
await this.createNewAurora(appDefinition, discoveredResources, result);
|
|
358
|
+
} else if (decisions.cluster.ownership === ResourceOwnership.STACK && !decisions.cluster.physicalId) {
|
|
359
|
+
// Create new cluster (stack, no existing)
|
|
360
|
+
console.log(' → Creating new Aurora cluster in stack');
|
|
361
|
+
await this.createNewAurora(appDefinition, discoveredResources, result);
|
|
362
|
+
} else {
|
|
363
|
+
// Fallback: discover mode
|
|
364
|
+
console.log(' → Discovering Aurora resources');
|
|
365
|
+
await this.discoverAurora(appDefinition, discoveredResources, result);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Create new Aurora cluster
|
|
371
|
+
*/
|
|
372
|
+
async createNewAurora(appDefinition, discoveredResources, result) {
|
|
373
|
+
console.log(' Creating new Aurora Serverless v2 cluster...');
|
|
374
|
+
|
|
375
|
+
const dbConfig = appDefinition.database.postgres;
|
|
376
|
+
const publiclyAccessible = dbConfig.publiclyAccessible === true;
|
|
377
|
+
|
|
378
|
+
// Get subnet IDs for DB Subnet Group
|
|
379
|
+
const subnetIds = publiclyAccessible
|
|
380
|
+
? [discoveredResources.publicSubnetId1, discoveredResources.publicSubnetId2]
|
|
381
|
+
: [discoveredResources.privateSubnetId1, discoveredResources.privateSubnetId2];
|
|
382
|
+
|
|
383
|
+
if (!subnetIds[0] || !subnetIds[1]) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
`Aurora requires 2 ${publiclyAccessible ? 'public' : 'private'} subnets in different AZs. ` +
|
|
386
|
+
'Ensure VPC is configured correctly.'
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Database Subnet Group
|
|
391
|
+
result.resources.FriggDBSubnetGroup = {
|
|
392
|
+
Type: 'AWS::RDS::DBSubnetGroup',
|
|
393
|
+
Properties: {
|
|
394
|
+
DBSubnetGroupName: '${self:service}-${self:provider.stage}-db-subnet-group',
|
|
395
|
+
DBSubnetGroupDescription: 'Subnet group for Frigg Aurora cluster',
|
|
396
|
+
SubnetIds: subnetIds,
|
|
397
|
+
Tags: [
|
|
398
|
+
{ Key: 'Name', Value: '${self:service}-${self:provider.stage}-db-subnet' },
|
|
399
|
+
{ Key: 'ManagedBy', Value: 'Frigg' },
|
|
400
|
+
],
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
// Database Credentials Secret
|
|
405
|
+
result.resources.FriggDBSecret = {
|
|
406
|
+
Type: 'AWS::SecretsManager::Secret',
|
|
407
|
+
Properties: {
|
|
408
|
+
Name: '${self:service}-${self:provider.stage}-db-credentials',
|
|
409
|
+
Description: 'Aurora database credentials',
|
|
410
|
+
GenerateSecretString: {
|
|
411
|
+
SecretStringTemplate: JSON.stringify({ username: dbConfig.username || 'postgres' }),
|
|
412
|
+
GenerateStringKey: 'password',
|
|
413
|
+
PasswordLength: 32,
|
|
414
|
+
// Exclude URL-special characters for Prisma connection string compatibility
|
|
415
|
+
// Prisma docs: https://www.prisma.io/docs/reference/database-reference/connection-urls#special-characters
|
|
416
|
+
// Exclude: " @ : / ? # [ ] % \ (all have special meaning in URLs or need escaping)
|
|
417
|
+
ExcludeCharacters: '"@:/?#[]%\\\\',
|
|
418
|
+
},
|
|
419
|
+
Tags: [
|
|
420
|
+
{ Key: 'Name', Value: '${self:service}-${self:provider.stage}-db-secret' },
|
|
421
|
+
{ Key: 'ManagedBy', Value: 'Frigg' },
|
|
422
|
+
],
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
// Aurora Cluster
|
|
427
|
+
result.resources.FriggAuroraCluster = {
|
|
428
|
+
Type: 'AWS::RDS::DBCluster',
|
|
429
|
+
DeletionPolicy: 'Snapshot',
|
|
430
|
+
Properties: {
|
|
431
|
+
Engine: 'aurora-postgresql',
|
|
432
|
+
EngineMode: 'provisioned',
|
|
433
|
+
EngineVersion: dbConfig.engineVersion || '15.13', // Configurable, defaults to 15.13 (latest as of Oct 2025)
|
|
434
|
+
Port: 5432, // Explicitly set PostgreSQL port (AWS may not auto-detect)
|
|
435
|
+
DatabaseName: dbConfig.database || 'frigg',
|
|
436
|
+
MasterUsername: {
|
|
437
|
+
'Fn::Sub': '{{resolve:secretsmanager:${FriggDBSecret}:SecretString:username}}',
|
|
438
|
+
},
|
|
439
|
+
MasterUserPassword: {
|
|
440
|
+
'Fn::Sub': '{{resolve:secretsmanager:${FriggDBSecret}:SecretString:password}}',
|
|
441
|
+
},
|
|
442
|
+
DBSubnetGroupName: { Ref: 'FriggDBSubnetGroup' },
|
|
443
|
+
VpcSecurityGroupIds: discoveredResources.vpcSecurityGroupIds || [
|
|
444
|
+
{ Ref: 'FriggLambdaSecurityGroup' },
|
|
445
|
+
],
|
|
446
|
+
// Note: PubliclyAccessible is NOT supported on Aurora clusters
|
|
447
|
+
// It should only be set on DB instances (see FriggAuroraInstance below)
|
|
448
|
+
// MaxCapacity default bumped 1 → 4 ACU: at 0.5–1 ACU Aurora is
|
|
449
|
+
// CPU-starved under 20-way concurrent writes from a Lambda
|
|
450
|
+
// fan-out sync, which starves worker queries and compounds
|
|
451
|
+
// the tail-latency problem. 4 ACU is still cheap (scales to
|
|
452
|
+
// min when idle) and gives the DB enough headroom to
|
|
453
|
+
// absorb bursty sync traffic. Apps can still override both
|
|
454
|
+
// via app definition dbConfig.
|
|
455
|
+
ServerlessV2ScalingConfiguration: {
|
|
456
|
+
MinCapacity: dbConfig.minCapacity || 0.5,
|
|
457
|
+
MaxCapacity: dbConfig.maxCapacity || 4,
|
|
458
|
+
},
|
|
459
|
+
EnableHttpEndpoint: false,
|
|
460
|
+
BackupRetentionPeriod: 7,
|
|
461
|
+
PreferredBackupWindow: '03:00-04:00',
|
|
462
|
+
PreferredMaintenanceWindow: 'sun:04:00-sun:05:00',
|
|
463
|
+
Tags: [
|
|
464
|
+
{ Key: 'Name', Value: '${self:service}-${self:provider.stage}-aurora' },
|
|
465
|
+
{ Key: 'ManagedBy', Value: 'Frigg' },
|
|
466
|
+
],
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// Aurora Instance
|
|
471
|
+
result.resources.FriggAuroraInstance = {
|
|
472
|
+
Type: 'AWS::RDS::DBInstance',
|
|
473
|
+
Properties: {
|
|
474
|
+
Engine: 'aurora-postgresql',
|
|
475
|
+
DBInstanceClass: 'db.serverless',
|
|
476
|
+
DBClusterIdentifier: { Ref: 'FriggAuroraCluster' },
|
|
477
|
+
PubliclyAccessible: publiclyAccessible,
|
|
478
|
+
Tags: [
|
|
479
|
+
{ Key: 'Name', Value: '${self:service}-${self:provider.stage}-aurora-instance' },
|
|
480
|
+
{ Key: 'ManagedBy', Value: 'Frigg' },
|
|
481
|
+
],
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
// Environment variables
|
|
486
|
+
result.environment.DATABASE_URL = this.buildDatabaseUrl(
|
|
487
|
+
{ 'Fn::GetAtt': ['FriggAuroraCluster', 'Endpoint.Address'] },
|
|
488
|
+
{ 'Fn::GetAtt': ['FriggAuroraCluster', 'Endpoint.Port'] },
|
|
489
|
+
dbConfig.database || 'frigg',
|
|
490
|
+
{ Ref: 'FriggDBSecret' }
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
// IAM permissions for Secrets Manager
|
|
494
|
+
result.iamStatements.push({
|
|
495
|
+
Effect: 'Allow',
|
|
496
|
+
Action: ['secretsmanager:GetSecretValue'],
|
|
497
|
+
Resource: { Ref: 'FriggDBSecret' },
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// Add self-referencing security group ingress rule to allow Lambda to connect to Aurora
|
|
501
|
+
// Since both Lambda and Aurora share the same security group, we need to allow the SG to accept traffic from itself
|
|
502
|
+
result.resources.FriggAuroraIngressRule = {
|
|
503
|
+
Type: 'AWS::EC2::SecurityGroupIngress',
|
|
504
|
+
Properties: {
|
|
505
|
+
GroupId: { Ref: 'FriggLambdaSecurityGroup' },
|
|
506
|
+
IpProtocol: 'tcp',
|
|
507
|
+
FromPort: 5432,
|
|
508
|
+
ToPort: 5432,
|
|
509
|
+
SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' },
|
|
510
|
+
Description: 'Allow Lambda functions to connect to Aurora PostgreSQL (self-referencing rule)',
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
console.log(' ✅ Aurora Serverless v2 cluster resources created');
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Use existing Aurora cluster
|
|
519
|
+
*/
|
|
520
|
+
async useExistingAurora(appDefinition, discoveredResources, result) {
|
|
521
|
+
console.log(' Using existing Aurora cluster...');
|
|
522
|
+
|
|
523
|
+
const dbConfig = appDefinition.database.postgres;
|
|
524
|
+
|
|
525
|
+
if (!dbConfig.endpoint) {
|
|
526
|
+
throw new Error('database.postgres.endpoint is required when management="use-existing"');
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Set environment variables for existing cluster
|
|
530
|
+
result.environment.DATABASE_HOST = dbConfig.endpoint;
|
|
531
|
+
result.environment.DATABASE_PORT = String(dbConfig.port || 5432);
|
|
532
|
+
result.environment.DATABASE_NAME = dbConfig.database || 'frigg';
|
|
533
|
+
result.environment.DATABASE_USER = dbConfig.username || 'postgres';
|
|
534
|
+
// Consumers that build DATABASE_URL from components at runtime MUST
|
|
535
|
+
// append `?${DATABASE_URL_PARAMS}` to get the same hang-prevention
|
|
536
|
+
// timeouts as the managed path.
|
|
537
|
+
result.environment.DATABASE_URL_PARAMS = LAMBDA_DATABASE_URL_QUERY_PARAMS;
|
|
538
|
+
|
|
539
|
+
console.log(` ✅ Using existing cluster: ${dbConfig.endpoint}`);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Discover existing Aurora cluster
|
|
544
|
+
*/
|
|
545
|
+
async discoverAurora(appDefinition, discoveredResources, result) {
|
|
546
|
+
console.log(' Discovering Aurora cluster...');
|
|
547
|
+
|
|
548
|
+
if (!discoveredResources.auroraClusterEndpoint) {
|
|
549
|
+
throw new Error(
|
|
550
|
+
'No Aurora cluster found in discovery mode. Set management to "managed" or provide endpoint with "use-existing".'
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
console.log(` ✅ Using discovered Aurora cluster: ${discoveredResources.auroraClusterEndpoint}`);
|
|
555
|
+
|
|
556
|
+
const dbConfig = appDefinition.database.postgres;
|
|
557
|
+
|
|
558
|
+
// Use discovered cluster details
|
|
559
|
+
result.environment.DATABASE_HOST = discoveredResources.auroraClusterEndpoint;
|
|
560
|
+
result.environment.DATABASE_PORT = String(discoveredResources.auroraPort || 5432);
|
|
561
|
+
|
|
562
|
+
// Check if we should auto-create credentials
|
|
563
|
+
if (dbConfig.autoCreateCredentials && !discoveredResources.databaseSecretArn) {
|
|
564
|
+
console.log(' Creating Secrets Manager secret and rotating Aurora password...');
|
|
565
|
+
|
|
566
|
+
// Create Secrets Manager secret with auto-generated password
|
|
567
|
+
result.resources.FriggDBSecret = {
|
|
568
|
+
Type: 'AWS::SecretsManager::Secret',
|
|
569
|
+
Properties: {
|
|
570
|
+
Name: '${self:service}-${self:provider.stage}-db-credentials',
|
|
571
|
+
Description: 'Aurora database credentials (auto-created for discovered cluster)',
|
|
572
|
+
GenerateSecretString: {
|
|
573
|
+
SecretStringTemplate: JSON.stringify({ username: dbConfig.username || 'postgres' }),
|
|
574
|
+
GenerateStringKey: 'password',
|
|
575
|
+
PasswordLength: 32,
|
|
576
|
+
// Exclude URL-special characters for Prisma connection string compatibility
|
|
577
|
+
// Prisma docs: https://www.prisma.io/docs/reference/database-reference/connection-urls#special-characters
|
|
578
|
+
// Exclude: " @ : / ? # [ ] % \ (all have special meaning in URLs or need escaping)
|
|
579
|
+
ExcludeCharacters: '"@:/?#[]%\\\\',
|
|
580
|
+
},
|
|
581
|
+
Tags: [
|
|
582
|
+
{ Key: 'Name', Value: '${self:service}-${self:provider.stage}-db-secret' },
|
|
583
|
+
{ Key: 'ManagedBy', Value: 'Frigg' },
|
|
584
|
+
{ Key: 'Purpose', Value: 'DiscoveredClusterCredentials' },
|
|
585
|
+
],
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
// Get the cluster identifier from the endpoint
|
|
590
|
+
// Format: cluster-name.cluster-xyz.region.rds.amazonaws.com
|
|
591
|
+
const clusterIdentifier = discoveredResources.auroraClusterEndpoint.split('.')[0];
|
|
592
|
+
|
|
593
|
+
// Create custom resource to rotate the Aurora master password
|
|
594
|
+
// This uses a Lambda-backed CloudFormation custom resource
|
|
595
|
+
result.resources.FriggAuroraPasswordRotator = {
|
|
596
|
+
Type: 'Custom::AuroraPasswordRotator',
|
|
597
|
+
Properties: {
|
|
598
|
+
ServiceToken: { 'Fn::GetAtt': ['PasswordRotatorLambda', 'Arn'] },
|
|
599
|
+
ClusterIdentifier: clusterIdentifier,
|
|
600
|
+
SecretArn: { Ref: 'FriggDBSecret' },
|
|
601
|
+
Region: '${self:provider.region}',
|
|
602
|
+
},
|
|
603
|
+
DependsOn: ['FriggDBSecret', 'PasswordRotatorLambda'],
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
// Lambda function to rotate the password
|
|
607
|
+
result.resources.PasswordRotatorLambda = {
|
|
608
|
+
Type: 'AWS::Lambda::Function',
|
|
609
|
+
Properties: {
|
|
610
|
+
FunctionName: '${self:service}-${self:provider.stage}-password-rotator',
|
|
611
|
+
Runtime: 'nodejs22.x',
|
|
612
|
+
Handler: 'index.handler',
|
|
613
|
+
Role: { 'Fn::GetAtt': ['PasswordRotatorRole', 'Arn'] },
|
|
614
|
+
Timeout: 60,
|
|
615
|
+
Code: {
|
|
616
|
+
ZipFile: `
|
|
617
|
+
const { RDSClient, ModifyDBClusterCommand } = require('@aws-sdk/client-rds');
|
|
618
|
+
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');
|
|
619
|
+
|
|
620
|
+
exports.handler = async (event, context) => {
|
|
621
|
+
console.log('Event:', JSON.stringify(event, null, 2));
|
|
622
|
+
|
|
623
|
+
const { RequestType, ResourceProperties } = event;
|
|
624
|
+
const { ClusterIdentifier, SecretArn, Region } = ResourceProperties;
|
|
625
|
+
|
|
626
|
+
const sendResponse = async (status, data = {}) => {
|
|
627
|
+
const responseBody = JSON.stringify({
|
|
628
|
+
Status: status,
|
|
629
|
+
Reason: data.Reason || 'See CloudWatch logs',
|
|
630
|
+
PhysicalResourceId: context.logStreamName,
|
|
631
|
+
StackId: event.StackId,
|
|
632
|
+
RequestId: event.RequestId,
|
|
633
|
+
LogicalResourceId: event.LogicalResourceId,
|
|
634
|
+
Data: data,
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
await fetch(event.ResponseURL, {
|
|
638
|
+
method: 'PUT',
|
|
639
|
+
body: responseBody,
|
|
640
|
+
headers: { 'Content-Type': '' },
|
|
641
|
+
});
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
try {
|
|
645
|
+
if (RequestType === 'Delete') {
|
|
646
|
+
await sendResponse('SUCCESS', { Message: 'Delete not required' });
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Get the new password from Secrets Manager
|
|
651
|
+
const smClient = new SecretsManagerClient({ region: Region });
|
|
652
|
+
const secretResponse = await smClient.send(
|
|
653
|
+
new GetSecretValueCommand({ SecretId: SecretArn })
|
|
654
|
+
);
|
|
655
|
+
const secret = JSON.parse(secretResponse.SecretString);
|
|
656
|
+
const newPassword = secret.password;
|
|
657
|
+
|
|
658
|
+
// Rotate the Aurora cluster master password
|
|
659
|
+
const rdsClient = new RDSClient({ region: Region });
|
|
660
|
+
await rdsClient.send(
|
|
661
|
+
new ModifyDBClusterCommand({
|
|
662
|
+
DBClusterIdentifier: ClusterIdentifier,
|
|
663
|
+
MasterUserPassword: newPassword,
|
|
664
|
+
ApplyImmediately: true,
|
|
665
|
+
})
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
console.log('Successfully rotated password for cluster: ' + ClusterIdentifier);
|
|
669
|
+
await sendResponse('SUCCESS', {
|
|
670
|
+
Message: 'Password rotated successfully',
|
|
671
|
+
ClusterIdentifier,
|
|
672
|
+
});
|
|
673
|
+
} catch (error) {
|
|
674
|
+
console.error('Error rotating password:', error);
|
|
675
|
+
await sendResponse('FAILED', { Reason: error.message });
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
`,
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
// IAM role for the password rotator Lambda
|
|
684
|
+
result.resources.PasswordRotatorRole = {
|
|
685
|
+
Type: 'AWS::IAM::Role',
|
|
686
|
+
Properties: {
|
|
687
|
+
AssumeRolePolicyDocument: {
|
|
688
|
+
Version: '2012-10-17',
|
|
689
|
+
Statement: [
|
|
690
|
+
{
|
|
691
|
+
Effect: 'Allow',
|
|
692
|
+
Principal: { Service: 'lambda.amazonaws.com' },
|
|
693
|
+
Action: 'sts:AssumeRole',
|
|
694
|
+
},
|
|
695
|
+
],
|
|
696
|
+
},
|
|
697
|
+
ManagedPolicyArns: [
|
|
698
|
+
'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole',
|
|
699
|
+
],
|
|
700
|
+
Policies: [
|
|
701
|
+
{
|
|
702
|
+
PolicyName: 'PasswordRotatorPolicy',
|
|
703
|
+
PolicyDocument: {
|
|
704
|
+
Version: '2012-10-17',
|
|
705
|
+
Statement: [
|
|
706
|
+
{
|
|
707
|
+
Effect: 'Allow',
|
|
708
|
+
Action: [
|
|
709
|
+
'rds:ModifyDBCluster',
|
|
710
|
+
'rds:DescribeDBClusters',
|
|
711
|
+
],
|
|
712
|
+
Resource: '*',
|
|
713
|
+
},
|
|
714
|
+
{
|
|
715
|
+
Effect: 'Allow',
|
|
716
|
+
Action: ['secretsmanager:GetSecretValue'],
|
|
717
|
+
Resource: { Ref: 'FriggDBSecret' },
|
|
718
|
+
},
|
|
719
|
+
],
|
|
720
|
+
},
|
|
721
|
+
},
|
|
722
|
+
],
|
|
723
|
+
},
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
// Use the secret for DATABASE_URL
|
|
727
|
+
result.environment.DATABASE_SECRET_ARN = { Ref: 'FriggDBSecret' };
|
|
728
|
+
result.environment.DATABASE_URL = this.buildDatabaseUrl(
|
|
729
|
+
discoveredResources.auroraClusterEndpoint,
|
|
730
|
+
discoveredResources.auroraPort || 5432,
|
|
731
|
+
dbConfig.database || 'frigg',
|
|
732
|
+
{ Ref: 'FriggDBSecret' }
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
// Grant Lambda functions permission to read the secret
|
|
736
|
+
result.iamStatements.push({
|
|
737
|
+
Effect: 'Allow',
|
|
738
|
+
Action: ['secretsmanager:GetSecretValue'],
|
|
739
|
+
Resource: { Ref: 'FriggDBSecret' },
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
console.log(' ✅ Credentials auto-creation configured');
|
|
743
|
+
} else if (discoveredResources.databaseSecretArn) {
|
|
744
|
+
// Use existing discovered secret
|
|
745
|
+
result.environment.DATABASE_SECRET_ARN = discoveredResources.databaseSecretArn;
|
|
746
|
+
result.environment.DATABASE_URL = this.buildDatabaseUrl(
|
|
747
|
+
discoveredResources.auroraClusterEndpoint,
|
|
748
|
+
discoveredResources.auroraPort || 5432,
|
|
749
|
+
dbConfig.database || 'frigg',
|
|
750
|
+
discoveredResources.databaseSecretArn
|
|
751
|
+
);
|
|
752
|
+
|
|
753
|
+
result.iamStatements.push({
|
|
754
|
+
Effect: 'Allow',
|
|
755
|
+
Action: ['secretsmanager:GetSecretValue'],
|
|
756
|
+
Resource: discoveredResources.databaseSecretArn,
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
console.log(' ✅ Using discovered Secrets Manager credentials');
|
|
760
|
+
} else {
|
|
761
|
+
// No secret and no auto-create - set individual DB connection components
|
|
762
|
+
// The application will construct DATABASE_URL at runtime from these components + DATABASE_USER + DATABASE_PASSWORD
|
|
763
|
+
const dbName = dbConfig.database || 'frigg';
|
|
764
|
+
|
|
765
|
+
result.environment.DATABASE_HOST = discoveredResources.auroraClusterEndpoint;
|
|
766
|
+
result.environment.DATABASE_PORT = String(discoveredResources.auroraPort || 5432);
|
|
767
|
+
result.environment.DATABASE_NAME = dbName;
|
|
768
|
+
// Consumers that build DATABASE_URL from components at runtime MUST
|
|
769
|
+
// append `?${DATABASE_URL_PARAMS}` to get the same hang-prevention
|
|
770
|
+
// timeouts as the managed path.
|
|
771
|
+
result.environment.DATABASE_URL_PARAMS = LAMBDA_DATABASE_URL_QUERY_PARAMS;
|
|
772
|
+
|
|
773
|
+
// Note: DATABASE_URL is NOT set here to avoid Serverless variable resolution errors
|
|
774
|
+
// The application (Frigg Core) should construct it at runtime from:
|
|
775
|
+
// DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD, DATABASE_URL_PARAMS
|
|
776
|
+
|
|
777
|
+
console.log(' ℹ️ No Secrets Manager secret found - set DATABASE_USER and DATABASE_PASSWORD in Lambda environment');
|
|
778
|
+
console.log(' ℹ️ Application will construct DATABASE_URL at runtime from DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD');
|
|
779
|
+
console.log(' ℹ️ Append `?${DATABASE_URL_PARAMS}` to the constructed URL for pool/timeout safety.');
|
|
780
|
+
console.log(' ℹ️ Or enable autoCreateCredentials=true to automatically create and rotate credentials');
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Add security group ingress rule to allow Lambda to connect to Aurora
|
|
784
|
+
if (discoveredResources.auroraSecurityGroupId) {
|
|
785
|
+
result.resources.FriggAuroraIngressRule = {
|
|
786
|
+
Type: 'AWS::EC2::SecurityGroupIngress',
|
|
787
|
+
Properties: {
|
|
788
|
+
GroupId: discoveredResources.auroraSecurityGroupId,
|
|
789
|
+
IpProtocol: 'tcp',
|
|
790
|
+
FromPort: discoveredResources.auroraPort || 5432,
|
|
791
|
+
ToPort: discoveredResources.auroraPort || 5432,
|
|
792
|
+
SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' },
|
|
793
|
+
Description: 'Allow Lambda functions to connect to Aurora PostgreSQL',
|
|
794
|
+
},
|
|
795
|
+
};
|
|
796
|
+
console.log(` ✅ Added security group ingress rule for Lambda → Aurora connectivity`);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
console.log(` ✅ Discovered cluster configuration complete`);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Build DATABASE_URL connection string
|
|
804
|
+
* @param {string|object} host - Database host (string or CloudFormation intrinsic function)
|
|
805
|
+
* @param {string|number|object} port - Database port (string/number or CloudFormation intrinsic function)
|
|
806
|
+
* @param {string} database - Database name
|
|
807
|
+
* @param {string|object} secretRef - Secret ARN (string) or CloudFormation Ref object
|
|
808
|
+
*/
|
|
809
|
+
buildDatabaseUrl(host, port, database, secretRef) {
|
|
810
|
+
// Handle secretRef as either a string ARN or CloudFormation Ref object
|
|
811
|
+
const resolveSecretRef = (secretRefValue) => {
|
|
812
|
+
if (typeof secretRefValue === 'object' && secretRefValue.Ref) {
|
|
813
|
+
// CloudFormation Ref - use nested Fn::Sub to resolve it
|
|
814
|
+
return {
|
|
815
|
+
'Fn::Sub': [
|
|
816
|
+
'{{resolve:secretsmanager:${SecretArn}:SecretString:username}}',
|
|
817
|
+
{ SecretArn: secretRefValue },
|
|
818
|
+
],
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
// String ARN - use directly
|
|
822
|
+
return `{{resolve:secretsmanager:${secretRefValue}:SecretString:username}}`;
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
const resolveSecretPassword = (secretRefValue) => {
|
|
826
|
+
if (typeof secretRefValue === 'object' && secretRefValue.Ref) {
|
|
827
|
+
// CloudFormation Ref - use nested Fn::Sub to resolve it
|
|
828
|
+
return {
|
|
829
|
+
'Fn::Sub': [
|
|
830
|
+
'{{resolve:secretsmanager:${SecretArn}:SecretString:password}}',
|
|
831
|
+
{ SecretArn: secretRefValue },
|
|
832
|
+
],
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
// String ARN - use directly
|
|
836
|
+
return `{{resolve:secretsmanager:${secretRefValue}:SecretString:password}}`;
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
// Query params are defined at module scope (LAMBDA_DATABASE_URL_QUERY_PARAMS)
|
|
840
|
+
// so runtime-URL-construction paths can emit the same timeouts as an env var.
|
|
841
|
+
return {
|
|
842
|
+
'Fn::Sub': [
|
|
843
|
+
`postgresql://\${Username}:\${Password}@\${Host}:\${Port}/\${Database}?${LAMBDA_DATABASE_URL_QUERY_PARAMS}`,
|
|
844
|
+
{
|
|
845
|
+
Username: resolveSecretRef(secretRef),
|
|
846
|
+
Password: resolveSecretPassword(secretRef),
|
|
847
|
+
Host: host,
|
|
848
|
+
Port: port,
|
|
849
|
+
Database: database,
|
|
850
|
+
},
|
|
851
|
+
],
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
module.exports = { AuroraBuilder };
|
|
857
|
+
|