@geekmidas/cloud 10.0.0-alpha.9 → 10.0.0-alpha.91
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/dist/index.cjs +3 -3
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +2 -3
- package/dist/utils/index.cjs +106 -5
- package/dist/utils/index.cjs.map +1 -0
- package/dist/utils/index.d.cts +62 -2
- package/dist/utils/index.d.cts.map +1 -0
- package/dist/utils/index.d.mts +62 -2
- package/dist/utils/index.d.mts.map +1 -0
- package/dist/utils/index.mjs +78 -2
- package/dist/utils/index.mjs.map +1 -0
- package/package.json +9 -8
- package/src/dokploy/Application.ts +52 -5
- package/src/dokploy/__tests__/Application.provider.spec.ts +206 -0
- package/src/sst/__tests__/api.spec.ts +185 -0
- package/src/sst/__tests__/encryption.spec.ts +90 -0
- package/src/sst/__tests__/fromManifest.integration.spec.ts +427 -0
- package/src/sst/__tests__/fromManifest.spec.ts +306 -5
- package/src/sst/__tests__/function.spec.ts +121 -0
- package/src/sst/__tests__/links.spec.ts +209 -0
- package/src/sst/__tests__/surfaces.spec.ts +17 -0
- package/src/sst/__type-tests__/overrides.type-test.ts +152 -0
- package/src/sst/aws/Api.ts +16 -44
- package/src/sst/aws/Cache.ts +4 -4
- package/src/sst/aws/Credential.ts +42 -9
- package/src/sst/aws/Cron.ts +5 -45
- package/src/sst/aws/Database.ts +15 -1
- package/src/sst/aws/DatabaseBootstrap.ts +3 -0
- package/src/sst/aws/Encryption.ts +91 -0
- package/src/sst/aws/ExternalApi.ts +58 -0
- package/src/sst/aws/Function.ts +9 -38
- package/src/sst/aws/Queue.ts +34 -1
- package/src/sst/aws/RestApiSurface.ts +36 -14
- package/src/sst/aws/bootstrap/handler.ts +10 -1
- package/src/sst/fromManifest.ts +261 -44
- package/src/sst/index.ts +6 -1
- package/src/sst/overrides.ts +133 -0
- package/dist/index-B5CZ1xVf.d.cts +0 -64
- package/dist/index-B5CZ1xVf.d.cts.map +0 -1
- package/dist/index-DhHRjduZ.d.mts +0 -64
- package/dist/index-DhHRjduZ.d.mts.map +0 -1
- package/dist/utils-B1a2UuEO.cjs +0 -129
- package/dist/utils-B1a2UuEO.cjs.map +0 -1
- package/dist/utils-DMOXJ27j.mjs +0 -83
- package/dist/utils-DMOXJ27j.mjs.map +0 -1
- package/src/sst/__type-tests__/manifest.type-test.ts +0 -77
|
@@ -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.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Type-level tests for `fromManifest`'s overrides, inferred from the manifest.
|
|
2
|
+
// Checked by `ts:check:sst`; each `@ts-expect-error` self-validates: if the
|
|
3
|
+
// inference regresses, the now-unused directive becomes a TS error the gate
|
|
4
|
+
// catches.
|
|
5
|
+
|
|
6
|
+
import type { ConstructManifest } from '@geekmidas/manifest';
|
|
7
|
+
import { App } from '../App';
|
|
8
|
+
import { fromManifest } from '../fromManifest';
|
|
9
|
+
|
|
10
|
+
const stack = new App({
|
|
11
|
+
name: 'a',
|
|
12
|
+
stage: 'dev',
|
|
13
|
+
domain: 'example.com',
|
|
14
|
+
hostedZoneId: 'Z',
|
|
15
|
+
region: 'us-east-1',
|
|
16
|
+
}).stack('shop');
|
|
17
|
+
|
|
18
|
+
declare const vpc: sst.aws.Vpc;
|
|
19
|
+
|
|
20
|
+
// The shape `gkm build` emits: `as const satisfies ConstructManifest`.
|
|
21
|
+
const constructs = {
|
|
22
|
+
Orders: {
|
|
23
|
+
kind: 'database',
|
|
24
|
+
id: 'Orders',
|
|
25
|
+
engine: 'postgres',
|
|
26
|
+
provides: ['ORDERS_URL'],
|
|
27
|
+
},
|
|
28
|
+
Billing: {
|
|
29
|
+
kind: 'database-schema',
|
|
30
|
+
id: 'Billing',
|
|
31
|
+
of: 'Orders',
|
|
32
|
+
schema: 'billing',
|
|
33
|
+
provides: ['BILLING_URL'],
|
|
34
|
+
},
|
|
35
|
+
Sessions: { kind: 'cache', id: 'Sessions', provides: ['SESSIONS_URL'] },
|
|
36
|
+
Rates: {
|
|
37
|
+
kind: 'cache',
|
|
38
|
+
id: 'Rates',
|
|
39
|
+
of: 'Orders',
|
|
40
|
+
provides: ['RATES_URL'],
|
|
41
|
+
},
|
|
42
|
+
Mail: { kind: 'email', id: 'Mail', provides: ['MAIL_URL'] },
|
|
43
|
+
Uploads: { kind: 'objects', id: 'Uploads', provides: ['UPLOADS_URL'] },
|
|
44
|
+
Api: {
|
|
45
|
+
kind: 'rest-api',
|
|
46
|
+
id: 'Api',
|
|
47
|
+
path: 'apps/api',
|
|
48
|
+
endpoints: [],
|
|
49
|
+
provides: ['API_URL'],
|
|
50
|
+
},
|
|
51
|
+
} as const satisfies ConstructManifest;
|
|
52
|
+
|
|
53
|
+
// Valid: the two the synth cannot guess, plus an optional bucket prop.
|
|
54
|
+
export const ok = fromManifest(stack, constructs, {
|
|
55
|
+
Orders: { vpc },
|
|
56
|
+
Mail: { from: 'no-reply@example.com' },
|
|
57
|
+
Uploads: { cors: false },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// A database without its VPC.
|
|
61
|
+
// @ts-expect-error — `Orders` is a database: `vpc` is required.
|
|
62
|
+
fromManifest(stack, constructs, { Mail: { from: 'a@example.com' } });
|
|
63
|
+
|
|
64
|
+
// Mail without a sender.
|
|
65
|
+
fromManifest(stack, constructs, {
|
|
66
|
+
Orders: { vpc },
|
|
67
|
+
// @ts-expect-error — every provider rejects an unverified sender.
|
|
68
|
+
Mail: {},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// A misspelt id.
|
|
72
|
+
fromManifest(stack, constructs, {
|
|
73
|
+
Orders: { vpc },
|
|
74
|
+
Mail: { from: 'a@example.com' },
|
|
75
|
+
// @ts-expect-error — not a construct in this manifest.
|
|
76
|
+
Ordrs: { vpc },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// A prop the kind's component never reads.
|
|
80
|
+
fromManifest(stack, constructs, {
|
|
81
|
+
Orders: { vpc },
|
|
82
|
+
Mail: { from: 'a@example.com' },
|
|
83
|
+
// @ts-expect-error — a surface takes API Gateway args, not bucket ones.
|
|
84
|
+
Api: { versioning: true },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// What the declaration already decides is not overridable.
|
|
88
|
+
fromManifest(stack, constructs, {
|
|
89
|
+
// @ts-expect-error — the schema is the declaration's, not the deploy's.
|
|
90
|
+
Orders: { vpc, schema: 'other' },
|
|
91
|
+
Mail: { from: 'a@example.com' },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// A kind with nothing to override takes no key.
|
|
95
|
+
fromManifest(stack, constructs, {
|
|
96
|
+
Orders: { vpc },
|
|
97
|
+
Mail: { from: 'a@example.com' },
|
|
98
|
+
// @ts-expect-error — a schema derives from its database.
|
|
99
|
+
Billing: {},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Nor does a cache in a database: same address, one more table.
|
|
103
|
+
fromManifest(stack, constructs, {
|
|
104
|
+
Orders: { vpc },
|
|
105
|
+
Mail: { from: 'a@example.com' },
|
|
106
|
+
// @ts-expect-error — `Rates` lives in `Orders`.
|
|
107
|
+
Rates: { region: 'eu-west-1' },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// The backend decides too: ElastiCache needs a VPC to live in…
|
|
111
|
+
fromManifest(
|
|
112
|
+
stack,
|
|
113
|
+
constructs,
|
|
114
|
+
// @ts-expect-error — `Sessions` on ElastiCache needs `vpc`.
|
|
115
|
+
{ Orders: { vpc }, Mail: { from: 'a@example.com' } },
|
|
116
|
+
{ cache: 'elasticache' },
|
|
117
|
+
);
|
|
118
|
+
export const elasticache = fromManifest(
|
|
119
|
+
stack,
|
|
120
|
+
constructs,
|
|
121
|
+
{
|
|
122
|
+
Orders: { vpc },
|
|
123
|
+
Sessions: { vpc: { subnets: [], securityGroups: [] } },
|
|
124
|
+
Mail: { from: 'a@example.com' },
|
|
125
|
+
},
|
|
126
|
+
{ cache: 'elasticache' },
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// …and mail that is not SES is an account somebody created, with its URL.
|
|
130
|
+
fromManifest(
|
|
131
|
+
stack,
|
|
132
|
+
constructs,
|
|
133
|
+
{
|
|
134
|
+
Orders: { vpc },
|
|
135
|
+
// @ts-expect-error — Resend's URL is not something a deploy can mint.
|
|
136
|
+
Mail: { from: 'a@example.com' },
|
|
137
|
+
},
|
|
138
|
+
{ email: 'resend' },
|
|
139
|
+
);
|
|
140
|
+
export const resend = fromManifest(
|
|
141
|
+
stack,
|
|
142
|
+
constructs,
|
|
143
|
+
{
|
|
144
|
+
Orders: { vpc },
|
|
145
|
+
Mail: { from: 'a@example.com', url: 'smtp://resend' },
|
|
146
|
+
},
|
|
147
|
+
{ email: 'resend' },
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// A manifest whose ids are not known statically keeps the untyped record.
|
|
151
|
+
declare const untyped: ConstructManifest;
|
|
152
|
+
export const loose = fromManifest(stack, untyped, { Anything: { at: 'all' } });
|
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';
|
|
@@ -153,48 +148,10 @@ export class Api<
|
|
|
153
148
|
// Fail the whole synth if any route is misconfigured, with one actionable
|
|
154
149
|
// message per offending route.
|
|
155
150
|
if (failures.length) {
|
|
156
|
-
throw new
|
|
151
|
+
throw new RoutesMissingEnvironment(id, failures);
|
|
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>,
|
|
@@ -307,3 +264,18 @@ export interface ApiProps<TAuthorizers extends Record<string, unknown> = {}>
|
|
|
307
264
|
* may override it. */
|
|
308
265
|
runtime?: sst.aws.FunctionArgs['runtime'];
|
|
309
266
|
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Routes that read variables nothing links or whitelists. Collected across
|
|
270
|
+
* every route so one synth reports all of them, each with its own suggestions,
|
|
271
|
+
* rather than stopping at the first.
|
|
272
|
+
*/
|
|
273
|
+
export class RoutesMissingEnvironment extends Error {
|
|
274
|
+
constructor(
|
|
275
|
+
readonly api: string,
|
|
276
|
+
readonly failures: readonly EnvValidationError[],
|
|
277
|
+
) {
|
|
278
|
+
super(failures.map((f) => f.message).join('\n\n'));
|
|
279
|
+
this.name = 'RoutesMissingEnvironment';
|
|
280
|
+
}
|
|
281
|
+
}
|
package/src/sst/aws/Cache.ts
CHANGED
|
@@ -206,7 +206,7 @@ export function withCacheTable(url: string, table: string): string {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
/**
|
|
209
|
-
* `
|
|
209
|
+
* A `'db'` cache in an app that declares more than one database.
|
|
210
210
|
*
|
|
211
211
|
* Picking one would put the cache somewhere nobody chose, and the symptom —
|
|
212
212
|
* entries that are never found — appears long after the deploy reported
|
|
@@ -222,7 +222,7 @@ export class CacheIsAmbiguous extends Error {
|
|
|
222
222
|
`'${id}' is a cache backed by the database, and this app declares ` +
|
|
223
223
|
`${databases.length}: ${databases.join(', ')}. Declare the cache from ` +
|
|
224
224
|
`its database — e.g. ${databases[0]}.cache('${id}') — rather than ` +
|
|
225
|
-
`
|
|
225
|
+
`as a bare new Cache().`,
|
|
226
226
|
);
|
|
227
227
|
this.name = 'CacheIsAmbiguous';
|
|
228
228
|
}
|
|
@@ -232,8 +232,8 @@ export class CacheNeedsDatabase extends Error {
|
|
|
232
232
|
constructor(readonly id: string) {
|
|
233
233
|
super(
|
|
234
234
|
`'${id}' is a cache backed by the database, and this app declares no ` +
|
|
235
|
-
`database for it to live in. Declare one, or
|
|
236
|
-
`
|
|
235
|
+
`database for it to live in. Declare one, or declare the cache from ` +
|
|
236
|
+
`the database it belongs to.`,
|
|
237
237
|
);
|
|
238
238
|
this.name = 'CacheNeedsDatabase';
|
|
239
239
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type GkmLinkable, ResourceType } from '../Linkable';
|
|
2
|
+
import type { StackType } from '../Stack';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* `Credential` — a third-party credential SST holds, and the infra half of the
|
|
@@ -8,21 +9,53 @@ import { Secret, type SecretProps } from './Secret';
|
|
|
8
9
|
* the lifecycle rather than the mechanism: a secret is generated and rotated by
|
|
9
10
|
* the platform, while a credential is issued by someone else and has a shape
|
|
10
11
|
* the construct validates on the way in. Both are values you set out of band
|
|
11
|
-
* with `sst secret set
|
|
12
|
+
* with `sst secret set`, under the construct's id.
|
|
12
13
|
*
|
|
13
|
-
* The role is `
|
|
14
|
+
* The role is `credentials` rather than `value`, and that is not cosmetic. The
|
|
14
15
|
* role *is* the contract — `providedKeyFor` turns it into the key the app
|
|
15
16
|
* declared — so a credential providing `value` would supply `STRIPE_VALUE`
|
|
16
|
-
* against a declared `
|
|
17
|
-
* stack at synth.
|
|
17
|
+
* against a declared `STRIPE_CREDENTIALS`, and `assertProvides` would reject
|
|
18
|
+
* the stack at synth.
|
|
19
|
+
*
|
|
20
|
+
* It holds an `sst.Secret` rather than being one, and reports its own type,
|
|
21
|
+
* for the same reason one level down: the type decides the keys a link
|
|
22
|
+
* resolves to. As an SST secret it resolved to `STRIPE`, so a function that
|
|
23
|
+
* declared `STRIPE_CREDENTIALS` was linked to nothing.
|
|
18
24
|
*/
|
|
19
25
|
export class Credential<
|
|
20
26
|
TStage extends string = string,
|
|
21
27
|
TDomain extends string = string,
|
|
22
|
-
>
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
> implements GkmLinkable
|
|
29
|
+
{
|
|
30
|
+
readonly _id: string;
|
|
31
|
+
|
|
32
|
+
private readonly secret: sst.Secret;
|
|
33
|
+
|
|
34
|
+
get _type() {
|
|
35
|
+
return ResourceType.Credential;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
_stack: StackType<TStage, TDomain>,
|
|
40
|
+
name: string,
|
|
41
|
+
props: CredentialProps = {},
|
|
42
|
+
) {
|
|
43
|
+
this._id = name;
|
|
44
|
+
// The id itself, as it always was: a value already set with
|
|
45
|
+
// `sst secret set Stripe …` is still the one read.
|
|
46
|
+
this.secret = new sst.Secret(name, props.placeholder);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
provides(): Record<string, $util.Input<string>> {
|
|
50
|
+
return { credentials: this.secret.value };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
getSSTLink() {
|
|
54
|
+
return { properties: { ...this.provides() } };
|
|
25
55
|
}
|
|
26
56
|
}
|
|
27
57
|
|
|
28
|
-
export interface CredentialProps
|
|
58
|
+
export interface CredentialProps {
|
|
59
|
+
/** A value until one is set, for a preview stage. */
|
|
60
|
+
placeholder?: string;
|
|
61
|
+
}
|
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
|
|
package/src/sst/aws/Database.ts
CHANGED
|
@@ -84,7 +84,21 @@ export class Database<
|
|
|
84
84
|
* produces a database that looks empty.
|
|
85
85
|
*/
|
|
86
86
|
provides(): Record<string, $util.Input<string>> {
|
|
87
|
-
return { url: this.urlFor({}) };
|
|
87
|
+
return { url: this.urlFor(this.role ? { as: this.role } : {}) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The runtime role this database's own handlers connect as.
|
|
92
|
+
*
|
|
93
|
+
* Set by the adapter once the bootstrap has registered the role — the
|
|
94
|
+
* cluster exists before any role does, so it cannot be a constructor
|
|
95
|
+
* argument. Until it is set (and under `roles: false`, where it never is),
|
|
96
|
+
* the URL is the master's.
|
|
97
|
+
*/
|
|
98
|
+
private role: { user: string; password: $util.Input<string> } | undefined;
|
|
99
|
+
|
|
100
|
+
connectAs(role: { user: string; password: $util.Input<string> }): void {
|
|
101
|
+
this.role = role;
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
/**
|
|
@@ -65,6 +65,8 @@ export class DatabaseBootstrap {
|
|
|
65
65
|
runtime: string;
|
|
66
66
|
owner: string;
|
|
67
67
|
reader?: string;
|
|
68
|
+
/** The database's own roles, whose owner may create in it. */
|
|
69
|
+
ownsDatabase?: boolean;
|
|
68
70
|
}): { runtime: $util.Output<string>; reader?: $util.Output<string> } {
|
|
69
71
|
const runtime = this.password(tenant.runtime);
|
|
70
72
|
const reader = tenant.reader ? this.password(tenant.reader) : undefined;
|
|
@@ -75,6 +77,7 @@ export class DatabaseBootstrap {
|
|
|
75
77
|
runtime: tenant.runtime,
|
|
76
78
|
owner: tenant.owner,
|
|
77
79
|
...(tenant.reader ? { reader: tenant.reader } : {}),
|
|
80
|
+
...(tenant.ownsDatabase ? { ownsDatabase: true } : {}),
|
|
78
81
|
// Filled in by `bootstrapEvent` once the outputs resolve. The function
|
|
79
82
|
// reads them straight out of its input and never fetches a secret, so
|
|
80
83
|
// it needs no IAM to read one.
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { externalApiUrl } from '@geekmidas/manifest';
|
|
2
|
+
import { type GkmLinkable, ResourceType } from '../Linkable';
|
|
3
|
+
import type { StackType } from '../Stack';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `ExternalApi` — the infra half of the `external-api` kind.
|
|
7
|
+
*
|
|
8
|
+
* Nothing is provisioned: the API is somebody else's. What this holds is the
|
|
9
|
+
* two facts a function needs to call it on this stage — the URL the
|
|
10
|
+
* declaration names for it, and the credentials the provider issued, kept as
|
|
11
|
+
* an SST secret and set out of band:
|
|
12
|
+
*
|
|
13
|
+
* ```
|
|
14
|
+
* sst secret set PolarCredentials '{"clientId":"…","clientSecret":"…"}' --stage prod
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export class ExternalApi<
|
|
18
|
+
TStage extends string = string,
|
|
19
|
+
TDomain extends string = string,
|
|
20
|
+
> implements GkmLinkable
|
|
21
|
+
{
|
|
22
|
+
readonly _id: string;
|
|
23
|
+
|
|
24
|
+
private readonly url: string;
|
|
25
|
+
|
|
26
|
+
private readonly credentials: sst.Secret;
|
|
27
|
+
|
|
28
|
+
get _type() {
|
|
29
|
+
return ResourceType.ExternalApi;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
stack: StackType<TStage, TDomain>,
|
|
34
|
+
name: string,
|
|
35
|
+
props: ExternalApiProps,
|
|
36
|
+
) {
|
|
37
|
+
this._id = name;
|
|
38
|
+
// Read at synth, so a stage the declaration has no URL for fails the
|
|
39
|
+
// deploy — with the stage named — rather than the first request.
|
|
40
|
+
this.url = externalApiUrl({ id: name, url: props.url }, stack.stage);
|
|
41
|
+
this.credentials = new sst.Secret(`${name}Credentials`, props.placeholder);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
provides(): Record<string, $util.Input<string>> {
|
|
45
|
+
return { url: this.url, credentials: this.credentials.value };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
getSSTLink() {
|
|
49
|
+
return { properties: { ...this.provides() } };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ExternalApiProps {
|
|
54
|
+
/** The declaration's `url` — one for every stage, or one per stage name. */
|
|
55
|
+
url: string | Readonly<Record<string, string>>;
|
|
56
|
+
/** A value for the credentials secret until one is set, for a preview stage. */
|
|
57
|
+
placeholder?: string;
|
|
58
|
+
}
|