@geekmidas/cloud 10.0.0-alpha.45 → 10.0.0-alpha.47
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/README.md +14 -6
- package/package.json +7 -7
- package/src/sst/__tests__/api.spec.ts +0 -46
- package/src/sst/__tests__/encryption.spec.ts +90 -0
- package/src/sst/__tests__/fromManifest.spec.ts +167 -2
- package/src/sst/__tests__/function.spec.ts +0 -71
- package/src/sst/__tests__/surfaces.spec.ts +17 -0
- package/src/sst/aws/Api.ts +0 -43
- package/src/sst/aws/Cron.ts +5 -45
- package/src/sst/aws/Encryption.ts +91 -0
- package/src/sst/aws/Function.ts +9 -38
- package/src/sst/aws/Queue.ts +4 -1
- package/src/sst/aws/RestApiSurface.ts +36 -14
- package/src/sst/fromManifest.ts +145 -19
- package/src/sst/index.ts +1 -0
- package/src/sst/__type-tests__/manifest.type-test.ts +0 -77
package/README.md
CHANGED
|
@@ -105,17 +105,25 @@ const cron = new Cron(stack, 'Nightly', { processor: fn, schedule: 'rate(1 day)'
|
|
|
105
105
|
|
|
106
106
|
### From a `gkm build` manifest
|
|
107
107
|
|
|
108
|
-
`gkm build`
|
|
109
|
-
|
|
108
|
+
The root `gkm build` writes the application's manifest to
|
|
109
|
+
`.gkm/manifest/aws.ts` (types in `@geekmidas/manifest`), and `fromManifest`
|
|
110
|
+
maps it straight into infra:
|
|
110
111
|
|
|
111
112
|
```ts
|
|
112
|
-
|
|
113
|
+
// sst.config.ts
|
|
114
|
+
const { App, fromManifest, Stack } = await import('@geekmidas/cloud/sst');
|
|
115
|
+
const { backends, constructs } = await import('./.gkm/manifest/aws.js');
|
|
113
116
|
|
|
114
|
-
|
|
115
|
-
const workers = Function.fromManifest(stack, functionsManifest, { links: [db] });
|
|
116
|
-
const crons = Cron.fromManifest(stack, cronsManifest, { links: [db] });
|
|
117
|
+
fromManifest(new Stack(app, 'Shop'), constructs, { Database: { vpc } }, backends);
|
|
117
118
|
```
|
|
118
119
|
|
|
120
|
+
`constructs` is every declared construct keyed by id; the overrides are keyed
|
|
121
|
+
by the same ids. One call provisions the resources, each `RestApi`'s endpoints
|
|
122
|
+
(each a Lambda linked only to its own dependencies, placed in the database's
|
|
123
|
+
VPC when it reaches one, with the `iam` authorizer enforced by the gateway),
|
|
124
|
+
queue consumers, every function (a Lambda with an IAM-authorized URL) and every
|
|
125
|
+
cron (a Lambda on its schedule).
|
|
126
|
+
|
|
119
127
|
## `@geekmidas/cloud/utils` (runtime)
|
|
120
128
|
|
|
121
129
|
```ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geekmidas/cloud",
|
|
3
|
-
"version": "10.0.0-alpha.
|
|
3
|
+
"version": "10.0.0-alpha.47",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"lodash.snakecase": "~4.1.1",
|
|
44
|
-
"@geekmidas/envkit": "10.0.0-alpha.
|
|
45
|
-
"@geekmidas/manifest": "10.0.0-alpha.
|
|
44
|
+
"@geekmidas/envkit": "10.0.0-alpha.47",
|
|
45
|
+
"@geekmidas/manifest": "10.0.0-alpha.47"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/lodash.get": "~4.4.9",
|
|
@@ -58,10 +58,10 @@
|
|
|
58
58
|
"@pulumi/aws": ">=7.47.0",
|
|
59
59
|
"@pulumi/random": ">=4.21.2",
|
|
60
60
|
"@pulumi/pulumi": ">=3.263.0",
|
|
61
|
-
"@geekmidas/manifest": "^10.0.0-alpha.
|
|
62
|
-
"@geekmidas/storage": "^10.0.0-alpha.
|
|
63
|
-
"@geekmidas/events": "^10.0.0-alpha.
|
|
64
|
-
"@geekmidas/db": "^10.0.0-alpha.
|
|
61
|
+
"@geekmidas/manifest": "^10.0.0-alpha.47",
|
|
62
|
+
"@geekmidas/storage": "^10.0.0-alpha.47",
|
|
63
|
+
"@geekmidas/events": "^10.0.0-alpha.47",
|
|
64
|
+
"@geekmidas/db": "^10.0.0-alpha.47"
|
|
65
65
|
},
|
|
66
66
|
"peerDependenciesMeta": {
|
|
67
67
|
"@geekmidas/manifest": {
|
|
@@ -183,49 +183,3 @@ describe('Api', () => {
|
|
|
183
183
|
expect(recorded(api).authorizers).toEqual([]);
|
|
184
184
|
});
|
|
185
185
|
});
|
|
186
|
-
|
|
187
|
-
describe('Api.fromManifest', () => {
|
|
188
|
-
it('turns built routes, flat or partitioned, into the route table', () => {
|
|
189
|
-
const routes = {
|
|
190
|
-
public: [
|
|
191
|
-
{
|
|
192
|
-
method: 'GET',
|
|
193
|
-
path: '/orders',
|
|
194
|
-
handler: '.gkm/aws-lambda/routes/orders.handler',
|
|
195
|
-
environment: ['DB_URL'],
|
|
196
|
-
authorizer: 'iam',
|
|
197
|
-
timeout: 30,
|
|
198
|
-
memorySize: 1024,
|
|
199
|
-
},
|
|
200
|
-
],
|
|
201
|
-
internal: [
|
|
202
|
-
{
|
|
203
|
-
method: 'POST',
|
|
204
|
-
path: '/jobs',
|
|
205
|
-
handler: '.gkm/aws-lambda/routes/jobs.handler',
|
|
206
|
-
environment: [],
|
|
207
|
-
},
|
|
208
|
-
],
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
const api = Api.fromManifest(stack, 'Http', routes as never, {
|
|
212
|
-
links: [db],
|
|
213
|
-
});
|
|
214
|
-
const { routes: recordedRoutes } = recorded(api);
|
|
215
|
-
|
|
216
|
-
expect(recordedRoutes.map((r) => r.key)).toEqual([
|
|
217
|
-
'GET /orders',
|
|
218
|
-
'POST /jobs',
|
|
219
|
-
]);
|
|
220
|
-
expect(recordedRoutes[0]!.handler).toMatchObject({
|
|
221
|
-
timeout: '30 seconds',
|
|
222
|
-
memory: '1024 MB',
|
|
223
|
-
link: [db],
|
|
224
|
-
});
|
|
225
|
-
expect(recordedRoutes[0]!.args).toEqual({ auth: { iam: true } });
|
|
226
|
-
expect(recordedRoutes[1]!.handler).toMatchObject({
|
|
227
|
-
timeout: undefined,
|
|
228
|
-
memory: undefined,
|
|
229
|
-
});
|
|
230
|
-
});
|
|
231
|
-
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { ResourceType } from '@geekmidas/envkit/sst';
|
|
2
|
+
import { providedKeyFor, provideKey } from '@geekmidas/manifest';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { Encryption } from '../aws/Encryption';
|
|
5
|
+
import { provisionerFor, resolveEdges } from '../fromManifest';
|
|
6
|
+
|
|
7
|
+
// A KMS key, as far as the component reads one: what it was created with, and
|
|
8
|
+
// an ARN. The SST globals are stubbed the same way for every component.
|
|
9
|
+
vi.mock('@pulumi/aws', () => ({
|
|
10
|
+
kms: {
|
|
11
|
+
Key: class {
|
|
12
|
+
readonly arn: string;
|
|
13
|
+
constructor(
|
|
14
|
+
readonly name: string,
|
|
15
|
+
readonly args: Record<string, unknown>,
|
|
16
|
+
) {
|
|
17
|
+
this.arn = `arn:aws:kms:eu-west-1:123456789012:key/${name}`;
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const stack = {} as never;
|
|
24
|
+
|
|
25
|
+
describe('Encryption on AWS', () => {
|
|
26
|
+
it('is two KMS keys: one that rotates for data, one HMAC key for the index', () => {
|
|
27
|
+
const pii = new Encryption(stack, 'Pii', { region: 'eu-west-1' });
|
|
28
|
+
|
|
29
|
+
expect(pii.key).toMatchObject({
|
|
30
|
+
name: 'PiiKey',
|
|
31
|
+
args: { enableKeyRotation: true, deletionWindowInDays: 30 },
|
|
32
|
+
});
|
|
33
|
+
expect(pii.indexKey).toMatchObject({
|
|
34
|
+
name: 'PiiIndexKey',
|
|
35
|
+
args: {
|
|
36
|
+
keyUsage: 'GENERATE_VERIFY_MAC',
|
|
37
|
+
customerMasterKeySpec: 'HMAC_256',
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('provides one kms:// URL, under the key the construct declared', () => {
|
|
43
|
+
const pii = new Encryption(stack, 'Pii', { region: 'eu-west-1' });
|
|
44
|
+
const url = new URL(pii.provides().url as string);
|
|
45
|
+
|
|
46
|
+
expect(url.protocol).toBe('kms:');
|
|
47
|
+
expect(url.hostname).toBe('eu-west-1');
|
|
48
|
+
expect(url.searchParams.get('key')).toBe(pii.key.arn);
|
|
49
|
+
expect(url.searchParams.get('index')).toBe(pii.indexKey.arn);
|
|
50
|
+
expect(
|
|
51
|
+
Object.keys(pii.provides()).map((role) =>
|
|
52
|
+
providedKeyFor('Pii', 'encryption', role),
|
|
53
|
+
),
|
|
54
|
+
).toEqual([provideKey('Pii', 'url')]);
|
|
55
|
+
expect(pii._type).toBe(ResourceType.Encryption);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('grants a linked function the calls the cipher makes, on these keys only', () => {
|
|
59
|
+
const pii = new Encryption(stack, 'Pii', { region: 'eu-west-1' });
|
|
60
|
+
|
|
61
|
+
expect(pii.getSSTLink().include).toEqual([
|
|
62
|
+
{
|
|
63
|
+
permission: {
|
|
64
|
+
actions: ['kms:GenerateDataKey', 'kms:Decrypt'],
|
|
65
|
+
resources: [pii.key.arn],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
permission: {
|
|
70
|
+
actions: ['kms:GenerateMac'],
|
|
71
|
+
resources: [pii.indexKey.arn],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('is provisioned from the manifest in the stage’s region, and links its URL', () => {
|
|
78
|
+
const pii = provisionerFor('encryption')(
|
|
79
|
+
stack,
|
|
80
|
+
{ kind: 'encryption', id: 'Pii', provides: ['PII_URL'] },
|
|
81
|
+
{},
|
|
82
|
+
{} as never,
|
|
83
|
+
) as Encryption;
|
|
84
|
+
|
|
85
|
+
expect(new URL(pii.provides().url as string).hostname).toBe('eu-west-1');
|
|
86
|
+
expect(
|
|
87
|
+
resolveEdges([{ target: 'Pii', kind: 'encryption' }], { Pii: pii }),
|
|
88
|
+
).toEqual({ link: [pii], envKeys: ['PII_URL'] });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import { resolveEnvKeys } from '@geekmidas/envkit/sst';
|
|
2
2
|
import { NoUrlForStage, providedKeyFor, provideKey } from '@geekmidas/manifest';
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { App } from '../App';
|
|
5
|
+
import { Cron } from '../aws/Cron';
|
|
6
|
+
import { Database } from '../aws/Database';
|
|
7
|
+
import { DatabaseSchema } from '../aws/DerivedDatabase';
|
|
8
|
+
import { Function } from '../aws/Function';
|
|
4
9
|
import { ObjectStorage } from '../aws/ObjectStorage';
|
|
10
|
+
import { RestApiSurface } from '../aws/RestApiSurface';
|
|
5
11
|
import { type ProvidesMismatch, UnknownDeclarationKind } from '../errors';
|
|
6
12
|
import {
|
|
7
13
|
assertProvides,
|
|
14
|
+
mountRoutes,
|
|
8
15
|
PROVISIONED_ELSEWHERE,
|
|
9
16
|
type ProvisionedManifest,
|
|
17
|
+
provisionCompute,
|
|
10
18
|
provisionerFor,
|
|
11
19
|
resolveEdges,
|
|
12
20
|
subscribeConsumers,
|
|
21
|
+
vpcFor,
|
|
13
22
|
} from '../fromManifest';
|
|
14
23
|
|
|
15
24
|
/**
|
|
@@ -363,7 +372,7 @@ describe('a queue’s one consumer', () => {
|
|
|
363
372
|
provides: ['EMAILS_PUBLISHER_CONNECTION_STRING'],
|
|
364
373
|
worker: {
|
|
365
374
|
id: 'EmailsWorker',
|
|
366
|
-
handler: '
|
|
375
|
+
handler: 'apps/api/.gkm/aws/queues/emails.handler',
|
|
367
376
|
dependencies: [{ target: 'Uploads', kind: 'objects' }],
|
|
368
377
|
},
|
|
369
378
|
},
|
|
@@ -372,7 +381,7 @@ describe('a queue’s one consumer', () => {
|
|
|
372
381
|
);
|
|
373
382
|
|
|
374
383
|
expect(consumed).toEqual([
|
|
375
|
-
{ handler: '
|
|
384
|
+
{ handler: 'apps/api/.gkm/aws/queues/emails.handler', link: [uploads] },
|
|
376
385
|
]);
|
|
377
386
|
});
|
|
378
387
|
|
|
@@ -391,6 +400,162 @@ describe('a queue’s one consumer', () => {
|
|
|
391
400
|
});
|
|
392
401
|
});
|
|
393
402
|
|
|
403
|
+
describe('a surface’s endpoints', () => {
|
|
404
|
+
const routesOf = (surface: RestApiSurface) =>
|
|
405
|
+
(
|
|
406
|
+
surface as unknown as {
|
|
407
|
+
routes: { key: string; handler: any; args: unknown }[];
|
|
408
|
+
}
|
|
409
|
+
).routes;
|
|
410
|
+
|
|
411
|
+
it('are mounted from the handlers the build wrote, each linked to what it depends on', () => {
|
|
412
|
+
// An API Gateway with no routes 404s everything — which is what a deploy
|
|
413
|
+
// produced while the routes sat in the manifest with nothing reading them.
|
|
414
|
+
const api = new RestApiSurface({} as never, 'Api');
|
|
415
|
+
const uploads = { _id: 'Uploads', _type: 'sst.aws.Bucket' };
|
|
416
|
+
|
|
417
|
+
mountRoutes(
|
|
418
|
+
{
|
|
419
|
+
Uploads: { kind: 'objects', id: 'Uploads', provides: ['UPLOADS_URL'] },
|
|
420
|
+
Api: {
|
|
421
|
+
kind: 'rest-api',
|
|
422
|
+
id: 'Api',
|
|
423
|
+
provides: ['API_URL'],
|
|
424
|
+
endpoints: [
|
|
425
|
+
{
|
|
426
|
+
id: 'ApiGET/users/{id}',
|
|
427
|
+
method: 'GET',
|
|
428
|
+
path: '/users/{id}',
|
|
429
|
+
handler: 'apps/api/.gkm/aws/routes/getUser.handler',
|
|
430
|
+
dependencies: [{ target: 'Uploads', kind: 'objects' }],
|
|
431
|
+
authorizer: 'none',
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
id: 'ApiGET/health',
|
|
435
|
+
method: 'GET',
|
|
436
|
+
path: '/health',
|
|
437
|
+
handler: 'apps/api/.gkm/aws/routes/health.handler',
|
|
438
|
+
dependencies: [],
|
|
439
|
+
},
|
|
440
|
+
],
|
|
441
|
+
},
|
|
442
|
+
} as never,
|
|
443
|
+
{ Uploads: uploads, Api: api } as unknown as ProvisionedManifest,
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
const [getUser, health] = routesOf(api);
|
|
447
|
+
expect(getUser?.key).toBe('GET /users/{id}');
|
|
448
|
+
expect(getUser?.handler.handler).toBe(
|
|
449
|
+
'apps/api/.gkm/aws/routes/getUser.handler',
|
|
450
|
+
);
|
|
451
|
+
expect(getUser?.handler.link).toEqual([uploads]);
|
|
452
|
+
expect(getUser?.args).toEqual({});
|
|
453
|
+
// Least privilege: an endpoint is linked to its own edges, not the app's.
|
|
454
|
+
expect(health?.key).toBe('GET /health');
|
|
455
|
+
expect(health?.handler.link).toEqual([]);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('leaves IAM to the gateway, and every other authorizer to the handler', () => {
|
|
459
|
+
const api = new RestApiSurface({} as never, 'Api');
|
|
460
|
+
|
|
461
|
+
api.mount({
|
|
462
|
+
method: 'POST',
|
|
463
|
+
path: '/x',
|
|
464
|
+
handler: 'x.handler',
|
|
465
|
+
authorizer: 'iam',
|
|
466
|
+
});
|
|
467
|
+
api.mount({
|
|
468
|
+
method: 'POST',
|
|
469
|
+
path: '/y',
|
|
470
|
+
handler: 'y.handler',
|
|
471
|
+
authorizer: 'session',
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
const [x, y] = routesOf(api);
|
|
475
|
+
expect(x?.args).toEqual({ auth: { iam: true } });
|
|
476
|
+
expect(y?.args).toEqual({});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('runs a handler that reaches a database inside the database’s network', () => {
|
|
480
|
+
const vpc = { id: 'stub-vpc' } as never;
|
|
481
|
+
const cluster = new Database({} as never, 'Orders', { vpc });
|
|
482
|
+
const tenant = new DatabaseSchema('Billing', cluster, 'billing');
|
|
483
|
+
|
|
484
|
+
expect(vpcFor([cluster as never])).toBe(vpc);
|
|
485
|
+
// A schema tenant lives in its parent's cluster, so in its network too.
|
|
486
|
+
expect(vpcFor([{ _id: 'Uploads' } as never, tenant as never])).toBe(vpc);
|
|
487
|
+
expect(vpcFor([{ _id: 'Uploads' } as never])).toBeUndefined();
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
describe('functions and crons', () => {
|
|
492
|
+
const stack = new App({
|
|
493
|
+
name: 'shop',
|
|
494
|
+
stage: 'prod',
|
|
495
|
+
domain: 'example.com',
|
|
496
|
+
hostedZoneId: 'Z',
|
|
497
|
+
region: 'eu-west-1',
|
|
498
|
+
}).stack('jobs');
|
|
499
|
+
const argsOf = (component: unknown) =>
|
|
500
|
+
(component as { args: Record<string, any> }).args;
|
|
501
|
+
|
|
502
|
+
it('are deployed from the declarations, each linked to its own edges', () => {
|
|
503
|
+
// The `manifest.crons` / `manifest.functions` tables they were built from
|
|
504
|
+
// are gone: the declarations carry the handler, schedule and edges.
|
|
505
|
+
const vpc = { id: 'stub-vpc' } as never;
|
|
506
|
+
const orders = new Database({} as never, 'Orders', { vpc });
|
|
507
|
+
const uploads = { _id: 'Uploads', _type: 'sst.aws.Bucket' };
|
|
508
|
+
const provisioned = {
|
|
509
|
+
Orders: orders,
|
|
510
|
+
Uploads: uploads,
|
|
511
|
+
} as unknown as ProvisionedManifest;
|
|
512
|
+
|
|
513
|
+
const [digest] = provisionCompute(
|
|
514
|
+
stack,
|
|
515
|
+
{
|
|
516
|
+
Orders: { kind: 'database', id: 'Orders' },
|
|
517
|
+
Uploads: { kind: 'objects', id: 'Uploads', provides: ['UPLOADS_URL'] },
|
|
518
|
+
Invoice: {
|
|
519
|
+
kind: 'function',
|
|
520
|
+
id: 'Invoice',
|
|
521
|
+
handler: 'apps/api/.gkm/aws/functions/invoice.handler',
|
|
522
|
+
dependencies: [{ target: 'Uploads', kind: 'objects' }],
|
|
523
|
+
provides: ['INVOICE_URL'],
|
|
524
|
+
},
|
|
525
|
+
Digest: {
|
|
526
|
+
kind: 'cron',
|
|
527
|
+
id: 'Digest',
|
|
528
|
+
handler: 'apps/api/.gkm/aws/crons/digest.handler',
|
|
529
|
+
schedule: 'rate(1 day)',
|
|
530
|
+
dependencies: [{ target: 'Orders', kind: 'database' }],
|
|
531
|
+
},
|
|
532
|
+
} as never,
|
|
533
|
+
provisioned,
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
const invoice = provisioned.Invoice as unknown as Function;
|
|
537
|
+
expect(invoice).toBeInstanceOf(Function);
|
|
538
|
+
expect(argsOf(invoice)).toMatchObject({
|
|
539
|
+
name: stack.logicalPrefixedName('Invoice'),
|
|
540
|
+
handler: 'apps/api/.gkm/aws/functions/invoice.handler',
|
|
541
|
+
link: [uploads],
|
|
542
|
+
url: { authorization: 'iam' },
|
|
543
|
+
});
|
|
544
|
+
expect(argsOf(invoice).vpc).toBeUndefined();
|
|
545
|
+
|
|
546
|
+
// A cron is a schedule, not something to link to; its Lambda reaches
|
|
547
|
+
// the database, so it runs in the database's network.
|
|
548
|
+
expect(provisioned.Digest).toBeUndefined();
|
|
549
|
+
expect(digest).toBeInstanceOf(Cron);
|
|
550
|
+
expect(argsOf(digest).schedule).toBe('rate(1 day)');
|
|
551
|
+
expect(argsOf(digest!.processor)).toMatchObject({
|
|
552
|
+
handler: 'apps/api/.gkm/aws/crons/digest.handler',
|
|
553
|
+
link: [orders],
|
|
554
|
+
vpc,
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
|
|
394
559
|
describe('kinds provisioned elsewhere', () => {
|
|
395
560
|
it('skips a worker, which is a process rather than a resource', () => {
|
|
396
561
|
// A worker in the manifest used to throw UnknownDeclarationKind at synth,
|
|
@@ -79,46 +79,6 @@ describe('Function', () => {
|
|
|
79
79
|
invalidVars: ['SMTP_URL'],
|
|
80
80
|
});
|
|
81
81
|
});
|
|
82
|
-
|
|
83
|
-
it('builds one function per manifest entry, named for the stack', () => {
|
|
84
|
-
const functions = Function.fromManifest(
|
|
85
|
-
stack,
|
|
86
|
-
{
|
|
87
|
-
billing: [
|
|
88
|
-
{
|
|
89
|
-
name: 'Invoice',
|
|
90
|
-
handler: '.gkm/aws-lambda/functions/invoice.handler',
|
|
91
|
-
environment: ['DB_URL'],
|
|
92
|
-
timeout: 60,
|
|
93
|
-
memorySize: 512,
|
|
94
|
-
},
|
|
95
|
-
],
|
|
96
|
-
ops: [
|
|
97
|
-
{
|
|
98
|
-
name: 'Ping',
|
|
99
|
-
handler: '.gkm/aws-lambda/functions/ping.handler',
|
|
100
|
-
environment: [],
|
|
101
|
-
},
|
|
102
|
-
],
|
|
103
|
-
} as never,
|
|
104
|
-
{ links: [db] },
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
expect(functions.map((f) => argsOf(f))).toEqual([
|
|
108
|
-
expect.objectContaining({
|
|
109
|
-
name: stack.logicalPrefixedName('Invoice'),
|
|
110
|
-
timeout: '60 seconds',
|
|
111
|
-
memory: '512 MB',
|
|
112
|
-
link: [db],
|
|
113
|
-
}),
|
|
114
|
-
expect.objectContaining({
|
|
115
|
-
name: stack.logicalPrefixedName('Ping'),
|
|
116
|
-
timeout: undefined,
|
|
117
|
-
memory: undefined,
|
|
118
|
-
link: [],
|
|
119
|
-
}),
|
|
120
|
-
]);
|
|
121
|
-
});
|
|
122
82
|
});
|
|
123
83
|
|
|
124
84
|
describe('Cron', () => {
|
|
@@ -136,37 +96,6 @@ describe('Cron', () => {
|
|
|
136
96
|
function: processor.arn,
|
|
137
97
|
});
|
|
138
98
|
});
|
|
139
|
-
|
|
140
|
-
it('builds a validated function and a schedule per manifest cron', () => {
|
|
141
|
-
const crons = Cron.fromManifest(
|
|
142
|
-
stack,
|
|
143
|
-
[
|
|
144
|
-
{
|
|
145
|
-
name: 'Digest',
|
|
146
|
-
handler: '.gkm/aws-lambda/crons/digest.handler',
|
|
147
|
-
schedule: 'cron(0 8 * * ? *)',
|
|
148
|
-
environment: ['DB_URL'],
|
|
149
|
-
timeout: 120,
|
|
150
|
-
memorySize: 1024,
|
|
151
|
-
},
|
|
152
|
-
{
|
|
153
|
-
name: 'Sweep',
|
|
154
|
-
handler: '.gkm/aws-lambda/crons/sweep.handler',
|
|
155
|
-
schedule: 'rate(1 hour)',
|
|
156
|
-
environment: [],
|
|
157
|
-
},
|
|
158
|
-
] as never,
|
|
159
|
-
{ links: [db], enabled: true },
|
|
160
|
-
);
|
|
161
|
-
|
|
162
|
-
const [digest, sweep] = crons.map((c) => argsOf(c));
|
|
163
|
-
expect(digest).toMatchObject({
|
|
164
|
-
schedule: 'cron(0 8 * * ? *)',
|
|
165
|
-
enabled: true,
|
|
166
|
-
function: expect.stringContaining('DigestFunction'),
|
|
167
|
-
});
|
|
168
|
-
expect(sweep).toMatchObject({ schedule: 'rate(1 hour)' });
|
|
169
|
-
});
|
|
170
99
|
});
|
|
171
100
|
|
|
172
101
|
describe('App and Stack', () => {
|
|
@@ -28,6 +28,11 @@ const manifest = {
|
|
|
28
28
|
provides: ['API_URL', 'API_TRUSTED_ORIGINS', 'API_COOKIE_DOMAIN'],
|
|
29
29
|
},
|
|
30
30
|
Orders: { kind: 'database', id: 'Orders', provides: ['ORDERS_URL'] },
|
|
31
|
+
Mailer: {
|
|
32
|
+
kind: 'email',
|
|
33
|
+
id: 'Mailer',
|
|
34
|
+
provides: ['MAILER_URL', 'MAILER_FROM'],
|
|
35
|
+
},
|
|
31
36
|
Console: {
|
|
32
37
|
kind: 'site',
|
|
33
38
|
id: 'Console',
|
|
@@ -37,6 +42,7 @@ const manifest = {
|
|
|
37
42
|
{ target: 'Api', kind: 'rest-api' },
|
|
38
43
|
{ target: 'Orders', kind: 'database' },
|
|
39
44
|
{ target: 'UploadsServer', kind: 'file-server' },
|
|
45
|
+
{ target: 'Mailer', kind: 'email' },
|
|
40
46
|
],
|
|
41
47
|
provides: ['CONSOLE_URL'],
|
|
42
48
|
},
|
|
@@ -101,6 +107,10 @@ describe('siteEnvironment', () => {
|
|
|
101
107
|
Api: provided({ url: 'https://api.example.com' }),
|
|
102
108
|
Orders: provided({ url: 'postgres://user:pw@db/orders' }),
|
|
103
109
|
UploadsServer: provided({ url: 'https://files.example.com' }),
|
|
110
|
+
Mailer: provided({
|
|
111
|
+
url: 'smtp://user:pw@email-smtp.example.com:587',
|
|
112
|
+
from: 'hello@shop.test',
|
|
113
|
+
}),
|
|
104
114
|
}),
|
|
105
115
|
);
|
|
106
116
|
|
|
@@ -122,6 +132,13 @@ describe('siteEnvironment', () => {
|
|
|
122
132
|
expect(built()).not.toHaveProperty('VITE_ORDERS_URL');
|
|
123
133
|
});
|
|
124
134
|
|
|
135
|
+
it('has no inbox to share for deployed mail, and never shares the mail URL', () => {
|
|
136
|
+
// The inbox is Mailpit's, and exists only locally: deployed, an app that
|
|
137
|
+
// reads it falls back to the phone's own mail app.
|
|
138
|
+
expect(built()).not.toHaveProperty('VITE_MAILER_INBOX_URL');
|
|
139
|
+
expect(built()).not.toHaveProperty('VITE_MAILER_URL');
|
|
140
|
+
});
|
|
141
|
+
|
|
125
142
|
it('fails loudly on an edge nothing provisioned', () => {
|
|
126
143
|
// A smaller environment builds and then fails at runtime against
|
|
127
144
|
// `http:///`, with nothing to point at.
|
package/src/sst/aws/Api.ts
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { EnvValidationError } from '@geekmidas/envkit/sst';
|
|
3
|
-
import {
|
|
4
|
-
flattenManifestField,
|
|
5
|
-
type ManifestField,
|
|
6
|
-
type RouteInfo,
|
|
7
|
-
} from '@geekmidas/manifest';
|
|
8
3
|
import { type GkmLinkable, ResourceType } from '../Linkable';
|
|
9
4
|
import { LinkedEnvironment } from '../LinkedEnvironment';
|
|
10
5
|
import type { StackType } from '../Stack';
|
|
@@ -157,44 +152,6 @@ export class Api<
|
|
|
157
152
|
}
|
|
158
153
|
}
|
|
159
154
|
|
|
160
|
-
/**
|
|
161
|
-
* Build an `Api` from a `gkm build` manifest's `routes` field (flat or
|
|
162
|
-
* partitioned): each `RouteInfo` becomes a route (env vars, authorizer,
|
|
163
|
-
* timeout/memory mapped). Supply `authorizers` (JWT/Lambda settings),
|
|
164
|
-
* `links`, and any native args via `props`.
|
|
165
|
-
*
|
|
166
|
-
* ```ts
|
|
167
|
-
* import { manifest } from './.gkm/manifest/aws';
|
|
168
|
-
* Api.fromManifest(stack, 'Api', manifest.routes, { links: [db] });
|
|
169
|
-
* ```
|
|
170
|
-
*/
|
|
171
|
-
static fromManifest<
|
|
172
|
-
TAuthorizers extends Record<string, unknown> = {},
|
|
173
|
-
TStage extends string = string,
|
|
174
|
-
TDomain extends string = string,
|
|
175
|
-
>(
|
|
176
|
-
stack: StackType<TStage, TDomain>,
|
|
177
|
-
id: string,
|
|
178
|
-
routes: ManifestField<RouteInfo>,
|
|
179
|
-
props: Omit<ApiProps<TAuthorizers>, 'routes'> = {},
|
|
180
|
-
): Api<TAuthorizers, TStage, TDomain> {
|
|
181
|
-
const routeTable = flattenManifestField(routes).map(
|
|
182
|
-
(route): Route<AuthorizerName<TAuthorizers>> => ({
|
|
183
|
-
method: route.method as Route['method'],
|
|
184
|
-
path: route.path,
|
|
185
|
-
handler: route.handler,
|
|
186
|
-
environment: route.environment,
|
|
187
|
-
authorizer: route.authorizer as AuthorizerName<TAuthorizers>,
|
|
188
|
-
timeout: route.timeout ? `${route.timeout} seconds` : undefined,
|
|
189
|
-
memory: route.memorySize ? `${route.memorySize} MB` : undefined,
|
|
190
|
-
}),
|
|
191
|
-
);
|
|
192
|
-
return new Api(stack, id, {
|
|
193
|
-
...props,
|
|
194
|
-
routes: routeTable,
|
|
195
|
-
} as ApiProps<TAuthorizers>);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
155
|
/** Resolves a route's `authorizer` name to the SST `auth` option. */
|
|
199
156
|
private static buildRouteAuth(
|
|
200
157
|
route: Route<string>,
|
package/src/sst/aws/Cron.ts
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type CronInfo,
|
|
3
|
-
flattenManifestField,
|
|
4
|
-
type ManifestField,
|
|
5
|
-
} from '@geekmidas/manifest';
|
|
6
|
-
import type { GkmLinkable } from '../Linkable';
|
|
7
1
|
import type { StackType } from '../Stack';
|
|
8
|
-
import { Function } from './Function';
|
|
2
|
+
import type { Function } from './Function';
|
|
9
3
|
|
|
10
4
|
export type CronExpressionValue = number | '*' | '?' | `${number}/${number}`;
|
|
11
5
|
export type CronExpressionDay =
|
|
@@ -52,6 +46,9 @@ export class Cron<
|
|
|
52
46
|
TStage extends string = string,
|
|
53
47
|
TDomain extends string = string,
|
|
54
48
|
> extends sst.aws.CronV2 {
|
|
49
|
+
/** The function it invokes. */
|
|
50
|
+
readonly processor: CronProps['processor'];
|
|
51
|
+
|
|
55
52
|
constructor(
|
|
56
53
|
_stack: StackType<TStage, TDomain>,
|
|
57
54
|
name: string,
|
|
@@ -63,44 +60,7 @@ export class Cron<
|
|
|
63
60
|
schedule,
|
|
64
61
|
function: processor.arn,
|
|
65
62
|
});
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Build one `Cron` per entry in a `gkm build` manifest's `crons` field (flat
|
|
70
|
-
* or partitioned). Each cron's handler becomes a validated `Function` (the
|
|
71
|
-
* cron's target), so pass `links` for that function's env validation;
|
|
72
|
-
* remaining `props` are CronV2 args.
|
|
73
|
-
*
|
|
74
|
-
* ```ts
|
|
75
|
-
* Cron.fromManifest(stack, manifest.crons, { links: [db] });
|
|
76
|
-
* ```
|
|
77
|
-
*/
|
|
78
|
-
static fromManifest<
|
|
79
|
-
TStage extends string = string,
|
|
80
|
-
TDomain extends string = string,
|
|
81
|
-
>(
|
|
82
|
-
stack: StackType<TStage, TDomain>,
|
|
83
|
-
crons: ManifestField<CronInfo>,
|
|
84
|
-
props: Omit<CronProps, 'processor' | 'schedule'> & {
|
|
85
|
-
links?: GkmLinkable[];
|
|
86
|
-
} = {},
|
|
87
|
-
): Cron<TStage, TDomain>[] {
|
|
88
|
-
const { links, ...cronArgs } = props;
|
|
89
|
-
return flattenManifestField(crons).map((cron) => {
|
|
90
|
-
const processor = new Function(stack, `${cron.name}Function`, {
|
|
91
|
-
name: stack.logicalPrefixedName(cron.name),
|
|
92
|
-
handler: cron.handler,
|
|
93
|
-
envVars: cron.environment,
|
|
94
|
-
links,
|
|
95
|
-
timeout: cron.timeout ? `${cron.timeout} seconds` : undefined,
|
|
96
|
-
memory: cron.memorySize ? `${cron.memorySize} MB` : undefined,
|
|
97
|
-
});
|
|
98
|
-
return new Cron(stack, cron.name, {
|
|
99
|
-
...cronArgs,
|
|
100
|
-
processor,
|
|
101
|
-
schedule: cron.schedule as CronSchedule,
|
|
102
|
-
});
|
|
103
|
-
});
|
|
63
|
+
this.processor = processor;
|
|
104
64
|
}
|
|
105
65
|
}
|
|
106
66
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { kmsUrl } from '@geekmidas/manifest';
|
|
2
|
+
import { kms } from '@pulumi/aws';
|
|
3
|
+
import { type GkmLinkable, ResourceType } from '../Linkable';
|
|
4
|
+
import type { StackType } from '../Stack';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `Encryption` — two KMS keys, and the infra half of the `encryption` kind.
|
|
8
|
+
*
|
|
9
|
+
* One key encrypts, by envelope: each value is sealed with a data key KMS
|
|
10
|
+
* mints and wraps, so the key itself never leaves KMS. It rotates yearly, and
|
|
11
|
+
* KMS keeps every version it rotated through, so nothing written is ever
|
|
12
|
+
* stranded. The other is an HMAC key for the blind index, which does not
|
|
13
|
+
* rotate — a rotated index key would make every stored index miss.
|
|
14
|
+
*
|
|
15
|
+
* Both are reached through one `kms://` URL, and a function linked to this is
|
|
16
|
+
* granted exactly the calls the cipher makes on exactly these two keys. Not
|
|
17
|
+
* `kms:*`, and not any other key in the account.
|
|
18
|
+
*
|
|
19
|
+
* It holds the keys rather than being one, and reports its own type, so a link
|
|
20
|
+
* resolves to `<ID>_URL` — the key the construct declared.
|
|
21
|
+
*/
|
|
22
|
+
export class Encryption<
|
|
23
|
+
TStage extends string = string,
|
|
24
|
+
TDomain extends string = string,
|
|
25
|
+
> implements GkmLinkable
|
|
26
|
+
{
|
|
27
|
+
readonly _id: string;
|
|
28
|
+
|
|
29
|
+
readonly key: kms.Key;
|
|
30
|
+
readonly indexKey: kms.Key;
|
|
31
|
+
private readonly region: string;
|
|
32
|
+
|
|
33
|
+
get _type() {
|
|
34
|
+
return ResourceType.Encryption;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
_stack: StackType<TStage, TDomain>,
|
|
39
|
+
name: string,
|
|
40
|
+
props: EncryptionProps,
|
|
41
|
+
) {
|
|
42
|
+
this._id = name;
|
|
43
|
+
this.region = props.region;
|
|
44
|
+
|
|
45
|
+
this.key = new kms.Key(`${name}Key`, {
|
|
46
|
+
description: `${name}: encrypts what the application stores`,
|
|
47
|
+
enableKeyRotation: true,
|
|
48
|
+
deletionWindowInDays: props.deletionWindowInDays ?? 30,
|
|
49
|
+
});
|
|
50
|
+
this.indexKey = new kms.Key(`${name}IndexKey`, {
|
|
51
|
+
description: `${name}: the blind index's HMAC key`,
|
|
52
|
+
keyUsage: 'GENERATE_VERIFY_MAC',
|
|
53
|
+
customerMasterKeySpec: 'HMAC_256',
|
|
54
|
+
deletionWindowInDays: props.deletionWindowInDays ?? 30,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
provides(): Record<string, $util.Input<string>> {
|
|
59
|
+
return {
|
|
60
|
+
url: $util
|
|
61
|
+
.all([this.key.arn, this.indexKey.arn])
|
|
62
|
+
.apply(([key, index]) => kmsUrl({ region: this.region, key, index })),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getSSTLink() {
|
|
67
|
+
return {
|
|
68
|
+
properties: { ...this.provides() },
|
|
69
|
+
include: [
|
|
70
|
+
sst.aws.permission({
|
|
71
|
+
actions: ['kms:GenerateDataKey', 'kms:Decrypt'],
|
|
72
|
+
resources: [this.key.arn],
|
|
73
|
+
}),
|
|
74
|
+
sst.aws.permission({
|
|
75
|
+
actions: ['kms:GenerateMac'],
|
|
76
|
+
resources: [this.indexKey.arn],
|
|
77
|
+
}),
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface EncryptionProps {
|
|
84
|
+
/** Where the keys live — the stage's region. */
|
|
85
|
+
region: string;
|
|
86
|
+
/**
|
|
87
|
+
* How long a deleted key can still be recovered, 7–30 days. A key's
|
|
88
|
+
* deletion is the deletion of everything it encrypted, so the longest.
|
|
89
|
+
*/
|
|
90
|
+
deletionWindowInDays?: number;
|
|
91
|
+
}
|
package/src/sst/aws/Function.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
1
|
import type { EnvValidator, ValidationResult } from '@geekmidas/envkit/sst';
|
|
2
|
-
import {
|
|
3
|
-
type FunctionInfo,
|
|
4
|
-
flattenManifestField,
|
|
5
|
-
type ManifestField,
|
|
6
|
-
} from '@geekmidas/manifest';
|
|
7
2
|
import { type GkmLinkable, ResourceType } from '../Linkable';
|
|
8
3
|
import { LinkedEnvironment } from '../LinkedEnvironment';
|
|
9
4
|
import type { StackType } from '../Stack';
|
|
@@ -66,9 +61,10 @@ export class Function<
|
|
|
66
61
|
super(id, {
|
|
67
62
|
...fnArgs,
|
|
68
63
|
environment: mergedEnvironment,
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
link:
|
|
64
|
+
// Resolved from `links` when `envVars` names what it needs; otherwise
|
|
65
|
+
// the `link` it was given — the edges a manifest declared.
|
|
66
|
+
link:
|
|
67
|
+
envVars.length > 0 ? linked.resolveLink(envVars) : (fnArgs.link ?? []),
|
|
72
68
|
runtime: runtime ?? 'nodejs24.x',
|
|
73
69
|
logging: logging ?? { format: 'json' },
|
|
74
70
|
});
|
|
@@ -78,40 +74,15 @@ export class Function<
|
|
|
78
74
|
this.envVars = envVars;
|
|
79
75
|
}
|
|
80
76
|
|
|
77
|
+
/** Its address, for whatever declared an edge to it. */
|
|
78
|
+
provides(): Record<string, $util.Input<string>> {
|
|
79
|
+
return { url: this.url };
|
|
80
|
+
}
|
|
81
|
+
|
|
81
82
|
/** Re-runs validation and returns the result (does not throw). */
|
|
82
83
|
validate(): ValidationResult {
|
|
83
84
|
return this.validator.validate(this.envVars);
|
|
84
85
|
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Build one `Function` per entry in a `gkm build` manifest's `functions`
|
|
88
|
-
* field (flat or partitioned). Shared `props` (e.g. `links`) apply to every
|
|
89
|
-
* function.
|
|
90
|
-
*
|
|
91
|
-
* ```ts
|
|
92
|
-
* Function.fromManifest(stack, manifest.functions, { links: [db] });
|
|
93
|
-
* ```
|
|
94
|
-
*/
|
|
95
|
-
static fromManifest<
|
|
96
|
-
TStage extends string = string,
|
|
97
|
-
TDomain extends string = string,
|
|
98
|
-
>(
|
|
99
|
-
stack: StackType<TStage, TDomain>,
|
|
100
|
-
functions: ManifestField<FunctionInfo>,
|
|
101
|
-
props: Omit<FunctionProps, 'handler'> = {},
|
|
102
|
-
): Function<TStage, TDomain>[] {
|
|
103
|
-
return flattenManifestField(functions).map(
|
|
104
|
-
(fn) =>
|
|
105
|
-
new Function(stack, fn.name, {
|
|
106
|
-
...props,
|
|
107
|
-
name: stack.logicalPrefixedName(fn.name),
|
|
108
|
-
handler: fn.handler,
|
|
109
|
-
envVars: fn.environment,
|
|
110
|
-
timeout: fn.timeout ? `${fn.timeout} seconds` : undefined,
|
|
111
|
-
memory: fn.memorySize ? `${fn.memorySize} MB` : undefined,
|
|
112
|
-
}),
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
86
|
}
|
|
116
87
|
|
|
117
88
|
export interface FunctionProps extends sst.aws.FunctionArgs {
|
package/src/sst/aws/Queue.ts
CHANGED
|
@@ -87,6 +87,7 @@ export class Queue<
|
|
|
87
87
|
handler: consumer.handler,
|
|
88
88
|
link: [this, ...(consumer.link ?? [])],
|
|
89
89
|
runtime: 'nodejs24.x',
|
|
90
|
+
...(consumer.vpc ? { vpc: consumer.vpc } : {}),
|
|
90
91
|
...(consumer.timeout ? { timeout: consumer.timeout } : {}),
|
|
91
92
|
},
|
|
92
93
|
consumer.batchSize ? { batch: { size: consumer.batchSize } } : {},
|
|
@@ -104,10 +105,12 @@ export class Queue<
|
|
|
104
105
|
|
|
105
106
|
/** The queue's one consumer, as {@link Queue.consume} subscribes it. */
|
|
106
107
|
export interface QueueConsumer {
|
|
107
|
-
/** The built handler —
|
|
108
|
+
/** The built handler — `apps/api/.gkm/aws/queues/emails.handler`. */
|
|
108
109
|
handler: string;
|
|
109
110
|
/** What the consumer depends on; the queue itself is always linked. */
|
|
110
111
|
link?: unknown[];
|
|
112
|
+
/** The database's network, when the consumer reaches one. */
|
|
113
|
+
vpc?: sst.aws.Vpc;
|
|
111
114
|
timeout?: `${number} seconds` | `${number} minutes`;
|
|
112
115
|
batchSize?: number;
|
|
113
116
|
}
|
|
@@ -4,20 +4,10 @@ import type { StackType } from '../Stack';
|
|
|
4
4
|
/**
|
|
5
5
|
* `RestApiSurface` — an HTTP API, and the infra half of the `rest-api` kind.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* So what this provisions is the surface and nothing on it. An API Gateway with
|
|
13
|
-
* no routes 404s everything, which is the honest state of things — and it is
|
|
14
|
-
* still worth provisioning, because the *address* is what everything downstream
|
|
15
|
-
* needs: a site inlines it as `VITE_API_URL`, an auth server puts it on its
|
|
16
|
-
* trusted-origin list, and the cookie domain derives from it. Those are blocked
|
|
17
|
-
* on the API existing, not on it answering.
|
|
18
|
-
*
|
|
19
|
-
* The two collapse into one component when the endpoint merge lands and the
|
|
20
|
-
* surface node carries its routes.
|
|
7
|
+
* Provisioned before its routes, because its *address* is what everything
|
|
8
|
+
* downstream needs: a site inlines it as `VITE_API_URL`, an auth server puts it
|
|
9
|
+
* on its trusted-origin list, and the cookie domain derives from it. The routes
|
|
10
|
+
* are mounted once everything they link to exists — see {@link mount}.
|
|
21
11
|
*/
|
|
22
12
|
|
|
23
13
|
export interface RestApiSurfaceProps extends sst.aws.ApiGatewayV2Args {
|
|
@@ -82,6 +72,25 @@ export class RestApiSurface<
|
|
|
82
72
|
};
|
|
83
73
|
}
|
|
84
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Mount one endpoint: a Lambda running the handler the build wrote for it,
|
|
77
|
+
* linked to exactly what that endpoint depends on.
|
|
78
|
+
*/
|
|
79
|
+
mount(endpoint: SurfaceRoute) {
|
|
80
|
+
return this.route(
|
|
81
|
+
`${endpoint.method} ${endpoint.path}`,
|
|
82
|
+
{
|
|
83
|
+
handler: endpoint.handler,
|
|
84
|
+
link: endpoint.link ?? [],
|
|
85
|
+
runtime: 'nodejs24.x',
|
|
86
|
+
...(endpoint.vpc ? { vpc: endpoint.vpc } : {}),
|
|
87
|
+
},
|
|
88
|
+
// `iam` is the one authorizer the gateway enforces itself; every other
|
|
89
|
+
// is the endpoint's own, checked in the handler.
|
|
90
|
+
endpoint.authorizer === 'iam' ? { auth: { iam: true } } : {},
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
85
94
|
override getSSTLink() {
|
|
86
95
|
const link = super.getSSTLink();
|
|
87
96
|
return {
|
|
@@ -90,3 +99,16 @@ export class RestApiSurface<
|
|
|
90
99
|
};
|
|
91
100
|
}
|
|
92
101
|
}
|
|
102
|
+
|
|
103
|
+
/** One endpoint, as {@link RestApiSurface.mount} mounts it. */
|
|
104
|
+
export interface SurfaceRoute {
|
|
105
|
+
method: string;
|
|
106
|
+
path: string;
|
|
107
|
+
/** The built handler — `apps/api/.gkm/aws/routes/getUser.handler`. */
|
|
108
|
+
handler: string;
|
|
109
|
+
/** What the endpoint depends on, and nothing else. */
|
|
110
|
+
link?: unknown[];
|
|
111
|
+
/** The database's network, when the endpoint reaches one. */
|
|
112
|
+
vpc?: sst.aws.Vpc;
|
|
113
|
+
authorizer?: string;
|
|
114
|
+
}
|
package/src/sst/fromManifest.ts
CHANGED
|
@@ -41,12 +41,15 @@ import {
|
|
|
41
41
|
withCacheTable,
|
|
42
42
|
} from './aws/Cache';
|
|
43
43
|
import { Credential } from './aws/Credential';
|
|
44
|
+
import { Cron, type CronSchedule } from './aws/Cron';
|
|
44
45
|
import { Database, DatabaseNeedsVpc, type DatabaseProps } from './aws/Database';
|
|
45
46
|
import { DatabaseBootstrap } from './aws/DatabaseBootstrap';
|
|
46
47
|
import { DatabaseReader, DatabaseSchema } from './aws/DerivedDatabase';
|
|
47
48
|
import { Email, EmailNeedsSender } from './aws/Email';
|
|
49
|
+
import { Encryption, type EncryptionProps } from './aws/Encryption';
|
|
48
50
|
import { ExternalApi, type ExternalApiProps } from './aws/ExternalApi';
|
|
49
51
|
import { FileServer } from './aws/FileServer';
|
|
52
|
+
import { Function } from './aws/Function';
|
|
50
53
|
import { ObjectStorage } from './aws/ObjectStorage';
|
|
51
54
|
import { Queue } from './aws/Queue';
|
|
52
55
|
import { RestApiSurface } from './aws/RestApiSurface';
|
|
@@ -378,17 +381,12 @@ const PROVISIONERS: Partial<Record<DeclarationKind, Provisioner>> = {
|
|
|
378
381
|
},
|
|
379
382
|
|
|
380
383
|
/**
|
|
381
|
-
* The surface, without its routes.
|
|
384
|
+
* The surface, without its routes yet.
|
|
382
385
|
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
* it is
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
* Provisioning it anyway is not ceremony. Its address is what a site inlines
|
|
389
|
-
* as `VITE_API_URL`, what an auth server puts on its trusted-origin list, and
|
|
390
|
-
* what the cookie domain derives from — so everything downstream of the API
|
|
391
|
-
* is blocked on the API *existing*, not on it answering.
|
|
386
|
+
* Its address is what a site inlines as `VITE_API_URL`, what an auth server
|
|
387
|
+
* puts on its trusted-origin list, and what the cookie domain derives from —
|
|
388
|
+
* so it is provisioned in order, and its endpoints are mounted by
|
|
389
|
+
* {@link mountRoutes} once everything they link to exists.
|
|
392
390
|
*/
|
|
393
391
|
'rest-api': (stack, d, props, context) =>
|
|
394
392
|
new RestApiSurface(stack, d.id, {
|
|
@@ -401,6 +399,17 @@ const PROVISIONERS: Partial<Record<DeclarationKind, Provisioner>> = {
|
|
|
401
399
|
// The same storage as a secret, under a different role — see the component.
|
|
402
400
|
credential: (stack, d, props) => new Credential(stack, d.id, props),
|
|
403
401
|
|
|
402
|
+
// In the stage's region, as mail is: a key in another region is a key
|
|
403
|
+
// every call crosses the world to reach.
|
|
404
|
+
encryption: (stack, d, props) =>
|
|
405
|
+
new Encryption(stack, d.id, {
|
|
406
|
+
...(props as Partial<EncryptionProps>),
|
|
407
|
+
region:
|
|
408
|
+
(props as Partial<EncryptionProps>).region ??
|
|
409
|
+
$app.providers?.aws?.region ??
|
|
410
|
+
'us-east-1',
|
|
411
|
+
}),
|
|
412
|
+
|
|
404
413
|
// Nothing to provision: the URL this stage calls, and the credentials kept
|
|
405
414
|
// as a secret.
|
|
406
415
|
'external-api': (stack, d, props) => {
|
|
@@ -593,9 +602,7 @@ function rootCluster(component: Provisioned): Database {
|
|
|
593
602
|
* printed: one line per route, naming what it can reach.
|
|
594
603
|
*
|
|
595
604
|
* A surface with no routes says so rather than printing nothing, because "no
|
|
596
|
-
* routes
|
|
597
|
-
* application's own API that is currently the true state, pending the endpoint
|
|
598
|
-
* merge.
|
|
605
|
+
* routes" and "this printed nothing" look identical otherwise.
|
|
599
606
|
*
|
|
600
607
|
* Pure, so what gets printed can be asserted without a deploy.
|
|
601
608
|
*/
|
|
@@ -751,21 +758,132 @@ export function isServed(id: string, manifest: ConstructManifest): boolean {
|
|
|
751
758
|
}
|
|
752
759
|
|
|
753
760
|
/**
|
|
754
|
-
* Kinds
|
|
761
|
+
* Kinds the in-order pass skips.
|
|
755
762
|
*
|
|
756
763
|
* - `mobile-app` ships through EAS and the stores. It is in the manifest for
|
|
757
764
|
* what it makes the surfaces trust, which `callersOf` reads off the graph.
|
|
758
765
|
* - `worker` is a process, not a resource: what it runs is provisioned as
|
|
759
766
|
* what it is — a queue's consumer by {@link subscribeConsumers}.
|
|
760
|
-
* - `cron` and `function` are Lambdas
|
|
761
|
-
*
|
|
767
|
+
* - `cron` and `function` are Lambdas linked to what they depend on, so they
|
|
768
|
+
* are provisioned once all of it exists, by {@link provisionCompute}.
|
|
762
769
|
*/
|
|
763
770
|
export const PROVISIONED_ELSEWHERE: ReadonlySet<DeclarationKind> =
|
|
764
771
|
new Set<DeclarationKind>(['mobile-app', 'worker', 'cron', 'function']);
|
|
765
772
|
|
|
773
|
+
/**
|
|
774
|
+
* Every function and cron: a Lambda running the handler the build wrote,
|
|
775
|
+
* linked to exactly what it depends on, in the database's network when it
|
|
776
|
+
* reaches one. A cron is that Lambda on its schedule; a function is reached
|
|
777
|
+
* at its URL, which only callers granted IAM access can invoke.
|
|
778
|
+
*
|
|
779
|
+
* After everything else is provisioned, because each links to things
|
|
780
|
+
* `provisionOrder` does not order before it.
|
|
781
|
+
*/
|
|
782
|
+
export function provisionCompute(
|
|
783
|
+
stack: StackType,
|
|
784
|
+
manifest: ConstructManifest,
|
|
785
|
+
provisioned: ProvisionedManifest,
|
|
786
|
+
): Cron[] {
|
|
787
|
+
const crons: Cron[] = [];
|
|
788
|
+
|
|
789
|
+
for (const [id, declaration] of Object.entries(manifest)) {
|
|
790
|
+
if (declaration?.kind !== 'function' && declaration?.kind !== 'cron') {
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const { link } = resolveEdges(declaration.dependencies, provisioned);
|
|
795
|
+
const vpc = vpcFor(link);
|
|
796
|
+
const lambda = new Function(
|
|
797
|
+
stack,
|
|
798
|
+
declaration.kind === 'cron' ? `${id}Function` : id,
|
|
799
|
+
{
|
|
800
|
+
name: stack.logicalPrefixedName(id),
|
|
801
|
+
handler: declaration.handler,
|
|
802
|
+
link,
|
|
803
|
+
...(vpc ? { vpc } : {}),
|
|
804
|
+
...(declaration.kind === 'function'
|
|
805
|
+
? { url: { authorization: 'iam' } }
|
|
806
|
+
: {}),
|
|
807
|
+
},
|
|
808
|
+
);
|
|
809
|
+
|
|
810
|
+
if (declaration.kind === 'cron') {
|
|
811
|
+
crons.push(
|
|
812
|
+
new Cron(stack, id, {
|
|
813
|
+
processor: lambda,
|
|
814
|
+
schedule: declaration.schedule as CronSchedule,
|
|
815
|
+
}),
|
|
816
|
+
);
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
assertProvides(
|
|
821
|
+
id,
|
|
822
|
+
declaration.provides,
|
|
823
|
+
Object.keys(lambda.provides()).map((role) =>
|
|
824
|
+
providedKeyFor(id, declaration.kind, role),
|
|
825
|
+
),
|
|
826
|
+
);
|
|
827
|
+
provisioned[id] = lambda;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
return crons;
|
|
831
|
+
}
|
|
832
|
+
|
|
766
833
|
/** A provisioned queue: what {@link subscribeConsumers} subscribes through. */
|
|
767
834
|
interface Consumable {
|
|
768
|
-
consume(consumer: {
|
|
835
|
+
consume(consumer: {
|
|
836
|
+
handler: string;
|
|
837
|
+
link?: unknown[];
|
|
838
|
+
vpc?: sst.aws.Vpc;
|
|
839
|
+
}): unknown;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* The network a function has to run in to reach what it links to.
|
|
844
|
+
*
|
|
845
|
+
* A database is private to its VPC, so a handler linked to one — or to a
|
|
846
|
+
* schema tenant or reader of one — runs inside it, or its connection times out.
|
|
847
|
+
*/
|
|
848
|
+
export function vpcFor(link: readonly Provisioned[]): sst.aws.Vpc | undefined {
|
|
849
|
+
for (const component of link) {
|
|
850
|
+
const cluster = rootCluster(component);
|
|
851
|
+
if (cluster instanceof Database) return cluster.vpc;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return undefined;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Mount every surface's endpoints — the handlers the build wrote, each linked
|
|
859
|
+
* to exactly what that endpoint depends on.
|
|
860
|
+
*
|
|
861
|
+
* After every construct is provisioned, for the reason consumers are: an
|
|
862
|
+
* endpoint links to things `provisionOrder` does not order before its surface.
|
|
863
|
+
*/
|
|
864
|
+
export function mountRoutes(
|
|
865
|
+
manifest: ConstructManifest,
|
|
866
|
+
provisioned: ProvisionedManifest,
|
|
867
|
+
): void {
|
|
868
|
+
for (const [id, declaration] of Object.entries(manifest)) {
|
|
869
|
+
if (declaration?.kind !== 'rest-api') continue;
|
|
870
|
+
|
|
871
|
+
const surface = provisioned[id];
|
|
872
|
+
if (!(surface instanceof RestApiSurface)) continue;
|
|
873
|
+
|
|
874
|
+
for (const endpoint of declaration.endpoints) {
|
|
875
|
+
const { link } = resolveEdges(endpoint.dependencies, provisioned);
|
|
876
|
+
const vpc = vpcFor(link);
|
|
877
|
+
surface.mount({
|
|
878
|
+
method: endpoint.method,
|
|
879
|
+
path: endpoint.path,
|
|
880
|
+
handler: endpoint.handler,
|
|
881
|
+
link,
|
|
882
|
+
...(vpc ? { vpc } : {}),
|
|
883
|
+
...(endpoint.authorizer ? { authorizer: endpoint.authorizer } : {}),
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
}
|
|
769
887
|
}
|
|
770
888
|
|
|
771
889
|
/**
|
|
@@ -786,7 +904,12 @@ export function subscribeConsumers(
|
|
|
786
904
|
if (!queue) continue;
|
|
787
905
|
|
|
788
906
|
const { link } = resolveEdges(declaration.worker.dependencies, provisioned);
|
|
789
|
-
|
|
907
|
+
const vpc = vpcFor(link);
|
|
908
|
+
queue.consume({
|
|
909
|
+
handler: declaration.worker.handler,
|
|
910
|
+
link,
|
|
911
|
+
...(vpc ? { vpc } : {}),
|
|
912
|
+
});
|
|
790
913
|
}
|
|
791
914
|
}
|
|
792
915
|
|
|
@@ -902,8 +1025,11 @@ export function fromManifest(
|
|
|
902
1025
|
provisioned[id] = component;
|
|
903
1026
|
}
|
|
904
1027
|
|
|
905
|
-
//
|
|
1028
|
+
// The functions and crons, each queue's one consumer and each surface's
|
|
1029
|
+
// endpoints, once everything they link to exists.
|
|
1030
|
+
provisionCompute(stack, manifest, provisioned);
|
|
906
1031
|
subscribeConsumers(manifest, provisioned);
|
|
1032
|
+
mountRoutes(manifest, provisioned);
|
|
907
1033
|
|
|
908
1034
|
for (const line of describeRoutes(manifest)) console.log(line);
|
|
909
1035
|
|
package/src/sst/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ export {
|
|
|
34
34
|
} from './aws/DatabaseBootstrap';
|
|
35
35
|
export { DatabaseReader, DatabaseSchema } from './aws/DerivedDatabase';
|
|
36
36
|
export { Email, EmailNeedsUrl, type EmailProps } from './aws/Email';
|
|
37
|
+
export { Encryption, type EncryptionProps } from './aws/Encryption';
|
|
37
38
|
export { FileServer, type FileServerProps } from './aws/FileServer';
|
|
38
39
|
export { Function, type FunctionProps } from './aws/Function';
|
|
39
40
|
export {
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
// Type-level checks that the `fromManifest` integrators accept the unified
|
|
2
|
-
// `gkm build` manifest shape (`export const manifest = { … } as const`) — both
|
|
3
|
-
// the flat and the partitioned `ManifestField` forms. Checked by `ts:check:sst`.
|
|
4
|
-
|
|
5
|
-
import type { Manifest, ManifestField, RouteInfo } from '@geekmidas/manifest';
|
|
6
|
-
import { App } from '../App';
|
|
7
|
-
import { Api } from '../aws/Api';
|
|
8
|
-
import { Cron } from '../aws/Cron';
|
|
9
|
-
import { Function } from '../aws/Function';
|
|
10
|
-
import { type GkmLinkable, ResourceType } from '../Linkable';
|
|
11
|
-
|
|
12
|
-
const stack = new App({
|
|
13
|
-
name: 'a',
|
|
14
|
-
stage: 'dev',
|
|
15
|
-
domain: 'example.com',
|
|
16
|
-
hostedZoneId: 'Z',
|
|
17
|
-
region: 'us-east-1',
|
|
18
|
-
}).stack('api');
|
|
19
|
-
|
|
20
|
-
const db: GkmLinkable = { _id: 'db', _type: ResourceType.Postgres };
|
|
21
|
-
|
|
22
|
-
// Mirrors a generated `manifest/aws.ts` — one object, `as const`.
|
|
23
|
-
const manifest = {
|
|
24
|
-
routes: [
|
|
25
|
-
{
|
|
26
|
-
path: '/users/{id}',
|
|
27
|
-
method: 'GET',
|
|
28
|
-
handler: 'users.handler',
|
|
29
|
-
authorizer: 'none',
|
|
30
|
-
environment: ['DB_HOST'],
|
|
31
|
-
timeout: 30,
|
|
32
|
-
memorySize: 1024,
|
|
33
|
-
},
|
|
34
|
-
],
|
|
35
|
-
functions: [
|
|
36
|
-
{ name: 'worker', handler: 'worker.handler', environment: ['DB_URL'] },
|
|
37
|
-
],
|
|
38
|
-
crons: [
|
|
39
|
-
{ name: 'nightly', handler: 'nightly.handler', schedule: 'rate(1 day)' },
|
|
40
|
-
],
|
|
41
|
-
} as const satisfies Manifest;
|
|
42
|
-
|
|
43
|
-
// Each integrator takes the manifest *field*.
|
|
44
|
-
export const api = Api.fromManifest(stack, 'Api', manifest.routes, {
|
|
45
|
-
links: [db],
|
|
46
|
-
authorizers: { jwt: { issuer: 'https://i', audiences: ['a'] } },
|
|
47
|
-
});
|
|
48
|
-
export const workers = Function.fromManifest(stack, manifest.functions, {
|
|
49
|
-
links: [db],
|
|
50
|
-
});
|
|
51
|
-
export const crons = Cron.fromManifest(stack, manifest.crons, { links: [db] });
|
|
52
|
-
|
|
53
|
-
// The partitioned `ManifestField` form is also accepted.
|
|
54
|
-
const partitionedRoutes = {
|
|
55
|
-
admin: [
|
|
56
|
-
{
|
|
57
|
-
path: '/admin',
|
|
58
|
-
method: 'GET',
|
|
59
|
-
handler: 'admin.handler',
|
|
60
|
-
authorizer: 'iam',
|
|
61
|
-
},
|
|
62
|
-
],
|
|
63
|
-
default: [
|
|
64
|
-
{
|
|
65
|
-
path: '/health',
|
|
66
|
-
method: 'GET',
|
|
67
|
-
handler: 'health.handler',
|
|
68
|
-
authorizer: 'none',
|
|
69
|
-
},
|
|
70
|
-
],
|
|
71
|
-
} satisfies ManifestField<RouteInfo>;
|
|
72
|
-
export const partitionedApi = Api.fromManifest(
|
|
73
|
-
stack,
|
|
74
|
-
'PartApi',
|
|
75
|
-
partitionedRoutes,
|
|
76
|
-
{ links: [db] },
|
|
77
|
-
);
|