@1agh/maude 0.48.0 → 0.49.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/apps/studio/api.ts +18 -2
- package/apps/studio/assets-s3.ts +291 -0
- package/apps/studio/collab/origins.ts +150 -0
- package/apps/studio/collab/protocol.ts +36 -9
- package/apps/studio/collab/room.ts +124 -0
- package/apps/studio/context.ts +9 -0
- package/apps/studio/dist/client.bundle.js +1 -1
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/http.ts +45 -0
- package/apps/studio/server.ts +75 -2
- package/apps/studio/sync/autocommit.ts +299 -0
- package/apps/studio/sync/doc-name.ts +228 -0
- package/apps/studio/sync/index.ts +95 -1
- package/apps/studio/sync/workspace-signin.ts +301 -0
- package/apps/studio/test/assets-s3.test.ts +249 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/collab-origin-gate.test.ts +323 -0
- package/apps/studio/test/sync-autocommit.test.ts +334 -0
- package/apps/studio/test/sync-doc-name.test.ts +281 -0
- package/apps/studio/test/workspace-containment.test.ts +258 -0
- package/apps/studio/test/workspace-signin.test.ts +270 -0
- package/apps/studio/use-collab.tsx +28 -1
- package/apps/studio/workspace-mode.ts +210 -0
- package/apps/studio/ws.ts +11 -2
- package/cli/commands/hub-workspace.mjs +341 -0
- package/cli/commands/hub.mjs +325 -3
- package/cli/commands/hub.test.mjs +14 -1
- package/cli/lib/cell-plan.mjs +302 -0
- package/cli/lib/cell-plan.test.mjs +225 -0
- package/cli/lib/gitignore-block.mjs +12 -1
- package/cli/lib/workspace-plan.mjs +422 -0
- package/cli/lib/workspace-plan.test.mjs +223 -0
- package/package.json +8 -8
|
@@ -32,7 +32,20 @@ function withDataDir(fn) {
|
|
|
32
32
|
test('hub help prints subcommand summary on stdout', () => {
|
|
33
33
|
const res = runCli(['hub', 'help']);
|
|
34
34
|
assert.equal(res.status, 0, res.stderr);
|
|
35
|
-
|
|
35
|
+
// Every verb must appear in the usage header — a subcommand that exists but
|
|
36
|
+
// isn't listed is one nobody finds.
|
|
37
|
+
assert.match(res.stdout, /maude hub <[a-z|-]+> \[options\]/);
|
|
38
|
+
for (const verb of [
|
|
39
|
+
'serve',
|
|
40
|
+
'token',
|
|
41
|
+
'status',
|
|
42
|
+
'deploy',
|
|
43
|
+
'backup',
|
|
44
|
+
'restore-drill',
|
|
45
|
+
'asset-check',
|
|
46
|
+
]) {
|
|
47
|
+
assert.ok(res.stdout.includes(verb), `usage must mention "${verb}"`);
|
|
48
|
+
}
|
|
36
49
|
assert.match(res.stdout, /token generate --label NAME/);
|
|
37
50
|
assert.match(res.stdout, /token rotate --label NAME/);
|
|
38
51
|
assert.match(res.stdout, /deploy <fly\|docker>/);
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// `maude cell` — the pure planning layer. Cloud Phase 5 Task 2.
|
|
2
|
+
//
|
|
3
|
+
// Naming, addressing, isolation boundaries, and the teardown sweep, as
|
|
4
|
+
// functions of their inputs. No Cloudflare API calls; `cli/commands/cell.mjs`
|
|
5
|
+
// makes those.
|
|
6
|
+
//
|
|
7
|
+
// Why this is worth separating (same argument as workspace-plan.mjs, and it
|
|
8
|
+
// matters more here): the failure modes of a multi-tenant provisioner are
|
|
9
|
+
// almost entirely NAMING and SCOPE mistakes — a tenant id that escapes its R2
|
|
10
|
+
// prefix, a hostname that collides with another tenant's, a destroy that
|
|
11
|
+
// misses one resource and leaves an orphan billing forever. All of those are
|
|
12
|
+
// decisions, and decisions can be tested exhaustively without a paid account.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A tenant id is not cosmetic. It becomes:
|
|
16
|
+
* • the R2 key prefix `tenants/<id>/`,
|
|
17
|
+
* • the subdomain `<id>.cloud.maude.sh`,
|
|
18
|
+
* • the Durable Object name,
|
|
19
|
+
* • the container instance name.
|
|
20
|
+
*
|
|
21
|
+
* So it has to satisfy the intersection of DNS labels, R2 keys, and Cloudflare
|
|
22
|
+
* resource names, and it must be impossible to construct one that reads as
|
|
23
|
+
* another tenant's namespace.
|
|
24
|
+
*/
|
|
25
|
+
const TENANT_ID_RE = /^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])?$/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reserved for the platform itself. `api.cloud.maude.sh` belonging to a tenant
|
|
29
|
+
* who signed up early would be a phishing surface handed out for free.
|
|
30
|
+
*/
|
|
31
|
+
const RESERVED_IDS = new Set([
|
|
32
|
+
'api',
|
|
33
|
+
'www',
|
|
34
|
+
'admin',
|
|
35
|
+
'app',
|
|
36
|
+
'dashboard',
|
|
37
|
+
'status',
|
|
38
|
+
'docs',
|
|
39
|
+
'help',
|
|
40
|
+
'support',
|
|
41
|
+
'billing',
|
|
42
|
+
'account',
|
|
43
|
+
'accounts',
|
|
44
|
+
'login',
|
|
45
|
+
'signin',
|
|
46
|
+
'signup',
|
|
47
|
+
'auth',
|
|
48
|
+
'cdn',
|
|
49
|
+
'assets',
|
|
50
|
+
'static',
|
|
51
|
+
'mail',
|
|
52
|
+
'smtp',
|
|
53
|
+
'ns',
|
|
54
|
+
'ns1',
|
|
55
|
+
'ns2',
|
|
56
|
+
'internal',
|
|
57
|
+
'system',
|
|
58
|
+
'maude',
|
|
59
|
+
'cloud',
|
|
60
|
+
'test',
|
|
61
|
+
'staging',
|
|
62
|
+
'prod',
|
|
63
|
+
'production',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
export function validateTenantId(raw) {
|
|
67
|
+
const id = String(raw ?? '')
|
|
68
|
+
.trim()
|
|
69
|
+
.toLowerCase();
|
|
70
|
+
if (!id) return { ok: false, error: 'a project id is required' };
|
|
71
|
+
if (id.length > 63) return { ok: false, error: 'project id must be at most 63 characters' };
|
|
72
|
+
if (!TENANT_ID_RE.test(id)) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
error:
|
|
76
|
+
'project id must be lowercase letters, digits and inner hyphens ' +
|
|
77
|
+
'(it becomes a hostname, an R2 key prefix and a Durable Object name)',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (RESERVED_IDS.has(id)) {
|
|
81
|
+
// Handing out `api.cloud.maude.sh` to whoever signs up first is a phishing
|
|
82
|
+
// surface given away for free.
|
|
83
|
+
return { ok: false, error: `"${id}" is reserved for the platform` };
|
|
84
|
+
}
|
|
85
|
+
if (id.startsWith('xn--')) {
|
|
86
|
+
return { ok: false, error: 'punycode-style ids are not allowed (homograph risk)' };
|
|
87
|
+
}
|
|
88
|
+
return { ok: true, id };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const DEFAULT_ZONE = 'cloud.maude.sh';
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Every name a cell occupies, derived from one id so they cannot drift apart.
|
|
95
|
+
* Drift is how a destroy misses a resource.
|
|
96
|
+
*/
|
|
97
|
+
export function cellNames(id, { zone = DEFAULT_ZONE } = {}) {
|
|
98
|
+
const check = validateTenantId(id);
|
|
99
|
+
if (!check.ok) throw new Error(`invalid project id: ${check.error}`);
|
|
100
|
+
const tenant = check.id;
|
|
101
|
+
return {
|
|
102
|
+
tenant,
|
|
103
|
+
hostname: `${tenant}.${zone}`,
|
|
104
|
+
/** Trailing slash included: it is what makes prefix matching exact. */
|
|
105
|
+
r2Prefix: `tenants/${tenant}/`,
|
|
106
|
+
durableObject: `cell-${tenant}`,
|
|
107
|
+
container: `maude-cell-${tenant}`,
|
|
108
|
+
/** Per-cell scoped token name — never the account master key (DDR-164). */
|
|
109
|
+
r2TokenName: `maude-cell-${tenant}-r2`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* True when `key` belongs to `tenant`'s prefix.
|
|
115
|
+
*
|
|
116
|
+
* Exists as its own function because "starts with the prefix" is subtly wrong
|
|
117
|
+
* without the trailing slash: `tenants/acme` would match `tenants/acme-evil/…`.
|
|
118
|
+
* That single character is the entire isolation boundary between two
|
|
119
|
+
* customers' data.
|
|
120
|
+
*/
|
|
121
|
+
export function keyBelongsToTenant(key, tenant) {
|
|
122
|
+
const names = cellNames(tenant);
|
|
123
|
+
return typeof key === 'string' && key.startsWith(names.r2Prefix);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The resources a cell owns, in the order `destroy` must remove them.
|
|
128
|
+
*
|
|
129
|
+
* Order is deliberate: stop routing FIRST, then compute, then credentials,
|
|
130
|
+
* then data. Deleting data while the hostname still resolves gives a live
|
|
131
|
+
* endpoint serving a half-deleted project; revoking the token before stopping
|
|
132
|
+
* the container gives a running cell erroring in a loop.
|
|
133
|
+
*
|
|
134
|
+
* `retainable` marks what a SUSPEND keeps. Suspension stops a cell; it does not
|
|
135
|
+
* delete state (DDR-193 §3) — the 30-day retention and export-before-teardown
|
|
136
|
+
* guarantee live here.
|
|
137
|
+
*/
|
|
138
|
+
export function cellResources(id, { zone = DEFAULT_ZONE } = {}) {
|
|
139
|
+
const n = cellNames(id, { zone });
|
|
140
|
+
return [
|
|
141
|
+
{
|
|
142
|
+
kind: 'dns',
|
|
143
|
+
name: n.hostname,
|
|
144
|
+
why: 'stop routing first — otherwise a half-deleted project stays reachable',
|
|
145
|
+
retainable: false,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
kind: 'worker-route',
|
|
149
|
+
name: `${n.hostname}/*`,
|
|
150
|
+
why: 'the ingress route that maps the hostname to this cell’s Durable Object',
|
|
151
|
+
retainable: false,
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
kind: 'container',
|
|
155
|
+
name: n.container,
|
|
156
|
+
why: 'the running cell',
|
|
157
|
+
retainable: false,
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
kind: 'durable-object',
|
|
161
|
+
name: n.durableObject,
|
|
162
|
+
why: 'lifecycle + hibernation state for this project',
|
|
163
|
+
retainable: false,
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
kind: 'r2-token',
|
|
167
|
+
name: n.r2TokenName,
|
|
168
|
+
why: 'the per-cell scoped credential; revoked after compute is gone, never before',
|
|
169
|
+
retainable: false,
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
kind: 'r2-prefix',
|
|
173
|
+
name: n.r2Prefix,
|
|
174
|
+
why: 'the tenant’s data — retained through suspension, deleted only after export',
|
|
175
|
+
retainable: true,
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* What `destroy` must verify is gone. A destroy that leaves an orphan bills
|
|
182
|
+
* forever and is invisible until the invoice, so the sweep is part of the
|
|
183
|
+
* operation rather than a separate cleanup task somebody remembers to run.
|
|
184
|
+
*/
|
|
185
|
+
export function destroySweep(id, { zone = DEFAULT_ZONE, purgeData = false } = {}) {
|
|
186
|
+
return cellResources(id, { zone }).filter((r) => purgeData || !r.retainable);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The tenant lifecycle from DDR-193 §3, as a machine.
|
|
191
|
+
*
|
|
192
|
+
* Encoded rather than implied because the ONE transition that must never exist
|
|
193
|
+
* is `* → purged` without passing through `exported`: there is no path from
|
|
194
|
+
* "stopped paying" to "your designs are gone" that skips "you were handed your
|
|
195
|
+
* files".
|
|
196
|
+
*/
|
|
197
|
+
const TRANSITIONS = {
|
|
198
|
+
pending: ['active', 'purged'],
|
|
199
|
+
active: ['past_due', 'suspended'],
|
|
200
|
+
past_due: ['active', 'suspended'],
|
|
201
|
+
suspended: ['active', 'exported'],
|
|
202
|
+
exported: ['active', 'purged'],
|
|
203
|
+
purged: [],
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export function canTransition(from, to) {
|
|
207
|
+
return (TRANSITIONS[from] ?? []).includes(to);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function assertTransition(from, to) {
|
|
211
|
+
if (!TRANSITIONS[from]) throw new Error(`unknown state "${from}"`);
|
|
212
|
+
if (!TRANSITIONS[to]) throw new Error(`unknown state "${to}"`);
|
|
213
|
+
if (!canTransition(from, to)) {
|
|
214
|
+
const extra =
|
|
215
|
+
to === 'purged' && from !== 'exported'
|
|
216
|
+
? ' — a tenant must be EXPORTED before purge (DDR-193 §3): there is no path from ' +
|
|
217
|
+
'"stopped paying" to "your designs are gone" that skips "you were handed your files"'
|
|
218
|
+
: '';
|
|
219
|
+
throw new Error(`illegal transition ${from} → ${to}${extra}`);
|
|
220
|
+
}
|
|
221
|
+
return to;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function lifecycleStates() {
|
|
225
|
+
return Object.keys(TRANSITIONS);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The next state on the shortest LEGAL path from `from` to `to`, or null when
|
|
230
|
+
* no path exists.
|
|
231
|
+
*
|
|
232
|
+
* Needed because a desired state is often not reachable in one hop and that is
|
|
233
|
+
* perfectly ordinary rather than an error: a `pending` tenant whose payment is
|
|
234
|
+
* already failing did subscribe, so the truthful route is
|
|
235
|
+
* `pending → active → past_due`, and a `suspended` tenant who resubscribes with
|
|
236
|
+
* a failing card goes `suspended → active → past_due`.
|
|
237
|
+
*
|
|
238
|
+
* Walking the path beats jumping to the destination, because every hop stays
|
|
239
|
+
* inside the machine — including the one hop the machine exists to forbid.
|
|
240
|
+
* `exported` is the only door to `purged` no matter how the search runs.
|
|
241
|
+
*/
|
|
242
|
+
export function stepToward(from, to) {
|
|
243
|
+
if (from === to) return null;
|
|
244
|
+
if (!TRANSITIONS[from] || !TRANSITIONS[to]) return null;
|
|
245
|
+
const queue = [[from, null]];
|
|
246
|
+
const seen = new Set([from]);
|
|
247
|
+
while (queue.length > 0) {
|
|
248
|
+
const [state, firstHop] = queue.shift();
|
|
249
|
+
for (const next of TRANSITIONS[state]) {
|
|
250
|
+
if (seen.has(next)) continue;
|
|
251
|
+
const hop = firstHop ?? next;
|
|
252
|
+
if (next === to) return hop;
|
|
253
|
+
seen.add(next);
|
|
254
|
+
queue.push([next, hop]);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Render the container + ingress config for one cell.
|
|
262
|
+
*
|
|
263
|
+
* Kept as data (not a template string) so a test can assert a specific field
|
|
264
|
+
* without matching a whole file, and so the control plane in Phase 7 can build
|
|
265
|
+
* the same object without re-parsing TOML.
|
|
266
|
+
*/
|
|
267
|
+
export function cellConfig(
|
|
268
|
+
id,
|
|
269
|
+
{ zone = DEFAULT_ZONE, memoryMib = 2048, imageTag = 'latest' } = {}
|
|
270
|
+
) {
|
|
271
|
+
const n = cellNames(id, { zone });
|
|
272
|
+
if (!Number.isInteger(memoryMib) || memoryMib < 512 || memoryMib > 4096) {
|
|
273
|
+
// Containers GA caps at 4 GiB; below 512 MiB the hub cannot hold a
|
|
274
|
+
// reasonable working set. Both bounds are refusals, not clamps — silently
|
|
275
|
+
// giving someone less memory than they asked for is a support ticket
|
|
276
|
+
// nobody can diagnose.
|
|
277
|
+
throw new Error('memoryMib must be an integer between 512 and 4096');
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
name: n.container,
|
|
281
|
+
image: `ghcr.io/1agh/maude-cell:${imageTag}`,
|
|
282
|
+
instanceType: { memoryMib, vcpu: 0.5 },
|
|
283
|
+
// Scale to zero: an idle cell should cost approximately nothing under
|
|
284
|
+
// Active-CPU pricing, which is the whole economic premise (DDR-193 §1).
|
|
285
|
+
lifecycle: { scaleToZero: true, wakeOn: ['http', 'websocket'], idleTimeoutSeconds: 300 },
|
|
286
|
+
durableObject: n.durableObject,
|
|
287
|
+
route: `${n.hostname}/*`,
|
|
288
|
+
env: {
|
|
289
|
+
MAUDE_TENANT_ID: n.tenant,
|
|
290
|
+
HUB_PUBLIC_URL: `https://${n.hostname}`,
|
|
291
|
+
HUB_WORKSPACE_MODE: '1',
|
|
292
|
+
MAUDE_WORKSPACE_MODE: '1',
|
|
293
|
+
// Caddy is gone — TLS terminates at the Cloudflare edge, and the Worker
|
|
294
|
+
// is the only thing that can reach the container.
|
|
295
|
+
HUB_TRUSTED_PROXIES: '10.0.0.0/8,172.16.0.0/12,fd00::/8',
|
|
296
|
+
},
|
|
297
|
+
// Secrets are NAMED here, never valued: this object is logged, diffed and
|
|
298
|
+
// stored by the control plane.
|
|
299
|
+
secrets: ['HUB_SECRET', 'MAUDE_S3_ACCESS_KEY_ID', 'MAUDE_S3_SECRET_ACCESS_KEY'],
|
|
300
|
+
r2: { prefix: n.r2Prefix, tokenName: n.r2TokenName },
|
|
301
|
+
};
|
|
302
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Cloud Phase 5 Task 2 — cell naming, isolation, teardown and lifecycle.
|
|
2
|
+
//
|
|
3
|
+
// A multi-tenant provisioner fails at NAMING and SCOPE: an id that escapes its
|
|
4
|
+
// R2 prefix, a hostname handed to the wrong party, a destroy that leaves an
|
|
5
|
+
// orphan billing forever. Those are decisions, and decisions test exhaustively
|
|
6
|
+
// without a paid Cloudflare account — which is the point of this file existing
|
|
7
|
+
// before the account does.
|
|
8
|
+
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
assertTransition,
|
|
14
|
+
canTransition,
|
|
15
|
+
cellConfig,
|
|
16
|
+
cellNames,
|
|
17
|
+
cellResources,
|
|
18
|
+
destroySweep,
|
|
19
|
+
keyBelongsToTenant,
|
|
20
|
+
lifecycleStates,
|
|
21
|
+
validateTenantId,
|
|
22
|
+
} from './cell-plan.mjs';
|
|
23
|
+
|
|
24
|
+
// ------------------------------------------------------------------ naming
|
|
25
|
+
|
|
26
|
+
test('a well-formed id is accepted and lowercased', () => {
|
|
27
|
+
assert.deepEqual(validateTenantId(' Alligators '), { ok: true, id: 'alligators' });
|
|
28
|
+
assert.deepEqual(validateTenantId('acme-design-2'), { ok: true, id: 'acme-design-2' });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('ids that would break a hostname, an R2 key or a DO name are refused', () => {
|
|
32
|
+
for (const bad of [
|
|
33
|
+
'',
|
|
34
|
+
' ',
|
|
35
|
+
'-leading',
|
|
36
|
+
'trailing-',
|
|
37
|
+
'has_underscore',
|
|
38
|
+
'has.dot',
|
|
39
|
+
'has space',
|
|
40
|
+
'UPPER-ONLY-OK-BUT/slash',
|
|
41
|
+
'../escape',
|
|
42
|
+
'tenants/other',
|
|
43
|
+
'a'.repeat(64),
|
|
44
|
+
'emoji-🙂',
|
|
45
|
+
]) {
|
|
46
|
+
assert.equal(validateTenantId(bad).ok, false, `${JSON.stringify(bad)} must be refused`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('platform names are reserved', () => {
|
|
51
|
+
// `api.cloud.maude.sh` belonging to whoever signs up first is a phishing
|
|
52
|
+
// surface given away for free.
|
|
53
|
+
for (const reserved of ['api', 'www', 'admin', 'billing', 'login', 'assets', 'maude']) {
|
|
54
|
+
const res = validateTenantId(reserved);
|
|
55
|
+
assert.equal(res.ok, false);
|
|
56
|
+
assert.match(res.error, /reserved/);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('punycode-style ids are refused (homograph risk)', () => {
|
|
61
|
+
assert.equal(validateTenantId('xn--80ak6aa92e').ok, false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('every name a cell occupies derives from ONE id', () => {
|
|
65
|
+
// Drift between these is how a destroy misses a resource.
|
|
66
|
+
const n = cellNames('alligators');
|
|
67
|
+
assert.deepEqual(n, {
|
|
68
|
+
tenant: 'alligators',
|
|
69
|
+
hostname: 'alligators.cloud.maude.sh',
|
|
70
|
+
r2Prefix: 'tenants/alligators/',
|
|
71
|
+
durableObject: 'cell-alligators',
|
|
72
|
+
container: 'maude-cell-alligators',
|
|
73
|
+
r2TokenName: 'maude-cell-alligators-r2',
|
|
74
|
+
});
|
|
75
|
+
assert.throws(() => cellNames('../evil'), /invalid project id/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// --------------------------------------------------------------- isolation
|
|
79
|
+
|
|
80
|
+
test('the trailing slash IS the isolation boundary between two customers', () => {
|
|
81
|
+
// Without it, `tenants/acme` matches `tenants/acme-evil/secret.png`. One
|
|
82
|
+
// character, and it is the entire boundary.
|
|
83
|
+
assert.equal(keyBelongsToTenant('tenants/acme/assets/deadbeef.png', 'acme'), true);
|
|
84
|
+
assert.equal(keyBelongsToTenant('tenants/acme/', 'acme'), true);
|
|
85
|
+
assert.equal(keyBelongsToTenant('tenants/acme-evil/assets/x.png', 'acme'), false);
|
|
86
|
+
assert.equal(keyBelongsToTenant('tenants/acmex/x.png', 'acme'), false);
|
|
87
|
+
assert.equal(keyBelongsToTenant('tenants/other/x.png', 'acme'), false);
|
|
88
|
+
assert.equal(keyBelongsToTenant('backups/hub.db.gz', 'acme'), false);
|
|
89
|
+
assert.equal(keyBelongsToTenant('', 'acme'), false);
|
|
90
|
+
assert.equal(keyBelongsToTenant(null, 'acme'), false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('two tenants can never share a prefix, hostname or DO name', () => {
|
|
94
|
+
const a = cellNames('acme');
|
|
95
|
+
const b = cellNames('acme-evil');
|
|
96
|
+
assert.notEqual(a.r2Prefix, b.r2Prefix);
|
|
97
|
+
assert.notEqual(a.hostname, b.hostname);
|
|
98
|
+
assert.notEqual(a.durableObject, b.durableObject);
|
|
99
|
+
assert.ok(!b.r2Prefix.startsWith(a.r2Prefix));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------- teardown
|
|
103
|
+
|
|
104
|
+
test('destroy removes routing first and data last', () => {
|
|
105
|
+
// Deleting data while the hostname resolves leaves a live endpoint serving a
|
|
106
|
+
// half-deleted project; revoking the credential before stopping the container
|
|
107
|
+
// leaves a cell erroring in a loop.
|
|
108
|
+
const order = cellResources('acme').map((r) => r.kind);
|
|
109
|
+
assert.deepEqual(order, [
|
|
110
|
+
'dns',
|
|
111
|
+
'worker-route',
|
|
112
|
+
'container',
|
|
113
|
+
'durable-object',
|
|
114
|
+
'r2-token',
|
|
115
|
+
'r2-prefix',
|
|
116
|
+
]);
|
|
117
|
+
assert.ok(order.indexOf('dns') < order.indexOf('container'));
|
|
118
|
+
assert.ok(order.indexOf('container') < order.indexOf('r2-token'));
|
|
119
|
+
assert.ok(order.indexOf('r2-token') < order.indexOf('r2-prefix'));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('every resource explains why it is in the list', () => {
|
|
123
|
+
for (const r of cellResources('acme')) {
|
|
124
|
+
assert.ok(r.why && r.why.length > 15, `${r.kind} needs a reason`);
|
|
125
|
+
assert.ok(r.name, `${r.kind} needs a name`);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('a suspend keeps the DATA and drops everything else', () => {
|
|
130
|
+
// Suspension stops a cell; it does not delete state (DDR-193 §3).
|
|
131
|
+
const suspend = destroySweep('acme').map((r) => r.kind);
|
|
132
|
+
assert.ok(!suspend.includes('r2-prefix'), 'suspension must never delete the tenant’s data');
|
|
133
|
+
assert.ok(suspend.includes('container'));
|
|
134
|
+
|
|
135
|
+
const purge = destroySweep('acme', { purgeData: true }).map((r) => r.kind);
|
|
136
|
+
assert.ok(purge.includes('r2-prefix'), 'a full purge does remove it');
|
|
137
|
+
assert.equal(purge.length, cellResources('acme').length);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// --------------------------------------------------------------- lifecycle
|
|
141
|
+
|
|
142
|
+
test('the state machine matches DDR-193 §3', () => {
|
|
143
|
+
assert.deepEqual(lifecycleStates(), [
|
|
144
|
+
'pending',
|
|
145
|
+
'active',
|
|
146
|
+
'past_due',
|
|
147
|
+
'suspended',
|
|
148
|
+
'exported',
|
|
149
|
+
'purged',
|
|
150
|
+
]);
|
|
151
|
+
assert.equal(canTransition('pending', 'active'), true);
|
|
152
|
+
assert.equal(canTransition('active', 'past_due'), true);
|
|
153
|
+
assert.equal(canTransition('past_due', 'active'), true, 'paying again must resurrect');
|
|
154
|
+
assert.equal(canTransition('past_due', 'suspended'), true);
|
|
155
|
+
assert.equal(canTransition('suspended', 'active'), true, 'suspension must be reversible');
|
|
156
|
+
assert.equal(canTransition('suspended', 'exported'), true);
|
|
157
|
+
assert.equal(canTransition('exported', 'purged'), true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('NOTHING reaches purged without passing through exported', () => {
|
|
161
|
+
// The one transition that must never exist: there is no path from "stopped
|
|
162
|
+
// paying" to "your designs are gone" that skips "you were handed your files".
|
|
163
|
+
for (const from of ['active', 'past_due', 'suspended']) {
|
|
164
|
+
assert.equal(canTransition(from, 'purged'), false, `${from} → purged must be illegal`);
|
|
165
|
+
assert.throws(() => assertTransition(from, 'purged'), /must be EXPORTED before purge/);
|
|
166
|
+
}
|
|
167
|
+
// `pending → purged` is allowed: a cell that never became active has no
|
|
168
|
+
// customer data to hand back.
|
|
169
|
+
assert.equal(canTransition('pending', 'purged'), true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('purged is terminal, and unknown states are refused', () => {
|
|
173
|
+
for (const to of lifecycleStates()) {
|
|
174
|
+
assert.equal(canTransition('purged', to), false);
|
|
175
|
+
}
|
|
176
|
+
assert.throws(() => assertTransition('nonsense', 'active'), /unknown state/);
|
|
177
|
+
assert.throws(() => assertTransition('active', 'deleted'), /unknown state/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ------------------------------------------------------------------ config
|
|
181
|
+
|
|
182
|
+
test('the rendered cell config carries the containment flags', () => {
|
|
183
|
+
const cfg = cellConfig('alligators');
|
|
184
|
+
assert.equal(cfg.env.MAUDE_WORKSPACE_MODE, '1');
|
|
185
|
+
assert.equal(cfg.env.HUB_WORKSPACE_MODE, '1');
|
|
186
|
+
assert.equal(cfg.env.MAUDE_TENANT_ID, 'alligators');
|
|
187
|
+
assert.equal(cfg.env.HUB_PUBLIC_URL, 'https://alligators.cloud.maude.sh');
|
|
188
|
+
assert.equal(cfg.route, 'alligators.cloud.maude.sh/*');
|
|
189
|
+
assert.equal(cfg.r2.prefix, 'tenants/alligators/');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('secrets are NAMED, never valued', () => {
|
|
193
|
+
// This object is logged, diffed and stored by the control plane.
|
|
194
|
+
const cfg = cellConfig('acme');
|
|
195
|
+
assert.deepEqual(cfg.secrets, [
|
|
196
|
+
'HUB_SECRET',
|
|
197
|
+
'MAUDE_S3_ACCESS_KEY_ID',
|
|
198
|
+
'MAUDE_S3_SECRET_ACCESS_KEY',
|
|
199
|
+
]);
|
|
200
|
+
for (const value of Object.values(cfg.env)) {
|
|
201
|
+
assert.ok(!/secret|password|key/i.test(String(value)), 'no secret may appear in env values');
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('an idle cell scales to zero and wakes on connect', () => {
|
|
206
|
+
// The economic premise: an idle cell costs approximately nothing under
|
|
207
|
+
// Active-CPU pricing (DDR-193 §1).
|
|
208
|
+
const cfg = cellConfig('acme');
|
|
209
|
+
assert.equal(cfg.lifecycle.scaleToZero, true);
|
|
210
|
+
assert.deepEqual(cfg.lifecycle.wakeOn, ['http', 'websocket']);
|
|
211
|
+
assert.ok(cfg.lifecycle.idleTimeoutSeconds > 0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('memory outside the platform bounds is REFUSED, not clamped', () => {
|
|
215
|
+
// Silently giving someone less memory than they asked for is a support
|
|
216
|
+
// ticket nobody can diagnose.
|
|
217
|
+
assert.equal(cellConfig('acme', { memoryMib: 4096 }).instanceType.memoryMib, 4096);
|
|
218
|
+
for (const bad of [0, 256, 8192, 1024.5, '2048']) {
|
|
219
|
+
assert.throws(() => cellConfig('acme', { memoryMib: bad }), /between 512 and 4096/);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('the image tag is pinnable', () => {
|
|
224
|
+
assert.match(cellConfig('acme', { imageTag: 'v0.48.0' }).image, /maude-cell:v0\.48\.0$/);
|
|
225
|
+
});
|
|
@@ -20,7 +20,7 @@ export const END_MARKER = '# maude:end';
|
|
|
20
20
|
* The runtime paths Maude ignores under the design root, relative to repo root.
|
|
21
21
|
* `designRel` defaults to `.design` (the only design root in v1.1 defaults).
|
|
22
22
|
*/
|
|
23
|
-
export function buildBlock(designRel = '.design') {
|
|
23
|
+
export function buildBlock(designRel = '.design', { s3Assets = false } = {}) {
|
|
24
24
|
const root = designRel.replace(/\/+$/, '');
|
|
25
25
|
// The canonical IGNORED set is the DDR-115 runtime-state taxonomy — kept in
|
|
26
26
|
// lockstep with `apps/studio/git/service.ts` (isMaudeRuntimeState backstop)
|
|
@@ -51,6 +51,17 @@ export function buildBlock(designRel = '.design') {
|
|
|
51
51
|
`${root}/_chat/`, // ACP transcripts (per-machine)
|
|
52
52
|
`${root}/_untrusted/`, // hub-synced untrusted file mirror (DDR-054)
|
|
53
53
|
`${root}/_comments/`, // hub-sync-only collab comments (DDR-102/DDR-115 — never git)
|
|
54
|
+
// Cloud Phase 3 Task 2 — when an S3/R2 asset lane is configured, binary
|
|
55
|
+
// media lives in the bucket and must STOP entering git: a 60 MB clip is
|
|
56
|
+
// 60 MB in every clone forever, and delta compression does nothing for it.
|
|
57
|
+
// Off by default, because without a bucket the only copy of an asset is the
|
|
58
|
+
// one in the repo and ignoring it would delete people's media on the next
|
|
59
|
+
// clean checkout.
|
|
60
|
+
...(s3Assets
|
|
61
|
+
? [
|
|
62
|
+
`${root}/assets/`, // binary media lives in the S3/R2 bucket (DDR-192 §1)
|
|
63
|
+
]
|
|
64
|
+
: []),
|
|
54
65
|
// kgai knowledge-graph projection (repo-root, NOT under the design root) —
|
|
55
66
|
// per-machine append-only store, rebuilds from the remote on `kg sync`
|
|
56
67
|
// (feature-kgai-ecosystem-integration, DDR-115). kgai itself also ignores it.
|