@friggframework/devtools 2.0.0--canary.627.ce32cdc.0 → 2.0.0--canary.640.b31eb4a.0
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/infrastructure/__tests__/aurora-scale-to-zero-connectivity.test.js +255 -0
- package/infrastructure/domains/admin-scripts/admin-script-builder.js +272 -50
- package/infrastructure/domains/admin-scripts/admin-script-builder.test.js +313 -0
- package/infrastructure/domains/database/aurora-builder.js +232 -39
- package/infrastructure/domains/networking/vpc-builder.js +65 -4
- package/infrastructure/domains/shared/types/app-definition.js +18 -2
- package/infrastructure/domains/shared/utilities/base-definition-factory.js +1 -10
- package/package.json +7 -7
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-033: Aurora Serverless v2 scale-to-zero + NAT-free public connectivity
|
|
3
|
+
*
|
|
4
|
+
* These tests assert on the GENERATED CloudFormation template (via the full
|
|
5
|
+
* composer) plus the AuroraBuilder validator. They are deterministic and require
|
|
6
|
+
* neither a live AWS account nor Prisma client generation:
|
|
7
|
+
* - Validator cases call AuroraBuilder.validate() directly (pure, no I/O).
|
|
8
|
+
* - Template cases use vpc.management='create-new' + database.postgres
|
|
9
|
+
* management='managed', which resolve to STACK ownership without depending on
|
|
10
|
+
* any discovered AWS resource, so cloud discovery returning empty is fine.
|
|
11
|
+
*
|
|
12
|
+
* Both new capabilities are opt-in and default-off; the final describe block
|
|
13
|
+
* guards the no-regression promise: a default (vpc, no minCapacity) definition
|
|
14
|
+
* composes the same template as before.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { composeServerlessDefinition } = require('../infrastructure-composer');
|
|
18
|
+
const { AuroraBuilder } = require('../domains/database/aurora-builder');
|
|
19
|
+
|
|
20
|
+
// Shared: an app definition that creates a fresh VPC + a Frigg-owned Aurora
|
|
21
|
+
// cluster in-stack, with overridable postgres config.
|
|
22
|
+
function makeApp(postgresOverrides = {}) {
|
|
23
|
+
return {
|
|
24
|
+
name: 'adr033-app',
|
|
25
|
+
provider: 'aws',
|
|
26
|
+
region: 'us-east-1',
|
|
27
|
+
integrations: [],
|
|
28
|
+
vpc: { enable: true, management: 'create-new' },
|
|
29
|
+
database: {
|
|
30
|
+
postgres: {
|
|
31
|
+
enable: true,
|
|
32
|
+
management: 'managed',
|
|
33
|
+
...postgresOverrides,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function findResources(template, predicate) {
|
|
40
|
+
return Object.entries(template.resources.Resources).filter(([, r]) => predicate(r));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('ADR-033: Aurora scale-to-zero + connectivity', () => {
|
|
44
|
+
beforeAll(() => {
|
|
45
|
+
process.env.AWS_REGION = 'us-east-1';
|
|
46
|
+
// Intentionally NOT setting FRIGG_SKIP_AWS_DISCOVERY — the builders must
|
|
47
|
+
// execute. create-new/managed resolve to STACK without needing discovery.
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(() => {
|
|
51
|
+
delete process.env.AWS_REGION;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------
|
|
55
|
+
// Validator
|
|
56
|
+
// ---------------------------------------------------------------------
|
|
57
|
+
describe('validator (AuroraBuilder.validate)', () => {
|
|
58
|
+
const build = new AuroraBuilder();
|
|
59
|
+
// ValidationResult exposes hasErrors(); "valid" means no errors.
|
|
60
|
+
const validateResult = (postgres) =>
|
|
61
|
+
build.validate({ database: { postgres: { enable: true, ...postgres } } });
|
|
62
|
+
const isValid = (postgres) => !validateResult(postgres).hasErrors();
|
|
63
|
+
|
|
64
|
+
test('accepts minCapacity: 0 (scale-to-zero)', () => {
|
|
65
|
+
expect(isValid({ minCapacity: 0 })).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('rejects minCapacity: 0.3 (inside the forbidden (0, 0.5) band)', () => {
|
|
69
|
+
const r = validateResult({ minCapacity: 0.3 });
|
|
70
|
+
expect(r.hasErrors()).toBe(true);
|
|
71
|
+
expect(r.errors.join(' ')).toMatch(/minCapacity must be 0 \(scale-to-zero\) or between 0\.5 and 128/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('accepts minCapacity: 0.5 and minCapacity: 64', () => {
|
|
75
|
+
expect(isValid({ minCapacity: 0.5 })).toBe(true);
|
|
76
|
+
expect(isValid({ minCapacity: 64 })).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('rejects secondsUntilAutoPause: 100 (below 300)', () => {
|
|
80
|
+
const r = validateResult({ minCapacity: 0, secondsUntilAutoPause: 100 });
|
|
81
|
+
expect(r.hasErrors()).toBe(true);
|
|
82
|
+
expect(r.errors.join(' ')).toMatch(/secondsUntilAutoPause must be an integer between 300 and 86400/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('accepts secondsUntilAutoPause: 3600', () => {
|
|
86
|
+
expect(isValid({ minCapacity: 0, secondsUntilAutoPause: 3600 })).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("accepts connectivity: 'public'", () => {
|
|
90
|
+
expect(isValid({ connectivity: 'public' })).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("rejects connectivity: 'nope'", () => {
|
|
94
|
+
const r = validateResult({ connectivity: 'nope' });
|
|
95
|
+
expect(r.hasErrors()).toBe(true);
|
|
96
|
+
expect(r.errors.join(' ')).toMatch(/Invalid database\.postgres\.connectivity/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('rejects non-array / non-CIDR allowedCidrs, accepts valid CIDRs', () => {
|
|
100
|
+
expect(isValid({ allowedCidrs: 'nope' })).toBe(false);
|
|
101
|
+
expect(isValid({ allowedCidrs: ['not-a-cidr'] })).toBe(false);
|
|
102
|
+
expect(isValid({ allowedCidrs: ['10.0.0.0/8', '203.0.113.5/32'] })).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('warns (does not fail) when minCapacity:0 with an older pinned engine version', () => {
|
|
106
|
+
const r = validateResult({ minCapacity: 0, engineVersion: '15.4' });
|
|
107
|
+
expect(r.hasErrors()).toBe(false); // warning, not error
|
|
108
|
+
expect(r.warnings.join(' ')).toMatch(/may not support .*scale-to-zero/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('does not warn about engine when minCapacity:0 on a capable version', () => {
|
|
112
|
+
const r = validateResult({ minCapacity: 0, engineVersion: '15.13' });
|
|
113
|
+
expect(r.warnings.join(' ')).not.toMatch(/may not support .*scale-to-zero/);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------
|
|
118
|
+
// Scale-to-zero (template shape)
|
|
119
|
+
// ---------------------------------------------------------------------
|
|
120
|
+
describe('scale-to-zero (minCapacity: 0)', () => {
|
|
121
|
+
test('MinCapacity is exactly 0 and SecondsUntilAutoPause defaults to 300', async () => {
|
|
122
|
+
const t = await composeServerlessDefinition(makeApp({ minCapacity: 0 }));
|
|
123
|
+
const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
|
|
124
|
+
|
|
125
|
+
// Mutation guard: with the old `|| 0.5` bug this would be 0.5, not 0.
|
|
126
|
+
expect(scaling.MinCapacity).toBe(0);
|
|
127
|
+
expect(scaling.MinCapacity).not.toBe(0.5);
|
|
128
|
+
expect(scaling.SecondsUntilAutoPause).toBe(300);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('SecondsUntilAutoPause honors a custom value', async () => {
|
|
132
|
+
const t = await composeServerlessDefinition(makeApp({ minCapacity: 0, secondsUntilAutoPause: 1800 }));
|
|
133
|
+
const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
|
|
134
|
+
expect(scaling.MinCapacity).toBe(0);
|
|
135
|
+
expect(scaling.SecondsUntilAutoPause).toBe(1800);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('MaxCapacity defaults to 4 and is preserved with scale-to-zero', async () => {
|
|
139
|
+
const t = await composeServerlessDefinition(makeApp({ minCapacity: 0 }));
|
|
140
|
+
const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
|
|
141
|
+
expect(scaling.MaxCapacity).toBe(4);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------
|
|
146
|
+
// Public connectivity (template shape)
|
|
147
|
+
// ---------------------------------------------------------------------
|
|
148
|
+
describe("connectivity: 'public'", () => {
|
|
149
|
+
test('Aurora ingress uses CidrIp — one rule per allowedCidr — not SourceSecurityGroupId', async () => {
|
|
150
|
+
const t = await composeServerlessDefinition(
|
|
151
|
+
makeApp({ connectivity: 'public', allowedCidrs: ['10.1.0.0/16', '203.0.113.7/32'] })
|
|
152
|
+
);
|
|
153
|
+
const ingress = findResources(
|
|
154
|
+
t,
|
|
155
|
+
(r) => r.Type === 'AWS::EC2::SecurityGroupIngress' && r.Properties.FromPort === 5432
|
|
156
|
+
);
|
|
157
|
+
expect(ingress).toHaveLength(2);
|
|
158
|
+
const cidrs = ingress.map(([, r]) => r.Properties.CidrIp).sort();
|
|
159
|
+
expect(cidrs).toEqual(['10.1.0.0/16', '203.0.113.7/32']);
|
|
160
|
+
ingress.forEach(([, r]) => {
|
|
161
|
+
expect(r.Properties.CidrIp).toBeDefined();
|
|
162
|
+
expect(r.Properties.SourceSecurityGroupId).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('allowedCidrs defaults to 0.0.0.0/0 when omitted', async () => {
|
|
167
|
+
const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
|
|
168
|
+
const ingress = findResources(
|
|
169
|
+
t,
|
|
170
|
+
(r) => r.Type === 'AWS::EC2::SecurityGroupIngress' && r.Properties.FromPort === 5432
|
|
171
|
+
);
|
|
172
|
+
expect(ingress).toHaveLength(1);
|
|
173
|
+
expect(ingress[0][1].Properties.CidrIp).toBe('0.0.0.0/0');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('Aurora instance is PubliclyAccessible and cluster sits in public subnets', async () => {
|
|
177
|
+
const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
|
|
178
|
+
expect(t.resources.Resources.FriggAuroraInstance.Properties.PubliclyAccessible).toBe(true);
|
|
179
|
+
expect(t.resources.Resources.FriggDBSubnetGroup.Properties.SubnetIds).toEqual([
|
|
180
|
+
{ Ref: 'FriggPublicSubnet' },
|
|
181
|
+
{ Ref: 'FriggPublicSubnet2' },
|
|
182
|
+
]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('NO NAT Gateway resource is emitted', async () => {
|
|
186
|
+
const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
|
|
187
|
+
const nats = findResources(t, (r) => r.Type === 'AWS::EC2::NatGateway');
|
|
188
|
+
expect(nats).toHaveLength(0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('Lambda is NOT attached to the VPC (provider.vpc unset)', async () => {
|
|
192
|
+
const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
|
|
193
|
+
expect(t.provider.vpc).toBeUndefined();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('DATABASE_URL enforces TLS (sslmode=require)', async () => {
|
|
197
|
+
const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
|
|
198
|
+
const url = t.provider.environment.DATABASE_URL;
|
|
199
|
+
expect(url['Fn::Sub'][0]).toContain('sslmode=require');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('combines with scale-to-zero: $0-idle NAT-free Aurora', async () => {
|
|
203
|
+
const t = await composeServerlessDefinition(makeApp({ connectivity: 'public', minCapacity: 0 }));
|
|
204
|
+
const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
|
|
205
|
+
expect(scaling.MinCapacity).toBe(0);
|
|
206
|
+
expect(scaling.SecondsUntilAutoPause).toBe(300);
|
|
207
|
+
expect(findResources(t, (r) => r.Type === 'AWS::EC2::NatGateway')).toHaveLength(0);
|
|
208
|
+
expect(t.provider.vpc).toBeUndefined();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------
|
|
213
|
+
// No-regression: default (vpc) connectivity, no new fields
|
|
214
|
+
// ---------------------------------------------------------------------
|
|
215
|
+
describe("default connectivity: 'vpc' (no regression)", () => {
|
|
216
|
+
test('MinCapacity defaults to 0.5 with no SecondsUntilAutoPause', async () => {
|
|
217
|
+
const t = await composeServerlessDefinition(makeApp());
|
|
218
|
+
const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
|
|
219
|
+
expect(scaling.MinCapacity).toBe(0.5);
|
|
220
|
+
expect(scaling.MaxCapacity).toBe(4);
|
|
221
|
+
expect(scaling.SecondsUntilAutoPause).toBeUndefined();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('Aurora ingress uses SourceSecurityGroupId (Lambda SG), not CidrIp', async () => {
|
|
225
|
+
const t = await composeServerlessDefinition(makeApp());
|
|
226
|
+
const ingress = findResources(
|
|
227
|
+
t,
|
|
228
|
+
(r) => r.Type === 'AWS::EC2::SecurityGroupIngress' && r.Properties.FromPort === 5432
|
|
229
|
+
);
|
|
230
|
+
expect(ingress).toHaveLength(1);
|
|
231
|
+
expect(ingress[0][1].Properties.SourceSecurityGroupId).toEqual({ Ref: 'FriggLambdaSecurityGroup' });
|
|
232
|
+
expect(ingress[0][1].Properties.CidrIp).toBeUndefined();
|
|
233
|
+
// Logical ID unchanged for the vpc path
|
|
234
|
+
expect(ingress[0][0]).toBe('FriggAuroraIngressRule');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('Aurora instance is not publicly accessible by default', async () => {
|
|
238
|
+
const t = await composeServerlessDefinition(makeApp());
|
|
239
|
+
expect(t.resources.Resources.FriggAuroraInstance.Properties.PubliclyAccessible).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('Lambda IS attached to the VPC (provider.vpc set)', async () => {
|
|
243
|
+
const t = await composeServerlessDefinition(makeApp());
|
|
244
|
+
expect(t.provider.vpc).toBeDefined();
|
|
245
|
+
expect(t.provider.vpc.subnetIds).toBeDefined();
|
|
246
|
+
expect(t.provider.vpc.securityGroupIds).toBeDefined();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('DATABASE_URL does not add sslmode in vpc mode', async () => {
|
|
250
|
+
const t = await composeServerlessDefinition(makeApp());
|
|
251
|
+
const url = t.provider.environment.DATABASE_URL;
|
|
252
|
+
expect(url['Fn::Sub'][0]).not.toContain('sslmode=require');
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -23,7 +23,15 @@ class AdminScriptBuilder extends InfrastructureBuilder {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
shouldExecute(appDefinition) {
|
|
26
|
-
|
|
26
|
+
const hasScripts =
|
|
27
|
+
Array.isArray(appDefinition.adminScripts) &&
|
|
28
|
+
appDefinition.adminScripts.length > 0;
|
|
29
|
+
const hasReports =
|
|
30
|
+
Array.isArray(appDefinition.reports) &&
|
|
31
|
+
appDefinition.reports.length > 0;
|
|
32
|
+
const hasBuiltinReports =
|
|
33
|
+
appDefinition.admin?.includeBuiltinReports === true;
|
|
34
|
+
return hasScripts || hasReports || hasBuiltinReports;
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
getDependencies() {
|
|
@@ -33,31 +41,53 @@ class AdminScriptBuilder extends InfrastructureBuilder {
|
|
|
33
41
|
validate(appDefinition) {
|
|
34
42
|
const result = new ValidationResult();
|
|
35
43
|
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
if (appDefinition.adminScripts !== undefined) {
|
|
45
|
+
if (!Array.isArray(appDefinition.adminScripts)) {
|
|
46
|
+
result.addError('adminScripts must be an array');
|
|
47
|
+
} else {
|
|
48
|
+
appDefinition.adminScripts.forEach((script, index) => {
|
|
49
|
+
if (!script?.Definition?.name) {
|
|
50
|
+
result.addError(`Admin script at index ${index} is missing Definition or name`);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
43
54
|
}
|
|
44
55
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
if (appDefinition.reports !== undefined) {
|
|
57
|
+
if (!Array.isArray(appDefinition.reports)) {
|
|
58
|
+
result.addError('reports must be an array');
|
|
59
|
+
} else {
|
|
60
|
+
appDefinition.reports.forEach((report, index) => {
|
|
61
|
+
if (!report?.Definition?.name) {
|
|
62
|
+
result.addError(`Report at index ${index} is missing Definition or name`);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
49
65
|
}
|
|
50
|
-
}
|
|
66
|
+
}
|
|
51
67
|
|
|
52
68
|
return result;
|
|
53
69
|
}
|
|
54
70
|
|
|
55
71
|
async build(appDefinition, discoveredResources) {
|
|
56
|
-
console.log(`\n[${this.name}] Configuring admin
|
|
57
|
-
console.log(` Processing ${appDefinition.adminScripts.length} scripts...`);
|
|
72
|
+
console.log(`\n[${this.name}] Configuring admin operations...`);
|
|
58
73
|
|
|
59
74
|
const usePrismaLayer = appDefinition.usePrismaLambdaLayer !== false;
|
|
60
75
|
const adminConfig = appDefinition.admin || {};
|
|
76
|
+
const adminScripts = Array.isArray(appDefinition.adminScripts)
|
|
77
|
+
? appDefinition.adminScripts
|
|
78
|
+
: [];
|
|
79
|
+
const reports = Array.isArray(appDefinition.reports)
|
|
80
|
+
? appDefinition.reports
|
|
81
|
+
: [];
|
|
82
|
+
const hasReports =
|
|
83
|
+
reports.length > 0 || adminConfig.includeBuiltinReports === true;
|
|
84
|
+
|
|
85
|
+
// Only non-JSON report output is stored in S3, so provision the bucket
|
|
86
|
+
// only for that. Built-in reports emit JSON, so they don't trigger it.
|
|
87
|
+
const reportsNeedArtifacts = reports.some((report) => {
|
|
88
|
+
const format = report?.Definition?.output?.format;
|
|
89
|
+
return Boolean(format) && format !== 'json';
|
|
90
|
+
});
|
|
61
91
|
|
|
62
92
|
const result = {
|
|
63
93
|
functions: {},
|
|
@@ -67,27 +97,45 @@ class AdminScriptBuilder extends InfrastructureBuilder {
|
|
|
67
97
|
iamStatements: [],
|
|
68
98
|
};
|
|
69
99
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
100
|
+
if (adminScripts.length > 0) {
|
|
101
|
+
console.log(` Processing ${adminScripts.length} scripts...`);
|
|
102
|
+
this.createAdminScriptQueue(result, appDefinition);
|
|
103
|
+
this.createScriptExecutorFunction(appDefinition, result, usePrismaLayer);
|
|
104
|
+
this.createAdminScriptRoutes(appDefinition, result, usePrismaLayer);
|
|
75
105
|
|
|
76
|
-
|
|
77
|
-
|
|
106
|
+
adminScripts.forEach(script => {
|
|
107
|
+
const name = script.Definition?.name || 'unknown';
|
|
108
|
+
console.log(` ✓ Registered script: ${name}`);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
78
111
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
this.
|
|
112
|
+
if (hasReports) {
|
|
113
|
+
this.createReportQueue(result, appDefinition);
|
|
114
|
+
this.createReportExecutorFunction(appDefinition, result, usePrismaLayer);
|
|
115
|
+
this.createReportRoutes(appDefinition, result, usePrismaLayer);
|
|
116
|
+
if (reportsNeedArtifacts) {
|
|
117
|
+
this.createReportArtifactBucket(result, appDefinition);
|
|
118
|
+
}
|
|
119
|
+
reports.forEach(report => {
|
|
120
|
+
const name = report.Definition?.name || 'unknown';
|
|
121
|
+
console.log(` ✓ Registered report: ${name}`);
|
|
122
|
+
});
|
|
123
|
+
if (adminConfig.includeBuiltinReports) {
|
|
124
|
+
console.log(' ✓ Built-in reports enabled');
|
|
125
|
+
}
|
|
82
126
|
}
|
|
83
127
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
128
|
+
if (
|
|
129
|
+
adminConfig.enableScheduling &&
|
|
130
|
+
(adminScripts.length > 0 || hasReports)
|
|
131
|
+
) {
|
|
132
|
+
this.createSchedulerResources(appDefinition, result, {
|
|
133
|
+
scriptsPresent: adminScripts.length > 0,
|
|
134
|
+
hasReports,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
89
137
|
|
|
90
|
-
console.log(`[${this.name}] ✅ Admin
|
|
138
|
+
console.log(`[${this.name}] ✅ Admin operations configuration completed`);
|
|
91
139
|
return result;
|
|
92
140
|
}
|
|
93
141
|
|
|
@@ -193,6 +241,151 @@ class AdminScriptBuilder extends InfrastructureBuilder {
|
|
|
193
241
|
console.log(' ✓ Created adminScriptRouter function');
|
|
194
242
|
}
|
|
195
243
|
|
|
244
|
+
createReportRoutes(appDefinition, result, usePrismaLayer) {
|
|
245
|
+
result.functions.reportRouter = {
|
|
246
|
+
handler: 'node_modules/@friggframework/admin-scripts/src/infrastructure/report-router.handler',
|
|
247
|
+
skipEsbuild: true,
|
|
248
|
+
package: this.skipEsbuildPackageConfig(appDefinition, usePrismaLayer),
|
|
249
|
+
...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }),
|
|
250
|
+
timeout: 30,
|
|
251
|
+
events: [
|
|
252
|
+
{ httpApi: { path: '/api/v2/reports', method: 'GET' } },
|
|
253
|
+
// Definition detail, snapshots, executions, schedule, back-compat alias
|
|
254
|
+
{ httpApi: { path: '/api/v2/reports/{proxy+}', method: 'GET' } },
|
|
255
|
+
// Run a report ({name}/run)
|
|
256
|
+
{ httpApi: { path: '/api/v2/reports/{proxy+}', method: 'POST' } },
|
|
257
|
+
// Schedule management (PUT/DELETE {name}/schedule)
|
|
258
|
+
{ httpApi: { path: '/api/v2/reports/{proxy+}', method: 'PUT' } },
|
|
259
|
+
{ httpApi: { path: '/api/v2/reports/{proxy+}', method: 'DELETE' } },
|
|
260
|
+
],
|
|
261
|
+
};
|
|
262
|
+
console.log(' ✓ Created reportRouter function');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
createReportQueue(result, appDefinition) {
|
|
266
|
+
result.resources.ReportQueue = {
|
|
267
|
+
Type: 'AWS::SQS::Queue',
|
|
268
|
+
Properties: {
|
|
269
|
+
QueueName: '${self:service}-${self:provider.stage}-ReportQueue',
|
|
270
|
+
MessageRetentionPeriod: 86400, // 1 day
|
|
271
|
+
VisibilityTimeout: 900, // 15 minutes (Lambda max)
|
|
272
|
+
RedrivePolicy: {
|
|
273
|
+
maxReceiveCount: 3,
|
|
274
|
+
deadLetterTargetArn: {
|
|
275
|
+
'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
if (isScopedEnvironmentActive(appDefinition)) {
|
|
282
|
+
// Only the report functions read this queue URL
|
|
283
|
+
result.functionEnvironments = result.functionEnvironments || {};
|
|
284
|
+
for (const fnName of ['reportRouter', 'reportExecutor']) {
|
|
285
|
+
result.functionEnvironments[fnName] = {
|
|
286
|
+
...result.functionEnvironments[fnName],
|
|
287
|
+
REPORT_QUEUE_URL: { Ref: 'ReportQueue' },
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
result.environment.REPORT_QUEUE_URL = { Ref: 'ReportQueue' };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// The report router enqueues async recorded/snapshot runs. The base
|
|
295
|
+
// role's wildcard does not cover this queue's name, so grant
|
|
296
|
+
// SendMessage explicitly.
|
|
297
|
+
result.iamStatements.push({
|
|
298
|
+
Effect: 'Allow',
|
|
299
|
+
Action: [
|
|
300
|
+
'sqs:SendMessage',
|
|
301
|
+
'sqs:SendMessageBatch',
|
|
302
|
+
'sqs:GetQueueUrl',
|
|
303
|
+
'sqs:GetQueueAttributes',
|
|
304
|
+
],
|
|
305
|
+
Resource: { 'Fn::GetAtt': ['ReportQueue', 'Arn'] },
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
console.log(' ✓ Created ReportQueue');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
createReportExecutorFunction(appDefinition, result, usePrismaLayer) {
|
|
312
|
+
result.functions.reportExecutor = {
|
|
313
|
+
handler: 'node_modules/@friggframework/admin-scripts/src/infrastructure/report-executor-handler.handler',
|
|
314
|
+
skipEsbuild: true,
|
|
315
|
+
package: this.skipEsbuildPackageConfig(appDefinition, usePrismaLayer),
|
|
316
|
+
...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }),
|
|
317
|
+
timeout: 900, // 15 minutes max
|
|
318
|
+
memorySize: 1024,
|
|
319
|
+
events: [
|
|
320
|
+
{
|
|
321
|
+
sqs: {
|
|
322
|
+
arn: { 'Fn::GetAtt': ['ReportQueue', 'Arn'] },
|
|
323
|
+
batchSize: 1,
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
};
|
|
328
|
+
console.log(' ✓ Created reportExecutor function');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Non-JSON report output; the router mints short-lived presigned URLs for reads.
|
|
332
|
+
createReportArtifactBucket(result, appDefinition) {
|
|
333
|
+
result.resources.ReportArtifactBucket = {
|
|
334
|
+
Type: 'AWS::S3::Bucket',
|
|
335
|
+
Properties: {
|
|
336
|
+
BucketName:
|
|
337
|
+
'${self:service}-${self:provider.stage}-report-artifacts',
|
|
338
|
+
BucketEncryption: {
|
|
339
|
+
ServerSideEncryptionConfiguration: [
|
|
340
|
+
{
|
|
341
|
+
ServerSideEncryptionByDefault: {
|
|
342
|
+
SSEAlgorithm: 'AES256',
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
},
|
|
347
|
+
PublicAccessBlockConfiguration: {
|
|
348
|
+
BlockPublicAcls: true,
|
|
349
|
+
BlockPublicPolicy: true,
|
|
350
|
+
IgnorePublicAcls: true,
|
|
351
|
+
RestrictPublicBuckets: true,
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
if (isScopedEnvironmentActive(appDefinition)) {
|
|
357
|
+
result.functionEnvironments = result.functionEnvironments || {};
|
|
358
|
+
for (const fnName of ['reportRouter', 'reportExecutor']) {
|
|
359
|
+
result.functionEnvironments[fnName] = {
|
|
360
|
+
...result.functionEnvironments[fnName],
|
|
361
|
+
REPORT_ARTIFACT_BUCKET: { Ref: 'ReportArtifactBucket' },
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
} else {
|
|
365
|
+
result.environment.REPORT_ARTIFACT_BUCKET = {
|
|
366
|
+
Ref: 'ReportArtifactBucket',
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Executor writes and router reads (via presign); scope to this bucket's objects.
|
|
371
|
+
result.iamStatements.push({
|
|
372
|
+
Effect: 'Allow',
|
|
373
|
+
Action: ['s3:PutObject', 's3:GetObject'],
|
|
374
|
+
Resource: {
|
|
375
|
+
'Fn::Sub': [
|
|
376
|
+
'${BucketArn}/*',
|
|
377
|
+
{
|
|
378
|
+
BucketArn: {
|
|
379
|
+
'Fn::GetAtt': ['ReportArtifactBucket', 'Arn'],
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
],
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
console.log(' ✓ Created ReportArtifactBucket');
|
|
387
|
+
}
|
|
388
|
+
|
|
196
389
|
// Without this, the skipEsbuild functions package the whole node_modules
|
|
197
390
|
// closure (aws-sdk, Prisma, dev deps) and blow past Lambda's 250 MB limit.
|
|
198
391
|
// Mirrors the exclusions the framework's other node_modules handlers use.
|
|
@@ -248,13 +441,25 @@ class AdminScriptBuilder extends InfrastructureBuilder {
|
|
|
248
441
|
};
|
|
249
442
|
}
|
|
250
443
|
|
|
251
|
-
createSchedulerResources(
|
|
252
|
-
|
|
444
|
+
createSchedulerResources(
|
|
445
|
+
appDefinition,
|
|
446
|
+
result,
|
|
447
|
+
{ scriptsPresent = true, hasReports = false } = {}
|
|
448
|
+
) {
|
|
449
|
+
// Constructed ARNs, not Fn::GetAtt: a GetAtt edge to an executor closes a
|
|
253
450
|
// CloudFormation circular dependency via the shared Lambda execution role.
|
|
254
|
-
const
|
|
255
|
-
'Fn::Sub':
|
|
256
|
-
|
|
257
|
-
|
|
451
|
+
const fnArn = (logicalName) => ({
|
|
452
|
+
'Fn::Sub': `arn:aws:lambda:\${AWS::Region}:\${AWS::AccountId}:function:\${self:service}-\${self:provider.stage}-${logicalName}`,
|
|
453
|
+
});
|
|
454
|
+
const scriptExecutorArn = fnArn('adminScriptExecutor');
|
|
455
|
+
const reportExecutorArn = fnArn('reportExecutor');
|
|
456
|
+
|
|
457
|
+
const invokeResources = [
|
|
458
|
+
...(scriptsPresent ? [scriptExecutorArn] : []),
|
|
459
|
+
...(hasReports ? [reportExecutorArn] : []),
|
|
460
|
+
];
|
|
461
|
+
const invokeResource =
|
|
462
|
+
invokeResources.length === 1 ? invokeResources[0] : invokeResources;
|
|
258
463
|
|
|
259
464
|
// Create IAM role for EventBridge Scheduler
|
|
260
465
|
result.resources.AdminScriptSchedulerRole = {
|
|
@@ -276,7 +481,7 @@ class AdminScriptBuilder extends InfrastructureBuilder {
|
|
|
276
481
|
Statement: [{
|
|
277
482
|
Effect: 'Allow',
|
|
278
483
|
Action: 'lambda:InvokeFunction',
|
|
279
|
-
Resource:
|
|
484
|
+
Resource: invokeResource,
|
|
280
485
|
}],
|
|
281
486
|
},
|
|
282
487
|
}],
|
|
@@ -293,18 +498,35 @@ class AdminScriptBuilder extends InfrastructureBuilder {
|
|
|
293
498
|
|
|
294
499
|
// Router-scoped, not shared provider env. Two reasons: broadcasting the
|
|
295
500
|
// resource references to every function creates CloudFormation circular
|
|
296
|
-
// deps; and SCHEDULER_PROVIDER='aws' is only valid for the admin-
|
|
297
|
-
// adapter (the
|
|
298
|
-
// by integration Lambdas, rejects 'aws', so it must not
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
501
|
+
// deps; and SCHEDULER_PROVIDER='aws' is only valid for the admin-scripts
|
|
502
|
+
// adapter (the routers are its sole consumers) — core's scheduler
|
|
503
|
+
// factory, used by integration Lambdas, rejects 'aws', so it must not
|
|
504
|
+
// leak app-wide.
|
|
505
|
+
if (scriptsPresent) {
|
|
506
|
+
result.functions.adminScriptRouter.environment = {
|
|
507
|
+
...(result.functions.adminScriptRouter.environment || {}),
|
|
508
|
+
SCHEDULER_PROVIDER: 'aws',
|
|
509
|
+
SCHEDULER_ROLE_ARN: {
|
|
510
|
+
'Fn::GetAtt': ['AdminScriptSchedulerRole', 'Arn'],
|
|
511
|
+
},
|
|
512
|
+
ADMIN_SCRIPT_SCHEDULE_GROUP: { Ref: 'AdminScriptScheduleGroup' },
|
|
513
|
+
ADMIN_SCRIPT_EXECUTOR_LAMBDA_ARN: scriptExecutorArn,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Report schedules reuse the shared role/group but must target the
|
|
518
|
+
// report executor, not the script one (REPORT_EXECUTOR_LAMBDA_ARN).
|
|
519
|
+
if (hasReports) {
|
|
520
|
+
result.functions.reportRouter.environment = {
|
|
521
|
+
...(result.functions.reportRouter.environment || {}),
|
|
522
|
+
SCHEDULER_PROVIDER: 'aws',
|
|
523
|
+
SCHEDULER_ROLE_ARN: {
|
|
524
|
+
'Fn::GetAtt': ['AdminScriptSchedulerRole', 'Arn'],
|
|
525
|
+
},
|
|
526
|
+
REPORT_SCHEDULE_GROUP: { Ref: 'AdminScriptScheduleGroup' },
|
|
527
|
+
REPORT_EXECUTOR_LAMBDA_ARN: reportExecutorArn,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
308
530
|
|
|
309
531
|
// The router manages schedules through the AWS scheduler adapter, so it
|
|
310
532
|
// needs scheduler:* on this group plus iam:PassRole for the role it hands
|