@geekmidas/cloud 9.0.2 → 10.0.0-alpha.1
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,309 @@
|
|
|
1
|
+
import * as aws from '@pulumi/aws';
|
|
2
|
+
import { secretsmanager } from '@pulumi/aws';
|
|
3
|
+
import { RandomPassword } from '@pulumi/random';
|
|
4
|
+
import type { BootstrapTenant } from './bootstrap/handler';
|
|
5
|
+
import type { Database } from './Database';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The roles a tenant needs, provisioned — and the function that creates them.
|
|
9
|
+
*
|
|
10
|
+
* Two halves that only make sense together. Pulumi can generate a password and
|
|
11
|
+
* store it; it cannot run `CREATE ROLE`. So the passwords are resources here and
|
|
12
|
+
* the DDL is a Lambda invoked once per deploy, inside the VPC, connecting as the
|
|
13
|
+
* cluster master — the only credential that exists before any role does.
|
|
14
|
+
*
|
|
15
|
+
* **One secret for the cluster, holding every role in it.** The obvious worry —
|
|
16
|
+
* that a shared secret lets anything holding it connect as any role — describes
|
|
17
|
+
* a read that does not happen. No function fetches a credential: each node's URL
|
|
18
|
+
* is carried in its own link (`getSSTLink` publishes `provides()` and nothing
|
|
19
|
+
* else), so a handler is *given* exactly its own role and has no IAM to read
|
|
20
|
+
* Secrets Manager at all. Splitting the secret would not narrow runtime
|
|
21
|
+
* privilege, because runtime never touches it.
|
|
22
|
+
*
|
|
23
|
+
* What the secret is actually for is out-of-band access — a person with
|
|
24
|
+
* break-glass, an external tool, a rotation job. That is where the case for
|
|
25
|
+
* splitting it lives, and it is a real one: per-tenant secrets are what let IAM
|
|
26
|
+
* grant somebody the reporting role's password without also handing them the
|
|
27
|
+
* auth schema's. Nothing here needs that yet, and one secret is one resource and
|
|
28
|
+
* one place to look, so it stays one until somebody has that second reader.
|
|
29
|
+
*
|
|
30
|
+
* :::caution
|
|
31
|
+
* Not verified against a live deploy. The shape is sound and every decision in
|
|
32
|
+
* it is asserted as data, but no stack has come up.
|
|
33
|
+
* :::
|
|
34
|
+
*/
|
|
35
|
+
export class DatabaseBootstrap {
|
|
36
|
+
/** The generated passwords, by role name. */
|
|
37
|
+
private readonly passwords = new Map<string, $util.Output<string>>();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The cluster's credentials, for out-of-band use.
|
|
41
|
+
*
|
|
42
|
+
* Created by `run()` rather than by `add()`, because it holds every tenant
|
|
43
|
+
* and there is no every-tenant until the last one is added.
|
|
44
|
+
*/
|
|
45
|
+
secret?: secretsmanager.Secret;
|
|
46
|
+
|
|
47
|
+
private readonly tenants: BootstrapTenant[] = [];
|
|
48
|
+
|
|
49
|
+
constructor(
|
|
50
|
+
private readonly name: string,
|
|
51
|
+
private readonly cluster: Database,
|
|
52
|
+
) {}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Add a tenant to bootstrap, and provision its credentials.
|
|
56
|
+
*
|
|
57
|
+
* Returns the runtime password so the tenant's URL can carry it. The value is
|
|
58
|
+
* a Pulumi output either way — it exists in state whether or not it is also
|
|
59
|
+
* in Secrets Manager — so putting it in the URL adds no exposure the link did
|
|
60
|
+
* not already have.
|
|
61
|
+
*/
|
|
62
|
+
add(tenant: {
|
|
63
|
+
id: string;
|
|
64
|
+
schema: string;
|
|
65
|
+
runtime: string;
|
|
66
|
+
owner: string;
|
|
67
|
+
reader?: string;
|
|
68
|
+
}): { runtime: $util.Output<string>; reader?: $util.Output<string> } {
|
|
69
|
+
const runtime = this.password(tenant.runtime);
|
|
70
|
+
const reader = tenant.reader ? this.password(tenant.reader) : undefined;
|
|
71
|
+
|
|
72
|
+
this.tenants.push({
|
|
73
|
+
id: tenant.id,
|
|
74
|
+
schema: tenant.schema,
|
|
75
|
+
runtime: tenant.runtime,
|
|
76
|
+
owner: tenant.owner,
|
|
77
|
+
...(tenant.reader ? { reader: tenant.reader } : {}),
|
|
78
|
+
// Filled in by `bootstrapEvent` once the outputs resolve. The function
|
|
79
|
+
// reads them straight out of its input and never fetches a secret, so
|
|
80
|
+
// it needs no IAM to read one.
|
|
81
|
+
passwords: { runtime: '', owner: '' },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return { runtime, ...(reader ? { reader } : {}) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The event this bootstrap should be invoked with.
|
|
89
|
+
*
|
|
90
|
+
* Kept as data rather than performed here, so the caller decides *when* — and
|
|
91
|
+
* so the decision is assertable without Pulumi. Feed it to a function
|
|
92
|
+
* invocation whose input changes when this changes, which is what makes a
|
|
93
|
+
* re-deploy re-apply only when something actually moved.
|
|
94
|
+
*/
|
|
95
|
+
event(): $util.Output<string> {
|
|
96
|
+
// Every password, flat, so one `all` resolves them and the shape is put
|
|
97
|
+
// back together by a pure function that can be asserted without Pulumi.
|
|
98
|
+
const roles = [...this.passwords.keys()];
|
|
99
|
+
|
|
100
|
+
return $util
|
|
101
|
+
.all([
|
|
102
|
+
this.cluster.host as $util.Input<unknown>,
|
|
103
|
+
this.cluster.port,
|
|
104
|
+
this.cluster.database,
|
|
105
|
+
this.cluster.username,
|
|
106
|
+
this.cluster.password,
|
|
107
|
+
...roles.map((role) => this.passwords.get(role)!),
|
|
108
|
+
] as $util.Input<unknown>[])
|
|
109
|
+
.apply((resolved) => {
|
|
110
|
+
const [host, port, database, username, password, ...secrets] =
|
|
111
|
+
resolved as unknown as [
|
|
112
|
+
string,
|
|
113
|
+
number,
|
|
114
|
+
string,
|
|
115
|
+
string,
|
|
116
|
+
string,
|
|
117
|
+
...string[],
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
return bootstrapEvent(
|
|
121
|
+
{ host, port, database, username, password },
|
|
122
|
+
this.tenants,
|
|
123
|
+
new Map(roles.map((role, index) => [role, secrets[index] as string])),
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The read-only credential for a runtime role, once its tenant is added.
|
|
130
|
+
*
|
|
131
|
+
* Returns nothing where no reader was provisioned — a reader role is created
|
|
132
|
+
* only where something points at one, so asking for one that was never asked
|
|
133
|
+
* for is a question with a real answer rather than an error.
|
|
134
|
+
*/
|
|
135
|
+
readerFor(
|
|
136
|
+
runtime: string,
|
|
137
|
+
): { user: string; password: $util.Output<string> } | undefined {
|
|
138
|
+
const role = `${runtime}_reader`;
|
|
139
|
+
const password = this.passwords.get(role);
|
|
140
|
+
|
|
141
|
+
return password ? { user: role, password } : undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Whether anything needs bootstrapping at all. */
|
|
145
|
+
get empty(): boolean {
|
|
146
|
+
return this.tenants.length === 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Create the function and invoke it once for this deploy.
|
|
151
|
+
*
|
|
152
|
+
* The invocation's input is `event()`, so it re-runs when — and only when —
|
|
153
|
+
* the roles, the schemas, or the cluster's address actually change. A deploy
|
|
154
|
+
* that touched neither costs nothing, which is what makes running this on
|
|
155
|
+
* every deploy acceptable rather than something to remember.
|
|
156
|
+
*
|
|
157
|
+
* @param vpc the database's VPC. The function has to reach the database, and
|
|
158
|
+
* a database reachable from outside its VPC is the problem this avoids.
|
|
159
|
+
*
|
|
160
|
+
* The component rather than a component's `vpc` argument: a function in a VPC
|
|
161
|
+
* needs subnets *and* security groups, and the two database components spell
|
|
162
|
+
* their argument differently — a cluster asks for both, an instance asks only
|
|
163
|
+
* for subnets. Naming the shared thing avoids inheriting whichever spelling
|
|
164
|
+
* the database happens to use, which is how this came to be typed as
|
|
165
|
+
* something that could not carry a security group.
|
|
166
|
+
*/
|
|
167
|
+
run(vpc: sst.aws.Vpc): void {
|
|
168
|
+
if (this.empty) return;
|
|
169
|
+
|
|
170
|
+
this.storeCredentials();
|
|
171
|
+
|
|
172
|
+
const fn = new sst.aws.Function(`${this.name}Bootstrap`, {
|
|
173
|
+
// Shipped by this package rather than by the application: the DDL is
|
|
174
|
+
// the framework's, and an app that had to carry a bootstrap handler
|
|
175
|
+
// could get it wrong.
|
|
176
|
+
handler: HANDLER,
|
|
177
|
+
runtime: 'nodejs24.x',
|
|
178
|
+
// Long enough for a database that is still coming up to answer —
|
|
179
|
+
// `min: 0 ACU` means the first connection of the day pays for a
|
|
180
|
+
// resume, and timing out there would leave a half-bootstrapped schema.
|
|
181
|
+
timeout: '5 minutes',
|
|
182
|
+
vpc,
|
|
183
|
+
nodejs: { install: ['pg'] },
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
new aws.lambda.Invocation(`${this.name}BootstrapRun`, {
|
|
187
|
+
functionName: fn.name,
|
|
188
|
+
input: this.event(),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Write every role's password to one secret, for out-of-band use.
|
|
194
|
+
*
|
|
195
|
+
* Nothing at runtime reads this — a handler is given its own URL through its
|
|
196
|
+
* link — so this exists for a person with break-glass, an external tool, or a
|
|
197
|
+
* rotation job. Keyed by role name rather than by tenant, because a role name
|
|
198
|
+
* is what somebody at a `psql` prompt actually has in hand.
|
|
199
|
+
*
|
|
200
|
+
* Note what this is *not*: the source of truth. The passwords are generated
|
|
201
|
+
* here and this records them. Making the secret authoritative — read at
|
|
202
|
+
* deploy, so rotating it in Secrets Manager propagates on the next deploy —
|
|
203
|
+
* is a better rotation story and a different design: it needs a first-deploy
|
|
204
|
+
* seed, and changing a value there does not change the role until the DDL
|
|
205
|
+
* runs again.
|
|
206
|
+
*/
|
|
207
|
+
private storeCredentials(): void {
|
|
208
|
+
this.secret = new secretsmanager.Secret(`${this.name}Credentials`, {
|
|
209
|
+
// A name somebody can find. `namePrefix` was the first attempt and it
|
|
210
|
+
// produced `KitchenSink/roles/20260830202841251200000001` — unique,
|
|
211
|
+
// and useless to the person doing break-glass at 3am, which is the
|
|
212
|
+
// only reason this secret exists.
|
|
213
|
+
name: `${$app.name}/${$app.stage}/${this.name}/roles`,
|
|
214
|
+
description: `Database roles for ${this.name} (${$app.stage})`,
|
|
215
|
+
// Rotating a password *updates* this secret — a new version on the
|
|
216
|
+
// same resource — so none of this applies to normal operation. It
|
|
217
|
+
// applies to one flow: tearing a stage down and standing it back up.
|
|
218
|
+
// Secrets Manager holds a deleted secret for a recovery window and
|
|
219
|
+
// keeps its name reserved for the duration, so the recreate fails on
|
|
220
|
+
// a name pointing at something nobody can see. That flow is what a
|
|
221
|
+
// disposable stage *is*, so those waive the window; a retained app
|
|
222
|
+
// keeps the default, because there the recovery window is the point.
|
|
223
|
+
...($app.removal === 'remove' ? { recoveryWindowInDays: 0 } : {}),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const roles = [...this.passwords.keys()];
|
|
227
|
+
|
|
228
|
+
new secretsmanager.SecretVersion(`${this.name}CredentialsValue`, {
|
|
229
|
+
secretId: this.secret.id,
|
|
230
|
+
secretString: $util
|
|
231
|
+
.all(roles.map((role) => this.passwords.get(role)!))
|
|
232
|
+
.apply((values) =>
|
|
233
|
+
JSON.stringify(
|
|
234
|
+
Object.fromEntries(
|
|
235
|
+
roles.map((role, index) => [role, values[index]]),
|
|
236
|
+
),
|
|
237
|
+
),
|
|
238
|
+
),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private password(role: string): $util.Output<string> {
|
|
243
|
+
const existing = this.passwords.get(role);
|
|
244
|
+
if (existing) return existing;
|
|
245
|
+
|
|
246
|
+
const generated = new RandomPassword(`${this.name}${role}Password`, {
|
|
247
|
+
length: 32,
|
|
248
|
+
// Postgres accepts these in a password; a `'` would need escaping in
|
|
249
|
+
// DDL and a `/` breaks a URL's authority section, so neither is worth
|
|
250
|
+
// the risk for entropy this module has plenty of.
|
|
251
|
+
special: true,
|
|
252
|
+
overrideSpecial: '-_',
|
|
253
|
+
}).result;
|
|
254
|
+
|
|
255
|
+
this.passwords.set(role, generated);
|
|
256
|
+
|
|
257
|
+
return generated;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Where the bootstrap handler lives, from the application's root.
|
|
263
|
+
*
|
|
264
|
+
* A path into this package's own published files, so an app that installed
|
|
265
|
+
* `@geekmidas/cloud` gets the handler without copying it — and so a fix to the
|
|
266
|
+
* DDL reaches every app on the next upgrade rather than the next time somebody
|
|
267
|
+
* remembers.
|
|
268
|
+
*
|
|
269
|
+
* `src/`, not `dist/`: `src/sst/**` is published as raw TypeScript on purpose,
|
|
270
|
+
* because it extends ambient globals that only exist inside a consuming SST
|
|
271
|
+
* app. SST bundles the handler with esbuild at deploy, which is the same thing
|
|
272
|
+
* it does for the application's own handlers.
|
|
273
|
+
*/
|
|
274
|
+
const HANDLER =
|
|
275
|
+
'node_modules/@geekmidas/cloud/src/sst/aws/bootstrap/handler.handler';
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The bootstrap's input, assembled from resolved values.
|
|
279
|
+
*
|
|
280
|
+
* Pure, and separate from the component for the reason every decision in this
|
|
281
|
+
* package is: composing it needs no Pulumi, so what the function will be handed
|
|
282
|
+
* can be asserted without a deploy — including that it is exactly what
|
|
283
|
+
* `roleStatements` accepts, which is the contract between the two halves.
|
|
284
|
+
*/
|
|
285
|
+
export function bootstrapEvent(
|
|
286
|
+
master: {
|
|
287
|
+
host: string;
|
|
288
|
+
port: number;
|
|
289
|
+
database: string;
|
|
290
|
+
username: string;
|
|
291
|
+
password: string;
|
|
292
|
+
},
|
|
293
|
+
tenants: readonly BootstrapTenant[],
|
|
294
|
+
passwords: ReadonlyMap<string, string>,
|
|
295
|
+
): string {
|
|
296
|
+
return JSON.stringify({
|
|
297
|
+
master,
|
|
298
|
+
tenants: tenants.map((tenant) => ({
|
|
299
|
+
...tenant,
|
|
300
|
+
passwords: {
|
|
301
|
+
runtime: passwords.get(tenant.runtime) ?? '',
|
|
302
|
+
owner: passwords.get(tenant.owner) ?? '',
|
|
303
|
+
...(tenant.reader
|
|
304
|
+
? { reader: passwords.get(tenant.reader) ?? '' }
|
|
305
|
+
: {}),
|
|
306
|
+
},
|
|
307
|
+
})),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { type GkmLinkable, ResourceType } from '../Linkable';
|
|
2
|
+
import type { Database } from './Database';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The two database kinds that provision nothing: a reader, and a schema tenant.
|
|
6
|
+
*
|
|
7
|
+
* Neither is an AWS resource. A reader is an endpoint the cluster already has,
|
|
8
|
+
* and a tenant is a schema *inside* the parent's database, created by DDL that
|
|
9
|
+
* `gkm` runs rather than by anything Pulumi declares. What they contribute is a
|
|
10
|
+
* URL — a different endpoint, or the same one with a different `search_path` —
|
|
11
|
+
* and the identity that lets an edge point at them.
|
|
12
|
+
*
|
|
13
|
+
* That makes them the first `Provisioned`s that wrap no component. They are
|
|
14
|
+
* still linkable, because a function depending on one still needs its URL
|
|
15
|
+
* injected, and the link is what carries it.
|
|
16
|
+
*/
|
|
17
|
+
abstract class DerivedDatabase implements GkmLinkable {
|
|
18
|
+
readonly _id: string;
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
id: string,
|
|
22
|
+
/**
|
|
23
|
+
* Public because `fromManifest` walks it: a tenant may derive from another
|
|
24
|
+
* tenant, and what both ultimately need is the cluster underneath.
|
|
25
|
+
*/
|
|
26
|
+
readonly parent: Database,
|
|
27
|
+
) {
|
|
28
|
+
this._id = id;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
abstract get _type(): ResourceType;
|
|
32
|
+
abstract provides(): Record<string, $util.Input<string>>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A link carrying only this node's own URL.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately *not* the parent's link. A handler depending on the reader
|
|
38
|
+
* should receive the reader's URL and no other, which is the same
|
|
39
|
+
* least-privilege rule edges get everywhere else — inheriting the parent's
|
|
40
|
+
* properties would hand it the writer's address under a second key.
|
|
41
|
+
*/
|
|
42
|
+
getSSTLink() {
|
|
43
|
+
return { properties: { ...this.provides() } };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A read-only endpoint on an existing cluster.
|
|
49
|
+
*
|
|
50
|
+
* Nothing is provisioned. An RDS instance has one endpoint, so this resolves to
|
|
51
|
+
* the same address the writer uses — which is safe rather than a silently
|
|
52
|
+
* writable connection behind a name that says reader, because read-only is
|
|
53
|
+
* enforced by the role's grants and never by which endpoint was reached.
|
|
54
|
+
*/
|
|
55
|
+
export class DatabaseReader extends DerivedDatabase {
|
|
56
|
+
constructor(
|
|
57
|
+
id: string,
|
|
58
|
+
parent: Database,
|
|
59
|
+
/**
|
|
60
|
+
* The read-only role, where one was provisioned.
|
|
61
|
+
*
|
|
62
|
+
* Absent under `roles: false`, where there is one credential and it is
|
|
63
|
+
* the master's — and read-only then holds by convention rather than by
|
|
64
|
+
* grant, which is exactly why that mode is a downgrade.
|
|
65
|
+
*/
|
|
66
|
+
private readonly role?: { user: string; password: $util.Input<string> },
|
|
67
|
+
) {
|
|
68
|
+
super(id, parent);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get _type() {
|
|
72
|
+
return ResourceType.SSTPostgres;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
provides(): Record<string, $util.Input<string>> {
|
|
76
|
+
return {
|
|
77
|
+
url: this.parent.urlFor({
|
|
78
|
+
reader: true,
|
|
79
|
+
...(this.role ? { as: this.role } : {}),
|
|
80
|
+
}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A second schema in the parent's database, with its own URL.
|
|
87
|
+
*
|
|
88
|
+
* The schema and its role are created by DDL, so what this contributes is the
|
|
89
|
+
* parent's connection pinned to a different `search_path`. Through the codec,
|
|
90
|
+
* because `?search_path=` is not a libpq parameter: a URL carrying it is
|
|
91
|
+
* accepted by every parser, ignored by the server, and produces a database that
|
|
92
|
+
* looks empty.
|
|
93
|
+
*/
|
|
94
|
+
export class DatabaseSchema extends DerivedDatabase {
|
|
95
|
+
constructor(
|
|
96
|
+
id: string,
|
|
97
|
+
parent: Database,
|
|
98
|
+
private readonly schema: string,
|
|
99
|
+
/** The tenant's runtime role, where one was provisioned. */
|
|
100
|
+
private readonly role?: { user: string; password: $util.Input<string> },
|
|
101
|
+
) {
|
|
102
|
+
super(id, parent);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get _type() {
|
|
106
|
+
return ResourceType.SSTPostgres;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
provides(): Record<string, $util.Input<string>> {
|
|
110
|
+
return {
|
|
111
|
+
url: this.parent.urlFor({
|
|
112
|
+
schema: this.schema,
|
|
113
|
+
...(this.role ? { as: this.role } : {}),
|
|
114
|
+
}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { iam } from '@pulumi/aws';
|
|
2
|
+
import { type GkmLinkable, ResourceType } from '../Linkable';
|
|
3
|
+
import type { StackType } from '../Stack';
|
|
4
|
+
import { sesSmtpUrl } from './ses';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `Email` — outbound mail, and the infra half of the `email` kind.
|
|
8
|
+
*
|
|
9
|
+
* The kind whose backends differ least. Every one of them speaks SMTP, so the
|
|
10
|
+
* declaration's `smtp://` URL is true of all of them and the client never
|
|
11
|
+
* changes — Mailpit locally, Resend or SES or somebody's relay deployed. What
|
|
12
|
+
* differs is only who issues the credential, which is why the backend is config
|
|
13
|
+
* rather than a field on the construct.
|
|
14
|
+
*
|
|
15
|
+
* Two of the three are barely a component: `resend` and `smtp` compose a URL
|
|
16
|
+
* from a value you already hold. Only `ses` provisions anything, and it
|
|
17
|
+
* provisions a *chain* — an identity, a user, an access key, and a password
|
|
18
|
+
* derived from that key — because SES does not issue SMTP passwords, it issues
|
|
19
|
+
* IAM credentials and documents the arithmetic.
|
|
20
|
+
*/
|
|
21
|
+
export class Email<
|
|
22
|
+
TStage extends string = string,
|
|
23
|
+
TDomain extends string = string,
|
|
24
|
+
> implements GkmLinkable
|
|
25
|
+
{
|
|
26
|
+
readonly _id: string;
|
|
27
|
+
|
|
28
|
+
/** Composed once, in the constructor, so `provides` cannot drift from it. */
|
|
29
|
+
private readonly url: $util.Input<string>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The address mail is sent from.
|
|
33
|
+
*
|
|
34
|
+
* Required rather than defaulted: an unverified sender is rejected by every
|
|
35
|
+
* provider, and a guess would be rejected at the first send rather than at
|
|
36
|
+
* synth.
|
|
37
|
+
*/
|
|
38
|
+
private readonly from: $util.Input<string>;
|
|
39
|
+
|
|
40
|
+
get _type() {
|
|
41
|
+
return ResourceType.Email;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
constructor(
|
|
45
|
+
_stack: StackType<TStage, TDomain>,
|
|
46
|
+
name: string,
|
|
47
|
+
props: EmailProps,
|
|
48
|
+
) {
|
|
49
|
+
this._id = name;
|
|
50
|
+
|
|
51
|
+
// A supplied URL wins over provisioning, for every backend including SES.
|
|
52
|
+
// Credentials that already exist are the common case — a sending identity
|
|
53
|
+
// set up once, by hand — and creating a second IAM user for it would be
|
|
54
|
+
// this deploy quietly adding another way into the account.
|
|
55
|
+
this.url = props.url ?? this.provision(name, props);
|
|
56
|
+
this.from = props.from;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Mint a credential, for the one backend that can.
|
|
61
|
+
*
|
|
62
|
+
* @throws {EmailNeedsUrl} for the backends that cannot. Resend and a plain
|
|
63
|
+
* relay *are* accounts somebody created, so a missing URL is a missing setup
|
|
64
|
+
* step — and saying so at synth beats composing something that cannot
|
|
65
|
+
* deliver and finding out at the first send.
|
|
66
|
+
*/
|
|
67
|
+
private provision(name: string, props: EmailProps): $util.Input<string> {
|
|
68
|
+
if (props.backend !== 'ses') throw new EmailNeedsUrl(name, props.backend);
|
|
69
|
+
|
|
70
|
+
return this.provisionSes(name, props);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One key, the `smtp://` URL — and a second the app declared, the sending
|
|
75
|
+
* identity, which is the one thing about mail that genuinely differs per
|
|
76
|
+
* stage.
|
|
77
|
+
*/
|
|
78
|
+
provides(): Record<string, $util.Input<string>> {
|
|
79
|
+
return { url: this.url, from: this.from };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
getSSTLink() {
|
|
83
|
+
return { properties: { ...this.provides() } };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The SES chain: a user that may send, a key for it, and the derived password.
|
|
88
|
+
*
|
|
89
|
+
* A user of its own rather than the application's role, because SMTP is a
|
|
90
|
+
* long-lived static credential and the execution role is not — there is no
|
|
91
|
+
* way to hand a relay a rotating token. Scoped to `ses:SendRawEmail` and
|
|
92
|
+
* nothing else, so a leaked SMTP password sends mail and does not read the
|
|
93
|
+
* account's send statistics or verify new identities.
|
|
94
|
+
*/
|
|
95
|
+
private provisionSes(name: string, props: EmailProps): $util.Input<string> {
|
|
96
|
+
const user = new iam.User(`${name}SmtpUser`, {
|
|
97
|
+
path: '/gkm/ses/',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
new iam.UserPolicy(`${name}SmtpPolicy`, {
|
|
101
|
+
user: user.name,
|
|
102
|
+
policy: JSON.stringify({
|
|
103
|
+
Version: '2012-10-17',
|
|
104
|
+
Statement: [
|
|
105
|
+
{ Effect: 'Allow', Action: 'ses:SendRawEmail', Resource: '*' },
|
|
106
|
+
],
|
|
107
|
+
}),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const key = new iam.AccessKey(`${name}SmtpKey`, { user: user.name });
|
|
111
|
+
|
|
112
|
+
return $util
|
|
113
|
+
.all([key.id, key.secret, props.region])
|
|
114
|
+
.apply(([accessKeyId, secretAccessKey, region]) =>
|
|
115
|
+
sesSmtpUrl({ accessKeyId, secretAccessKey, region }),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface EmailProps {
|
|
121
|
+
/** Who delivers the mail. Only `ses` provisions anything. */
|
|
122
|
+
backend: 'resend' | 'ses' | 'smtp';
|
|
123
|
+
/**
|
|
124
|
+
* The `smtp://` URL, where the credentials already exist.
|
|
125
|
+
*
|
|
126
|
+
* Required for `resend` and `smtp`, which are accounts rather than
|
|
127
|
+
* infrastructure. Optional for `ses`, and supplying it is the difference
|
|
128
|
+
* between using the sending identity you have and provisioning a second one.
|
|
129
|
+
*/
|
|
130
|
+
url?: $util.Input<string>;
|
|
131
|
+
/** The region to derive SES credentials for. Required for `ses`. */
|
|
132
|
+
region?: $util.Input<string>;
|
|
133
|
+
/**
|
|
134
|
+
* The identity mail is sent from — a verified address or domain.
|
|
135
|
+
*
|
|
136
|
+
* Stage-varying by nature, which is why it is here rather than on the
|
|
137
|
+
* construct: `noreply@myapp.test` locally, a verified domain deployed.
|
|
138
|
+
*/
|
|
139
|
+
from: $util.Input<string>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A backend that cannot mint its own credentials was given none.
|
|
144
|
+
*
|
|
145
|
+
* Resend and a plain relay are accounts somebody created, so there is nothing
|
|
146
|
+
* for a deploy to provision and a missing URL is a missing setup step.
|
|
147
|
+
*/
|
|
148
|
+
export class EmailNeedsUrl extends Error {
|
|
149
|
+
constructor(
|
|
150
|
+
readonly id: string,
|
|
151
|
+
readonly backend: string,
|
|
152
|
+
) {
|
|
153
|
+
super(
|
|
154
|
+
`'${id}' sends through ${backend}, which is an account rather than ` +
|
|
155
|
+
`something to provision, and no URL was supplied for it. Set it with ` +
|
|
156
|
+
`\`sst secret set\` and pass it through the deploy layer — ` +
|
|
157
|
+
`fromManifest(stack, manifest, { ${id}: { url } }).`,
|
|
158
|
+
);
|
|
159
|
+
this.name = 'EmailNeedsUrl';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Mail was declared and no sending identity was supplied.
|
|
165
|
+
*
|
|
166
|
+
* Not defaultable: every provider rejects an unverified sender, so a guess would
|
|
167
|
+
* deploy cleanly and fail at the first send. The address is also stage-varying
|
|
168
|
+
* by nature, which is why it belongs in the deploy layer rather than the
|
|
169
|
+
* construct.
|
|
170
|
+
*/
|
|
171
|
+
export class EmailNeedsSender extends Error {
|
|
172
|
+
constructor(readonly id: string) {
|
|
173
|
+
super(
|
|
174
|
+
`'${id}' sends mail and no sending identity was supplied. Pass one ` +
|
|
175
|
+
`through the deploy layer — ` +
|
|
176
|
+
`fromManifest(stack, manifest, { ${id}: { from: 'noreply@example.com' } }) ` +
|
|
177
|
+
`— and make sure it is verified with the provider.`,
|
|
178
|
+
);
|
|
179
|
+
this.name = 'EmailNeedsSender';
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type GkmLinkable, ResourceType } from '../Linkable';
|
|
2
|
+
import type { StackType } from '../Stack';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `FileServer` — a CloudFront distribution over a bucket, and the infra half of
|
|
6
|
+
* the `file-server` kind.
|
|
7
|
+
*
|
|
8
|
+
* It owns the certificate and the DNS record, which is the whole reason the
|
|
9
|
+
* construct is its own rather than a flag on the bucket: a domain lifecycle has
|
|
10
|
+
* no business living inside an `objects` provisioner. It is also the same
|
|
11
|
+
* infrastructure a static site is — a distribution over an origin — with live
|
|
12
|
+
* bucket contents in place of a build output.
|
|
13
|
+
*
|
|
14
|
+
* The origin bucket must permit CloudFront to read it, which is set on the
|
|
15
|
+
* *bucket* rather than here: `fromManifest` gives a bucket `access:
|
|
16
|
+
* 'cloudfront'` when anything in the manifest serves it. That lookup is the cost
|
|
17
|
+
* this design named — the bucket alone no longer says whether it is served, so
|
|
18
|
+
* you find whoever points at it — paid once, in one place.
|
|
19
|
+
*
|
|
20
|
+
* What it does **not** do is signing. A CloudFront signed URL or signed cookie
|
|
21
|
+
* needs a key group and a private key, which is key material nothing declares
|
|
22
|
+
* yet; the construct's `signedUrl` is an S3 presign at the bucket instead. So
|
|
23
|
+
* open paths are served here, and a signed read goes to the bucket's own host.
|
|
24
|
+
* That is a real difference in hostname, and it is documented rather than hidden
|
|
25
|
+
* because a presign and a CloudFront signature share only the word "signed".
|
|
26
|
+
*/
|
|
27
|
+
export class FileServer<
|
|
28
|
+
TStage extends string = string,
|
|
29
|
+
TDomain extends string = string,
|
|
30
|
+
>
|
|
31
|
+
extends sst.aws.Router
|
|
32
|
+
implements GkmLinkable
|
|
33
|
+
{
|
|
34
|
+
readonly _id!: string;
|
|
35
|
+
|
|
36
|
+
get _type() {
|
|
37
|
+
return ResourceType.FileServer;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
_stack: StackType<TStage, TDomain>,
|
|
42
|
+
name: string,
|
|
43
|
+
props: FileServerProps,
|
|
44
|
+
) {
|
|
45
|
+
const { origin, ...args } = props;
|
|
46
|
+
|
|
47
|
+
super(name, args);
|
|
48
|
+
this._id = name;
|
|
49
|
+
|
|
50
|
+
// Everything at the root, so a served path *is* a bucket key and the
|
|
51
|
+
// client's `url(key)` and `getUploadURL(key)` speak the same language.
|
|
52
|
+
// Several origins would cost exactly that, which is why multi-origin is
|
|
53
|
+
// deferred until something asks for it.
|
|
54
|
+
this.routeBucket('/*', origin);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The one value this server resolves: where its objects answer.
|
|
59
|
+
*
|
|
60
|
+
* A custom domain when one is configured and the distribution's assigned
|
|
61
|
+
* hostname otherwise — `Router.url` already prefers the former. The assigned
|
|
62
|
+
* hostname is a fallback rather than a destination: it changes if the
|
|
63
|
+
* distribution is replaced, which turns every URL anyone emailed or cached
|
|
64
|
+
* into a dead one.
|
|
65
|
+
*/
|
|
66
|
+
provides(): Record<string, $util.Input<string>> {
|
|
67
|
+
return { url: this.url };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
override getSSTLink() {
|
|
71
|
+
const link = super.getSSTLink();
|
|
72
|
+
return {
|
|
73
|
+
...link,
|
|
74
|
+
properties: { ...link.properties, ...this.provides() },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface FileServerProps extends sst.aws.RouterArgs {
|
|
80
|
+
/** The bucket whose objects this serves. */
|
|
81
|
+
origin: sst.aws.Bucket;
|
|
82
|
+
}
|
|
@@ -4,9 +4,9 @@ import {
|
|
|
4
4
|
flattenManifestField,
|
|
5
5
|
type ManifestField,
|
|
6
6
|
} from '@geekmidas/manifest';
|
|
7
|
-
import { type GkmLinkable, ResourceType } from '
|
|
8
|
-
import { LinkedEnvironment } from '
|
|
9
|
-
import type { StackType } from '
|
|
7
|
+
import { type GkmLinkable, ResourceType } from '../Linkable';
|
|
8
|
+
import { LinkedEnvironment } from '../LinkedEnvironment';
|
|
9
|
+
import type { StackType } from '../Stack';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* `Function` — wraps SST's `sst.aws.Function` with standard env defaults and
|