@geekmidas/cloud 9.0.2 → 10.0.0-alpha.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/dist/{index-ByEJy40r.d.cts → index-B5CZ1xVf.d.cts} +17 -9
- package/dist/index-B5CZ1xVf.d.cts.map +1 -0
- package/dist/{index-DZ0QrJQr.d.mts → index-DhHRjduZ.d.mts} +17 -9
- package/dist/index-DhHRjduZ.d.mts.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/utils/index.cjs +1 -1
- package/dist/utils/index.d.cts +1 -1
- package/dist/utils/index.d.mts +1 -1
- package/dist/utils/index.mjs +1 -1
- package/dist/{utils-CtMjuIMR.cjs → utils-B1a2UuEO.cjs} +5 -5
- package/dist/utils-B1a2UuEO.cjs.map +1 -0
- package/dist/{utils-BdKG20_m.mjs → utils-DMOXJ27j.mjs} +5 -5
- package/dist/utils-DMOXJ27j.mjs.map +1 -0
- package/package.json +44 -7
- package/src/dokploy/Application.ts +259 -0
- package/src/dokploy/__tests__/Application.spec.ts +69 -0
- package/src/dokploy/index.ts +18 -0
- package/src/sst/__tests__/LinkedEnvironment.spec.ts +4 -1
- package/src/sst/__tests__/backends.spec.ts +249 -0
- package/src/sst/__tests__/bootstrap.spec.ts +140 -0
- package/src/sst/__tests__/database.spec.ts +119 -0
- package/src/sst/__tests__/fromManifest.spec.ts +266 -0
- package/src/sst/__tests__/provides.spec.ts +107 -0
- package/src/sst/__tests__/ses.spec.ts +93 -0
- package/src/sst/__tests__/surfaces.spec.ts +132 -0
- package/src/sst/__type-tests__/authorizers.type-test.ts +2 -2
- package/src/sst/__type-tests__/manifest.type-test.ts +3 -3
- package/src/sst/__type-tests__/messaging.type-test.ts +3 -3
- package/src/sst/__type-tests__/storage.type-test.ts +2 -2
- package/src/sst/{Api.ts → aws/Api.ts} +3 -3
- package/src/sst/aws/Cache.ts +259 -0
- package/src/sst/aws/Credential.ts +28 -0
- package/src/sst/{Cron.ts → aws/Cron.ts} +2 -2
- package/src/sst/aws/Database.ts +190 -0
- package/src/sst/aws/DatabaseBootstrap.ts +309 -0
- package/src/sst/aws/DerivedDatabase.ts +117 -0
- package/src/sst/aws/Email.ts +181 -0
- package/src/sst/aws/FileServer.ts +82 -0
- package/src/sst/{Function.ts → aws/Function.ts} +3 -3
- package/src/sst/aws/ObjectStorage.ts +76 -0
- package/src/sst/aws/Queue.ts +116 -0
- package/src/sst/aws/RestApiSurface.ts +92 -0
- package/src/sst/aws/Secret.ts +76 -0
- package/src/sst/aws/StaticSite.ts +92 -0
- package/src/sst/{Storage.ts → aws/Storage.ts} +3 -3
- package/src/sst/aws/Topic.ts +68 -0
- package/src/sst/aws/bootstrap/handler.ts +110 -0
- package/src/sst/aws/ses.ts +132 -0
- package/src/sst/errors.ts +65 -0
- package/src/sst/fromManifest.ts +899 -0
- package/src/sst/index.ts +62 -7
- package/src/sst/naming.ts +37 -16
- package/src/sst/tsconfig.json +2 -2
- package/src/sst/upstash.d.ts +38 -0
- package/dist/index-ByEJy40r.d.cts.map +0 -1
- package/dist/index-DZ0QrJQr.d.mts.map +0 -1
- package/dist/utils-BdKG20_m.mjs.map +0 -1
- package/dist/utils-CtMjuIMR.cjs.map +0 -1
- package/src/sst/Queue.ts +0 -46
- package/src/sst/Topic.ts +0 -37
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest → SST.
|
|
3
|
+
*
|
|
4
|
+
* Split deliberately into decisions and instantiation. The decisions —
|
|
5
|
+
* which component provisions a kind, whether what it supplies matches what the
|
|
6
|
+
* app declared — are pure functions, so they can be asserted as data. Only
|
|
7
|
+
* {@link fromManifest} touches Pulumi, and it is thin enough that little is
|
|
8
|
+
* hidden behind a runtime nothing can unit-test.
|
|
9
|
+
*
|
|
10
|
+
* Built for AWS. Extending to another provider means adding entries to
|
|
11
|
+
* {@link PROVISIONERS}, not restructuring: the manifest names a *kind*, never a
|
|
12
|
+
* cloud.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { ownerRole, readerRole } from '@geekmidas/db/pg/roles';
|
|
16
|
+
import { resolveEnvKeys } from '@geekmidas/envkit/sst';
|
|
17
|
+
import type {
|
|
18
|
+
ConstructManifest,
|
|
19
|
+
Declaration,
|
|
20
|
+
DeclarationKind,
|
|
21
|
+
Dependency,
|
|
22
|
+
SiteDeclaration,
|
|
23
|
+
} from '@geekmidas/manifest';
|
|
24
|
+
import {
|
|
25
|
+
cacheTable,
|
|
26
|
+
cookieDomain,
|
|
27
|
+
DEFAULT_POSTGRES_VERSION,
|
|
28
|
+
dependentsOf,
|
|
29
|
+
PUBLIC,
|
|
30
|
+
PUBLIC_PREFIX,
|
|
31
|
+
providedKeyFor,
|
|
32
|
+
provisionOrder,
|
|
33
|
+
} from '@geekmidas/manifest';
|
|
34
|
+
import {
|
|
35
|
+
Cache,
|
|
36
|
+
CacheIsAmbiguous,
|
|
37
|
+
CacheNeedsDatabase,
|
|
38
|
+
type CacheProps,
|
|
39
|
+
withCacheTable,
|
|
40
|
+
} from './aws/Cache';
|
|
41
|
+
import { Credential } from './aws/Credential';
|
|
42
|
+
import { Database, DatabaseNeedsVpc } from './aws/Database';
|
|
43
|
+
import { DatabaseBootstrap } from './aws/DatabaseBootstrap';
|
|
44
|
+
import { DatabaseReader, DatabaseSchema } from './aws/DerivedDatabase';
|
|
45
|
+
import { Email, EmailNeedsSender } from './aws/Email';
|
|
46
|
+
import { FileServer } from './aws/FileServer';
|
|
47
|
+
import { ObjectStorage } from './aws/ObjectStorage';
|
|
48
|
+
import { Queue } from './aws/Queue';
|
|
49
|
+
import { RestApiSurface } from './aws/RestApiSurface';
|
|
50
|
+
import { Secret } from './aws/Secret';
|
|
51
|
+
import { StaticSite } from './aws/StaticSite';
|
|
52
|
+
import { Topic } from './aws/Topic';
|
|
53
|
+
import {
|
|
54
|
+
ProvidesMismatch,
|
|
55
|
+
UnknownDeclarationKind,
|
|
56
|
+
UnresolvedDependency,
|
|
57
|
+
} from './errors';
|
|
58
|
+
import type { GkmLinkable } from './Linkable';
|
|
59
|
+
import type { StackType } from './Stack';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A provisioned construct — the component, which *is* the linkable.
|
|
63
|
+
*
|
|
64
|
+
* `provides()` returns its values keyed by role. They reach the running code by
|
|
65
|
+
* being spread into the link's properties, which SST injects and envkit's
|
|
66
|
+
* resolvers flatten into env; keeping them behind a method is what lets the
|
|
67
|
+
* contract be asserted at synth without a deploy.
|
|
68
|
+
*/
|
|
69
|
+
export interface Provisioned extends GkmLinkable {
|
|
70
|
+
provides(): Record<string, $util.Input<string>>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Everything the manifest declared, keyed by id — the shape edges resolve against. */
|
|
74
|
+
export type ProvisionedManifest = Record<string, Provisioned>;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* What a provisioner can see beyond its own declaration.
|
|
78
|
+
*
|
|
79
|
+
* Two kinds need it, and both are consequences of decisions the design took
|
|
80
|
+
* deliberately. A bucket has to know whether anything serves it, because the
|
|
81
|
+
* file server is a construct of its own rather than a flag — the cost that
|
|
82
|
+
* decision names, paid here by looking rather than by reading a flag. And a
|
|
83
|
+
* derived node needs the component its parent became, since it provisions
|
|
84
|
+
* nothing and resolves an address off that one.
|
|
85
|
+
*/
|
|
86
|
+
export interface ProvisionContext {
|
|
87
|
+
manifest: ConstructManifest;
|
|
88
|
+
/** Everything already provisioned. Parents are present; siblings may not be. */
|
|
89
|
+
provisioned: ProvisionedManifest;
|
|
90
|
+
/**
|
|
91
|
+
* The role bootstrap for each cluster, keyed by the cluster's id.
|
|
92
|
+
*
|
|
93
|
+
* Populated as tenants are provisioned and run once at the end: Pulumi can
|
|
94
|
+
* generate a password and store it but cannot run `CREATE ROLE`, so the DDL
|
|
95
|
+
* is a function invoked after everything it operates on exists.
|
|
96
|
+
*/
|
|
97
|
+
bootstraps: Map<string, DatabaseBootstrap>;
|
|
98
|
+
/**
|
|
99
|
+
* Where a declared cache lives, and who delivers mail.
|
|
100
|
+
*
|
|
101
|
+
* Deployment choices rather than declarations, for the same reason
|
|
102
|
+
* `services.events` is: the same application code caches into any of them and
|
|
103
|
+
* sends through any of them. They reach the provisioner here because it is
|
|
104
|
+
* the only place that knows both the choice and the manifest.
|
|
105
|
+
*/
|
|
106
|
+
cache?: 'upstash' | 'elasticache' | 'db';
|
|
107
|
+
email?: 'resend' | 'ses' | 'smtp';
|
|
108
|
+
/**
|
|
109
|
+
* One deferred caller list per surface, resolved once everything exists.
|
|
110
|
+
*
|
|
111
|
+
* A surface's origins come from its *inbound* edges, and one of those is
|
|
112
|
+
* usually a site whose own build needs this surface's address — so the values
|
|
113
|
+
* are circular even though the resources are not. Deferring the value is
|
|
114
|
+
* what breaks it; deferring the resource would deadlock.
|
|
115
|
+
*/
|
|
116
|
+
callers?: Map<string, Deferred>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A promise and the handle to settle it. */
|
|
120
|
+
interface Deferred {
|
|
121
|
+
promise: Promise<{ trustedOrigins: string; cookieDomain?: string }>;
|
|
122
|
+
resolve: (
|
|
123
|
+
value:
|
|
124
|
+
| { trustedOrigins: string; cookieDomain?: string }
|
|
125
|
+
| Promise<{ trustedOrigins: string; cookieDomain?: string }>,
|
|
126
|
+
) => void;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type Provisioner = (
|
|
130
|
+
stack: StackType,
|
|
131
|
+
declaration: Declaration,
|
|
132
|
+
props: Record<string, unknown>,
|
|
133
|
+
context: ProvisionContext,
|
|
134
|
+
) => Provisioned;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Provider-specific props, per construct id.
|
|
138
|
+
*
|
|
139
|
+
* Neutral options — `versioned`, and later `cdn` — travel in the declaration,
|
|
140
|
+
* because the app legitimately has an opinion about them. Anything with S3 in
|
|
141
|
+
* its name does not belong in application code, so lifecycle rules, CORS, and
|
|
142
|
+
* canned ACLs are supplied here, in the deploy layer, keyed by the id they
|
|
143
|
+
* apply to and typed against the component that receives them.
|
|
144
|
+
*
|
|
145
|
+
* A third escape hatch needs no API at all: `fromManifest` returns the
|
|
146
|
+
* components, so `provisioned.Uploads.nodes.bucket` is reachable for anything
|
|
147
|
+
* neither route covers.
|
|
148
|
+
*/
|
|
149
|
+
export type ComponentOverrides = Record<string, Record<string, unknown>>;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Which component provisions which kind.
|
|
153
|
+
*
|
|
154
|
+
* The extension point: a second provider adds entries here, and nothing above
|
|
155
|
+
* this line changes.
|
|
156
|
+
*/
|
|
157
|
+
const PROVISIONERS: Partial<Record<DeclarationKind, Provisioner>> = {
|
|
158
|
+
objects: (stack, d, props, context) =>
|
|
159
|
+
new ObjectStorage(stack, d.id, {
|
|
160
|
+
// Neutral options the app declared, mapped to this provider's words.
|
|
161
|
+
...(d.kind === 'objects' && d.versioned ? { versioning: true } : {}),
|
|
162
|
+
// A served bucket has to let CloudFront read it, and only the manifest
|
|
163
|
+
// knows whether anything serves it — the cost of making the file
|
|
164
|
+
// server its own construct, paid here rather than by a flag on the
|
|
165
|
+
// bucket that every consumer would then branch on.
|
|
166
|
+
...(isServed(d.id, context.manifest) ? { access: 'cloudfront' } : {}),
|
|
167
|
+
// Overrides win: they are the more specific statement, and the escape
|
|
168
|
+
// hatch is worthless if it cannot override the general case.
|
|
169
|
+
...props,
|
|
170
|
+
}),
|
|
171
|
+
|
|
172
|
+
'file-server': (stack, d, props, context) => {
|
|
173
|
+
if (d.kind !== 'file-server') throw new UnknownDeclarationKind(d.kind, []);
|
|
174
|
+
|
|
175
|
+
const origin = context.provisioned[d.of];
|
|
176
|
+
if (!origin) {
|
|
177
|
+
throw new UnresolvedDependency(d.of, Object.keys(context.provisioned));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return new FileServer(stack, d.id, {
|
|
181
|
+
origin: origin as unknown as sst.aws.Bucket,
|
|
182
|
+
...props,
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
site: (stack, d, props, context) => {
|
|
187
|
+
if (d.kind !== 'site') throw new UnknownDeclarationKind(d.kind, []);
|
|
188
|
+
|
|
189
|
+
return new StaticSite(stack, d.id, {
|
|
190
|
+
path: d.app.path,
|
|
191
|
+
variant: d.variant,
|
|
192
|
+
environment: siteEnvironment(d, context),
|
|
193
|
+
...props,
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
queue: (stack, d, props) =>
|
|
198
|
+
new Queue(stack, d.id, {
|
|
199
|
+
// FIFO is a neutral option — the app legitimately has an opinion about
|
|
200
|
+
// ordering — and `fifo` is also what SST calls it.
|
|
201
|
+
...(d.kind === 'queue' && d.fifo ? { fifo: true } : {}),
|
|
202
|
+
...props,
|
|
203
|
+
}),
|
|
204
|
+
|
|
205
|
+
// A topic declares nothing beyond its id: which events it carries is the
|
|
206
|
+
// producer's and subscribers' business, and SNS has no per-event
|
|
207
|
+
// configuration to map it onto.
|
|
208
|
+
topic: (stack, d, props) => new Topic(stack, d.id, props),
|
|
209
|
+
|
|
210
|
+
database: (stack, d, props, context) => {
|
|
211
|
+
if (d.kind !== 'database') throw new UnknownDeclarationKind(d.kind, []);
|
|
212
|
+
// Required rather than defaulted: creating a VPC means creating a NAT
|
|
213
|
+
// gateway, which is a monthly cost in an account whose networking may
|
|
214
|
+
// already be someone else's decision.
|
|
215
|
+
if (!('vpc' in props)) throw new DatabaseNeedsVpc(d.id);
|
|
216
|
+
|
|
217
|
+
const cluster = new Database(stack, d.id, {
|
|
218
|
+
...(d.schema ? { schema: d.schema } : {}),
|
|
219
|
+
// The declared major, so the cluster runs what the declaration says
|
|
220
|
+
// rather than whatever Aurora defaults to that month. Local ran 18
|
|
221
|
+
// while this ran 17.7, and neither was written down anywhere.
|
|
222
|
+
version: String(d.version ?? DEFAULT_POSTGRES_VERSION),
|
|
223
|
+
...(props as unknown as sst.aws.PostgresArgs),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// A database needs its own roles, not just its tenants'. Registering only
|
|
227
|
+
// tenants left the *database* connecting as the cluster master and its
|
|
228
|
+
// reader falling back to the same — so the split the local target
|
|
229
|
+
// enforces was absent deployed, which is the worst shape a gap can take:
|
|
230
|
+
// a developer sees it working and production silently does not have it.
|
|
231
|
+
if (d.schema) {
|
|
232
|
+
const bootstrap = bootstrapFor(cluster, d.id, context);
|
|
233
|
+
const runtime = roleNameFor(d, context);
|
|
234
|
+
|
|
235
|
+
bootstrap?.add({
|
|
236
|
+
id: d.id,
|
|
237
|
+
schema: d.schema,
|
|
238
|
+
runtime,
|
|
239
|
+
owner: ownerRole(runtime),
|
|
240
|
+
...(hasReader(d.id, context) ? { reader: readerRole(runtime) } : {}),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return cluster;
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
'database-reader': (_stack, d, _props, context) =>
|
|
248
|
+
derived(d, context, (id, parent) => {
|
|
249
|
+
// A reader reads through the *parent's* read-only role: read-only is
|
|
250
|
+
// enforced by the grants, which is what makes falling back to the
|
|
251
|
+
// writer's endpoint safe where a cluster has no replica.
|
|
252
|
+
const source = parentOf(d, context);
|
|
253
|
+
const roles = context.bootstraps.get(rootId(source, context));
|
|
254
|
+
const runtime = roleNameFor(source, context);
|
|
255
|
+
const reader = roles?.readerFor(runtime);
|
|
256
|
+
|
|
257
|
+
return new DatabaseReader(id, parent, reader);
|
|
258
|
+
}),
|
|
259
|
+
|
|
260
|
+
'database-schema': (_stack, d, _props, context) => {
|
|
261
|
+
if (d.kind !== 'database-schema')
|
|
262
|
+
throw new UnknownDeclarationKind(d.kind, []);
|
|
263
|
+
|
|
264
|
+
return derived(d, context, (id, parent) => {
|
|
265
|
+
const bootstrap = bootstrapFor(parent, rootId(d, context), context);
|
|
266
|
+
const runtime = roleNameFor(d, context);
|
|
267
|
+
|
|
268
|
+
// Registering the tenant is what creates its passwords and its secret;
|
|
269
|
+
// the DDL that uses them runs at the end, from one function.
|
|
270
|
+
const credentials = bootstrap?.add({
|
|
271
|
+
id,
|
|
272
|
+
schema: d.schema,
|
|
273
|
+
runtime,
|
|
274
|
+
owner: ownerRole(runtime),
|
|
275
|
+
...(hasReader(d.id, context) ? { reader: readerRole(runtime) } : {}),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return new DatabaseSchema(
|
|
279
|
+
id,
|
|
280
|
+
parent,
|
|
281
|
+
d.schema,
|
|
282
|
+
credentials
|
|
283
|
+
? { user: runtime, password: credentials.runtime }
|
|
284
|
+
: undefined,
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
cache: (stack, d, props, context) => {
|
|
290
|
+
const backend = context.cache ?? 'upstash';
|
|
291
|
+
|
|
292
|
+
// A cache that named a database is in *that* one, whatever the backend
|
|
293
|
+
// config says — the declaration is the stronger statement. Without one,
|
|
294
|
+
// the `db` backend resolves to the declared database, which is an answer
|
|
295
|
+
// only while there is one of them: picking the first of two would put a
|
|
296
|
+
// cache in a database nobody chose, and that surfaces as missing entries
|
|
297
|
+
// long after the deploy reported success.
|
|
298
|
+
const databases =
|
|
299
|
+
d.kind === 'cache' && !d.of && backend === 'db'
|
|
300
|
+
? Object.entries(context.manifest)
|
|
301
|
+
.filter(([, declaration]) => declaration.kind === 'database')
|
|
302
|
+
.map(([id]) => id)
|
|
303
|
+
: [];
|
|
304
|
+
|
|
305
|
+
if (databases.length > 1) throw new CacheIsAmbiguous(d.id, databases);
|
|
306
|
+
|
|
307
|
+
const parentId = d.kind === 'cache' && d.of ? d.of : databases[0];
|
|
308
|
+
|
|
309
|
+
if (parentId) {
|
|
310
|
+
const parent = context.provisioned[parentId];
|
|
311
|
+
if (!parent) throw new CacheNeedsDatabase(d.id);
|
|
312
|
+
|
|
313
|
+
// Same address, same role, one more table — which is what makes this
|
|
314
|
+
// backend cost nothing to run. The table travels *in* the URL, because
|
|
315
|
+
// two caches in one database resolve the same connection string and a
|
|
316
|
+
// client built from the URL alone could not tell them apart.
|
|
317
|
+
const table =
|
|
318
|
+
(d.kind === 'cache' && d.table) || cacheTable(d.id as string);
|
|
319
|
+
|
|
320
|
+
return new Cache(stack, d.id, {
|
|
321
|
+
url: $util
|
|
322
|
+
.output(parent.provides().url!)
|
|
323
|
+
.apply((url) => withCacheTable(url, table)),
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const supplied = props as {
|
|
328
|
+
url?: $util.Input<string>;
|
|
329
|
+
vpc?: CacheProps['vpc'];
|
|
330
|
+
region?: string;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// Both remaining backends create something, and both take a URL instead
|
|
334
|
+
// for a cache that already exists.
|
|
335
|
+
return new Cache(stack, d.id, {
|
|
336
|
+
backend,
|
|
337
|
+
...(supplied.url ? { url: supplied.url } : {}),
|
|
338
|
+
...(supplied.vpc ? { vpc: supplied.vpc } : {}),
|
|
339
|
+
...(supplied.region ? { region: supplied.region } : {}),
|
|
340
|
+
});
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
email: (stack, d, props, context) => {
|
|
344
|
+
const backend = context.email ?? 'ses';
|
|
345
|
+
const supplied = props as {
|
|
346
|
+
url?: $util.Input<string>;
|
|
347
|
+
region?: $util.Input<string>;
|
|
348
|
+
from?: $util.Input<string>;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// Not defaultable: every provider rejects an unverified sender, so a
|
|
352
|
+
// guess deploys cleanly and fails at the first send.
|
|
353
|
+
if (!supplied.from) throw new EmailNeedsSender(d.id);
|
|
354
|
+
|
|
355
|
+
return new Email(stack, d.id, {
|
|
356
|
+
backend,
|
|
357
|
+
from: supplied.from,
|
|
358
|
+
...(supplied.url ? { url: supplied.url } : {}),
|
|
359
|
+
// SES derives its own credential and needs to know which region's
|
|
360
|
+
// endpoint to derive it for; the others were handed a URL already.
|
|
361
|
+
region: supplied.region ?? $app.providers?.aws?.region ?? 'us-east-1',
|
|
362
|
+
});
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The surface, without its routes.
|
|
367
|
+
*
|
|
368
|
+
* An API Gateway with no routes 404s everything, and that is the honest
|
|
369
|
+
* state: the *surface* is what this kind declares, and mounting handlers on
|
|
370
|
+
* it is the endpoint merge that has not landed — routes still reach the
|
|
371
|
+
* deploy target through the separate `RouteInfo[]` pipeline.
|
|
372
|
+
*
|
|
373
|
+
* Provisioning it anyway is not ceremony. Its address is what a site inlines
|
|
374
|
+
* as `VITE_API_URL`, what an auth server puts on its trusted-origin list, and
|
|
375
|
+
* what the cookie domain derives from — so everything downstream of the API
|
|
376
|
+
* is blocked on the API *existing*, not on it answering.
|
|
377
|
+
*/
|
|
378
|
+
'rest-api': (stack, d, props, context) =>
|
|
379
|
+
new RestApiSurface(stack, d.id, {
|
|
380
|
+
...(props as sst.aws.ApiGatewayV2Args),
|
|
381
|
+
callers: context.callers?.get(d.id)?.promise,
|
|
382
|
+
}),
|
|
383
|
+
|
|
384
|
+
secret: (stack, d, props) => new Secret(stack, d.id, props),
|
|
385
|
+
|
|
386
|
+
// The same storage as a secret, under a different role — see the component.
|
|
387
|
+
credential: (stack, d, props) => new Credential(stack, d.id, props),
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* A site's build-time environment: the actual values, under the names its
|
|
392
|
+
* bundler inlines.
|
|
393
|
+
*
|
|
394
|
+
* The names come from the shared derivation, so a site built by `gkm dev` and
|
|
395
|
+
* the same site built here inline the same keys. The *values* can only come
|
|
396
|
+
* from the provisioned components — a static site has no server half to read a
|
|
397
|
+
* link at runtime, so an address it needs has to be an input to its build.
|
|
398
|
+
*
|
|
399
|
+
* @throws {UnresolvedDependency} when an edge names something not provisioned.
|
|
400
|
+
* Silently emitting a smaller environment would produce a frontend that builds
|
|
401
|
+
* and then fails against `http:///`, with nothing to point at.
|
|
402
|
+
*/
|
|
403
|
+
export function siteEnvironment(
|
|
404
|
+
declaration: SiteDeclaration,
|
|
405
|
+
context: ProvisionContext,
|
|
406
|
+
): Record<string, $util.Input<string>> {
|
|
407
|
+
const prefix = PUBLIC_PREFIX[declaration.variant];
|
|
408
|
+
const environment: Record<string, $util.Input<string>> = {};
|
|
409
|
+
|
|
410
|
+
for (const edge of declaration.dependencies) {
|
|
411
|
+
const target = context.manifest[edge.target];
|
|
412
|
+
if (!target) continue;
|
|
413
|
+
|
|
414
|
+
const roles = PUBLIC[target.kind] ?? [];
|
|
415
|
+
if (roles.length === 0) continue;
|
|
416
|
+
|
|
417
|
+
const component = context.provisioned[edge.target];
|
|
418
|
+
if (!component) {
|
|
419
|
+
throw new UnresolvedDependency(
|
|
420
|
+
edge.target,
|
|
421
|
+
Object.keys(context.provisioned),
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const provided = component.provides();
|
|
426
|
+
for (const role of roles) {
|
|
427
|
+
const value = provided[role as string];
|
|
428
|
+
if (value === undefined) continue;
|
|
429
|
+
|
|
430
|
+
environment[
|
|
431
|
+
`${prefix}${providedKeyFor(edge.target, target.kind, role as string)}`
|
|
432
|
+
] = value;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return environment;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Resolve a derived database node against the cluster its parent became.
|
|
441
|
+
*
|
|
442
|
+
* A reader and a schema tenant provision nothing, so the whole of their
|
|
443
|
+
* provisioning is finding the parent — which `provisionOrder` guarantees is
|
|
444
|
+
* already there, and `assertDerivations` guarantees exists at all.
|
|
445
|
+
*
|
|
446
|
+
* @throws {UnresolvedDependency} if neither guarantee held, which would mean the
|
|
447
|
+
* manifest reached here without its own validation having run.
|
|
448
|
+
*/
|
|
449
|
+
function derived(
|
|
450
|
+
declaration: Declaration,
|
|
451
|
+
context: ProvisionContext,
|
|
452
|
+
build: (id: string, parent: Database) => Provisioned,
|
|
453
|
+
): Provisioned {
|
|
454
|
+
if (!('of' in declaration)) {
|
|
455
|
+
throw new UnknownDeclarationKind(declaration.kind, []);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const parent = context.provisioned[declaration.of];
|
|
459
|
+
if (!parent) {
|
|
460
|
+
throw new UnresolvedDependency(
|
|
461
|
+
declaration.of,
|
|
462
|
+
Object.keys(context.provisioned),
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// A tenant may derive from another tenant, and what both ultimately need is
|
|
467
|
+
// the cluster underneath. `DerivedDatabase` holds its parent, so walking up
|
|
468
|
+
// is following the same chain `provisionOrder` walked to get here.
|
|
469
|
+
return build(declaration.id, rootCluster(parent));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** The declaration a derived node hangs off. */
|
|
473
|
+
function parentOf(
|
|
474
|
+
declaration: Declaration,
|
|
475
|
+
context: ProvisionContext,
|
|
476
|
+
): Declaration {
|
|
477
|
+
if (!('of' in declaration)) return declaration;
|
|
478
|
+
|
|
479
|
+
return context.manifest[declaration.of] ?? declaration;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The id of the cluster at the bottom of a chain of derived nodes.
|
|
484
|
+
*
|
|
485
|
+
* A tenant may derive from another tenant, and what both ultimately live in is
|
|
486
|
+
* one database — so this follows `of` to the end rather than reading the
|
|
487
|
+
* immediate parent.
|
|
488
|
+
*/
|
|
489
|
+
function rootId(declaration: Declaration, context: ProvisionContext): string {
|
|
490
|
+
let current = declaration;
|
|
491
|
+
|
|
492
|
+
while ('of' in current) {
|
|
493
|
+
const parent = context.manifest[current.of];
|
|
494
|
+
if (!parent) break;
|
|
495
|
+
current = parent;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return current.id;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* The runtime role a node connects as: its own id, lowercased.
|
|
503
|
+
*
|
|
504
|
+
* The same rule the local target uses, so a role a developer sees in `\du` is
|
|
505
|
+
* the role that exists in production. Roles are cluster-scoped, so two stages
|
|
506
|
+
* sharing a cluster would collide — which is why a deployed stage gets its own.
|
|
507
|
+
*/
|
|
508
|
+
function roleNameFor(
|
|
509
|
+
declaration: Declaration,
|
|
510
|
+
_context: ProvisionContext,
|
|
511
|
+
): string {
|
|
512
|
+
return declaration.id.toLowerCase();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Whether anything in the manifest reads through this node. */
|
|
516
|
+
function hasReader(id: string, context: ProvisionContext): boolean {
|
|
517
|
+
return Object.values(context.manifest).some(
|
|
518
|
+
(declaration) =>
|
|
519
|
+
declaration.kind === 'database-reader' && declaration.of === id,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The bootstrap for a cluster, created on first use.
|
|
525
|
+
*
|
|
526
|
+
* One per cluster rather than one per tenant: the DDL for every tenant runs on
|
|
527
|
+
* the same connection, as the same master, so a function each would be the same
|
|
528
|
+
* work done N times with N cold starts.
|
|
529
|
+
*/
|
|
530
|
+
function bootstrapFor(
|
|
531
|
+
cluster: Database,
|
|
532
|
+
clusterId: string,
|
|
533
|
+
context: ProvisionContext,
|
|
534
|
+
): DatabaseBootstrap | undefined {
|
|
535
|
+
// `roles: false` is the documented downgrade: no roles, and both URLs fall
|
|
536
|
+
// back to the master. Nothing to bootstrap.
|
|
537
|
+
const declaration = context.manifest[clusterId];
|
|
538
|
+
if (declaration && 'roles' in declaration && declaration.roles === false) {
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const existing = context.bootstraps.get(clusterId);
|
|
543
|
+
if (existing) return existing;
|
|
544
|
+
|
|
545
|
+
const created = new DatabaseBootstrap(clusterId, cluster);
|
|
546
|
+
context.bootstraps.set(clusterId, created);
|
|
547
|
+
|
|
548
|
+
return created;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** The `Database` at the bottom of a chain of derived nodes. */
|
|
552
|
+
function rootCluster(component: Provisioned): Database {
|
|
553
|
+
const parent = (component as { parent?: Provisioned }).parent;
|
|
554
|
+
|
|
555
|
+
return parent ? rootCluster(parent) : (component as unknown as Database);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Every route and exactly what it depends on, as lines to print.
|
|
560
|
+
*
|
|
561
|
+
* The design's central claim is that a function is linked to the constructs it
|
|
562
|
+
* declared and nothing else — least privilege falling out of the graph rather
|
|
563
|
+
* than out of discipline. That is easy to assert in a test and invisible during
|
|
564
|
+
* a deploy, which is the moment somebody would want to check it. So it is
|
|
565
|
+
* printed: one line per route, naming what it can reach.
|
|
566
|
+
*
|
|
567
|
+
* A surface with no routes says so rather than printing nothing, because "no
|
|
568
|
+
* routes yet" and "this printed nothing" look identical otherwise — and for an
|
|
569
|
+
* application's own API that is currently the true state, pending the endpoint
|
|
570
|
+
* merge.
|
|
571
|
+
*
|
|
572
|
+
* Pure, so what gets printed can be asserted without a deploy.
|
|
573
|
+
*/
|
|
574
|
+
export function describeRoutes(manifest: ConstructManifest): string[] {
|
|
575
|
+
const lines: string[] = [];
|
|
576
|
+
|
|
577
|
+
for (const [id, declaration] of Object.entries(manifest)) {
|
|
578
|
+
if (declaration.kind !== 'rest-api') continue;
|
|
579
|
+
|
|
580
|
+
lines.push(`${id}:`);
|
|
581
|
+
|
|
582
|
+
if (declaration.endpoints.length === 0) {
|
|
583
|
+
// Empty is now genuinely empty: the build folds discovered routes into
|
|
584
|
+
// the surface that serves them, so a surface with none has none.
|
|
585
|
+
lines.push(' (no routes)');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
for (const endpoint of declaration.endpoints) {
|
|
589
|
+
const reaches = endpoint.dependencies
|
|
590
|
+
.map((edge) => `${edge.target} (${edge.kind})`)
|
|
591
|
+
.join(', ');
|
|
592
|
+
|
|
593
|
+
lines.push(
|
|
594
|
+
` ${endpoint.method} ${endpoint.path} → ${reaches || 'nothing'}`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// A caller relationship, printed apart from the routes because it grants
|
|
599
|
+
// nothing — see `RestApiDeclaration.calls`.
|
|
600
|
+
if (declaration.calls?.length) {
|
|
601
|
+
lines.push(
|
|
602
|
+
` calls ${declaration.calls.map((edge) => edge.target).join(', ')} (origin only; grants nothing)`,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return lines;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** A promise with its settle function, for a value that arrives later. */
|
|
611
|
+
function defer(): Deferred {
|
|
612
|
+
let resolve!: Deferred['resolve'];
|
|
613
|
+
const promise = new Promise<{
|
|
614
|
+
trustedOrigins: string;
|
|
615
|
+
cookieDomain?: string;
|
|
616
|
+
}>((settle) => {
|
|
617
|
+
resolve = settle;
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
return { promise, resolve };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Who may call one surface, and the domain they can share a cookie on.
|
|
625
|
+
*
|
|
626
|
+
* The same derivation the local target runs, from the same two functions —
|
|
627
|
+
* `dependentsOf` reads the graph backwards and `cookieDomain` finds the shared
|
|
628
|
+
* parent. What differs is only where an address comes from: a published port
|
|
629
|
+
* there, a provisioned component here.
|
|
630
|
+
*
|
|
631
|
+
* A surface is left off its own origin list: it does not need permission to call
|
|
632
|
+
* itself, and adding it would make every surface trust every other one sharing a
|
|
633
|
+
* host.
|
|
634
|
+
*/
|
|
635
|
+
async function callersOf(
|
|
636
|
+
id: string,
|
|
637
|
+
manifest: ConstructManifest,
|
|
638
|
+
provisioned: ProvisionedManifest,
|
|
639
|
+
): Promise<{ trustedOrigins: string; cookieDomain?: string }> {
|
|
640
|
+
// Every address here is a Pulumi output, not a string — a CloudFront domain
|
|
641
|
+
// and an API Gateway endpoint are both known only after their resource
|
|
642
|
+
// exists. An earlier version filtered for `typeof url === 'string'` and so
|
|
643
|
+
// filtered out *everything*, which deployed cleanly with an empty
|
|
644
|
+
// trusted-origin list: the failure mode this whole derivation exists to
|
|
645
|
+
// avoid, arrived at by a type guard that looked defensive.
|
|
646
|
+
const urls = await Promise.all(
|
|
647
|
+
dependentsOf(manifest, id).map((caller) =>
|
|
648
|
+
resolved(provisioned[caller]?.provides().url),
|
|
649
|
+
),
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
const origins = [
|
|
653
|
+
...new Set(
|
|
654
|
+
urls
|
|
655
|
+
.map((url) => (url ? originOf(url) : undefined))
|
|
656
|
+
.filter((origin): origin is string => Boolean(origin)),
|
|
657
|
+
),
|
|
658
|
+
].sort();
|
|
659
|
+
|
|
660
|
+
const own = await resolved(provisioned[id]?.provides().url);
|
|
661
|
+
const domain = cookieDomain([...(own ? [own] : []), ...origins]);
|
|
662
|
+
|
|
663
|
+
return {
|
|
664
|
+
trustedOrigins: origins.join(','),
|
|
665
|
+
...(domain ? { cookieDomain: domain } : {}),
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* A Pulumi input as the string it eventually is.
|
|
671
|
+
*
|
|
672
|
+
* `$util.output(…).apply()` is how a value is read, and the promise it returns
|
|
673
|
+
* is what makes the caller list resolvable at all — the whole reason a surface's
|
|
674
|
+
* origins are deferred rather than computed at construction.
|
|
675
|
+
*/
|
|
676
|
+
function resolved(
|
|
677
|
+
value: $util.Input<string> | undefined,
|
|
678
|
+
): Promise<string | undefined> {
|
|
679
|
+
if (value === undefined) return Promise.resolve(undefined);
|
|
680
|
+
if (typeof value === 'string') return Promise.resolve(value);
|
|
681
|
+
|
|
682
|
+
return new Promise((settle) => {
|
|
683
|
+
$util.output(value).apply((url) => {
|
|
684
|
+
settle(url);
|
|
685
|
+
return url;
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** An address reduced to the origin a browser compares against. */
|
|
691
|
+
function originOf(address: string): string | undefined {
|
|
692
|
+
try {
|
|
693
|
+
return new URL(address).origin;
|
|
694
|
+
} catch {
|
|
695
|
+
return undefined;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Whether anything in the manifest serves this bucket.
|
|
701
|
+
*
|
|
702
|
+
* The question the design's chosen shape makes you ask. Under a `cdn: true`
|
|
703
|
+
* flag you read one declaration; here you find whoever points at it — which is
|
|
704
|
+
* a real regression in auditability, answered by making the lookup one function
|
|
705
|
+
* that every consumer shares rather than something each caller re-derives.
|
|
706
|
+
*/
|
|
707
|
+
export function isServed(id: string, manifest: ConstructManifest): boolean {
|
|
708
|
+
return Object.values(manifest).some(
|
|
709
|
+
(declaration) =>
|
|
710
|
+
declaration.kind === 'file-server' && declaration.of === id,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** The provisioner for a kind. Pure — the lookup is testable without Pulumi. */
|
|
715
|
+
export function provisionerFor(kind: DeclarationKind): Provisioner {
|
|
716
|
+
const provisioner = PROVISIONERS[kind];
|
|
717
|
+
if (!provisioner) {
|
|
718
|
+
throw new UnknownDeclarationKind(kind, Object.keys(PROVISIONERS));
|
|
719
|
+
}
|
|
720
|
+
return provisioner;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Assert that a link yields exactly the env keys the app declared.
|
|
725
|
+
*
|
|
726
|
+
* `supplied` comes from `resolveEnvKeys`, which derives the keys a resource type
|
|
727
|
+
* produces — the same derivation that runs for real. The app↔infra contract is
|
|
728
|
+
* the one guarantee spanning two packages, two build phases, and two authors,
|
|
729
|
+
* and the one a JavaScript consumer gets no compiler help with, so it is checked
|
|
730
|
+
* at synth rather than trusted.
|
|
731
|
+
*/
|
|
732
|
+
export function assertProvides(
|
|
733
|
+
id: string,
|
|
734
|
+
declared: readonly string[] = [],
|
|
735
|
+
supplied: readonly string[] = [],
|
|
736
|
+
): void {
|
|
737
|
+
const missing = declared.filter((key) => !supplied.includes(key));
|
|
738
|
+
const extra = supplied.filter((key) => !declared.includes(key));
|
|
739
|
+
|
|
740
|
+
if (missing.length || extra.length) {
|
|
741
|
+
throw new ProvidesMismatch(id, missing, extra);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Provision everything the manifest declares.
|
|
747
|
+
*
|
|
748
|
+
* In `provisionOrder`, which puts every parent before its children — a schema
|
|
749
|
+
* tenant, a read replica, and the surface over a bucket all resolve an address
|
|
750
|
+
* off something else, and a pass in map order would find it half the time.
|
|
751
|
+
*/
|
|
752
|
+
export function fromManifest(
|
|
753
|
+
stack: StackType,
|
|
754
|
+
manifest: ConstructManifest,
|
|
755
|
+
overrides: ComponentOverrides = {},
|
|
756
|
+
/**
|
|
757
|
+
* The backend choices that are config rather than declaration.
|
|
758
|
+
*
|
|
759
|
+
* Defaulted the same way the local target defaults them, so a stage deployed
|
|
760
|
+
* without saying gets the same backend a developer ran against.
|
|
761
|
+
*/
|
|
762
|
+
backends: {
|
|
763
|
+
cache?: 'upstash' | 'elasticache' | 'db';
|
|
764
|
+
email?: 'resend' | 'ses' | 'smtp';
|
|
765
|
+
} = {},
|
|
766
|
+
): ProvisionedManifest {
|
|
767
|
+
const provisioned: ProvisionedManifest = {};
|
|
768
|
+
|
|
769
|
+
// In provisioning order, so a derived node finds the component its parent
|
|
770
|
+
// became. `assertDerivations` has already ruled out a missing parent, so the
|
|
771
|
+
// order is total rather than best-effort.
|
|
772
|
+
//
|
|
773
|
+
// Sites come last, and separately, because `provisionOrder` orders `of` and
|
|
774
|
+
// not `dependencies` — a site needs its edges' *values* at construction
|
|
775
|
+
// time, since a static site has no server half to read a link at runtime.
|
|
776
|
+
// It is a pure consumer of addresses, so building it after everything else
|
|
777
|
+
// is enough; the day a kind needs a site's URL at construction, this needs a
|
|
778
|
+
// real topological sort rather than a second pass.
|
|
779
|
+
const order = provisionOrder(manifest);
|
|
780
|
+
const sites = order.filter((id) => manifest[id]?.kind === 'site');
|
|
781
|
+
const rest = order.filter((id) => manifest[id]?.kind !== 'site');
|
|
782
|
+
|
|
783
|
+
// One per surface, created up front so a surface can be handed its own before
|
|
784
|
+
// the things that call it exist.
|
|
785
|
+
const callers = new Map<string, Deferred>();
|
|
786
|
+
for (const [id, declaration] of Object.entries(manifest)) {
|
|
787
|
+
if (declaration.kind === 'rest-api') callers.set(id, defer());
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const context: ProvisionContext = {
|
|
791
|
+
manifest,
|
|
792
|
+
provisioned,
|
|
793
|
+
callers,
|
|
794
|
+
bootstraps: new Map<string, DatabaseBootstrap>(),
|
|
795
|
+
...(backends.cache ? { cache: backends.cache } : {}),
|
|
796
|
+
...(backends.email ? { email: backends.email } : {}),
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
for (const id of [...rest, ...sites]) {
|
|
800
|
+
const declaration = manifest[id];
|
|
801
|
+
if (!declaration) continue;
|
|
802
|
+
|
|
803
|
+
const component = provisionerFor(declaration.kind)(
|
|
804
|
+
stack,
|
|
805
|
+
declaration,
|
|
806
|
+
overrides[id] ?? {},
|
|
807
|
+
context,
|
|
808
|
+
);
|
|
809
|
+
|
|
810
|
+
assertProvides(
|
|
811
|
+
id,
|
|
812
|
+
declaration.provides,
|
|
813
|
+
// A role becomes the env key the app declared: `url` → `UPLOADS_URL`.
|
|
814
|
+
// Through the shared derivation, not a local copy of it — a secret's
|
|
815
|
+
// name *is* its key, and a check that derived it differently from the
|
|
816
|
+
// thing being checked would pass on drift instead of catching it.
|
|
817
|
+
Object.keys(component.provides()).map((role) =>
|
|
818
|
+
providedKeyFor(id, declaration.kind, role),
|
|
819
|
+
),
|
|
820
|
+
);
|
|
821
|
+
|
|
822
|
+
provisioned[id] = component;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
for (const line of describeRoutes(manifest)) console.log(line);
|
|
826
|
+
|
|
827
|
+
// Now everything exists, so every surface's callers can be resolved — the
|
|
828
|
+
// site's address included, which is what the deferral was for.
|
|
829
|
+
for (const [id, deferred] of callers) {
|
|
830
|
+
// Resolved with the promise itself: the addresses it needs are Pulumi
|
|
831
|
+
// outputs, so the answer arrives when they do.
|
|
832
|
+
deferred.resolve(callersOf(id, manifest, provisioned));
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Last, and only now: the roles exist as passwords and secrets from the
|
|
836
|
+
// moment each tenant was provisioned, but nothing has run `CREATE ROLE`.
|
|
837
|
+
// Pulumi cannot — so one function per cluster does, inside the VPC, invoked
|
|
838
|
+
// with an input that changes only when the roles or the cluster do.
|
|
839
|
+
for (const [clusterId, bootstrap] of context.bootstraps) {
|
|
840
|
+
const cluster = provisioned[clusterId] as unknown as Database | undefined;
|
|
841
|
+
if (cluster) bootstrap.run(cluster.vpc);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return provisioned;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** What one function receives from its edges. */
|
|
848
|
+
export interface ResolvedEdges {
|
|
849
|
+
/** The components it may reach. SST turns these into IAM *and* injects their
|
|
850
|
+
* properties, so this is the whole delivery mechanism. */
|
|
851
|
+
link: Provisioned[];
|
|
852
|
+
/**
|
|
853
|
+
* The env keys those links yield.
|
|
854
|
+
*
|
|
855
|
+
* Not values: the values are injected at runtime by the link, and a map of
|
|
856
|
+
* key→placeholder would be a lie that ships. Keys are what the adapter needs,
|
|
857
|
+
* so a function's `requires` can be checked against what its own edges cover.
|
|
858
|
+
*/
|
|
859
|
+
envKeys: string[];
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Resolve one function's dependencies into what it is given.
|
|
864
|
+
*
|
|
865
|
+
* A function is linked to exactly the constructs it declared, never the app's
|
|
866
|
+
* full set — so least privilege falls out of the edges rather than out of
|
|
867
|
+
* discipline. Adding an unrelated construct to the manifest cannot widen what
|
|
868
|
+
* an existing function can reach, which is the property worth testing as an
|
|
869
|
+
* exclusion.
|
|
870
|
+
*
|
|
871
|
+
* Pure: given a provisioned map, this is data in and data out, so the whole
|
|
872
|
+
* filtering rule is assertable without a deploy.
|
|
873
|
+
*/
|
|
874
|
+
export function resolveEdges(
|
|
875
|
+
dependencies: readonly Dependency[] = [],
|
|
876
|
+
provisioned: ProvisionedManifest,
|
|
877
|
+
): ResolvedEdges {
|
|
878
|
+
const link: Provisioned[] = [];
|
|
879
|
+
const envKeys = new Set<string>();
|
|
880
|
+
|
|
881
|
+
for (const dependency of dependencies) {
|
|
882
|
+
const component = provisioned[dependency.target];
|
|
883
|
+
if (!component) {
|
|
884
|
+
throw new UnresolvedDependency(
|
|
885
|
+
dependency.target,
|
|
886
|
+
Object.keys(provisioned),
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
link.push(component);
|
|
891
|
+
for (const key of resolveEnvKeys({
|
|
892
|
+
[dependency.target]: { type: component._type as string },
|
|
893
|
+
})) {
|
|
894
|
+
envKeys.add(key);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
return { link, envKeys: [...envKeys].sort() };
|
|
899
|
+
}
|