@geekmidas/cloud 10.0.0-alpha.2 → 10.0.0-alpha.21

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.
@@ -0,0 +1,368 @@
1
+ import {
2
+ type ConstructManifest,
3
+ PUBLIC,
4
+ PUBLIC_PREFIX,
5
+ providedKeyFor,
6
+ } from '@geekmidas/manifest';
7
+ import { afterEach, describe, expect, it, vi } from 'vitest';
8
+ import { DatabaseNeedsVpc } from '../aws/Database';
9
+ import { EmailNeedsSender } from '../aws/Email';
10
+ import { CacheIsAmbiguous, ProvidesMismatch } from '../errors';
11
+ import { describeRoutes, fromManifest } from '../fromManifest';
12
+
13
+ /**
14
+ * A whole manifest through the adapter: every kind provisioned in order,
15
+ * each checked against the keys it declared, the site built against its API's
16
+ * address, and each surface told who calls it. SST's components are recording
17
+ * stubs (`test/sst-globals.ts`), so this runs without a deploy.
18
+ */
19
+
20
+ const stack = {} as never;
21
+ const vpc = { subnets: ['subnet-1'], securityGroups: ['sg-1'] } as never;
22
+
23
+ const keys = (id: string, kind: string, roles: string[]) =>
24
+ roles.map((role) => providedKeyFor(id, kind as never, role));
25
+ const SURFACE = ['url', 'trustedOrigins', 'cookieDomain'];
26
+
27
+ function manifest(): ConstructManifest {
28
+ return {
29
+ Uploads: {
30
+ kind: 'objects',
31
+ id: 'Uploads',
32
+ versioned: true,
33
+ provides: keys('Uploads', 'objects', ['url']),
34
+ },
35
+ Files: {
36
+ kind: 'file-server',
37
+ id: 'Files',
38
+ of: 'Uploads',
39
+ provides: keys('Files', 'file-server', ['url']),
40
+ },
41
+ Emails: {
42
+ kind: 'queue',
43
+ id: 'Emails',
44
+ fifo: true,
45
+ provides: keys('Emails', 'queue', ['publisherConnectionString']),
46
+ },
47
+ Users: {
48
+ kind: 'topic',
49
+ id: 'Users',
50
+ provides: keys('Users', 'topic', ['publisherConnectionString']),
51
+ },
52
+ Orders: {
53
+ kind: 'database',
54
+ id: 'Orders',
55
+ engine: 'postgres',
56
+ provides: keys('Orders', 'database', ['url']),
57
+ },
58
+ OrdersReader: {
59
+ kind: 'database-reader',
60
+ id: 'OrdersReader',
61
+ of: 'Orders',
62
+ provides: keys('OrdersReader', 'database-reader', ['url']),
63
+ },
64
+ Sessions: {
65
+ kind: 'cache',
66
+ id: 'Sessions',
67
+ provides: keys('Sessions', 'cache', ['url']),
68
+ },
69
+ Api: {
70
+ kind: 'rest-api',
71
+ id: 'Api',
72
+ path: 'apps/api',
73
+ endpoints: [
74
+ {
75
+ id: 'ListOrders',
76
+ handler: 'orders.handler',
77
+ method: 'GET',
78
+ path: '/orders',
79
+ dependencies: [{ target: 'Orders', kind: 'database' }],
80
+ },
81
+ {
82
+ id: 'Health',
83
+ handler: 'health.handler',
84
+ method: 'GET',
85
+ path: '/health',
86
+ dependencies: [],
87
+ },
88
+ ],
89
+ calls: [{ target: 'Auth', kind: 'rest-api' }],
90
+ provides: keys('Api', 'rest-api', SURFACE),
91
+ },
92
+ Auth: {
93
+ kind: 'rest-api',
94
+ id: 'Auth',
95
+ path: 'apps/auth',
96
+ endpoints: [],
97
+ provides: keys('Auth', 'rest-api', SURFACE),
98
+ },
99
+ Web: {
100
+ kind: 'site',
101
+ id: 'Web',
102
+ variant: 'static',
103
+ app: { path: 'apps/web' },
104
+ dependencies: [
105
+ { target: 'Api', kind: 'rest-api' },
106
+ { target: 'Users', kind: 'topic' },
107
+ ],
108
+ provides: keys('Web', 'site', ['url']),
109
+ },
110
+ Mail: {
111
+ kind: 'email',
112
+ id: 'Mail',
113
+ provides: keys('Mail', 'email', ['url', 'from']),
114
+ },
115
+ AuthSecret: {
116
+ kind: 'secret',
117
+ id: 'AuthSecret',
118
+ provides: keys('AuthSecret', 'secret', ['value']),
119
+ },
120
+ } as unknown as ConstructManifest;
121
+ }
122
+
123
+ const overrides = {
124
+ Orders: { vpc },
125
+ Mail: { from: 'hello@shop.test', url: 'smtp://relay.test:587' },
126
+ };
127
+
128
+ const argsOf = (component: unknown) =>
129
+ (component as { args: Record<string, unknown> }).args;
130
+
131
+ /** A Pulumi-ish input as the value it resolves to. */
132
+ const settle = async (value: unknown): Promise<unknown> =>
133
+ typeof value === 'object' && value && 'apply' in value
134
+ ? (value as { apply: (fn: (v: unknown) => unknown) => unknown }).apply(
135
+ (v) => v,
136
+ )
137
+ : value;
138
+
139
+ describe('fromManifest', () => {
140
+ afterEach(() => vi.restoreAllMocks());
141
+
142
+ it('provisions every declaration, each supplying exactly what it declared', () => {
143
+ vi.spyOn(console, 'log').mockImplementation(() => {});
144
+
145
+ const provisioned = fromManifest(stack, manifest(), overrides, {
146
+ cache: 'db',
147
+ email: 'smtp',
148
+ });
149
+
150
+ expect(Object.keys(provisioned).sort()).toEqual(
151
+ Object.keys(manifest()).sort(),
152
+ );
153
+ });
154
+
155
+ it('maps each declaration’s options onto its component', () => {
156
+ vi.spyOn(console, 'log').mockImplementation(() => {});
157
+
158
+ const provisioned = fromManifest(stack, manifest(), overrides, {
159
+ cache: 'db',
160
+ email: 'smtp',
161
+ });
162
+
163
+ // A bucket something serves lets CloudFront read it; its declared
164
+ // versioning is carried in this provider's word for it.
165
+ expect(argsOf(provisioned.Uploads)).toMatchObject({
166
+ versioning: true,
167
+ access: 'cloudfront',
168
+ });
169
+ expect(argsOf(provisioned.Emails)).toMatchObject({ fifo: true });
170
+ // The declared Postgres major, not whatever the default is that month.
171
+ expect(argsOf(provisioned.Orders)).toMatchObject({ vpc, version: '18' });
172
+ });
173
+
174
+ it('builds the site against the addresses its edges resolve to', () => {
175
+ vi.spyOn(console, 'log').mockImplementation(() => {});
176
+
177
+ const provisioned = fromManifest(stack, manifest(), overrides, {
178
+ cache: 'db',
179
+ });
180
+ const env = argsOf(provisioned.Web).environment as Record<string, unknown>;
181
+
182
+ const apiKey = `${PUBLIC_PREFIX.static}${providedKeyFor('Api', 'rest-api', 'url')}`;
183
+ expect(env[apiKey]).toBe(provisioned.Api!.provides().url);
184
+ // A topic has nothing a browser may see, so the site gets nothing from it.
185
+ expect(PUBLIC.topic ?? []).toEqual([]);
186
+ expect(Object.keys(env)).toEqual([apiKey]);
187
+ });
188
+
189
+ it('keeps a db-backed cache in the declared database, in its own table', async () => {
190
+ vi.spyOn(console, 'log').mockImplementation(() => {});
191
+
192
+ const provisioned = fromManifest(stack, manifest(), overrides, {
193
+ cache: 'db',
194
+ });
195
+ const url = String(await settle(provisioned.Sessions!.provides().url));
196
+
197
+ expect(url).toContain('db.stub.rds.amazonaws.com');
198
+ expect(url).toMatch(/sessions/i);
199
+ });
200
+
201
+ it('tells each surface who calls it once everything exists', async () => {
202
+ vi.spyOn(console, 'log').mockImplementation(() => {});
203
+
204
+ const provisioned = fromManifest(stack, manifest(), overrides, {
205
+ cache: 'db',
206
+ });
207
+ const origin = (id: string) =>
208
+ new URL(String(provisioned[id]!.provides().url)).origin;
209
+
210
+ // The site depends on the API, so its origin is on the API's list.
211
+ expect(await settle(provisioned.Api!.provides().trustedOrigins)).toContain(
212
+ origin('Web'),
213
+ );
214
+ // The API calls the auth server, so it is on the auth server's list.
215
+ expect(await settle(provisioned.Auth!.provides().trustedOrigins)).toContain(
216
+ origin('Api'),
217
+ );
218
+ });
219
+
220
+ it('prints every route and exactly what it can reach', () => {
221
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
222
+
223
+ fromManifest(stack, manifest(), overrides, { cache: 'db' });
224
+
225
+ expect(log.mock.calls.map(([line]) => line)).toEqual(
226
+ describeRoutes(manifest()),
227
+ );
228
+ expect(describeRoutes(manifest())).toEqual([
229
+ 'Api:',
230
+ ' GET /orders → Orders (database)',
231
+ ' GET /health → nothing',
232
+ ' calls Auth (origin only; grants nothing)',
233
+ 'Auth:',
234
+ ' (no routes)',
235
+ ]);
236
+ });
237
+
238
+ describe('schema tenants', () => {
239
+ const tenants = (roles?: false) =>
240
+ ({
241
+ Orders: {
242
+ kind: 'database',
243
+ id: 'Orders',
244
+ engine: 'postgres',
245
+ schema: 'app',
246
+ ...(roles === false ? { roles: false } : {}),
247
+ provides: keys('Orders', 'database', ['url']),
248
+ },
249
+ OrdersReader: {
250
+ kind: 'database-reader',
251
+ id: 'OrdersReader',
252
+ of: 'Orders',
253
+ provides: keys('OrdersReader', 'database-reader', ['url']),
254
+ },
255
+ AuthDb: {
256
+ kind: 'database-schema',
257
+ id: 'AuthDb',
258
+ of: 'Orders',
259
+ schema: 'authdb',
260
+ provides: keys('AuthDb', 'database-schema', ['url']),
261
+ },
262
+ // A tenant of a tenant still lives in the one cluster.
263
+ AuditDb: {
264
+ kind: 'database-schema',
265
+ id: 'AuditDb',
266
+ of: 'AuthDb',
267
+ schema: 'audit',
268
+ provides: keys('AuditDb', 'database-schema', ['url']),
269
+ },
270
+ }) as unknown as ConstructManifest;
271
+
272
+ it('gives each tenant its own role, and the reader the database’s read-only one', async () => {
273
+ vi.spyOn(console, 'log').mockImplementation(() => {});
274
+
275
+ const provisioned = fromManifest(stack, tenants(), { Orders: { vpc } });
276
+ const url = async (id: string) =>
277
+ String(await settle(provisioned[id]!.provides().url));
278
+
279
+ // Each connects as its own role, never the master.
280
+ expect(await url('AuthDb')).toContain('authdb:');
281
+ expect(await url('AuditDb')).toContain('auditdb:');
282
+ expect(await url('OrdersReader')).toContain('orders_reader:');
283
+ expect(await url('Orders')).not.toContain('postgres:stub-password');
284
+ });
285
+
286
+ it('falls back to the master everywhere under roles: false', async () => {
287
+ vi.spyOn(console, 'log').mockImplementation(() => {});
288
+
289
+ const provisioned = fromManifest(stack, tenants(false), {
290
+ Orders: { vpc },
291
+ });
292
+
293
+ expect(
294
+ String(await settle(provisioned.AuthDb!.provides().url)),
295
+ ).toContain('postgres:stub-password');
296
+ });
297
+ });
298
+
299
+ it('takes an existing cache and mailer rather than creating them', async () => {
300
+ vi.spyOn(console, 'log').mockImplementation(() => {});
301
+ const provisioned = fromManifest(
302
+ stack,
303
+ manifest(),
304
+ {
305
+ ...overrides,
306
+ Sessions: { url: 'https://:token@cache.upstash.test' },
307
+ Mail: { ...overrides.Mail, region: 'af-south-1' },
308
+ },
309
+ { cache: 'upstash', email: 'ses' },
310
+ );
311
+
312
+ expect(await settle(provisioned.Sessions!.provides().url)).toBe(
313
+ 'https://:token@cache.upstash.test',
314
+ );
315
+ expect(provisioned.Mail!.provides().url).toBe('smtp://relay.test:587');
316
+ });
317
+
318
+ describe('refuses', () => {
319
+ it('a database with no VPC to live in', () => {
320
+ expect(() =>
321
+ fromManifest(
322
+ stack,
323
+ manifest(),
324
+ { Mail: overrides.Mail },
325
+ { cache: 'db' },
326
+ ),
327
+ ).toThrow(DatabaseNeedsVpc);
328
+ });
329
+
330
+ it('a mailer with no sending identity', () => {
331
+ expect(() =>
332
+ fromManifest(stack, manifest(), { Orders: { vpc } }, { cache: 'db' }),
333
+ ).toThrow(EmailNeedsSender);
334
+ });
335
+
336
+ it('a db-backed cache when two databases could hold it', () => {
337
+ const two = {
338
+ ...manifest(),
339
+ Billing: {
340
+ kind: 'database',
341
+ id: 'Billing',
342
+ engine: 'postgres',
343
+ provides: keys('Billing', 'database', ['url']),
344
+ },
345
+ } as unknown as ConstructManifest;
346
+
347
+ expect(() =>
348
+ fromManifest(
349
+ stack,
350
+ two,
351
+ { ...overrides, Billing: { vpc } },
352
+ { cache: 'db' },
353
+ ),
354
+ ).toThrow(CacheIsAmbiguous);
355
+ });
356
+
357
+ it('a component supplying keys the declaration did not name', () => {
358
+ const drifted = {
359
+ ...manifest(),
360
+ Users: { ...manifest().Users, provides: [] },
361
+ } as unknown as ConstructManifest;
362
+
363
+ expect(() =>
364
+ fromManifest(stack, drifted, overrides, { cache: 'db' }),
365
+ ).toThrow(ProvidesMismatch);
366
+ });
367
+ });
368
+ });
@@ -0,0 +1,192 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { App } from '../App';
3
+ import { Cron } from '../aws/Cron';
4
+ import { Function } from '../aws/Function';
5
+ import { type GkmLinkable, ResourceType } from '../Linkable';
6
+
7
+ /**
8
+ * A Lambda and a schedule as data: the args each hands SST (a recording stub
9
+ * in `test/sst-globals.ts`), and the env validation that fails before deploy.
10
+ */
11
+
12
+ const app = new App({
13
+ name: 'shop',
14
+ stage: 'prod',
15
+ domain: 'shop.test',
16
+ hostedZoneId: 'Z1',
17
+ region: 'eu-west-1',
18
+ });
19
+ const stack = app.stack('jobs');
20
+ const db: GkmLinkable = { _id: 'db', _type: ResourceType.Postgres };
21
+
22
+ const argsOf = (component: unknown) =>
23
+ (component as { args: Record<string, unknown> }).args;
24
+
25
+ describe('Function', () => {
26
+ it('defaults the runtime and logging, and links only what its variables need', () => {
27
+ const fn = new Function(stack, 'Reports', {
28
+ handler: 'reports.handler',
29
+ links: [db],
30
+ envVars: ['DB_URL'],
31
+ environment: { FEATURE: 'on' },
32
+ });
33
+
34
+ expect(fn._type).toBe(ResourceType.Function);
35
+ expect(argsOf(fn)).toMatchObject({
36
+ handler: 'reports.handler',
37
+ runtime: 'nodejs24.x',
38
+ logging: { format: 'json' },
39
+ link: [db],
40
+ environment: expect.objectContaining({
41
+ SERVICE_NAME: 'Reports',
42
+ STAGE: 'prod',
43
+ FEATURE: 'on',
44
+ }),
45
+ });
46
+ expect(fn.validate().valid).toBe(true);
47
+ });
48
+
49
+ it('keeps a runtime and logging it was given', () => {
50
+ const fn = new Function(stack, 'Legacy', {
51
+ handler: 'legacy.handler',
52
+ runtime: 'nodejs20.x',
53
+ logging: { format: 'text' },
54
+ });
55
+
56
+ expect(argsOf(fn)).toMatchObject({
57
+ runtime: 'nodejs20.x',
58
+ logging: { format: 'text' },
59
+ link: [],
60
+ });
61
+ });
62
+
63
+ it('fails the synth on a variable no link provides, unless told not to check', () => {
64
+ expect(
65
+ () =>
66
+ new Function(stack, 'Mailer', {
67
+ handler: 'mailer.handler',
68
+ envVars: ['SMTP_URL'],
69
+ }),
70
+ ).toThrow(/SMTP_URL/);
71
+
72
+ const unchecked = new Function(stack, 'Mailer', {
73
+ handler: 'mailer.handler',
74
+ envVars: ['SMTP_URL'],
75
+ autoValidate: false,
76
+ });
77
+ expect(unchecked.validate()).toMatchObject({
78
+ valid: false,
79
+ invalidVars: ['SMTP_URL'],
80
+ });
81
+ });
82
+
83
+ it('builds one function per manifest entry, named for the stack', () => {
84
+ const functions = Function.fromManifest(
85
+ stack,
86
+ {
87
+ billing: [
88
+ {
89
+ name: 'Invoice',
90
+ handler: '.gkm/aws-lambda/functions/invoice.handler',
91
+ environment: ['DB_URL'],
92
+ timeout: 60,
93
+ memorySize: 512,
94
+ },
95
+ ],
96
+ ops: [
97
+ {
98
+ name: 'Ping',
99
+ handler: '.gkm/aws-lambda/functions/ping.handler',
100
+ environment: [],
101
+ },
102
+ ],
103
+ } as never,
104
+ { links: [db] },
105
+ );
106
+
107
+ expect(functions.map((f) => argsOf(f))).toEqual([
108
+ expect.objectContaining({
109
+ name: stack.logicalPrefixedName('Invoice'),
110
+ timeout: '60 seconds',
111
+ memory: '512 MB',
112
+ link: [db],
113
+ }),
114
+ expect.objectContaining({
115
+ name: stack.logicalPrefixedName('Ping'),
116
+ timeout: undefined,
117
+ memory: undefined,
118
+ link: [],
119
+ }),
120
+ ]);
121
+ });
122
+ });
123
+
124
+ describe('Cron', () => {
125
+ it('schedules the function it is given, by ARN', () => {
126
+ const processor = { arn: 'arn:aws:lambda:eu-west-1:1:function:nightly' };
127
+ const cron = new Cron(stack, 'Nightly', {
128
+ processor: processor as never,
129
+ schedule: 'rate(1 day)',
130
+ enabled: false,
131
+ });
132
+
133
+ expect(argsOf(cron)).toEqual({
134
+ enabled: false,
135
+ schedule: 'rate(1 day)',
136
+ function: processor.arn,
137
+ });
138
+ });
139
+
140
+ it('builds a validated function and a schedule per manifest cron', () => {
141
+ const crons = Cron.fromManifest(
142
+ stack,
143
+ [
144
+ {
145
+ name: 'Digest',
146
+ handler: '.gkm/aws-lambda/crons/digest.handler',
147
+ schedule: 'cron(0 8 * * ? *)',
148
+ environment: ['DB_URL'],
149
+ timeout: 120,
150
+ memorySize: 1024,
151
+ },
152
+ {
153
+ name: 'Sweep',
154
+ handler: '.gkm/aws-lambda/crons/sweep.handler',
155
+ schedule: 'rate(1 hour)',
156
+ environment: [],
157
+ },
158
+ ] as never,
159
+ { links: [db], enabled: true },
160
+ );
161
+
162
+ const [digest, sweep] = crons.map((c) => argsOf(c));
163
+ expect(digest).toMatchObject({
164
+ schedule: 'cron(0 8 * * ? *)',
165
+ enabled: true,
166
+ function: expect.stringContaining('DigestFunction'),
167
+ });
168
+ expect(sweep).toMatchObject({ schedule: 'rate(1 hour)' });
169
+ });
170
+ });
171
+
172
+ describe('App and Stack', () => {
173
+ it('pick a value for the stage, falling back to the default', () => {
174
+ expect(app.select({ prod: 'live', default: 'test' })).toBe('live');
175
+ expect(stack.select({ staging: 'test', default: 'dev' } as never)).toBe(
176
+ 'dev',
177
+ );
178
+ });
179
+
180
+ it('name subdomains and URLs under the root domain', () => {
181
+ expect(stack.domain).toBe('shop.test');
182
+ expect(stack.getSubdomain('api')).toBe('api.shop.test');
183
+ expect(stack.getURL('api')).toBe('https://api.shop.test');
184
+ expect(stack.getURL()).toBe('https://shop.test');
185
+ expect(app.getURL()).toBe('https://shop.test');
186
+ });
187
+
188
+ it('prefix physical names with the stage, app and stack', () => {
189
+ expect(app.logicalPrefixedName('Uploads')).toBe('prod-shop-uploads');
190
+ expect(stack.logicalPrefixedName('Uploads')).toBe('prod-shop-jobs-uploads');
191
+ });
192
+ });