ankka-console 0.10.0 → 0.11.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 (67) hide show
  1. package/dist/client/control-plane.d.ts +25 -1
  2. package/dist/client/control-plane.js +60 -1
  3. package/dist/client/schemas.d.ts +525 -0
  4. package/dist/client/schemas.js +174 -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 +7 -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.js +12 -6
  17. package/dist/routes/logs.d.ts +35 -0
  18. package/dist/routes/logs.js +15 -14
  19. package/dist/routes/members.js +11 -8
  20. package/dist/routes/organization-new.js +7 -4
  21. package/dist/routes/organization.js +13 -6
  22. package/dist/routes/project-new.js +8 -4
  23. package/dist/routes/project.d.ts +73 -2
  24. package/dist/routes/project.js +81 -9
  25. package/dist/routes/service-apply.d.ts +5 -2
  26. package/dist/routes/service-apply.js +17 -14
  27. package/dist/routes/service-history.d.ts +30 -0
  28. package/dist/routes/service-history.js +79 -0
  29. package/dist/routes/service-topology.d.ts +153 -0
  30. package/dist/routes/service-topology.js +54 -0
  31. package/dist/routes/service.d.ts +34 -10
  32. package/dist/routes/service.js +27 -22
  33. package/dist/routes/stream.service.js +18 -1
  34. package/dist/routes/tokens.js +8 -6
  35. package/dist/routes.js +2 -0
  36. package/dist/styles.css +2 -481
  37. package/dist/testing/fake-control-plane.d.ts +155 -0
  38. package/dist/testing/fake-control-plane.js +379 -12
  39. package/dist/testing/index.d.ts +1 -1
  40. package/dist/testing/scenarios.js +6 -0
  41. package/dist/theme.css +66 -0
  42. package/dist/ui/console.d.ts +19 -6
  43. package/dist/ui/console.js +9 -2
  44. package/dist/ui/errors.js +7 -5
  45. package/dist/ui/hosting.d.ts +11 -0
  46. package/dist/ui/hosting.js +20 -0
  47. package/dist/ui/primitives/button.d.ts +11 -0
  48. package/dist/ui/primitives/button.js +21 -0
  49. package/dist/ui/primitives/select.d.ts +10 -0
  50. package/dist/ui/primitives/select.js +4 -0
  51. package/dist/ui/primitives/switch.d.ts +9 -0
  52. package/dist/ui/primitives/switch.js +4 -0
  53. package/dist/ui/shape.d.ts +4 -0
  54. package/dist/ui/shape.js +36 -0
  55. package/dist/ui/shell.d.ts +76 -0
  56. package/dist/ui/shell.js +107 -0
  57. package/dist/ui/tokens.d.ts +82 -0
  58. package/dist/ui/tokens.js +172 -0
  59. package/dist/ui/topology/Graph.d.ts +13 -0
  60. package/dist/ui/topology/Graph.js +56 -0
  61. package/dist/ui/topology/Table.d.ts +10 -0
  62. package/dist/ui/topology/Table.js +14 -0
  63. package/dist/ui/topology/layout.d.ts +113 -0
  64. package/dist/ui/topology/layout.js +177 -0
  65. package/dist/ui/use-stream.d.ts +137 -1
  66. package/dist/ui/use-stream.js +11 -1
  67. package/package.json +11 -3
@@ -66,13 +66,63 @@ interface Registry {
66
66
  setAt: string;
67
67
  setBy?: string;
68
68
  }
69
+ interface ProjectSecret {
70
+ /** Values held as the cluster holds them; the control plane never answers with one. */
71
+ entries: Map<string, string>;
72
+ setAt: string;
73
+ setBy?: string;
74
+ }
69
75
  interface Project {
70
76
  id: string;
71
77
  name: string;
72
78
  organizationId: string;
73
79
  registry?: Registry;
80
+ secrets?: Map<string, ProjectSecret>;
81
+ /** Declared topics, by name: their partitions, compaction, contract and what the broker reported of each. */
82
+ topics?: Map<string, TopicRecord>;
83
+ /** Declared brokers beside the installation's, by name. */
84
+ brokers?: Map<string, BrokerRecord>;
74
85
  hidden: boolean;
75
86
  }
87
+ interface BrokerRecord {
88
+ bootstrap: string;
89
+ shape: string;
90
+ secret: string;
91
+ declaredAt: string;
92
+ }
93
+ /** A topic source as an instance reports it, with how far behind it is. */
94
+ interface TopicSourceRecord {
95
+ kind: string;
96
+ component: string;
97
+ topic: string;
98
+ group: string;
99
+ start?: string;
100
+ version?: number;
101
+ broker?: string;
102
+ contract?: string;
103
+ lag?: number;
104
+ failing?: string;
105
+ }
106
+ interface TopicRecord {
107
+ partitions: number;
108
+ phase?: string;
109
+ detail?: string;
110
+ compacted?: boolean;
111
+ contract?: {
112
+ name: string;
113
+ fingerprint: string;
114
+ schema: unknown;
115
+ };
116
+ }
117
+ /** One side a running service takes on a declared topic with a contract, against it; `topic` is the fake's own, for the project's listing. */
118
+ interface TopicCheckRecord {
119
+ topic?: string;
120
+ service: string;
121
+ component: string;
122
+ direction: string;
123
+ stated?: string;
124
+ state: string;
125
+ }
76
126
  interface Service {
77
127
  name: string;
78
128
  projectId: string;
@@ -84,6 +134,8 @@ interface Service {
84
134
  detail?: string;
85
135
  exposed: boolean;
86
136
  paused: boolean;
137
+ /** What the cluster reports of its database; `null` until it has reported one. */
138
+ database: string | null;
87
139
  history: {
88
140
  kind: string;
89
141
  generation: number;
@@ -93,9 +145,65 @@ interface Service {
93
145
  administrative: boolean;
94
146
  };
95
147
  at: string;
148
+ image?: string;
149
+ digest?: string;
150
+ rolledBackTo?: number;
151
+ }[];
152
+ /** The descriptor in force, and the applied ones a rollback can return to, newest first, at most fifty. */
153
+ descriptor?: unknown;
154
+ kept: {
155
+ generation: number;
156
+ descriptor: unknown;
96
157
  }[];
97
158
  logs: Map<string, string[]>;
98
159
  previousLogs: Map<string, string[]>;
160
+ /** The platform's container's output: the sidecar beside a process, the proxy beside a web-hosted one. */
161
+ platformLogs: Map<string, string[]>;
162
+ hosting?: string;
163
+ mounts?: {
164
+ path: string;
165
+ service: string;
166
+ state: string;
167
+ }[];
168
+ callers?: string[];
169
+ processPort?: number;
170
+ /** What the operator reported of the service's credential on the installation's broker. */
171
+ broker?: string;
172
+ /** Topics the service's components use that its project does not declare, as last read. */
173
+ undeclaredTopics?: string[];
174
+ /** The sides it takes on declared topics with a contract, as last read. */
175
+ topicChecks?: TopicCheckRecord[];
176
+ /** Its topic sources with how far behind each is, as last read. */
177
+ topicSources?: TopicSourceRecord[];
178
+ /** The descriptor asked for a bucket (feature 034). */
179
+ provisionObjectStorage?: boolean;
180
+ /** The descriptor gave an ANKKA_S3_ variable: an object store of its own. */
181
+ ownObjectStore?: boolean;
182
+ /** The descriptor asked that the bucket be reachable from the internet. */
183
+ exposeObjectStorage?: boolean;
184
+ /** What the instances report when asked for the topology; generated from the service when unset. */
185
+ topology?: FakeTopology;
186
+ }
187
+ /** A service's instances, what each answered, and the merged document, as a suite scripts it. */
188
+ export interface FakeTopology {
189
+ instances: {
190
+ pod: string;
191
+ status: "ok" | "unreachable" | "unsupported" | "failed";
192
+ problem?: string;
193
+ }[];
194
+ nodes: unknown[];
195
+ declared: unknown[];
196
+ calls: unknown[];
197
+ differences?: {
198
+ node: string;
199
+ presentOn: string[];
200
+ }[];
201
+ window?: {
202
+ seconds: number;
203
+ since: string;
204
+ calls: number;
205
+ unanswered?: number;
206
+ };
99
207
  }
100
208
  interface Token {
101
209
  id: string;
@@ -120,6 +228,8 @@ export interface FakeControlPlane {
120
228
  /** Advance every service in a transitional state by one step. */
121
229
  tick(): void;
122
230
  appendLog(projectId: string, name: string, line: string, instance?: string): void;
231
+ /** What a service's instances report when its topology is read, from now on. */
232
+ scriptTopology(projectId: string, name: string, topology: FakeTopology): void;
123
233
  /** Who may create organizations from now on, and where the refusal sends people. */
124
234
  policy(creation: "open" | "platform-admin", signupUrl?: string): void;
125
235
  /** From now on, created organizations and projects are missing from listings until `settle()`. */
@@ -155,6 +265,51 @@ export interface FakeSeed {
155
265
  image?: string;
156
266
  lifecycle?: string;
157
267
  instances?: number;
268
+ hosting?: string;
269
+ mounts?: {
270
+ path: string;
271
+ service: string;
272
+ state: string;
273
+ }[];
274
+ callers?: string[];
275
+ processPort?: number;
276
+ exposed?: boolean;
277
+ /** What the cluster reports of its database; `null` until it has reported one. */
278
+ database?: string | null;
279
+ broker?: string;
280
+ undeclaredTopics?: string[];
281
+ topicChecks?: TopicCheckRecord[];
282
+ topicSources?: TopicSourceRecord[];
283
+ provisionObjectStorage?: boolean;
284
+ exposeObjectStorage?: boolean;
285
+ ownObjectStore?: boolean;
286
+ }[];
287
+ /** Project secrets, by name and entry: a value each, never listed. */
288
+ secrets?: {
289
+ projectId: string;
290
+ name: string;
291
+ entries: Record<string, string>;
292
+ }[];
293
+ /** Brokers a project declares beside the installation's. */
294
+ brokers?: {
295
+ projectId: string;
296
+ name: string;
297
+ bootstrap: string;
298
+ shape: string;
299
+ secret: string;
300
+ }[];
301
+ /** Topics a project declares, as the control plane holds them, with the broker's report. */
302
+ topics?: {
303
+ projectId: string;
304
+ name: string;
305
+ partitions: number;
306
+ phase?: string;
307
+ detail?: string;
308
+ compacted?: boolean;
309
+ contract?: {
310
+ name: string;
311
+ schema: unknown;
312
+ };
158
313
  }[];
159
314
  }
160
315
  export declare function fakeControlPlane(options?: FakeControlPlaneOptions): Promise<FakeControlPlane>;
@@ -11,7 +11,38 @@
11
11
  * template exercised, which the parity test reads.
12
12
  */
13
13
  import { createServer } from "node:http";
14
- import { randomBytes } from "node:crypto";
14
+ import { createHash, randomBytes } from "node:crypto";
15
+ /** The entries a project secret must hold for a broker's shape, as the control plane checks. */
16
+ const brokerNeeds = (shape) => shape === "certificate" ? ["ca.crt", "tls.crt", "tls.key"] : shape === "sasl" ? ["ca.crt", "username", "password"] : [];
17
+ const topicSourceOnTheWire = (t) => ({
18
+ kind: t.kind,
19
+ component: t.component,
20
+ topic: t.topic,
21
+ group: t.group,
22
+ start: t.start ?? "earliest",
23
+ version: t.version ?? 1,
24
+ recordedVersion: null,
25
+ behind: false,
26
+ broker: t.broker ?? null,
27
+ contract: t.contract ?? null,
28
+ lag: t.lag ?? null,
29
+ failing: t.failing ?? null,
30
+ });
31
+ const checkOnTheWire = (c) => ({ service: c.service, component: c.component, direction: c.direction, stated: c.stated ?? null, state: c.state });
32
+ /** A stable fingerprint of the right shape; the control plane's is RFC 8785 over the document, which the fake need not reproduce. */
33
+ function fakeFingerprint(schema) {
34
+ const text = JSON.stringify(schema);
35
+ let h1 = 0x811c9dc5;
36
+ let h2 = 0x01000193;
37
+ for (let i = 0; i < text.length; i++) {
38
+ h1 = Math.imul(h1 ^ text.charCodeAt(i), 0x01000193) >>> 0;
39
+ h2 = Math.imul(h2 + text.charCodeAt(i), 0x9e3779b1) >>> 0;
40
+ }
41
+ const hex = (h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0")).repeat(4);
42
+ return `sha256:${hex}`;
43
+ }
44
+ /** The hostings whose pods hold the platform's container beside the developer's. */
45
+ const twoContainers = (hosting) => hosting === "process" || hosting === "web";
15
46
  class HttpError extends Error {
16
47
  status;
17
48
  constructor(status, message) {
@@ -68,6 +99,29 @@ export async function fakeControlPlane(options = {}) {
68
99
  };
69
100
  };
70
101
  const registrySummary = (r) => r ? { server: r.server, username: r.username, setAt: r.setAt, setBy: r.setBy ?? null } : null;
102
+ /** An apply, or a rollback when `rolledBackTo` is given: a new generation with this descriptor. */
103
+ const record = (s, descriptor, by, rolledBackTo) => {
104
+ const d = descriptor;
105
+ s.generation += 1;
106
+ s.image = d.service?.image ?? "";
107
+ // Object storage (feature 034), from the descriptor, so a rolled-back one says what it said then.
108
+ s.provisionObjectStorage = d.service?.provisionObjectStorage ?? false;
109
+ s.exposeObjectStorage = d.service?.exposeObjectStorage ?? false;
110
+ s.ownObjectStore = (d.service?.env ?? []).some((e) => e.name.startsWith("ANKKA_S3_"));
111
+ s.desiredInstances = d.service?.resources?.autoscaling?.minInstances ?? 1;
112
+ s.lifecycle = s.paused ? "Paused" : "UpdateInProgress";
113
+ s.descriptor = descriptor;
114
+ s.kept = [{ generation: s.generation, descriptor }, ...s.kept].slice(0, 50);
115
+ s.history.push({
116
+ kind: rolledBackTo === undefined ? "applied" : "rolled-back",
117
+ generation: s.generation,
118
+ actor: by,
119
+ at: now(),
120
+ image: s.image,
121
+ digest: digestOf(descriptor),
122
+ ...(rolledBackTo === undefined ? {} : { rolledBackTo }),
123
+ });
124
+ };
71
125
  const serviceStatus = (s) => {
72
126
  const org = organizations.get(projects.get(s.projectId)?.organizationId ?? "");
73
127
  const suspended = org?.disabled ?? false;
@@ -81,13 +135,25 @@ export async function fakeControlPlane(options = {}) {
81
135
  desiredInstances: s.desiredInstances,
82
136
  detail: s.detail ?? null,
83
137
  confirmed: true,
84
- database: "provisioned",
138
+ database: s.hosting === "web" ? "none" : s.database,
85
139
  hostname: s.exposed ? `https://${s.name}-${s.projectId}.${options.baseDomain ?? "example.test"}` : null,
86
140
  exposed: s.exposed,
87
141
  suspended,
88
142
  paused: s.paused,
89
- hosting: "embedded",
143
+ hosting: s.hosting ?? "embedded",
90
144
  protocol: null,
145
+ mounts: s.mounts ?? [],
146
+ callers: s.callers ?? [],
147
+ processPort: s.processPort ?? null,
148
+ broker: s.broker ?? null,
149
+ undeclaredTopics: s.undeclaredTopics ?? null,
150
+ topicChecks: s.topicChecks ? s.topicChecks.map(checkOnTheWire) : null,
151
+ topicSources: s.topicSources ? s.topicSources.map(topicSourceOnTheWire) : null,
152
+ objectStorage: s.provisionObjectStorage ? "provisioned" : s.ownObjectStore ? "supplied" : null,
153
+ bucket: s.provisionObjectStorage ? `${s.projectId}.${s.name}` : null,
154
+ bucketAddress: s.provisionObjectStorage && s.exposeObjectStorage
155
+ ? `https://storage.${options.baseDomain ?? "example.test"}/${s.projectId}.${s.name}`
156
+ : null,
91
157
  };
92
158
  };
93
159
  // ── Authorization, the control plane's rules ──────────────────────────────
@@ -432,6 +498,173 @@ export async function fakeControlPlane(options = {}) {
432
498
  project.registry = undefined;
433
499
  return "done";
434
500
  });
501
+ // Project secrets: the control plane's rules, its merge, and names only in what it answers.
502
+ const SecretName = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
503
+ const EntryName = /^[A-Za-z0-9._-]{1,253}$/;
504
+ const PlatformSuffixes = ["-db", "-cluster-tls", "-service-tls", "-database-tls", "-secret-key", "-mount-tls", "-storage"];
505
+ route("PUT", "/projects/{projectId}/secrets/{name}", (c, p, body) => {
506
+ const { project, org } = requireProject(c, p.projectId);
507
+ requireWrite(org);
508
+ const name = p.name;
509
+ const entries = (body.entries ?? {});
510
+ const problems = [];
511
+ if (!SecretName.test(name) || name.length > 253)
512
+ 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`);
513
+ else if (name.startsWith("ankka-") || PlatformSuffixes.some((s) => name.endsWith(s)))
514
+ problems.push(`project secret name '${name}' is one the platform uses for its own Secrets in a project`);
515
+ if (Object.keys(entries).length === 0)
516
+ problems.push("a project secret is set with at least one entry");
517
+ for (const [entry, value] of Object.entries(entries)) {
518
+ if (!EntryName.test(entry))
519
+ problems.push(`entry name '${entry}' must be 1 to 253 letters, digits, '.', '_' or '-'`);
520
+ if (value === "")
521
+ problems.push(`entry '${entry}' must not be empty`);
522
+ }
523
+ if (problems.length > 0)
524
+ throw new HttpError(400, problems.join("; "));
525
+ project.secrets ??= new Map();
526
+ const held = project.secrets.get(name)?.entries ?? new Map();
527
+ for (const [entry, value] of Object.entries(entries))
528
+ held.set(entry, value);
529
+ project.secrets.set(name, { entries: held, setAt: now(), setBy: c.name ?? c.subject });
530
+ return "done";
531
+ });
532
+ route("DELETE", "/projects/{projectId}/secrets/{name}", (c, p, _body, url) => {
533
+ const { project, org } = requireProject(c, p.projectId);
534
+ requireWrite(org);
535
+ const entry = url.searchParams.get("entry") ?? "";
536
+ const secret = project.secrets?.get(p.name);
537
+ if (!secret?.entries.has(entry))
538
+ throw new HttpError(404, `project secret '${p.name}' has no entry '${entry}'`);
539
+ // A broker's credential is not pulled from under it: the broker goes first.
540
+ const naming = [...(project.brokers ?? new Map()).entries()].filter(([, b]) => b.secret === p.name && brokerNeeds(b.shape).includes(entry)).map(([name]) => `'${name}'`);
541
+ if (naming.length > 0)
542
+ throw new HttpError(409, `project secret '${p.name}' is the credential of broker ${naming.sort().join(", ")}; remove the broker first`);
543
+ secret.entries.delete(entry);
544
+ return "done";
545
+ });
546
+ route("GET", "/projects/{projectId}/secrets", (c, p) => {
547
+ const { project } = requireProject(c, p.projectId);
548
+ return [...(project.secrets ?? new Map()).entries()]
549
+ .filter(([, s]) => s.entries.size > 0)
550
+ .sort(([a], [b]) => a.localeCompare(b))
551
+ .map(([name, s]) => ({ name, entries: [...s.entries.keys()].sort(), setAt: s.setAt, setBy: s.setBy }));
552
+ });
553
+ // Project topics: the control plane's rules, one declaration per name, never fewer partitions.
554
+ const TopicName = /^[a-z0-9]([a-z0-9.-]{0,98}[a-z0-9])?$/;
555
+ const TopicNameRule = 'a name is lower-case letters, digits, "-" and ".", starting and ending with a letter or digit, at most 100 characters';
556
+ const ContractName = /^[a-z0-9][a-z0-9._-]{0,98}[a-z0-9]$/;
557
+ const ContractNameRule = "[a-z0-9][a-z0-9._-]{0,98}[a-z0-9]";
558
+ route("PUT", "/projects/{projectId}/topics/{name}", (c, p, body) => {
559
+ const { project, org } = requireProject(c, p.projectId);
560
+ requireWrite(org);
561
+ const name = p.name;
562
+ const request = body;
563
+ const partitions = Number(request.partitions);
564
+ const problems = [];
565
+ if (!TopicName.test(name))
566
+ problems.push(`topic '${name}': ${TopicNameRule}`);
567
+ if (!Number.isInteger(partitions) || partitions < 1 || partitions > 1000)
568
+ problems.push(`topic '${name}': partitions ${partitions} is outside the range 1-1000`);
569
+ // A contract's own rules: its name, and a schema that is a JSON document (feature 037).
570
+ if (request.contract !== undefined && request.contract !== null) {
571
+ const contractName = String(request.contract.name ?? "");
572
+ if (!ContractName.test(contractName))
573
+ problems.push(`topic '${name}': contract name '${contractName}' is not ${ContractNameRule}`);
574
+ if (request.contract.schema === undefined || request.contract.schema === null)
575
+ problems.push(`topic '${name}': the contract's schema is missing`);
576
+ }
577
+ if (problems.length > 0)
578
+ throw new HttpError(400, problems.join("; "));
579
+ project.topics ??= new Map();
580
+ const has = project.topics.get(name)?.partitions;
581
+ if (has !== undefined && has > partitions)
582
+ throw new HttpError(409, `topic '${name}' has ${has} partitions and cannot have fewer; ${partitions} was asked`);
583
+ const contract = request.contract
584
+ ? { name: String(request.contract.name), schema: request.contract.schema, fingerprint: fakeFingerprint(request.contract.schema) }
585
+ : undefined;
586
+ project.topics.set(name, { ...(project.topics.get(name) ?? {}), partitions, compacted: request.compacted === true, contract });
587
+ return "done";
588
+ });
589
+ // The schema a topic's contract was declared with (feature 037); 404 without a contract.
590
+ route("GET", "/projects/{projectId}/topics/{name}/schema", (c, p) => {
591
+ const { project } = requireProject(c, p.projectId);
592
+ const topic = project.topics?.get(p.name);
593
+ if (!topic?.contract)
594
+ throw new HttpError(404, `project '${p.projectId}' declares no contract on topic '${p.name}'`);
595
+ return topic.contract.schema;
596
+ });
597
+ route("DELETE", "/projects/{projectId}/topics/{name}", (c, p) => {
598
+ const { project, org } = requireProject(c, p.projectId);
599
+ requireWrite(org);
600
+ if (!project.topics?.delete(p.name))
601
+ throw new HttpError(404, `project '${p.projectId}' declares no topic '${p.name}'`);
602
+ return "done";
603
+ });
604
+ route("GET", "/projects/{projectId}/topics", (c, p) => {
605
+ const { project } = requireProject(c, p.projectId);
606
+ // The sides the project's services were seeded with, by topic: the control plane reads them
607
+ // from every service's instances; the fake reads them from what it was told.
608
+ const checks = [...services.values()].filter((s) => s.projectId === p.projectId).flatMap((s) => s.topicChecks ?? []);
609
+ return [...(project.topics ?? new Map()).entries()]
610
+ .sort(([a], [b]) => a.localeCompare(b))
611
+ .map(([name, t]) => ({
612
+ name,
613
+ partitions: t.partitions,
614
+ phase: t.phase,
615
+ detail: t.detail,
616
+ compacted: t.compacted ?? false,
617
+ contract: t.contract ? { name: t.contract.name, fingerprint: t.contract.fingerprint } : null,
618
+ checks: t.contract ? checks.filter((c) => c.topic === name).map(checkOnTheWire) : [],
619
+ }));
620
+ });
621
+ // Declared brokers (feature 037): the control plane's rules, and the secret's entries checked by name.
622
+ const BrokerBootstrap = /^[A-Za-z0-9._-]+:[0-9]+(,[A-Za-z0-9._-]+:[0-9]+)*$/;
623
+ route("PUT", "/projects/{projectId}/brokers/{name}", (c, p, body) => {
624
+ const { project, org } = requireProject(c, p.projectId);
625
+ requireWrite(org);
626
+ const name = p.name;
627
+ const request = body;
628
+ const bootstrap = String(request.bootstrap ?? "").trim();
629
+ const shape = String(request.shape ?? "");
630
+ const secret = String(request.secret ?? "");
631
+ const problems = [];
632
+ if (!TopicName.test(name))
633
+ problems.push(`broker '${name}': ${TopicNameRule}`);
634
+ if (!BrokerBootstrap.test(bootstrap))
635
+ problems.push(`broker '${name}': bootstrap '${bootstrap}' is not host:port[,host:port]`);
636
+ if (shape !== "certificate" && shape !== "sasl")
637
+ problems.push(`broker '${name}': shape '${shape}' is not one of certificate, sasl`);
638
+ if (secret === "")
639
+ problems.push(`broker '${name}': secret a project secret needs a name`);
640
+ else if (secret.startsWith("ankka-") || PlatformSuffixes.some((s) => secret.endsWith(s)))
641
+ problems.push(`broker '${name}': secret project secret name '${secret}' is one the platform uses for its own Secrets in a project`);
642
+ if (problems.length > 0)
643
+ throw new HttpError(400, problems.join("; "));
644
+ const held = project.secrets?.get(secret);
645
+ if (!held)
646
+ throw new HttpError(400, `project '${p.projectId}' has no project secret '${secret}'`);
647
+ const missing = brokerNeeds(shape).filter((e) => !held.entries.has(e));
648
+ if (missing.length > 0)
649
+ throw new HttpError(400, `project secret '${secret}' lacks ${missing.map((m) => `'${m}'`).join(", ")}, which shape '${shape}' needs`);
650
+ project.brokers ??= new Map();
651
+ const declaredAt = project.brokers.get(name)?.declaredAt ?? now();
652
+ project.brokers.set(name, { bootstrap, shape, secret, declaredAt });
653
+ return "done";
654
+ });
655
+ route("DELETE", "/projects/{projectId}/brokers/{name}", (c, p) => {
656
+ const { project, org } = requireProject(c, p.projectId);
657
+ requireWrite(org);
658
+ if (!project.brokers?.delete(p.name))
659
+ throw new HttpError(404, `project '${p.projectId}' declares no broker '${p.name}'`);
660
+ return "done";
661
+ });
662
+ route("GET", "/projects/{projectId}/brokers", (c, p) => {
663
+ const { project } = requireProject(c, p.projectId);
664
+ return [...(project.brokers ?? new Map()).entries()]
665
+ .sort(([a], [b]) => a.localeCompare(b))
666
+ .map(([name, b]) => ({ name, bootstrap: b.bootstrap, shape: b.shape, secret: b.secret, declaredAt: b.declaredAt }));
667
+ });
435
668
  route("GET", "/services/{projectId}", (c, p) => {
436
669
  requireProject(c, p.projectId);
437
670
  return [...services.values()].filter((s) => s.projectId === p.projectId).map(serviceStatus);
@@ -463,18 +696,58 @@ export async function fakeControlPlane(options = {}) {
463
696
  desiredInstances: instances,
464
697
  exposed: false,
465
698
  paused: false,
699
+ database: "provisioned",
466
700
  history: [],
701
+ kept: [],
467
702
  logs: new Map(),
468
703
  previousLogs: new Map(),
704
+ platformLogs: new Map(),
469
705
  };
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() });
706
+ record(s, d, actor(c));
475
707
  services.set(key, s);
476
708
  return serviceStatus(s);
477
709
  });
710
+ // Rollbacks, by the control plane's rules: the same refusals, in the same words and statuses. The
711
+ // digest need only be equal for equal descriptors; it is not the control plane's.
712
+ const descriptorAt = (s, n) => {
713
+ if (!Number.isInteger(n) || n < 1 || n > s.generation)
714
+ throw new HttpError(404, `service '${s.name}' has no generation ${n}`);
715
+ const found = s.kept.find((k) => k.generation === n);
716
+ if (found)
717
+ return found;
718
+ const oldest = s.kept.at(-1);
719
+ const ran = s.kept.find((k) => k.generation < n);
720
+ if (oldest && ran && n > oldest.generation)
721
+ throw new HttpError(409, `generation ${n} was a restart and ran the descriptor of generation ${ran.generation}`);
722
+ throw new HttpError(409, `the descriptor of generation ${n} is no longer kept; the oldest kept is generation ${oldest?.generation ?? s.generation}`);
723
+ };
724
+ route("POST", "/services/{projectId}/{name}/rollback", (c, p, body) => {
725
+ const { service: s, org } = requireService(c, p.projectId, p.name);
726
+ requireWrite(org);
727
+ const named = body?.generation;
728
+ const current = digestOf(s.descriptor);
729
+ let target;
730
+ if (named !== undefined) {
731
+ target = descriptorAt(s, named);
732
+ if (digestOf(target.descriptor) === current)
733
+ throw new HttpError(409, `service '${s.name}' already has the descriptor of generation ${named}`);
734
+ }
735
+ else {
736
+ const differs = s.kept.find((k) => digestOf(k.descriptor) !== current);
737
+ if (!differs)
738
+ throw new HttpError(409, `service '${s.name}' has no earlier generation with a different descriptor`);
739
+ target = differs;
740
+ }
741
+ record(s, target.descriptor, actor(c), target.generation);
742
+ return { rolledBackTo: target.generation, status: serviceStatus(s) };
743
+ });
744
+ route("GET", "/services/{projectId}/{name}/descriptor", (c, p, _body, url) => {
745
+ const { service: s } = requireService(c, p.projectId, p.name);
746
+ const raw = url.searchParams.get("generation");
747
+ if (raw === null)
748
+ throw new HttpError(400, "query parameter 'generation' is required");
749
+ return descriptorAt(s, Number(raw)).descriptor;
750
+ });
478
751
  const operation = (op) => (c, p) => {
479
752
  const { service: s, org } = requireService(c, p.projectId, p.name);
480
753
  requireWrite(org);
@@ -513,9 +786,12 @@ export async function fakeControlPlane(options = {}) {
513
786
  if (s.paused || s.readyInstances === 0 && s.logs.size === 0)
514
787
  throw new HttpError(404, `service '${s.name}' has no running instance`);
515
788
  const previous = url.searchParams.get("previous") === "true";
789
+ const platform = url.searchParams.get("platform") === "true";
790
+ if (platform && !twoContainers(s.hosting))
791
+ throw new HttpError(400, "--platform applies to a service with process or web hosting");
516
792
  const tail = url.searchParams.get("tail");
517
793
  const only = url.searchParams.get("instance");
518
- const source = previous ? s.previousLogs : s.logs;
794
+ const source = platform ? s.platformLogs : previous ? s.previousLogs : s.logs;
519
795
  const names = s.readyInstances > 0 ? Array.from({ length: s.readyInstances }, (_x, i) => `${s.name}-${i}`) : [...source.keys()];
520
796
  return {
521
797
  instances: names
@@ -527,6 +803,32 @@ export async function fakeControlPlane(options = {}) {
527
803
  }),
528
804
  };
529
805
  });
806
+ // The control plane's rules: a non-member is told the service does not exist; no running instance
807
+ // is the logs route's 404; one instance that is not ok makes the result partial.
808
+ route("GET", "/services/{projectId}/{name}/topology", (c, p) => {
809
+ const { service: s } = requireService(c, p.projectId, p.name);
810
+ if (s.paused || s.readyInstances === 0)
811
+ throw new HttpError(404, `service '${s.name}' has no running instance; it may be paused`);
812
+ const t = s.topology ?? {
813
+ instances: Array.from({ length: s.readyInstances }, (_x, i) => ({ pod: `${s.name}-${i}`, status: "ok" })),
814
+ nodes: [{ id: s.name, kind: "EventSourcedEntity", layer: 1, platform: false, handlers: [] }],
815
+ declared: [],
816
+ calls: [],
817
+ };
818
+ const contributing = t.instances.filter((i) => i.status === "ok").length;
819
+ return {
820
+ service: s.name,
821
+ running: t.instances.length,
822
+ contributing,
823
+ partial: contributing < t.instances.length,
824
+ 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 })),
825
+ window: t.window ?? { seconds: 600, since: now(), calls: 0, unanswered: 0 },
826
+ nodes: t.nodes,
827
+ declared: t.declared,
828
+ calls: t.calls,
829
+ differences: t.differences ?? [],
830
+ };
831
+ });
530
832
  route("GET", "/services/{projectId}/{name}/history", (c, p) => requireService(c, p.projectId, p.name).service.history);
531
833
  route("DELETE", "/services/{projectId}/{name}", (c, p) => {
532
834
  const { service: s, org } = requireService(c, p.projectId, p.name);
@@ -650,6 +952,12 @@ export async function fakeControlPlane(options = {}) {
650
952
  }
651
953
  }
652
954
  },
955
+ scriptTopology(projectId, name, topology) {
956
+ const s = services.get(serviceKey(projectId, name));
957
+ if (!s)
958
+ throw new Error(`no service ${projectId}/${name}`);
959
+ s.topology = topology;
960
+ },
653
961
  appendLog(projectId, name, line, instance) {
654
962
  const s = services.get(serviceKey(projectId, name));
655
963
  if (!s)
@@ -688,8 +996,13 @@ export async function fakeControlPlane(options = {}) {
688
996
  const n = s.instances ?? 1;
689
997
  const ready = lifecycle === "Ready" ? n : 0;
690
998
  const logs = new Map();
691
- for (let i = 0; i < ready; i++)
999
+ const platformLogs = new Map();
1000
+ for (let i = 0; i < ready; i++) {
692
1001
  logs.set(`${s.name}-${i}`, [`${s.name} started`]);
1002
+ if (twoContainers(s.hosting))
1003
+ platformLogs.set(`${s.name}-${i}`, [`${s.hosting === "web" ? "proxy" : "sidecar"} of ${s.name} started`]);
1004
+ }
1005
+ const seeded = { name: s.name, service: { image: s.image ?? `${s.name}:latest` } };
693
1006
  services.set(serviceKey(s.projectId, s.name), {
694
1007
  name: s.name,
695
1008
  projectId: s.projectId,
@@ -698,11 +1011,56 @@ export async function fakeControlPlane(options = {}) {
698
1011
  image: s.image ?? `${s.name}:latest`,
699
1012
  readyInstances: ready,
700
1013
  desiredInstances: n,
701
- exposed: false,
1014
+ exposed: s.exposed ?? false,
702
1015
  paused: lifecycle === "Paused",
703
- history: [{ kind: "applied", generation: 1, at: now() }],
1016
+ database: s.database === undefined ? "provisioned" : s.database,
1017
+ history: [{ kind: "applied", generation: 1, at: now(), image: s.image ?? `${s.name}:latest`, digest: digestOf(seeded) }],
1018
+ descriptor: seeded,
1019
+ kept: [{ generation: 1, descriptor: seeded }],
704
1020
  logs,
705
1021
  previousLogs: new Map(),
1022
+ platformLogs,
1023
+ hosting: s.hosting,
1024
+ mounts: s.mounts,
1025
+ callers: s.callers,
1026
+ processPort: s.processPort,
1027
+ broker: s.broker,
1028
+ undeclaredTopics: s.undeclaredTopics,
1029
+ topicChecks: s.topicChecks,
1030
+ topicSources: s.topicSources,
1031
+ provisionObjectStorage: s.provisionObjectStorage,
1032
+ exposeObjectStorage: s.exposeObjectStorage,
1033
+ ownObjectStore: s.ownObjectStore,
1034
+ });
1035
+ }
1036
+ for (const sec of seed.secrets ?? []) {
1037
+ const project = projects.get(sec.projectId);
1038
+ if (!project)
1039
+ throw new Error(`no project ${sec.projectId}`);
1040
+ project.secrets ??= new Map();
1041
+ const held = project.secrets.get(sec.name)?.entries ?? new Map();
1042
+ for (const [entry, value] of Object.entries(sec.entries))
1043
+ held.set(entry, value);
1044
+ project.secrets.set(sec.name, { entries: held, setAt: now(), setBy: "seed" });
1045
+ }
1046
+ for (const b of seed.brokers ?? []) {
1047
+ const project = projects.get(b.projectId);
1048
+ if (!project)
1049
+ throw new Error(`no project ${b.projectId}`);
1050
+ project.brokers ??= new Map();
1051
+ project.brokers.set(b.name, { bootstrap: b.bootstrap, shape: b.shape, secret: b.secret, declaredAt: now() });
1052
+ }
1053
+ for (const t of seed.topics ?? []) {
1054
+ const project = projects.get(t.projectId);
1055
+ if (!project)
1056
+ throw new Error(`no project ${t.projectId}`);
1057
+ project.topics ??= new Map();
1058
+ project.topics.set(t.name, {
1059
+ partitions: t.partitions,
1060
+ phase: t.phase,
1061
+ detail: t.detail,
1062
+ compacted: t.compacted,
1063
+ contract: t.contract ? { ...t.contract, fingerprint: fakeFingerprint(t.contract.schema) } : undefined,
706
1064
  });
707
1065
  }
708
1066
  },
@@ -713,3 +1071,12 @@ export async function fakeControlPlane(options = {}) {
713
1071
  };
714
1072
  return fake;
715
1073
  }
1074
+ /** Equal for equal descriptors, whatever order their keys were written in. */
1075
+ function digestOf(descriptor) {
1076
+ const canonical = (v) => Array.isArray(v)
1077
+ ? v.map(canonical)
1078
+ : v && typeof v === "object"
1079
+ ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical(v[k])]))
1080
+ : v;
1081
+ return createHash("sha256").update(JSON.stringify(canonical(descriptor ?? null))).digest("hex");
1082
+ }