ankka-console 0.10.0 → 0.12.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.
Files changed (75) hide show
  1. package/dist/client/control-plane.d.ts +87 -1
  2. package/dist/client/control-plane.js +201 -1
  3. package/dist/client/schemas.d.ts +1835 -0
  4. package/dist/client/schemas.js +595 -0
  5. package/dist/context.d.ts +98 -0
  6. package/dist/context.js +73 -0
  7. package/dist/extensions/render.d.ts +2 -0
  8. package/dist/extensions/render.js +5 -0
  9. package/dist/extensions/types.d.ts +1 -1
  10. package/dist/extensions/types.js +29 -0
  11. package/dist/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
  12. package/dist/fonts/inter-latin-wght-normal.woff2 +0 -0
  13. package/dist/index.d.ts +9 -2
  14. package/dist/index.js +7 -1
  15. package/dist/routes/auth.sign-out.js +8 -3
  16. package/dist/routes/front.d.ts +62 -2
  17. package/dist/routes/front.js +45 -10
  18. package/dist/routes/logs.d.ts +92 -0
  19. package/dist/routes/logs.js +15 -14
  20. package/dist/routes/machines.d.ts +78 -0
  21. package/dist/routes/machines.js +78 -0
  22. package/dist/routes/members.js +11 -8
  23. package/dist/routes/organization-new.js +7 -4
  24. package/dist/routes/organization.d.ts +29 -1
  25. package/dist/routes/organization.js +38 -9
  26. package/dist/routes/project-new.js +8 -4
  27. package/dist/routes/project.d.ts +347 -2
  28. package/dist/routes/project.js +182 -9
  29. package/dist/routes/service-apply.d.ts +5 -2
  30. package/dist/routes/service-apply.js +17 -14
  31. package/dist/routes/service-history.d.ts +32 -0
  32. package/dist/routes/service-history.js +82 -0
  33. package/dist/routes/service-topology.d.ts +210 -0
  34. package/dist/routes/service-topology.js +54 -0
  35. package/dist/routes/service.d.ts +91 -10
  36. package/dist/routes/service.js +87 -23
  37. package/dist/routes/stream.service.js +18 -1
  38. package/dist/routes/tokens.js +8 -6
  39. package/dist/routes.js +3 -0
  40. package/dist/runtime.d.ts +3 -1
  41. package/dist/runtime.js +1 -0
  42. package/dist/styles.css +2 -481
  43. package/dist/testing/fake-control-plane.d.ts +334 -1
  44. package/dist/testing/fake-control-plane.js +1194 -15
  45. package/dist/testing/index.d.ts +1 -1
  46. package/dist/testing/scenarios.js +6 -0
  47. package/dist/theme.css +66 -0
  48. package/dist/ui/console.d.ts +19 -6
  49. package/dist/ui/console.js +9 -2
  50. package/dist/ui/errors.js +7 -5
  51. package/dist/ui/grants.d.ts +3 -0
  52. package/dist/ui/grants.js +13 -0
  53. package/dist/ui/hosting.d.ts +11 -0
  54. package/dist/ui/hosting.js +20 -0
  55. package/dist/ui/primitives/button.d.ts +11 -0
  56. package/dist/ui/primitives/button.js +21 -0
  57. package/dist/ui/primitives/select.d.ts +10 -0
  58. package/dist/ui/primitives/select.js +4 -0
  59. package/dist/ui/primitives/switch.d.ts +9 -0
  60. package/dist/ui/primitives/switch.js +4 -0
  61. package/dist/ui/shape.d.ts +4 -0
  62. package/dist/ui/shape.js +36 -0
  63. package/dist/ui/shell.d.ts +76 -0
  64. package/dist/ui/shell.js +114 -0
  65. package/dist/ui/tokens.d.ts +82 -0
  66. package/dist/ui/tokens.js +172 -0
  67. package/dist/ui/topology/Graph.d.ts +13 -0
  68. package/dist/ui/topology/Graph.js +56 -0
  69. package/dist/ui/topology/Table.d.ts +10 -0
  70. package/dist/ui/topology/Table.js +14 -0
  71. package/dist/ui/topology/layout.d.ts +113 -0
  72. package/dist/ui/topology/layout.js +177 -0
  73. package/dist/ui/use-stream.d.ts +251 -1
  74. package/dist/ui/use-stream.js +11 -1
  75. package/package.json +11 -3
@@ -2,7 +2,8 @@
2
2
  * A control plane in one process: every route the console uses, over in-memory state, with the
3
3
  * control plane's rules as the console can observe them — who sees what, `404` rather than `403`
4
4
  * for an outsider, owner-only operations, tombstoned ids, `409` for a non-empty delete, a disabled
5
- * organization refusing changes, descriptor validation, a deploy token shown once.
5
+ * organization refusing changes, descriptor validation, a deploy token shown once, grants made,
6
+ * listed and ended with their effect, and machines registered with a secret shown once.
6
7
  *
7
8
  * It answers from state rather than from a script, so it cannot run out quietly. What a test needs
8
9
  * to steer is explicit: `script()` makes the next answer on one route a given status, `tick()` moves
@@ -11,7 +12,90 @@
11
12
  * template exercised, which the parity test reads.
12
13
  */
13
14
  import { createServer } from "node:http";
14
- import { randomBytes } from "node:crypto";
15
+ import { createHash, randomBytes } from "node:crypto";
16
+ /** The entries a project secret must hold for a broker's shape, as the control plane checks. */
17
+ const brokerNeeds = (shape) => shape === "certificate" ? ["ca.crt", "tls.crt", "tls.key"] : shape === "sasl" ? ["ca.crt", "username", "password"] : [];
18
+ const topicSourceOnTheWire = (t) => ({
19
+ kind: t.kind,
20
+ component: t.component,
21
+ topic: t.topic,
22
+ group: t.group,
23
+ start: t.start ?? "earliest",
24
+ version: t.version ?? 1,
25
+ recordedVersion: null,
26
+ behind: false,
27
+ broker: t.broker ?? null,
28
+ contract: t.contract ?? null,
29
+ lag: t.lag ?? null,
30
+ failing: t.failing ?? null,
31
+ gap: t.gap ?? null,
32
+ });
33
+ const SettingNames = [
34
+ "retention",
35
+ "retentionSize",
36
+ "cleanup",
37
+ "tombstoneWindow",
38
+ "minCompactionLag",
39
+ "maxCompactionLag",
40
+ "copies",
41
+ "minInSync",
42
+ ];
43
+ /** The installation's shipped defaults and bounds, as the control plane's reference.conf has them. */
44
+ const TopicDefaults = {
45
+ retention: "7d",
46
+ retentionSize: "none",
47
+ cleanup: "delete",
48
+ tombstoneWindow: "1d",
49
+ minCompactionLag: "0s",
50
+ maxCompactionLag: "none",
51
+ copies: 1,
52
+ minInSync: 1,
53
+ };
54
+ const MostCopies = 3;
55
+ const WarningThresholdMs = 30 * 86400000;
56
+ const DurationUnits = { ms: 1, s: 1000, m: 60000, h: 3600000, d: 86400000 };
57
+ /** Milliseconds, Infinity for everything; NaN for words that are not a duration. */
58
+ const millis = (text) => {
59
+ if (text === "everything" || text === "none")
60
+ return Infinity;
61
+ const m = /^([0-9]{1,15})(ms|s|m|h|d)$/.exec(text.trim());
62
+ return m ? Number(m[1]) * DurationUnits[m[2]] : NaN;
63
+ };
64
+ const SizeUnits = { B: 1, KiB: 1024, MiB: 1024 ** 2, GiB: 1024 ** 3, TiB: 1024 ** 4 };
65
+ const bytes = (text) => {
66
+ if (text === "none")
67
+ return Infinity;
68
+ const m = /^([0-9]{1,15})(B|KiB|MiB|GiB|TiB)$/.exec(text.trim());
69
+ return m ? Number(m[1]) * SizeUnits[m[2]] : NaN;
70
+ };
71
+ /** What a change removes, in the control plane's words, or nothing. */
72
+ const removalOf = (before, after) => {
73
+ const deletes = after.cleanup !== "compact";
74
+ const parts = [];
75
+ const shorter = deletes && millis(after.retention) < millis(before.retention);
76
+ if (shorter)
77
+ parts.push(`messages older than ${after.retention}`);
78
+ if (!shorter && deletes && before.cleanup === "compact" && Number.isFinite(millis(after.retention)))
79
+ parts.push(`messages older than ${after.retention}, which compaction kept`);
80
+ if (deletes && bytes(after.retentionSize) < bytes(before.retentionSize))
81
+ parts.push(`messages beyond ${after.retentionSize} on a partition`);
82
+ return parts.length > 0 ? parts.join("; ") : undefined;
83
+ };
84
+ const checkOnTheWire = (c) => ({ service: c.service, component: c.component, direction: c.direction, stated: c.stated ?? null, state: c.state });
85
+ /** A stable fingerprint of the right shape; the control plane's is RFC 8785 over the document, which the fake need not reproduce. */
86
+ function fakeFingerprint(schema) {
87
+ const text = JSON.stringify(schema);
88
+ let h1 = 0x811c9dc5;
89
+ let h2 = 0x01000193;
90
+ for (let i = 0; i < text.length; i++) {
91
+ h1 = Math.imul(h1 ^ text.charCodeAt(i), 0x01000193) >>> 0;
92
+ h2 = Math.imul(h2 + text.charCodeAt(i), 0x9e3779b1) >>> 0;
93
+ }
94
+ const hex = (h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0")).repeat(4);
95
+ return `sha256:${hex}`;
96
+ }
97
+ /** The hostings whose pods hold the platform's container beside the developer's. */
98
+ const twoContainers = (hosting) => hosting === "process" || hosting === "web";
15
99
  class HttpError extends Error {
16
100
  status;
17
101
  constructor(status, message) {
@@ -42,11 +126,15 @@ export async function fakeControlPlane(options = {}) {
42
126
  const services = new Map();
43
127
  const tokens = new Map();
44
128
  const tombstones = new Set();
129
+ const grants = new Map();
130
+ const machines = new Map();
45
131
  const visited = new Set();
46
132
  const counts = new Map();
47
133
  const scripted = new Map();
48
134
  let hideNew = false;
49
135
  let base = "";
136
+ let objectStore = "garage";
137
+ let cloud = null;
50
138
  const serviceKey = (p, n) => `${p}/${n}`;
51
139
  // ── Views of state ─────────────────────────────────────────────────────────
52
140
  const orgSummary = (org, caller) => {
@@ -68,6 +156,34 @@ export async function fakeControlPlane(options = {}) {
68
156
  };
69
157
  };
70
158
  const registrySummary = (r) => r ? { server: r.server, username: r.username, setAt: r.setAt, setBy: r.setBy ?? null } : null;
159
+ /** An apply, or a rollback when `rolledBackTo` is given: a new generation with this descriptor. */
160
+ const record = (s, descriptor, by, rolledBackTo) => {
161
+ const d = descriptor;
162
+ s.generation += 1;
163
+ s.image = d.service?.image ?? "";
164
+ // Object storage (feature 034), from the descriptor, so a rolled-back one says what it said then.
165
+ s.provisionObjectStorage = d.service?.provisionObjectStorage ?? false;
166
+ s.exposeObjectStorage = d.service?.exposeObjectStorage ?? false;
167
+ s.ownObjectStore = (d.service?.env ?? []).some((e) => e.name.startsWith("ANKKA_S3_"));
168
+ if (s.provisionObjectStorage && !s.objectStore) {
169
+ s.objectStore = objectStore;
170
+ if (objectStore === "gcs")
171
+ s.bucketLocation = projects.get(s.projectId)?.location ?? "europe-west2";
172
+ }
173
+ s.desiredInstances = d.service?.resources?.autoscaling?.minInstances ?? 1;
174
+ s.lifecycle = s.paused ? "Paused" : "UpdateInProgress";
175
+ s.descriptor = descriptor;
176
+ s.kept = [{ generation: s.generation, descriptor }, ...s.kept].slice(0, 50);
177
+ s.history.push({
178
+ kind: rolledBackTo === undefined ? "applied" : "rolled-back",
179
+ generation: s.generation,
180
+ actor: by,
181
+ at: now(),
182
+ image: s.image,
183
+ digest: digestOf(descriptor),
184
+ ...(rolledBackTo === undefined ? {} : { rolledBackTo }),
185
+ });
186
+ };
71
187
  const serviceStatus = (s) => {
72
188
  const org = organizations.get(projects.get(s.projectId)?.organizationId ?? "");
73
189
  const suspended = org?.disabled ?? false;
@@ -81,15 +197,64 @@ export async function fakeControlPlane(options = {}) {
81
197
  desiredInstances: s.desiredInstances,
82
198
  detail: s.detail ?? null,
83
199
  confirmed: true,
84
- database: "provisioned",
200
+ database: s.hosting === "web" ? "none" : s.database,
85
201
  hostname: s.exposed ? `https://${s.name}-${s.projectId}.${options.baseDomain ?? "example.test"}` : null,
86
202
  exposed: s.exposed,
87
203
  suspended,
88
204
  paused: s.paused,
89
- hosting: "embedded",
205
+ hosting: s.hosting ?? "embedded",
206
+ databaseCluster: s.databaseCluster ?? null,
90
207
  protocol: null,
208
+ mounts: s.mounts ?? [],
209
+ callers: s.callers ?? [],
210
+ processPort: s.processPort ?? null,
211
+ broker: s.broker ?? null,
212
+ undeclaredTopics: s.undeclaredTopics ?? null,
213
+ topicChecks: s.topicChecks ? s.topicChecks.map(checkOnTheWire) : null,
214
+ topicSources: s.topicSources ? s.topicSources.map(topicSourceOnTheWire) : null,
215
+ warnings: warningsOf(s),
216
+ objectStorage: s.provisionObjectStorage ? "provisioned" : s.ownObjectStore ? "supplied" : null,
217
+ bucket: s.provisionObjectStorage ? `${s.projectId}.${s.name}` : null,
218
+ bucketAddress: s.provisionObjectStorage && s.exposeObjectStorage
219
+ ? `https://storage.${options.baseDomain ?? "example.test"}/${s.projectId}.${s.name}`
220
+ : null,
221
+ objectStore: s.provisionObjectStorage ? (s.objectStore ?? "garage") : null,
222
+ bucketLocation: s.provisionObjectStorage && s.objectStore === "gcs" ? (s.bucketLocation ?? null) : null,
223
+ softDeleteDays: s.provisionObjectStorage && s.objectStore === "gcs" ? 7 : null,
224
+ storageMove: s.storageMove ?? null,
225
+ customHostnames: (s.customHostnames ?? []).map((hostname) => ({
226
+ hostname,
227
+ // The fake has no authority to wait for: an exposed service's hostnames serve at once.
228
+ state: s.exposed ? "serving" : "pending",
229
+ ...(s.exposed ? {} : { reason: "the service is not exposed" }),
230
+ ...(hostname.split(".").length === 2
231
+ ? { note: "an apex cannot be a CNAME; this installation has published no address" }
232
+ : { record: { name: hostname, kind: "CNAME", value: `${s.name}-${s.projectId}.${options.baseDomain ?? "example.test"}` } }),
233
+ })),
91
234
  };
92
235
  };
236
+ /** A read of one service carries the proof record its project's custom hostnames need. */
237
+ const oneService = (s) => ({
238
+ ...serviceStatus(s),
239
+ proofRecord: { name: "_ankka.<hostname>", kind: "TXT", value: `ankka-project=${s.projectId}` },
240
+ });
241
+ /** The control plane's refusals 1 to 5 (contracts/control-plane.md), in its words. */
242
+ const hostnameProblem = (requested) => {
243
+ const h = requested.trim().toLowerCase().replace(/\.$/, "");
244
+ const base = options.baseDomain ?? "example.test";
245
+ if (/[\/:\s]/.test(h))
246
+ return `a custom hostname is a name alone: '${requested}' has a scheme, a path or a port`;
247
+ if (h.includes("*"))
248
+ return "a custom hostname cannot be a wildcard";
249
+ const bad = h.split(".").find((l) => !/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(l));
250
+ if (bad !== undefined)
251
+ return `'${h}' is not a hostname: label '${bad}' must be letters, digits and '-', and cannot start or end with '-'`;
252
+ if (h.split(".").length < 2)
253
+ return `'${h}' is not a name on the internet: a custom hostname has at least two labels`;
254
+ if (h === base || h.endsWith(`.${base}`))
255
+ return `a custom hostname cannot be under the base domain '${base}': those names are the platform's`;
256
+ return null;
257
+ };
93
258
  // ── Authorization, the control plane's rules ──────────────────────────────
94
259
  const isAdmin = (c) => c.admin;
95
260
  function claimInvitations(c) {
@@ -112,6 +277,29 @@ export async function fakeControlPlane(options = {}) {
112
277
  throw new HttpError(404, `organization '${id}' not found`);
113
278
  return { org, role: member?.role };
114
279
  }
280
+ /**
281
+ * A warning for each view reading a topic its project declares to keep less than the installation's
282
+ * threshold (feature 043), worked out from the declarations as they are now, as the control plane does.
283
+ */
284
+ function warningsOf(s) {
285
+ const topics = projects.get(s.projectId)?.topics;
286
+ const warnings = (s.topicSources ?? []).flatMap((source) => {
287
+ const settings = topics?.get(source.topic)?.settings;
288
+ if (source.kind !== "view" || !settings || settings.cleanup !== "delete")
289
+ return [];
290
+ if (!(millis(settings.retention) < WarningThresholdMs))
291
+ return [];
292
+ return [
293
+ {
294
+ kind: "retention",
295
+ component: source.component,
296
+ topic: source.topic,
297
+ message: `view '${source.component}' reads topic '${source.topic}', which keeps ${settings.retention}; the installation warns below 30d`,
298
+ },
299
+ ];
300
+ });
301
+ return warnings.length > 0 ? warnings : null;
302
+ }
115
303
  function requireWrite(org) {
116
304
  if (org.disabled)
117
305
  throw new HttpError(409, `organization '${org.id}' is disabled`);
@@ -165,6 +353,26 @@ export async function fakeControlPlane(options = {}) {
165
353
  .map((o) => ({ id: o.id, name: o.name, role: o.members.get(c.subject).role })),
166
354
  };
167
355
  });
356
+ // Feature 044: what the installation is. The wrapping key's name is shown only to an owner of an
357
+ // organization or a platform administrator, as the control plane decides in its endpoint.
358
+ route("GET", "/installation", (c) => {
359
+ const owns = isAdmin(c) || [...organizations.values()].some((o) => o.members.get(c.subject)?.role === "owner");
360
+ const { kmsKey, ...where } = cloud ?? { provider: "", account: "", location: "" };
361
+ return {
362
+ platformVersion: "0.0.0-fake",
363
+ ...(cloud ? { cloud: { ...where, ...(kmsKey && owns ? { kmsKey } : {}) } } : {}),
364
+ // Feature 038: where secrets are kept, as an installation that has said nothing answers.
365
+ secrets: { backend: "postgres", recordRetention: "365d", auditLog: "unknown" },
366
+ // Feature 041: where the backups go, as an installation with Garage and no copy says it.
367
+ backups: {
368
+ backupTarget: "object-store",
369
+ retentionDays: 30,
370
+ copyRequired: false,
371
+ sharesFailureDomain: true,
372
+ encryption: "none: Garage holds no key the archiver can send",
373
+ },
374
+ };
375
+ });
168
376
  route("GET", "/organizations", (c) => {
169
377
  claimInvitations(c);
170
378
  return [...organizations.values()]
@@ -432,11 +640,777 @@ export async function fakeControlPlane(options = {}) {
432
640
  project.registry = undefined;
433
641
  return "done";
434
642
  });
643
+ // A project's bucket location (feature 039): only where new buckets are made in Google Cloud Storage.
644
+ const requireGcs = () => {
645
+ if (objectStore !== "gcs")
646
+ throw new HttpError(409, "the installation keeps new buckets in Garage, which has no location to choose");
647
+ };
648
+ route("PUT", "/projects/{projectId}/location", (c, p, body) => {
649
+ const { project, org } = requireProject(c, p.projectId);
650
+ requireWrite(org);
651
+ const location = (body.location ?? "").trim();
652
+ if (location === "")
653
+ throw new HttpError(400, "a location must name something");
654
+ requireGcs();
655
+ project.location = location;
656
+ return "done";
657
+ });
658
+ route("DELETE", "/projects/{projectId}/location", (c, p) => {
659
+ const { project, org } = requireProject(c, p.projectId);
660
+ requireWrite(org);
661
+ requireGcs();
662
+ project.location = undefined;
663
+ return "done";
664
+ });
665
+ // Project secrets: the control plane's rules, its merge, and names only in what it answers.
666
+ const SecretName = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
667
+ const EntryName = /^[A-Za-z0-9._-]{1,253}$/;
668
+ const PlatformSuffixes = ["-db", "-cluster-tls", "-service-tls", "-database-tls", "-secret-key", "-mount-tls", "-storage"];
669
+ route("PUT", "/projects/{projectId}/secrets/{name}", (c, p, body) => {
670
+ const { project, org } = requireProject(c, p.projectId);
671
+ requireWrite(org);
672
+ const name = p.name;
673
+ const entries = (body.entries ?? {});
674
+ const problems = [];
675
+ if (!SecretName.test(name) || name.length > 253)
676
+ problems.push(`project secret name '${name}' must be lowercase letters, digits, '-' and '.', begin and end with a letter or digit, and be at most 253 characters`);
677
+ else if (name.startsWith("ankka-") || PlatformSuffixes.some((s) => name.endsWith(s)))
678
+ problems.push(`project secret name '${name}' is one the platform uses for its own Secrets in a project`);
679
+ if (Object.keys(entries).length === 0)
680
+ problems.push("a project secret is set with at least one entry");
681
+ for (const [entry, value] of Object.entries(entries)) {
682
+ if (!EntryName.test(entry))
683
+ problems.push(`entry name '${entry}' must be 1 to 253 letters, digits, '.', '_' or '-'`);
684
+ if (value === "")
685
+ problems.push(`entry '${entry}' must not be empty`);
686
+ }
687
+ if (problems.length > 0)
688
+ throw new HttpError(400, problems.join("; "));
689
+ project.secrets ??= new Map();
690
+ const held = project.secrets.get(name)?.entries ?? new Map();
691
+ for (const [entry, value] of Object.entries(entries))
692
+ held.set(entry, value);
693
+ project.secrets.set(name, { entries: held, setAt: now(), setBy: c.name ?? c.subject });
694
+ return "done";
695
+ });
696
+ route("DELETE", "/projects/{projectId}/secrets/{name}", (c, p, _body, url) => {
697
+ const { project, org } = requireProject(c, p.projectId);
698
+ requireWrite(org);
699
+ const entry = url.searchParams.get("entry") ?? "";
700
+ const secret = project.secrets?.get(p.name);
701
+ if (!secret?.entries.has(entry))
702
+ throw new HttpError(404, `project secret '${p.name}' has no entry '${entry}'`);
703
+ // A broker's credential is not pulled from under it: the broker goes first.
704
+ const naming = [...(project.brokers ?? new Map()).entries()].filter(([, b]) => b.secret === p.name && brokerNeeds(b.shape).includes(entry)).map(([name]) => `'${name}'`);
705
+ if (naming.length > 0)
706
+ throw new HttpError(409, `project secret '${p.name}' is the credential of broker ${naming.sort().join(", ")}; remove the broker first`);
707
+ secret.entries.delete(entry);
708
+ return "done";
709
+ });
710
+ route("GET", "/projects/{projectId}/secrets", (c, p) => {
711
+ const { project } = requireProject(c, p.projectId);
712
+ return [...(project.secrets ?? new Map()).entries()]
713
+ .filter(([, s]) => s.entries.size > 0)
714
+ .sort(([a], [b]) => a.localeCompare(b))
715
+ .map(([name, s]) => ({ name, entries: [...s.entries.keys()].sort(), setAt: s.setAt, setBy: s.setBy }));
716
+ });
717
+ // Project topics: the control plane's rules, one declaration per name, never fewer partitions.
718
+ const TopicName = /^[a-z0-9]([a-z0-9.-]{0,98}[a-z0-9])?$/;
719
+ const TopicNameRule = 'a name is lower-case letters, digits, "-" and ".", starting and ending with a letter or digit, at most 100 characters';
720
+ const ContractName = /^[a-z0-9][a-z0-9._-]{0,98}[a-z0-9]$/;
721
+ const ContractNameRule = "[a-z0-9][a-z0-9._-]{0,98}[a-z0-9]";
722
+ route("PUT", "/projects/{projectId}/topics/{name}", (c, p, body) => {
723
+ const { project, org } = requireProject(c, p.projectId);
724
+ requireWrite(org);
725
+ const name = p.name;
726
+ const request = body;
727
+ project.topics ??= new Map();
728
+ const current = project.topics.get(name);
729
+ if (request.partitions === undefined && current === undefined)
730
+ throw new HttpError(400, `topic '${name}': partitions is required for a new topic`);
731
+ const partitions = request.partitions === undefined ? current.partitions : Number(request.partitions);
732
+ const problems = [];
733
+ if (!TopicName.test(name))
734
+ problems.push(`topic '${name}': ${TopicNameRule}`);
735
+ if (!Number.isInteger(partitions) || partitions < 1 || partitions > 1000)
736
+ problems.push(`topic '${name}': partitions ${partitions} is outside the range 1-1000`);
737
+ // Settings (feature 043): the words, the bounds, fixed copies and an owner's removal.
738
+ for (const key of ["retention", "tombstoneWindow", "minCompactionLag", "maxCompactionLag"]) {
739
+ const value = request[key];
740
+ if (value !== undefined && Number.isNaN(millis(value)))
741
+ problems.push(`topic '${name}': ${key} "${value}" is not a duration`);
742
+ }
743
+ if (request.retentionSize !== undefined && Number.isNaN(bytes(request.retentionSize)))
744
+ problems.push(`topic '${name}': retentionSize "${request.retentionSize}" is not a size`);
745
+ if (request.cleanup !== undefined && !["delete", "compact", "compact,delete"].includes(request.cleanup))
746
+ problems.push(`topic '${name}': cleanup "${request.cleanup}" is not "delete", "compact" or "compact,delete"`);
747
+ if (request.compacted === true && request.cleanup === "delete")
748
+ problems.push(`topic '${name}': compacted and cleanup disagree`);
749
+ if (request.copies !== undefined && request.copies > MostCopies)
750
+ problems.push(`topic '${name}': copies ${request.copies} is more than the installation's most, ${MostCopies}`);
751
+ // A contract's own rules: its name, and a schema that is a JSON document (feature 037).
752
+ if (request.contract !== undefined && request.contract !== null) {
753
+ const contractName = String(request.contract.name ?? "");
754
+ if (!ContractName.test(contractName))
755
+ problems.push(`topic '${name}': contract name '${contractName}' is not ${ContractNameRule}`);
756
+ if (request.contract.schema === undefined || request.contract.schema === null)
757
+ problems.push(`topic '${name}': the contract's schema is missing`);
758
+ }
759
+ if (problems.length > 0)
760
+ throw new HttpError(400, problems.join("; "));
761
+ const has = current?.partitions;
762
+ if (has !== undefined && has > partitions)
763
+ throw new HttpError(409, `topic '${name}' has ${has} partitions and cannot have fewer; ${partitions} was asked`);
764
+ const asked = {};
765
+ for (const key of SettingNames)
766
+ if (request[key] !== undefined)
767
+ asked[key] = request[key];
768
+ if (request.compacted === true && asked.cleanup === undefined)
769
+ asked.cleanup = "compact";
770
+ const before = current?.settings;
771
+ if (before !== undefined) {
772
+ const fixed = (key) => asked[key] !== undefined && asked[key] !== before[key];
773
+ if (fixed("copies") || fixed("minInSync"))
774
+ throw new HttpError(409, `topic '${name}': copies and minimum in-sync copies are fixed when a topic is declared`);
775
+ }
776
+ const settings = { ...(before ?? TopicDefaults), ...asked };
777
+ const defaulted = (current?.defaulted ?? (before ? [] : [...SettingNames])).filter((s) => asked[s] === undefined);
778
+ const removal = before ? removalOf(before, settings) : undefined;
779
+ if (removal === undefined && request.removes !== undefined)
780
+ throw new HttpError(400, `topic '${name}': this declaration removes nothing`);
781
+ if (removal !== undefined) {
782
+ const { role } = requireOrg(c, org.id);
783
+ if (role !== "owner" && !isAdmin(c))
784
+ throw new HttpError(403, `owner role required: this declaration removes ${removal}`);
785
+ if (request.removes?.trim() !== removal)
786
+ throw new HttpError(400, `this declaration removes ${removal}; a declaration that removes messages says so with "removes"`);
787
+ }
788
+ const changes = before
789
+ ? SettingNames.flatMap((s) => String(before[s] ?? "the broker's") === String(settings[s] ?? "the broker's")
790
+ ? []
791
+ : [{ setting: s, from: String(before[s] ?? "the broker's"), to: String(settings[s] ?? "the broker's") }])
792
+ : [];
793
+ const contract = request.contract
794
+ ? { name: String(request.contract.name), schema: request.contract.schema, fingerprint: fakeFingerprint(request.contract.schema) }
795
+ : undefined;
796
+ const same = current !== undefined && current.partitions === partitions && changes.length === 0 &&
797
+ (current.contract?.fingerprint ?? null) === (contract?.fingerprint ?? null);
798
+ if (same)
799
+ return "done";
800
+ project.topics.set(name, {
801
+ ...(current ?? {}),
802
+ partitions,
803
+ compacted: settings.cleanup !== "delete",
804
+ contract,
805
+ settings,
806
+ defaulted,
807
+ });
808
+ project.history = [
809
+ { kind: current ? "topic-changed" : "topic-declared", topic: name, actor: actor(c), at: now(), changes },
810
+ ...(project.history ?? []),
811
+ ].slice(0, 50);
812
+ return "done";
813
+ });
814
+ // The schema a topic's contract was declared with (feature 037); 404 without a contract.
815
+ route("GET", "/projects/{projectId}/topics/{name}/schema", (c, p) => {
816
+ const { project } = requireProject(c, p.projectId);
817
+ const topic = project.topics?.get(p.name);
818
+ if (!topic?.contract)
819
+ throw new HttpError(404, `project '${p.projectId}' declares no contract on topic '${p.name}'`);
820
+ return topic.contract.schema;
821
+ });
822
+ route("DELETE", "/projects/{projectId}/topics/{name}", (c, p) => {
823
+ const { project, org } = requireProject(c, p.projectId);
824
+ requireWrite(org);
825
+ if (!project.topics?.delete(p.name))
826
+ throw new HttpError(404, `project '${p.projectId}' declares no topic '${p.name}'`);
827
+ return "done";
828
+ });
829
+ route("GET", "/projects/{projectId}/topics", (c, p) => {
830
+ const { project } = requireProject(c, p.projectId);
831
+ // The sides the project's services were seeded with, by topic: the control plane reads them
832
+ // from every service's instances; the fake reads them from what it was told.
833
+ const checks = [...services.values()].filter((s) => s.projectId === p.projectId).flatMap((s) => s.topicChecks ?? []);
834
+ return [...(project.topics ?? new Map()).entries()]
835
+ .sort(([a], [b]) => a.localeCompare(b))
836
+ .map(([name, t]) => ({
837
+ name,
838
+ partitions: t.partitions,
839
+ phase: t.phase,
840
+ detail: t.detail,
841
+ compacted: t.compacted ?? false,
842
+ contract: t.contract ? { name: t.contract.name, fingerprint: t.contract.fingerprint } : null,
843
+ checks: t.contract ? checks.filter((c) => c.topic === name).map(checkOnTheWire) : [],
844
+ settings: t.settings
845
+ ? {
846
+ ...t.settings,
847
+ copies: t.settings.copies ?? null,
848
+ minInSync: t.settings.minInSync ?? null,
849
+ defaulted: (t.defaulted ?? []).filter((s) => SettingNames.includes(s)),
850
+ }
851
+ : null,
852
+ copiesHeld: t.copiesHeld ?? t.settings?.copies ?? null,
853
+ brokerNodes: null,
854
+ }));
855
+ });
856
+ // Declared brokers (feature 037): the control plane's rules, and the secret's entries checked by name.
857
+ const BrokerBootstrap = /^[A-Za-z0-9._-]+:[0-9]+(,[A-Za-z0-9._-]+:[0-9]+)*$/;
858
+ route("PUT", "/projects/{projectId}/brokers/{name}", (c, p, body) => {
859
+ const { project, org } = requireProject(c, p.projectId);
860
+ requireWrite(org);
861
+ const name = p.name;
862
+ const request = body;
863
+ const bootstrap = String(request.bootstrap ?? "").trim();
864
+ const shape = String(request.shape ?? "");
865
+ const secret = String(request.secret ?? "");
866
+ const problems = [];
867
+ if (!TopicName.test(name))
868
+ problems.push(`broker '${name}': ${TopicNameRule}`);
869
+ if (!BrokerBootstrap.test(bootstrap))
870
+ problems.push(`broker '${name}': bootstrap '${bootstrap}' is not host:port[,host:port]`);
871
+ if (shape !== "certificate" && shape !== "sasl")
872
+ problems.push(`broker '${name}': shape '${shape}' is not one of certificate, sasl`);
873
+ if (secret === "")
874
+ problems.push(`broker '${name}': secret a project secret needs a name`);
875
+ else if (secret.startsWith("ankka-") || PlatformSuffixes.some((s) => secret.endsWith(s)))
876
+ problems.push(`broker '${name}': secret project secret name '${secret}' is one the platform uses for its own Secrets in a project`);
877
+ if (problems.length > 0)
878
+ throw new HttpError(400, problems.join("; "));
879
+ const held = project.secrets?.get(secret);
880
+ if (!held)
881
+ throw new HttpError(400, `project '${p.projectId}' has no project secret '${secret}'`);
882
+ const missing = brokerNeeds(shape).filter((e) => !held.entries.has(e));
883
+ if (missing.length > 0)
884
+ throw new HttpError(400, `project secret '${secret}' lacks ${missing.map((m) => `'${m}'`).join(", ")}, which shape '${shape}' needs`);
885
+ project.brokers ??= new Map();
886
+ const declaredAt = project.brokers.get(name)?.declaredAt ?? now();
887
+ project.brokers.set(name, { bootstrap, shape, secret, declaredAt });
888
+ return "done";
889
+ });
890
+ route("DELETE", "/projects/{projectId}/brokers/{name}", (c, p) => {
891
+ const { project, org } = requireProject(c, p.projectId);
892
+ requireWrite(org);
893
+ if (!project.brokers?.delete(p.name))
894
+ throw new HttpError(404, `project '${p.projectId}' declares no broker '${p.name}'`);
895
+ return "done";
896
+ });
897
+ route("GET", "/projects/{projectId}/brokers", (c, p) => {
898
+ const { project } = requireProject(c, p.projectId);
899
+ return [...(project.brokers ?? new Map()).entries()]
900
+ .sort(([a], [b]) => a.localeCompare(b))
901
+ .map(([name, b]) => ({ name, bootstrap: b.bootstrap, shape: b.shape, secret: b.secret, declaredAt: b.declaredAt }));
902
+ });
903
+ // Backups and recovery (feature 041). The fake's project database is backed up from a day ago to
904
+ // now, and a restore is verified the moment it is asked for, holding every service's database.
905
+ const restoreView = (project, r) => ({
906
+ name: r.name,
907
+ line: r.line,
908
+ moment: r.moment,
909
+ phase: [...services.values()].some((s) => s.projectId === project.id && s.databaseCluster === r.name) ? "InUse" : "Verified",
910
+ requestedBy: r.requestedBy,
911
+ requestedAt: r.requestedAt,
912
+ reachedAt: r.moment,
913
+ services: [...services.values()]
914
+ .filter((s) => s.projectId === project.id)
915
+ .map((s) => ({ name: s.name, present: true, journalRows: 1, highestSequence: 1 })),
916
+ });
917
+ const remember = (project, kind, c, detail) => {
918
+ project.databaseHistory ??= [];
919
+ project.databaseHistory.unshift({ kind, by: c.name ?? c.subject, at: now(), detail });
920
+ };
921
+ let hold;
922
+ route("GET", "/installation/restore", () => hold
923
+ ? { held: hold.releasedAt === undefined, targetTime: hold.targetTime, restoredAt: hold.targetTime, releasedAt: hold.releasedAt, releasedBy: hold.releasedBy, services: hold.releasedAt ? [] : (hold.services ?? []) }
924
+ : { held: false });
925
+ route("POST", "/installation/restore/release", (c) => {
926
+ if (!isAdmin(c))
927
+ throw new HttpError(403, "only a platform administrator releases a held control plane");
928
+ if (hold && hold.releasedAt === undefined)
929
+ hold = { ...hold, releasedAt: now(), releasedBy: c.name ?? c.subject };
930
+ return hold ? { held: false, targetTime: hold.targetTime, releasedAt: hold.releasedAt, releasedBy: hold.releasedBy } : { held: false };
931
+ });
932
+ route("GET", "/projects/{projectId}/restores", (c, p) => {
933
+ const { project } = requireProject(c, p.projectId);
934
+ return (project.restores ?? []).map((r) => restoreView(project, r));
935
+ });
936
+ route("POST", "/projects/{projectId}/backups/credential", (c, p) => {
937
+ const { project, org } = requireProject(c, p.projectId);
938
+ requireOwner(c, org.id);
939
+ project.credentialGeneration = (project.credentialGeneration ?? 0) + 1;
940
+ remember(project, "backup-credential-reissued", c, `generation ${project.credentialGeneration}`);
941
+ return { project: project.id, generation: project.credentialGeneration };
942
+ });
943
+ // One history, newest first: the database's (feature 041) and the topics' (feature 043).
944
+ route("GET", "/projects/{projectId}/history", (c, p) => {
945
+ const { project } = requireProject(c, p.projectId);
946
+ const topics = (project.history ?? []).map((h) => ({
947
+ kind: h.kind,
948
+ by: h.actor ? (h.actor.display ?? h.actor.subject) : undefined,
949
+ at: h.at,
950
+ topic: h.topic,
951
+ changes: h.changes,
952
+ }));
953
+ return [...(project.databaseHistory ?? []), ...topics].sort((a, b) => (b.at ?? "").localeCompare(a.at ?? ""));
954
+ });
955
+ route("GET", "/projects/{projectId}/database", (c, p) => {
956
+ const { project } = requireProject(c, p.projectId);
957
+ return project.database ?? { replicas: 0, synchronous: false };
958
+ });
959
+ route("PUT", "/projects/{projectId}/database", (c, p, body) => {
960
+ const { project, org } = requireProject(c, p.projectId);
961
+ requireWrite(org);
962
+ const b = body;
963
+ const setting = { replicas: b.replicas ?? 0, synchronous: b.synchronous ?? false, retentionDays: b.retentionDays, rehearse: b.rehearse };
964
+ const problems = [];
965
+ if (setting.replicas < 0 || setting.replicas > 4)
966
+ problems.push(`replicas must be between 0 and 4, not ${setting.replicas}`);
967
+ if (setting.synchronous && setting.replicas === 0)
968
+ problems.push("a synchronous database needs at least one replica to wait for");
969
+ if (setting.retentionDays !== undefined && setting.retentionDays < 30)
970
+ problems.push(`backups are kept at least 30 days on this installation, not ${setting.retentionDays}`);
971
+ if (problems.length > 0)
972
+ throw new HttpError(400, problems.join("; "));
973
+ project.database = setting;
974
+ remember(project, "database-set", c, `${setting.replicas} replica${setting.replicas === 1 ? "" : "s"}${setting.synchronous ? ", synchronous" : ""}`);
975
+ return setting;
976
+ });
977
+ const minuteName = (prefix) => `ankka-db-${prefix}${new Date().toISOString().replace(/[^0-9]/g, "").slice(0, 12)}`;
978
+ route("GET", "/projects/{projectId}/status", (c, p) => {
979
+ const { project } = requireProject(c, p.projectId);
980
+ const day = new Date(Date.now() - 86_400_000).toISOString();
981
+ const onCluster = (name) => [...services.values()].filter((s) => s.projectId === project.id && (s.databaseCluster ?? "ankka-db") === name).map((s) => s.name);
982
+ const restores = project.restores ?? [];
983
+ return {
984
+ id: project.id,
985
+ backedUp: true,
986
+ target: "object-store",
987
+ lines: [{ line: "ankka-db", cluster: "ankka-db", phase: "backing up", lastBaseBackup: day, firstRestorable: day, lastRestorable: now() }],
988
+ database: { cluster: "ankka-db", instances: 1, readyInstances: 1, primary: "ankka-db-1" },
989
+ clusters: [
990
+ { name: "ankka-db", line: "ankka-db", phase: onCluster("ankka-db").length === 0 && restores.length > 0 ? "left" : "live", services: onCluster("ankka-db") },
991
+ ...restores.map((r) => ({ name: r.name, line: r.line, phase: "restore", services: onCluster(r.name), since: r.requestedAt })),
992
+ ],
993
+ restores: restores.map((r) => restoreView(project, r)),
994
+ };
995
+ });
996
+ route("POST", "/projects/{projectId}/restores", (c, p, body) => {
997
+ const { project, org } = requireProject(c, p.projectId);
998
+ requireOwner(c, org.id);
999
+ const request = body;
1000
+ const moment = String(request.moment ?? "");
1001
+ if (Number.isNaN(Date.parse(moment)) || Date.parse(moment) > Date.now() || Date.parse(moment) < Date.now() - 86_400_000)
1002
+ throw new HttpError(400, `${project.id} can be restored between ${new Date(Date.now() - 86_400_000).toISOString()} and ${now()}`);
1003
+ project.restores ??= [];
1004
+ const restore = { name: minuteName("r"), line: request.line ?? "ankka-db", moment, requestedBy: c.name ?? c.subject, requestedAt: now() };
1005
+ if (project.restores.some((r) => r.name === restore.name))
1006
+ throw new HttpError(409, `a restore of ${project.id} is in progress: ${restore.name}`);
1007
+ project.restores.push(restore);
1008
+ remember(project, "restore-requested", c, `${restore.name}, of ${restore.line} at ${moment}`);
1009
+ return { ...restoreView(project, restore), phase: "Restoring", services: [] };
1010
+ });
1011
+ route("GET", "/projects/{projectId}/restores/{name}", (c, p) => {
1012
+ const { project } = requireProject(c, p.projectId);
1013
+ const restore = project.restores?.find((r) => r.name === p.name);
1014
+ if (!restore)
1015
+ throw new HttpError(404, `${project.id} has no restore named ${p.name}`);
1016
+ return restoreView(project, restore);
1017
+ });
1018
+ route("POST", "/projects/{projectId}/rehearsals", (c, p) => {
1019
+ const { project, org } = requireProject(c, p.projectId);
1020
+ requireWrite(org);
1021
+ project.rehearsals ??= [];
1022
+ const rehearsal = { name: minuteName("x"), line: "ankka-db", moment: now(), requestedBy: c.name ?? c.subject, requestedAt: now() };
1023
+ project.rehearsals.push(rehearsal);
1024
+ remember(project, "rehearsal-requested", c, rehearsal.name);
1025
+ return { ...rehearsal, outcome: "Running" };
1026
+ });
1027
+ route("GET", "/projects/{projectId}/rehearsals", (c, p) => {
1028
+ const { project } = requireProject(c, p.projectId);
1029
+ return (project.rehearsals ?? []).map((r) => ({ ...r, outcome: "Completed", elapsedSeconds: 118 }));
1030
+ });
1031
+ route("POST", "/services/{projectId}/{name}/switch", (c, p, body) => {
1032
+ const { service, org } = requireService(c, p.projectId, p.name);
1033
+ requireOwner(c, org.id);
1034
+ const cluster = String(body.cluster ?? "");
1035
+ const project = projects.get(p.projectId);
1036
+ if (cluster !== "ankka-db" && !project.restores?.some((r) => r.name === cluster))
1037
+ throw new HttpError(400, `${cluster} is not a restore of ${p.projectId} that can be switched to`);
1038
+ const next = cluster === "ankka-db" ? undefined : cluster;
1039
+ if (next !== service.databaseCluster) {
1040
+ service.databaseCluster = next;
1041
+ service.generation += 1;
1042
+ }
1043
+ return serviceStatus(service);
1044
+ });
1045
+ // ── Grants (feature 040): the control plane's rules and words ─────────────
1046
+ const display = (c) => c.name ?? c.email ?? c.subject;
1047
+ const noSuchProject = (id) => new HttpError(404, `no such project '${id}'`);
1048
+ /** A project's grants are read by its organization's members and written by its owners; an outsider is told there is no project. */
1049
+ function grantProject(c, id, owner) {
1050
+ const project = projects.get(id);
1051
+ if (!project)
1052
+ throw noSuchProject(id);
1053
+ const org = organizations.get(project.organizationId);
1054
+ const role = org?.members.get(c.subject)?.role;
1055
+ if (!org || (!role && !isAdmin(c)))
1056
+ throw noSuchProject(id);
1057
+ if (owner) {
1058
+ if (role !== "owner" && !isAdmin(c))
1059
+ throw new HttpError(403, `owner role required in organization '${org.id}'`);
1060
+ requireWrite(org);
1061
+ }
1062
+ return { project, org };
1063
+ }
1064
+ const parseGrantee = (text) => {
1065
+ const m = /^(service|machine):([^/]+)\/([^/]+)$/.exec(text);
1066
+ return m ? { kind: m[1], scope: m[2], name: m[3] } : null;
1067
+ };
1068
+ const HttpMethods = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
1069
+ const GrpcMethod = /^[A-Za-z_][A-Za-z0-9_.]*\/[A-Za-z_][A-Za-z0-9_]*$/;
1070
+ const MachineNameRule = "lowercase letters, digits and '-', starting with a letter";
1071
+ const machineNameProblems = (name) => name === "" ? ["a machine needs a name"] : NameRule.test(name) ? [] : [`machine name '${name}' is invalid: ${MachineNameRule}`];
1072
+ /** Everything wrong with the grantee and the target, all at once, as `GrantRules.problems` words it. */
1073
+ function grantProblems(grantee, t) {
1074
+ const problems = [];
1075
+ const g = parseGrantee(grantee);
1076
+ if (!g)
1077
+ problems.push(`grantee '${grantee}' is not "service:<project>/<service>" or "machine:<organization>/<name>"`);
1078
+ else if (g.kind === "service") {
1079
+ if (!NameRule.test(g.scope))
1080
+ problems.push(`grantee: project id '${g.scope}' is invalid: lowercase letters, digits and '-', starting with a letter`);
1081
+ if (!NameRule.test(g.name))
1082
+ problems.push(`grantee: service name '${g.name}' is invalid`);
1083
+ }
1084
+ else {
1085
+ if (!NameRule.test(g.scope))
1086
+ problems.push(`grantee: organization '${g.scope}' cannot have machines: an organization's id is part of a machine's name on the broker, so it must be lowercase letters, digits and '-', starting with a letter`);
1087
+ problems.push(...machineNameProblems(g.name).map((p) => `grantee: ${p}`));
1088
+ }
1089
+ const none = (field) => (t[field] !== undefined ? [`a ${t.kind} target has no ${field}`] : []);
1090
+ switch (t.kind) {
1091
+ case "route":
1092
+ if (!t.service)
1093
+ problems.push("a route target needs a service");
1094
+ if (!t.method || !HttpMethods.includes(t.method))
1095
+ problems.push(`a route target's method is one of ${HttpMethods.join(", ")}`);
1096
+ if (!t.path?.startsWith("/"))
1097
+ problems.push("a route target's path is a template beginning with '/'");
1098
+ problems.push(...none("topic"), ...none("right"));
1099
+ break;
1100
+ case "method":
1101
+ if (!t.service)
1102
+ problems.push("a method target needs a service");
1103
+ if (!t.method || !GrpcMethod.test(t.method))
1104
+ problems.push('a method target\'s method is a gRPC method, "<Service>/<Method>"');
1105
+ problems.push(...none("path"), ...none("topic"), ...none("right"));
1106
+ break;
1107
+ case "topic":
1108
+ if (!t.topic)
1109
+ problems.push("a topic target needs a topic");
1110
+ else if (!TopicName.test(t.topic))
1111
+ problems.push(`topic '${t.topic}': ${TopicNameRule}`);
1112
+ if (t.right !== "consume" && t.right !== "produce")
1113
+ problems.push("a topic target's right is one of consume, produce");
1114
+ problems.push(...none("service"), ...none("method"), ...none("path"));
1115
+ break;
1116
+ case "erasure":
1117
+ problems.push(...none("service"), ...none("method"), ...none("path"), ...none("topic"), ...none("right"));
1118
+ break;
1119
+ default:
1120
+ problems.push(`target kind '${t.kind}' is not one of route, method, topic, erasure`);
1121
+ }
1122
+ if (t.service !== undefined && !NameRule.test(t.service))
1123
+ problems.push(`target: service name '${t.service}' is invalid`);
1124
+ if (t.decrypt && !(t.kind === "topic" && t.right === "consume"))
1125
+ problems.push("only a grant to consume a topic may allow decryption");
1126
+ return problems;
1127
+ }
1128
+ /** The target as the wire carries it: only the fields it has, and `decrypt` always. */
1129
+ const targetOnTheWire = (t) => ({
1130
+ kind: t.kind,
1131
+ ...(t.service === undefined ? {} : { service: t.service }),
1132
+ ...(t.method === undefined ? {} : { method: t.method }),
1133
+ ...(t.path === undefined ? {} : { path: t.path }),
1134
+ ...(t.topic === undefined ? {} : { topic: t.topic }),
1135
+ ...(t.right === undefined ? {} : { right: t.right }),
1136
+ decrypt: t.decrypt ?? false,
1137
+ });
1138
+ const sameTarget = (a, b) => JSON.stringify(targetOnTheWire(a)) === JSON.stringify(targetOnTheWire(b));
1139
+ const live = (g) => g.state === "pending" || g.state === "accepted";
1140
+ /**
1141
+ * Whether a grant opens what it names, or why not, as `GrantEffect` reads it — short of the
1142
+ * instances' topology, which the fake does not have: a service with a ready instance that is not
1143
+ * web-hosted is taken to have the route and to have mounted its grants.
1144
+ */
1145
+ function effectOf(g) {
1146
+ if (g.state !== "accepted")
1147
+ return g.state;
1148
+ if (g.target.kind === "route" || g.target.kind === "method") {
1149
+ const s = services.get(serviceKey(g.projectId, g.target.service ?? ""));
1150
+ if (!s)
1151
+ return "route not seen";
1152
+ if (s.hosting === "web")
1153
+ return "route not grantable";
1154
+ if (s.paused || s.readyInstances < 1)
1155
+ return "route not seen";
1156
+ return "in effect";
1157
+ }
1158
+ if (g.target.kind === "topic" && g.grantee.startsWith("machine:") && !options.brokerBootstrap)
1159
+ return "broker not exposed";
1160
+ return "in effect";
1161
+ }
1162
+ const grantDetail = (g, effect) => ({
1163
+ id: g.id,
1164
+ grantee: g.grantee,
1165
+ target: targetOnTheWire(g.target),
1166
+ state: g.state,
1167
+ effect,
1168
+ granted: g.granted,
1169
+ ...(g.answered ? { answered: g.answered } : {}),
1170
+ ...(g.ended ? { ended: g.ended } : {}),
1171
+ });
1172
+ const receivedDetail = (g) => {
1173
+ const granting = projects.get(g.projectId);
1174
+ const topic = g.target.kind === "topic" && g.target.topic ? granting?.topics?.get(g.target.topic) : undefined;
1175
+ return {
1176
+ id: g.id,
1177
+ grantingProject: g.projectId,
1178
+ grantingOrganization: granting?.organizationId ?? "",
1179
+ grantee: g.grantee,
1180
+ target: targetOnTheWire(g.target),
1181
+ state: g.state,
1182
+ changes: g.changes,
1183
+ ...(topic ? { topic: { partitions: topic.partitions, compacted: topic.compacted ?? false } } : {}),
1184
+ };
1185
+ };
1186
+ const byGranted = (a, b) => a.granted.at.localeCompare(b.granted.at) || a.id.localeCompare(b.id);
1187
+ function makeGrant(project, org, grantee, target, by, state) {
1188
+ const g = parseGrantee(grantee);
1189
+ const granteeOrg = g.kind === "service" ? projects.get(g.scope)?.organizationId : g.scope;
1190
+ const pending = granteeOrg !== org.id;
1191
+ const at = now();
1192
+ const grant = {
1193
+ id: randomBytes(8).toString("hex"),
1194
+ projectId: project.id,
1195
+ grantee,
1196
+ target,
1197
+ state: state ?? (pending ? "pending" : "accepted"),
1198
+ granted: { by, at },
1199
+ changes: [{ change: pending ? "offered" : "made", by, at }],
1200
+ };
1201
+ grants.set(grant.id, grant);
1202
+ return grant;
1203
+ }
1204
+ route("POST", "/projects/{projectId}/grants", (c, p, body) => {
1205
+ const { project, org } = grantProject(c, p.projectId, true);
1206
+ const b = (body ?? {});
1207
+ const grantee = String(b.grantee ?? "");
1208
+ const raw = b.target ?? { kind: "" };
1209
+ const target = {
1210
+ kind: String(raw.kind ?? ""),
1211
+ service: raw.service ?? undefined,
1212
+ method: raw.method ?? undefined,
1213
+ path: raw.path ?? undefined,
1214
+ topic: raw.topic ?? undefined,
1215
+ right: raw.right ?? undefined,
1216
+ decrypt: raw.decrypt === true,
1217
+ };
1218
+ const problems = grantProblems(grantee, target);
1219
+ if (problems.length > 0)
1220
+ throw new HttpError(400, problems.join("; "));
1221
+ const g = parseGrantee(grantee);
1222
+ if (g.kind === "service" && !projects.has(g.scope))
1223
+ throw noSuchProject(g.scope);
1224
+ if (g.kind === "machine" && !organizations.has(g.scope))
1225
+ throw new HttpError(404, `no such organization '${g.scope}'`);
1226
+ if (target.kind === "topic" && !project.topics?.has(target.topic))
1227
+ throw new HttpError(404, `project '${project.id}' has not declared the topic '${target.topic}'`);
1228
+ if (g.kind === "service" && g.scope === project.id)
1229
+ throw new HttpError(400, "a project's own services need no grant: name them in the route's ACL instead");
1230
+ // Granting what a live grant already grants answers that grant.
1231
+ const existing = [...grants.values()].find((x) => x.projectId === project.id && x.grantee === grantee && live(x) && sameTarget(x.target, target));
1232
+ const grant = existing ?? makeGrant(project, org, grantee, target, display(c));
1233
+ return grantDetail(grant, grant.state === "accepted" ? "in effect" : grant.state);
1234
+ });
1235
+ route("GET", "/projects/{projectId}/grants", (c, p) => {
1236
+ const { project } = grantProject(c, p.projectId, false);
1237
+ return [...grants.values()]
1238
+ .filter((g) => g.projectId === project.id)
1239
+ .sort(byGranted)
1240
+ .map((g) => grantDetail(g, effectOf(g)));
1241
+ });
1242
+ route("GET", "/projects/{projectId}/grants/received", (c, p) => {
1243
+ const { project } = grantProject(c, p.projectId, false);
1244
+ return [...grants.values()]
1245
+ .filter((g) => g.grantee.startsWith(`service:${project.id}/`))
1246
+ .sort((a, b) => a.id.localeCompare(b.id))
1247
+ .map(receivedDetail);
1248
+ });
1249
+ route("DELETE", "/projects/{projectId}/grants/{grantId}", (c, p) => {
1250
+ const { project } = grantProject(c, p.projectId, true);
1251
+ const g = grants.get(p.grantId);
1252
+ if (!g || g.projectId !== project.id)
1253
+ throw new HttpError(404, `project '${project.id}' has no grant '${p.grantId}'`);
1254
+ const [to, verb, applies] = g.state === "pending" ? ["withdrawn", "withdraw", "a pending"] : ["revoked", "revoke", "an accepted"];
1255
+ if (g.state !== "pending" && g.state !== "accepted")
1256
+ throw new HttpError(409, `grant '${g.id}' is ${g.state}; ${verb} applies to ${applies} grant`);
1257
+ const at = now();
1258
+ g.state = to;
1259
+ g.ended = { by: display(c), at };
1260
+ g.changes.push({ change: to, by: display(c), at });
1261
+ return "done";
1262
+ });
1263
+ /** The grants naming one of the organization's machines, or a service of one of its projects. */
1264
+ function offeredTo(org) {
1265
+ const own = new Set([...projects.values()].filter((x) => x.organizationId === org.id).map((x) => x.id));
1266
+ return [...grants.values()]
1267
+ .filter((g) => {
1268
+ const grantee = parseGrantee(g.grantee);
1269
+ return grantee !== null && (grantee.kind === "machine" ? grantee.scope === org.id : own.has(grantee.scope));
1270
+ })
1271
+ .sort((a, b) => a.id.localeCompare(b.id));
1272
+ }
1273
+ route("GET", "/organizations/{organizationId}/grants", (c, p) => {
1274
+ const { org } = requireOrg(c, p.organizationId);
1275
+ return offeredTo(org).map(receivedDetail);
1276
+ });
1277
+ /**
1278
+ * An owner of the grantee's organization answers a grant: a member is 403, a grant not offered to
1279
+ * the organization 404 whoever asks (its id discloses nothing), a grant in the wrong state 409.
1280
+ */
1281
+ const answers = {
1282
+ accept: { from: "pending", to: "accepted", applies: "a pending" },
1283
+ decline: { from: "pending", to: "declined", applies: "a pending" },
1284
+ relinquish: { from: "accepted", to: "relinquished", applies: "an accepted" },
1285
+ };
1286
+ for (const [verb, rule] of Object.entries(answers)) {
1287
+ route("POST", `/organizations/{organizationId}/grants/{grantId}/${verb}`, (c, p) => {
1288
+ const org = requireOwner(c, p.organizationId);
1289
+ requireWrite(org);
1290
+ const g = offeredTo(org).find((x) => x.id === p.grantId);
1291
+ if (!g)
1292
+ throw new HttpError(404, `no grant '${p.grantId}' is offered to '${org.id}'`);
1293
+ if (g.state !== rule.from)
1294
+ throw new HttpError(409, `grant '${g.id}' is ${g.state}; ${verb} applies to ${rule.applies} grant`);
1295
+ const at = now();
1296
+ const act = { by: display(c), at };
1297
+ g.state = rule.to;
1298
+ if (verb !== "relinquish")
1299
+ g.answered = act;
1300
+ if (verb !== "accept")
1301
+ g.ended = act;
1302
+ g.changes.push({ change: rule.to, ...act });
1303
+ return "done";
1304
+ });
1305
+ }
1306
+ // ── Machines (feature 040): an owner registers, limits and deletes; a member lists ──
1307
+ const machineKey = (o, n) => `${o}/${n}`;
1308
+ // `ankka.controlplane.machines.byte-rate-ceiling`, 32 MiB a second unless the installation says otherwise.
1309
+ const ByteRateCeiling = options.byteRateCeiling ?? 33_554_432;
1310
+ const machineSummary = (m) => ({
1311
+ name: m.name,
1312
+ clientId: `machine:${m.organizationId}/${m.name}`,
1313
+ registeredBy: m.registeredBy,
1314
+ registeredAt: m.registeredAt,
1315
+ ...(m.byteRates ? { byteRates: m.byteRates } : {}),
1316
+ });
1317
+ route("GET", "/organizations/{organizationId}/machines", (c, p) => {
1318
+ const { org } = requireOrg(c, p.organizationId);
1319
+ return [...machines.values()]
1320
+ .filter((m) => m.organizationId === org.id && !m.hidden)
1321
+ .sort((a, b) => a.name.localeCompare(b.name))
1322
+ .map(machineSummary);
1323
+ });
1324
+ route("POST", "/organizations/{organizationId}/machines", (c, p, body) => {
1325
+ const org = requireOwner(c, p.organizationId);
1326
+ requireWrite(org);
1327
+ const name = String(body?.name ?? "");
1328
+ const problems = machineNameProblems(name);
1329
+ if (problems.length > 0)
1330
+ throw new HttpError(400, problems.join("; "));
1331
+ if (machines.has(machineKey(org.id, name)))
1332
+ throw new HttpError(409, `machine '${name}' is already registered on '${org.id}'`);
1333
+ machines.set(machineKey(org.id, name), { organizationId: org.id, name, registeredBy: display(c), registeredAt: now(), hidden: hideNew });
1334
+ return {
1335
+ name,
1336
+ clientId: `machine:${org.id}/${name}`,
1337
+ // 32 random bytes as hex: the only time the secret exists outside the machine that holds it.
1338
+ clientSecret: randomBytes(32).toString("hex"),
1339
+ tokenUrl: `${base}/oauth/token`,
1340
+ ...(options.brokerBootstrap ? { brokerBootstrap: options.brokerBootstrap } : {}),
1341
+ };
1342
+ });
1343
+ route("DELETE", "/organizations/{organizationId}/machines/{name}", (c, p) => {
1344
+ const org = requireOwner(c, p.organizationId);
1345
+ requireWrite(org);
1346
+ if (!machines.delete(machineKey(org.id, p.name)))
1347
+ throw new HttpError(404, `no such machine '${machineKey(org.id, p.name)}'`);
1348
+ return "done";
1349
+ });
1350
+ route("PUT", "/organizations/{organizationId}/machines/{name}/byte-rates", (c, p, body) => {
1351
+ const org = requireOwner(c, p.organizationId);
1352
+ requireWrite(org);
1353
+ const b = (body ?? {});
1354
+ const produce = Number(b.produceBytesPerSecond ?? 0);
1355
+ const consume = Number(b.consumeBytesPerSecond ?? 0);
1356
+ const percentage = Number(b.requestPercentage ?? 0);
1357
+ if (![produce, consume, percentage].every(Number.isInteger))
1358
+ throw new HttpError(400, "byte rates and the request percentage are whole numbers");
1359
+ const rate = (label, value) => value <= 0 ? [`${label} must be more than nothing`] : value > ByteRateCeiling ? [`${label} of ${value} bytes a second is over the installation's ceiling of ${ByteRateCeiling}`] : [];
1360
+ const problems = [
1361
+ ...rate("the produce byte rate", produce),
1362
+ ...rate("the consume byte rate", consume),
1363
+ ...(percentage < 1 || percentage > 100 ? ["the request percentage is from 1 to 100"] : []),
1364
+ ];
1365
+ if (problems.length > 0)
1366
+ throw new HttpError(400, problems.join("; "));
1367
+ const m = machines.get(machineKey(org.id, p.name));
1368
+ if (!m)
1369
+ throw new HttpError(404, `no such machine '${machineKey(org.id, p.name)}'`);
1370
+ m.byteRates = { produceBytesPerSecond: produce, consumeBytesPerSecond: consume, requestPercentage: percentage };
1371
+ return machineSummary(m);
1372
+ });
435
1373
  route("GET", "/services/{projectId}", (c, p) => {
436
1374
  requireProject(c, p.projectId);
437
1375
  return [...services.values()].filter((s) => s.projectId === p.projectId).map(serviceStatus);
438
1376
  });
439
- route("GET", "/services/{projectId}/{name}", (c, p) => serviceStatus(requireService(c, p.projectId, p.name).service));
1377
+ route("GET", "/services/{projectId}/{name}", (c, p) => oneService(requireService(c, p.projectId, p.name).service));
1378
+ // Custom hostnames (feature 045). A name whose first label is `unproved` stands for one that does not
1379
+ // carry the project's proof record.
1380
+ route("PUT", "/services/{projectId}/{name}/hostnames/{hostname}", (c, p) => {
1381
+ const { service: s, org } = requireService(c, p.projectId, p.name);
1382
+ requireWrite(org);
1383
+ const hostname = p.hostname.trim().toLowerCase().replace(/\.$/, "");
1384
+ const problem = hostnameProblem(p.hostname);
1385
+ if (problem !== null)
1386
+ throw new HttpError(409, problem);
1387
+ const held = s.customHostnames ?? [];
1388
+ if (held.includes(hostname))
1389
+ return oneService(s);
1390
+ if (!s.exposed)
1391
+ throw new HttpError(409, `service '${s.name}' is not exposed`);
1392
+ if (held.length >= 5)
1393
+ throw new HttpError(409, `service '${s.name}' holds 5 custom hostnames, the most a service can hold`);
1394
+ const holder = [...services.values()].find((o) => o !== s && (o.customHostnames ?? []).includes(hostname));
1395
+ if (holder)
1396
+ throw new HttpError(409, `'${hostname}' is held by service '${holder.name}' in project '${holder.projectId}'`);
1397
+ if (hostname.split(".")[0] === "unproved")
1398
+ throw new HttpError(409, `'${hostname}' does not carry the proof record of project '${s.projectId}': create TXT _ankka.${hostname} with the value "ankka-project=${s.projectId}"`);
1399
+ s.customHostnames = [...held, hostname];
1400
+ s.history.push({ kind: "hostname added", generation: s.generation, actor: actor(c), at: now(), hostname });
1401
+ return oneService(s);
1402
+ });
1403
+ route("DELETE", "/services/{projectId}/{name}/hostnames/{hostname}", (c, p) => {
1404
+ const { service: s, org } = requireService(c, p.projectId, p.name);
1405
+ if (!isAdmin(c))
1406
+ requireWrite(org);
1407
+ const hostname = p.hostname.trim().toLowerCase().replace(/\.$/, "");
1408
+ if ((s.customHostnames ?? []).includes(hostname)) {
1409
+ s.customHostnames = (s.customHostnames ?? []).filter((h) => h !== hostname);
1410
+ s.history.push({ kind: isAdmin(c) ? "hostname taken away" : "hostname removed", generation: s.generation, actor: actor(c), at: now(), hostname });
1411
+ }
1412
+ return oneService(s);
1413
+ });
440
1414
  route("PUT", "/services/{projectId}/{name}", (c, p, body) => {
441
1415
  const { org } = requireProject(c, p.projectId);
442
1416
  requireWrite(org);
@@ -463,18 +1437,58 @@ export async function fakeControlPlane(options = {}) {
463
1437
  desiredInstances: instances,
464
1438
  exposed: false,
465
1439
  paused: false,
1440
+ database: "provisioned",
466
1441
  history: [],
1442
+ kept: [],
467
1443
  logs: new Map(),
468
1444
  previousLogs: new Map(),
1445
+ platformLogs: new Map(),
469
1446
  };
470
- s.image = d.service.image;
471
- s.generation += 1;
472
- s.desiredInstances = instances;
473
- s.lifecycle = s.paused ? "Paused" : "UpdateInProgress";
474
- s.history.push({ kind: "applied", generation: s.generation, actor: actor(c), at: now() });
1447
+ record(s, d, actor(c));
475
1448
  services.set(key, s);
476
1449
  return serviceStatus(s);
477
1450
  });
1451
+ // Rollbacks, by the control plane's rules: the same refusals, in the same words and statuses. The
1452
+ // digest need only be equal for equal descriptors; it is not the control plane's.
1453
+ const descriptorAt = (s, n) => {
1454
+ if (!Number.isInteger(n) || n < 1 || n > s.generation)
1455
+ throw new HttpError(404, `service '${s.name}' has no generation ${n}`);
1456
+ const found = s.kept.find((k) => k.generation === n);
1457
+ if (found)
1458
+ return found;
1459
+ const oldest = s.kept.at(-1);
1460
+ const ran = s.kept.find((k) => k.generation < n);
1461
+ if (oldest && ran && n > oldest.generation)
1462
+ throw new HttpError(409, `generation ${n} was a restart and ran the descriptor of generation ${ran.generation}`);
1463
+ throw new HttpError(409, `the descriptor of generation ${n} is no longer kept; the oldest kept is generation ${oldest?.generation ?? s.generation}`);
1464
+ };
1465
+ route("POST", "/services/{projectId}/{name}/rollback", (c, p, body) => {
1466
+ const { service: s, org } = requireService(c, p.projectId, p.name);
1467
+ requireWrite(org);
1468
+ const named = body?.generation;
1469
+ const current = digestOf(s.descriptor);
1470
+ let target;
1471
+ if (named !== undefined) {
1472
+ target = descriptorAt(s, named);
1473
+ if (digestOf(target.descriptor) === current)
1474
+ throw new HttpError(409, `service '${s.name}' already has the descriptor of generation ${named}`);
1475
+ }
1476
+ else {
1477
+ const differs = s.kept.find((k) => digestOf(k.descriptor) !== current);
1478
+ if (!differs)
1479
+ throw new HttpError(409, `service '${s.name}' has no earlier generation with a different descriptor`);
1480
+ target = differs;
1481
+ }
1482
+ record(s, target.descriptor, actor(c), target.generation);
1483
+ return { rolledBackTo: target.generation, status: serviceStatus(s) };
1484
+ });
1485
+ route("GET", "/services/{projectId}/{name}/descriptor", (c, p, _body, url) => {
1486
+ const { service: s } = requireService(c, p.projectId, p.name);
1487
+ const raw = url.searchParams.get("generation");
1488
+ if (raw === null)
1489
+ throw new HttpError(400, "query parameter 'generation' is required");
1490
+ return descriptorAt(s, Number(raw)).descriptor;
1491
+ });
478
1492
  const operation = (op) => (c, p) => {
479
1493
  const { service: s, org } = requireService(c, p.projectId, p.name);
480
1494
  requireWrite(org);
@@ -508,14 +1522,62 @@ export async function fakeControlPlane(options = {}) {
508
1522
  for (const op of ["pause", "resume", "restart", "expose", "unexpose"]) {
509
1523
  route("POST", `/services/{projectId}/{name}/${op}`, operation(op));
510
1524
  }
1525
+ // A service's storage (feature 039): the control plane's refusals, and the history it records.
1526
+ const moving = (s) => s.storageMove !== undefined && s.storageMove !== "moved" && s.storageMove !== "move failed";
1527
+ route("POST", "/services/{projectId}/{name}/storage-credential", (c, p) => {
1528
+ const { service: s, org } = requireService(c, p.projectId, p.name);
1529
+ requireWrite(org);
1530
+ if (!s.provisionObjectStorage)
1531
+ throw new HttpError(409, `service '${s.name}' has no storage credential to reissue: its descriptor asks for no bucket`);
1532
+ if (moving(s))
1533
+ throw new HttpError(409, `the storage of service '${s.name}' is moving`);
1534
+ s.history.push({ kind: "storage-credential-reissued", generation: s.generation, actor: actor(c), at: now() });
1535
+ return serviceStatus(s);
1536
+ });
1537
+ route("POST", "/services/{projectId}/{name}/storage/settings", (c, p) => {
1538
+ const { service: s, org } = requireService(c, p.projectId, p.name);
1539
+ requireWrite(org);
1540
+ if (!s.provisionObjectStorage || s.objectStore !== "gcs")
1541
+ throw new HttpError(409, `the bucket of service '${s.name}' is not in Google Cloud Storage; settings are reapplied there only`);
1542
+ s.history.push({ kind: "storage-settings-reapplied", generation: s.generation, actor: actor(c), at: now() });
1543
+ return serviceStatus(s);
1544
+ });
1545
+ route("POST", "/services/{projectId}/{name}/storage/move", (c, p, body) => {
1546
+ const { service: s, org } = requireService(c, p.projectId, p.name);
1547
+ requireWrite(org);
1548
+ if (objectStore !== "gcs")
1549
+ throw new HttpError(409, "the installation keeps new buckets in Garage; there is nowhere to move a bucket to");
1550
+ if (!s.provisionObjectStorage)
1551
+ throw new HttpError(409, `service '${s.name}' has no bucket to move: its descriptor asks for none`);
1552
+ if (moving(s))
1553
+ throw new HttpError(409, `the storage of service '${s.name}' is moving`);
1554
+ if (s.objectStore === "gcs")
1555
+ throw new HttpError(409, `the bucket of service '${s.name}' is in Google Cloud Storage already`);
1556
+ const bound = body?.writePauseBound ?? "10m";
1557
+ const m = /^(\d+)([smh])$/.exec(bound);
1558
+ if (!m)
1559
+ throw new HttpError(400, `writePauseBound '${bound}' is not a duration such as 90s, 10m or 2h`);
1560
+ const seconds = Number(m[1]) * (m[2] === "s" ? 1 : m[2] === "m" ? 60 : 3600);
1561
+ if (seconds < 60 || seconds > 86400)
1562
+ throw new HttpError(400, `writePauseBound '${bound}' is outside one minute to 24 hours`);
1563
+ // As the control plane says it: how long the write pause may last, from the moment it is asked.
1564
+ const unit = m[2] === "s" ? "second" : m[2] === "m" ? "minute" : "hour";
1565
+ const words = `${m[1]} ${unit}${m[1] === "1" ? "" : "s"}`;
1566
+ s.storageMove = `waiting for its bucket in Google Cloud Storage; its write pause may last ${words} at most`;
1567
+ s.history.push({ kind: "storage-moved", generation: s.generation, actor: actor(c), at: now() });
1568
+ return serviceStatus(s);
1569
+ });
511
1570
  route("GET", "/services/{projectId}/{name}/logs", (c, p, _body, url) => {
512
1571
  const { service: s } = requireService(c, p.projectId, p.name);
513
1572
  if (s.paused || s.readyInstances === 0 && s.logs.size === 0)
514
1573
  throw new HttpError(404, `service '${s.name}' has no running instance`);
515
1574
  const previous = url.searchParams.get("previous") === "true";
1575
+ const platform = url.searchParams.get("platform") === "true";
1576
+ if (platform && !twoContainers(s.hosting))
1577
+ throw new HttpError(400, "--platform applies to a service with process or web hosting");
516
1578
  const tail = url.searchParams.get("tail");
517
1579
  const only = url.searchParams.get("instance");
518
- const source = previous ? s.previousLogs : s.logs;
1580
+ const source = platform ? s.platformLogs : previous ? s.previousLogs : s.logs;
519
1581
  const names = s.readyInstances > 0 ? Array.from({ length: s.readyInstances }, (_x, i) => `${s.name}-${i}`) : [...source.keys()];
520
1582
  return {
521
1583
  instances: names
@@ -527,6 +1589,32 @@ export async function fakeControlPlane(options = {}) {
527
1589
  }),
528
1590
  };
529
1591
  });
1592
+ // The control plane's rules: a non-member is told the service does not exist; no running instance
1593
+ // is the logs route's 404; one instance that is not ok makes the result partial.
1594
+ route("GET", "/services/{projectId}/{name}/topology", (c, p) => {
1595
+ const { service: s } = requireService(c, p.projectId, p.name);
1596
+ if (s.paused || s.readyInstances === 0)
1597
+ throw new HttpError(404, `service '${s.name}' has no running instance; it may be paused`);
1598
+ const t = s.topology ?? {
1599
+ instances: Array.from({ length: s.readyInstances }, (_x, i) => ({ pod: `${s.name}-${i}`, status: "ok" })),
1600
+ nodes: [{ id: s.name, kind: "EventSourcedEntity", layer: 1, platform: false, handlers: [] }],
1601
+ declared: [],
1602
+ calls: [],
1603
+ };
1604
+ const contributing = t.instances.filter((i) => i.status === "ok").length;
1605
+ return {
1606
+ service: s.name,
1607
+ running: t.instances.length,
1608
+ contributing,
1609
+ partial: contributing < t.instances.length,
1610
+ instances: t.instances.map((i) => ({ pod: i.pod, status: i.status, problem: i.problem ?? null, runtime: i.status === "ok" ? "0.0.0" : null, readAt: i.status === "ok" ? now() : null })),
1611
+ window: t.window ?? { seconds: 600, since: now(), calls: 0, unanswered: 0 },
1612
+ nodes: t.nodes,
1613
+ declared: t.declared,
1614
+ calls: t.calls,
1615
+ differences: t.differences ?? [],
1616
+ };
1617
+ });
530
1618
  route("GET", "/services/{projectId}/{name}/history", (c, p) => requireService(c, p.projectId, p.name).service.history);
531
1619
  route("DELETE", "/services/{projectId}/{name}", (c, p) => {
532
1620
  const { service: s, org } = requireService(c, p.projectId, p.name);
@@ -650,6 +1738,12 @@ export async function fakeControlPlane(options = {}) {
650
1738
  }
651
1739
  }
652
1740
  },
1741
+ scriptTopology(projectId, name, topology) {
1742
+ const s = services.get(serviceKey(projectId, name));
1743
+ if (!s)
1744
+ throw new Error(`no service ${projectId}/${name}`);
1745
+ s.topology = topology;
1746
+ },
653
1747
  appendLog(projectId, name, line, instance) {
654
1748
  const s = services.get(serviceKey(projectId, name));
655
1749
  if (!s)
@@ -670,9 +1764,17 @@ export async function fakeControlPlane(options = {}) {
670
1764
  o.hidden = false;
671
1765
  for (const p of projects.values())
672
1766
  p.hidden = false;
1767
+ for (const m of machines.values())
1768
+ m.hidden = false;
673
1769
  },
674
- state: { organizations, projects, services, tokens, tombstones },
1770
+ state: { organizations, projects, services, tokens, tombstones, grants, machines },
675
1771
  seed(seed) {
1772
+ if (seed.objectStore)
1773
+ objectStore = seed.objectStore;
1774
+ if (seed.cloud !== undefined)
1775
+ cloud = seed.cloud;
1776
+ if (seed.held)
1777
+ hold = { ...seed.held, releasedAt: undefined, releasedBy: undefined };
676
1778
  for (const o of seed.organizations ?? []) {
677
1779
  const members = new Map();
678
1780
  for (const s of o.owners ?? [])
@@ -688,8 +1790,13 @@ export async function fakeControlPlane(options = {}) {
688
1790
  const n = s.instances ?? 1;
689
1791
  const ready = lifecycle === "Ready" ? n : 0;
690
1792
  const logs = new Map();
691
- for (let i = 0; i < ready; i++)
1793
+ const platformLogs = new Map();
1794
+ for (let i = 0; i < ready; i++) {
692
1795
  logs.set(`${s.name}-${i}`, [`${s.name} started`]);
1796
+ if (twoContainers(s.hosting))
1797
+ platformLogs.set(`${s.name}-${i}`, [`${s.hosting === "web" ? "proxy" : "sidecar"} of ${s.name} started`]);
1798
+ }
1799
+ const seeded = { name: s.name, service: { image: s.image ?? `${s.name}:latest` } };
693
1800
  services.set(serviceKey(s.projectId, s.name), {
694
1801
  name: s.name,
695
1802
  projectId: s.projectId,
@@ -698,13 +1805,76 @@ export async function fakeControlPlane(options = {}) {
698
1805
  image: s.image ?? `${s.name}:latest`,
699
1806
  readyInstances: ready,
700
1807
  desiredInstances: n,
701
- exposed: false,
1808
+ exposed: s.exposed ?? false,
702
1809
  paused: lifecycle === "Paused",
703
- history: [{ kind: "applied", generation: 1, at: now() }],
1810
+ database: s.database === undefined ? "provisioned" : s.database,
1811
+ history: [{ kind: "applied", generation: 1, at: now(), image: s.image ?? `${s.name}:latest`, digest: digestOf(seeded) }],
1812
+ descriptor: seeded,
1813
+ kept: [{ generation: 1, descriptor: seeded }],
704
1814
  logs,
705
1815
  previousLogs: new Map(),
1816
+ platformLogs,
1817
+ hosting: s.hosting,
1818
+ mounts: s.mounts,
1819
+ callers: s.callers,
1820
+ processPort: s.processPort,
1821
+ broker: s.broker,
1822
+ undeclaredTopics: s.undeclaredTopics,
1823
+ topicChecks: s.topicChecks,
1824
+ topicSources: s.topicSources,
1825
+ provisionObjectStorage: s.provisionObjectStorage,
1826
+ exposeObjectStorage: s.exposeObjectStorage,
1827
+ ownObjectStore: s.ownObjectStore,
1828
+ objectStore: s.objectStore,
1829
+ bucketLocation: s.bucketLocation,
1830
+ });
1831
+ }
1832
+ for (const sec of seed.secrets ?? []) {
1833
+ const project = projects.get(sec.projectId);
1834
+ if (!project)
1835
+ throw new Error(`no project ${sec.projectId}`);
1836
+ project.secrets ??= new Map();
1837
+ const held = project.secrets.get(sec.name)?.entries ?? new Map();
1838
+ for (const [entry, value] of Object.entries(sec.entries))
1839
+ held.set(entry, value);
1840
+ project.secrets.set(sec.name, { entries: held, setAt: now(), setBy: "seed" });
1841
+ }
1842
+ for (const b of seed.brokers ?? []) {
1843
+ const project = projects.get(b.projectId);
1844
+ if (!project)
1845
+ throw new Error(`no project ${b.projectId}`);
1846
+ project.brokers ??= new Map();
1847
+ project.brokers.set(b.name, { bootstrap: b.bootstrap, shape: b.shape, secret: b.secret, declaredAt: now() });
1848
+ }
1849
+ for (const t of seed.topics ?? []) {
1850
+ const project = projects.get(t.projectId);
1851
+ if (!project)
1852
+ throw new Error(`no project ${t.projectId}`);
1853
+ project.topics ??= new Map();
1854
+ project.topics.set(t.name, {
1855
+ partitions: t.partitions,
1856
+ phase: t.phase,
1857
+ detail: t.detail,
1858
+ compacted: t.compacted,
1859
+ contract: t.contract ? { ...t.contract, fingerprint: fakeFingerprint(t.contract.schema) } : undefined,
1860
+ settings: t.settings ? { ...TopicDefaults, ...t.settings } : undefined,
1861
+ defaulted: t.settings ? SettingNames.filter((s) => t.settings[s] === undefined) : undefined,
706
1862
  });
707
1863
  }
1864
+ for (const m of seed.machines ?? []) {
1865
+ machines.set(machineKey(m.organizationId, m.name), { organizationId: m.organizationId, name: m.name, registeredBy: "seed", registeredAt: now(), byteRates: m.byteRates, hidden: false });
1866
+ }
1867
+ for (const g of seed.grants ?? []) {
1868
+ const project = projects.get(g.projectId);
1869
+ const org = project && organizations.get(project.organizationId);
1870
+ if (!project || !org)
1871
+ throw new Error(`no project ${g.projectId}`);
1872
+ if (!parseGrantee(g.grantee))
1873
+ throw new Error(`grantee ${g.grantee} is not service:<project>/<name> or machine:<organization>/<name>`);
1874
+ const grant = makeGrant(project, org, g.grantee, g.target, g.by ?? "seed", g.state);
1875
+ if (g.state && g.state !== "pending" && g.state !== "accepted")
1876
+ grant.ended = { by: g.by ?? "seed", at: grant.granted.at };
1877
+ }
708
1878
  },
709
1879
  close: () => new Promise((resolve) => {
710
1880
  server.closeAllConnections();
@@ -713,3 +1883,12 @@ export async function fakeControlPlane(options = {}) {
713
1883
  };
714
1884
  return fake;
715
1885
  }
1886
+ /** Equal for equal descriptors, whatever order their keys were written in. */
1887
+ function digestOf(descriptor) {
1888
+ const canonical = (v) => Array.isArray(v)
1889
+ ? v.map(canonical)
1890
+ : v && typeof v === "object"
1891
+ ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical(v[k])]))
1892
+ : v;
1893
+ return createHash("sha256").update(JSON.stringify(canonical(descriptor ?? null))).digest("hex");
1894
+ }