ankka-console 0.11.0 → 0.13.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 (39) hide show
  1. package/dist/ankka-mark.png +0 -0
  2. package/dist/client/control-plane.d.ts +63 -1
  3. package/dist/client/control-plane.js +142 -1
  4. package/dist/client/schemas.d.ts +1460 -2
  5. package/dist/client/schemas.js +462 -2
  6. package/dist/context.d.ts +1 -1
  7. package/dist/context.js +1 -1
  8. package/dist/extensions/types.d.ts +1 -1
  9. package/dist/extensions/types.js +22 -0
  10. package/dist/routes/front.d.ts +76 -2
  11. package/dist/routes/front.js +37 -8
  12. package/dist/routes/logs.d.ts +67 -0
  13. package/dist/routes/machines.d.ts +78 -0
  14. package/dist/routes/machines.js +78 -0
  15. package/dist/routes/organization.d.ts +29 -1
  16. package/dist/routes/organization.js +27 -5
  17. package/dist/routes/project.d.ts +285 -1
  18. package/dist/routes/project.js +107 -6
  19. package/dist/routes/service-history.d.ts +2 -0
  20. package/dist/routes/service-history.js +3 -0
  21. package/dist/routes/service-topology.d.ts +67 -0
  22. package/dist/routes/service.d.ts +67 -0
  23. package/dist/routes/service.js +64 -4
  24. package/dist/routes.js +1 -0
  25. package/dist/runtime.d.ts +3 -1
  26. package/dist/runtime.js +1 -0
  27. package/dist/styles.css +1 -1
  28. package/dist/testing/fake-control-plane.d.ts +193 -1
  29. package/dist/testing/fake-control-plane.js +840 -7
  30. package/dist/ui/buckets.d.ts +14 -0
  31. package/dist/ui/buckets.js +9 -0
  32. package/dist/ui/grants.d.ts +3 -0
  33. package/dist/ui/grants.js +13 -0
  34. package/dist/ui/shell.d.ts +1 -1
  35. package/dist/ui/shell.js +14 -3
  36. package/dist/ui/topology/Table.d.ts +8 -0
  37. package/dist/ui/topology/Table.js +8 -1
  38. package/dist/ui/use-stream.d.ts +134 -0
  39. package/package.json +1 -1
@@ -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
@@ -27,7 +28,59 @@ const topicSourceOnTheWire = (t) => ({
27
28
  contract: t.contract ?? null,
28
29
  lag: t.lag ?? null,
29
30
  failing: t.failing ?? null,
31
+ gap: t.gap ?? null,
30
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
+ };
31
84
  const checkOnTheWire = (c) => ({ service: c.service, component: c.component, direction: c.direction, stated: c.stated ?? null, state: c.state });
32
85
  /** A stable fingerprint of the right shape; the control plane's is RFC 8785 over the document, which the fake need not reproduce. */
33
86
  function fakeFingerprint(schema) {
@@ -73,11 +126,15 @@ export async function fakeControlPlane(options = {}) {
73
126
  const services = new Map();
74
127
  const tokens = new Map();
75
128
  const tombstones = new Set();
129
+ const grants = new Map();
130
+ const machines = new Map();
76
131
  const visited = new Set();
77
132
  const counts = new Map();
78
133
  const scripted = new Map();
79
134
  let hideNew = false;
80
135
  let base = "";
136
+ let objectStore = "garage";
137
+ let cloud = null;
81
138
  const serviceKey = (p, n) => `${p}/${n}`;
82
139
  // ── Views of state ─────────────────────────────────────────────────────────
83
140
  const orgSummary = (org, caller) => {
@@ -108,6 +165,30 @@ export async function fakeControlPlane(options = {}) {
108
165
  s.provisionObjectStorage = d.service?.provisionObjectStorage ?? false;
109
166
  s.exposeObjectStorage = d.service?.exposeObjectStorage ?? false;
110
167
  s.ownObjectStore = (d.service?.env ?? []).some((e) => e.name.startsWith("ANKKA_S3_"));
168
+ // Named buckets (feature 055): one report each, and one the descriptor stopped naming is kept.
169
+ const named = (d.service?.buckets ?? []).map((b) => {
170
+ if (b.supplied)
171
+ return { name: b.name, phase: "Supplied", bucket: b.supplied.bucket, exposure: "supplied" };
172
+ const exposure = b.exposure ?? "private";
173
+ const bucket = `${s.projectId}.${s.name}.${b.name}`;
174
+ return {
175
+ name: b.name,
176
+ phase: "Provisioned",
177
+ store: "garage",
178
+ bucket,
179
+ exposure,
180
+ ...(exposure === "private" ? {} : { publicAddress: `https://storage.${options.baseDomain ?? "example.test"}/${bucket}` }),
181
+ };
182
+ });
183
+ const dropped = (s.buckets ?? [])
184
+ .filter((b) => b.phase !== "Supplied" && !named.some((n) => n.name === b.name))
185
+ .map((b) => ({ ...b, phase: "Kept", kept: true }));
186
+ s.buckets = [...named, ...dropped];
187
+ if (s.provisionObjectStorage && !s.objectStore) {
188
+ s.objectStore = objectStore;
189
+ if (objectStore === "gcs")
190
+ s.bucketLocation = projects.get(s.projectId)?.location ?? "europe-west2";
191
+ }
111
192
  s.desiredInstances = d.service?.resources?.autoscaling?.minInstances ?? 1;
112
193
  s.lifecycle = s.paused ? "Paused" : "UpdateInProgress";
113
194
  s.descriptor = descriptor;
@@ -141,6 +222,7 @@ export async function fakeControlPlane(options = {}) {
141
222
  suspended,
142
223
  paused: s.paused,
143
224
  hosting: s.hosting ?? "embedded",
225
+ databaseCluster: s.databaseCluster ?? null,
144
226
  protocol: null,
145
227
  mounts: s.mounts ?? [],
146
228
  callers: s.callers ?? [],
@@ -149,13 +231,50 @@ export async function fakeControlPlane(options = {}) {
149
231
  undeclaredTopics: s.undeclaredTopics ?? null,
150
232
  topicChecks: s.topicChecks ? s.topicChecks.map(checkOnTheWire) : null,
151
233
  topicSources: s.topicSources ? s.topicSources.map(topicSourceOnTheWire) : null,
234
+ warnings: warningsOf(s),
152
235
  objectStorage: s.provisionObjectStorage ? "provisioned" : s.ownObjectStore ? "supplied" : null,
153
236
  bucket: s.provisionObjectStorage ? `${s.projectId}.${s.name}` : null,
154
237
  bucketAddress: s.provisionObjectStorage && s.exposeObjectStorage
155
238
  ? `https://storage.${options.baseDomain ?? "example.test"}/${s.projectId}.${s.name}`
156
239
  : null,
240
+ objectStore: s.provisionObjectStorage ? (s.objectStore ?? "garage") : null,
241
+ bucketLocation: s.provisionObjectStorage && s.objectStore === "gcs" ? (s.bucketLocation ?? null) : null,
242
+ softDeleteDays: s.provisionObjectStorage && s.objectStore === "gcs" ? 7 : null,
243
+ storageMove: s.storageMove ?? null,
244
+ ...(s.buckets && s.buckets.length > 0 ? { buckets: s.buckets } : {}),
245
+ customHostnames: (s.customHostnames ?? []).map((hostname) => ({
246
+ hostname,
247
+ // The fake has no authority to wait for: an exposed service's hostnames serve at once.
248
+ state: s.exposed ? "serving" : "pending",
249
+ ...(s.exposed ? {} : { reason: "the service is not exposed" }),
250
+ ...(hostname.split(".").length === 2
251
+ ? { note: "an apex cannot be a CNAME; this installation has published no address" }
252
+ : { record: { name: hostname, kind: "CNAME", value: `${s.name}-${s.projectId}.${options.baseDomain ?? "example.test"}` } }),
253
+ })),
157
254
  };
158
255
  };
256
+ /** A read of one service carries the proof record its project's custom hostnames need. */
257
+ const oneService = (s) => ({
258
+ ...serviceStatus(s),
259
+ proofRecord: { name: "_ankka.<hostname>", kind: "TXT", value: `ankka-project=${s.projectId}` },
260
+ });
261
+ /** The control plane's refusals 1 to 5 (contracts/control-plane.md), in its words. */
262
+ const hostnameProblem = (requested) => {
263
+ const h = requested.trim().toLowerCase().replace(/\.$/, "");
264
+ const base = options.baseDomain ?? "example.test";
265
+ if (/[\/:\s]/.test(h))
266
+ return `a custom hostname is a name alone: '${requested}' has a scheme, a path or a port`;
267
+ if (h.includes("*"))
268
+ return "a custom hostname cannot be a wildcard";
269
+ const bad = h.split(".").find((l) => !/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(l));
270
+ if (bad !== undefined)
271
+ return `'${h}' is not a hostname: label '${bad}' must be letters, digits and '-', and cannot start or end with '-'`;
272
+ if (h.split(".").length < 2)
273
+ return `'${h}' is not a name on the internet: a custom hostname has at least two labels`;
274
+ if (h === base || h.endsWith(`.${base}`))
275
+ return `a custom hostname cannot be under the base domain '${base}': those names are the platform's`;
276
+ return null;
277
+ };
159
278
  // ── Authorization, the control plane's rules ──────────────────────────────
160
279
  const isAdmin = (c) => c.admin;
161
280
  function claimInvitations(c) {
@@ -178,6 +297,29 @@ export async function fakeControlPlane(options = {}) {
178
297
  throw new HttpError(404, `organization '${id}' not found`);
179
298
  return { org, role: member?.role };
180
299
  }
300
+ /**
301
+ * A warning for each view reading a topic its project declares to keep less than the installation's
302
+ * threshold (feature 043), worked out from the declarations as they are now, as the control plane does.
303
+ */
304
+ function warningsOf(s) {
305
+ const topics = projects.get(s.projectId)?.topics;
306
+ const warnings = (s.topicSources ?? []).flatMap((source) => {
307
+ const settings = topics?.get(source.topic)?.settings;
308
+ if (source.kind !== "view" || !settings || settings.cleanup !== "delete")
309
+ return [];
310
+ if (!(millis(settings.retention) < WarningThresholdMs))
311
+ return [];
312
+ return [
313
+ {
314
+ kind: "retention",
315
+ component: source.component,
316
+ topic: source.topic,
317
+ message: `view '${source.component}' reads topic '${source.topic}', which keeps ${settings.retention}; the installation warns below 30d`,
318
+ },
319
+ ];
320
+ });
321
+ return warnings.length > 0 ? warnings : null;
322
+ }
181
323
  function requireWrite(org) {
182
324
  if (org.disabled)
183
325
  throw new HttpError(409, `organization '${org.id}' is disabled`);
@@ -231,6 +373,26 @@ export async function fakeControlPlane(options = {}) {
231
373
  .map((o) => ({ id: o.id, name: o.name, role: o.members.get(c.subject).role })),
232
374
  };
233
375
  });
376
+ // Feature 044: what the installation is. The wrapping key's name is shown only to an owner of an
377
+ // organization or a platform administrator, as the control plane decides in its endpoint.
378
+ route("GET", "/installation", (c) => {
379
+ const owns = isAdmin(c) || [...organizations.values()].some((o) => o.members.get(c.subject)?.role === "owner");
380
+ const { kmsKey, ...where } = cloud ?? { provider: "", account: "", location: "" };
381
+ return {
382
+ platformVersion: "0.0.0-fake",
383
+ ...(cloud ? { cloud: { ...where, ...(kmsKey && owns ? { kmsKey } : {}) } } : {}),
384
+ // Feature 038: where secrets are kept, as an installation that has said nothing answers.
385
+ secrets: { backend: "postgres", recordRetention: "365d", auditLog: "unknown" },
386
+ // Feature 041: where the backups go, as an installation with Garage and no copy says it.
387
+ backups: {
388
+ backupTarget: "object-store",
389
+ retentionDays: 30,
390
+ copyRequired: false,
391
+ sharesFailureDomain: true,
392
+ encryption: "none: Garage holds no key the archiver can send",
393
+ },
394
+ };
395
+ });
234
396
  route("GET", "/organizations", (c) => {
235
397
  claimInvitations(c);
236
398
  return [...organizations.values()]
@@ -498,6 +660,28 @@ export async function fakeControlPlane(options = {}) {
498
660
  project.registry = undefined;
499
661
  return "done";
500
662
  });
663
+ // A project's bucket location (feature 039): only where new buckets are made in Google Cloud Storage.
664
+ const requireGcs = () => {
665
+ if (objectStore !== "gcs")
666
+ throw new HttpError(409, "the installation keeps new buckets in Garage, which has no location to choose");
667
+ };
668
+ route("PUT", "/projects/{projectId}/location", (c, p, body) => {
669
+ const { project, org } = requireProject(c, p.projectId);
670
+ requireWrite(org);
671
+ const location = (body.location ?? "").trim();
672
+ if (location === "")
673
+ throw new HttpError(400, "a location must name something");
674
+ requireGcs();
675
+ project.location = location;
676
+ return "done";
677
+ });
678
+ route("DELETE", "/projects/{projectId}/location", (c, p) => {
679
+ const { project, org } = requireProject(c, p.projectId);
680
+ requireWrite(org);
681
+ requireGcs();
682
+ project.location = undefined;
683
+ return "done";
684
+ });
501
685
  // Project secrets: the control plane's rules, its merge, and names only in what it answers.
502
686
  const SecretName = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
503
687
  const EntryName = /^[A-Za-z0-9._-]{1,253}$/;
@@ -560,12 +744,30 @@ export async function fakeControlPlane(options = {}) {
560
744
  requireWrite(org);
561
745
  const name = p.name;
562
746
  const request = body;
563
- const partitions = Number(request.partitions);
747
+ project.topics ??= new Map();
748
+ const current = project.topics.get(name);
749
+ if (request.partitions === undefined && current === undefined)
750
+ throw new HttpError(400, `topic '${name}': partitions is required for a new topic`);
751
+ const partitions = request.partitions === undefined ? current.partitions : Number(request.partitions);
564
752
  const problems = [];
565
753
  if (!TopicName.test(name))
566
754
  problems.push(`topic '${name}': ${TopicNameRule}`);
567
755
  if (!Number.isInteger(partitions) || partitions < 1 || partitions > 1000)
568
756
  problems.push(`topic '${name}': partitions ${partitions} is outside the range 1-1000`);
757
+ // Settings (feature 043): the words, the bounds, fixed copies and an owner's removal.
758
+ for (const key of ["retention", "tombstoneWindow", "minCompactionLag", "maxCompactionLag"]) {
759
+ const value = request[key];
760
+ if (value !== undefined && Number.isNaN(millis(value)))
761
+ problems.push(`topic '${name}': ${key} "${value}" is not a duration`);
762
+ }
763
+ if (request.retentionSize !== undefined && Number.isNaN(bytes(request.retentionSize)))
764
+ problems.push(`topic '${name}': retentionSize "${request.retentionSize}" is not a size`);
765
+ if (request.cleanup !== undefined && !["delete", "compact", "compact,delete"].includes(request.cleanup))
766
+ problems.push(`topic '${name}': cleanup "${request.cleanup}" is not "delete", "compact" or "compact,delete"`);
767
+ if (request.compacted === true && request.cleanup === "delete")
768
+ problems.push(`topic '${name}': compacted and cleanup disagree`);
769
+ if (request.copies !== undefined && request.copies > MostCopies)
770
+ problems.push(`topic '${name}': copies ${request.copies} is more than the installation's most, ${MostCopies}`);
569
771
  // A contract's own rules: its name, and a schema that is a JSON document (feature 037).
570
772
  if (request.contract !== undefined && request.contract !== null) {
571
773
  const contractName = String(request.contract.name ?? "");
@@ -576,14 +778,57 @@ export async function fakeControlPlane(options = {}) {
576
778
  }
577
779
  if (problems.length > 0)
578
780
  throw new HttpError(400, problems.join("; "));
579
- project.topics ??= new Map();
580
- const has = project.topics.get(name)?.partitions;
781
+ const has = current?.partitions;
581
782
  if (has !== undefined && has > partitions)
582
783
  throw new HttpError(409, `topic '${name}' has ${has} partitions and cannot have fewer; ${partitions} was asked`);
784
+ const asked = {};
785
+ for (const key of SettingNames)
786
+ if (request[key] !== undefined)
787
+ asked[key] = request[key];
788
+ if (request.compacted === true && asked.cleanup === undefined)
789
+ asked.cleanup = "compact";
790
+ const before = current?.settings;
791
+ if (before !== undefined) {
792
+ const fixed = (key) => asked[key] !== undefined && asked[key] !== before[key];
793
+ if (fixed("copies") || fixed("minInSync"))
794
+ throw new HttpError(409, `topic '${name}': copies and minimum in-sync copies are fixed when a topic is declared`);
795
+ }
796
+ const settings = { ...(before ?? TopicDefaults), ...asked };
797
+ const defaulted = (current?.defaulted ?? (before ? [] : [...SettingNames])).filter((s) => asked[s] === undefined);
798
+ const removal = before ? removalOf(before, settings) : undefined;
799
+ if (removal === undefined && request.removes !== undefined)
800
+ throw new HttpError(400, `topic '${name}': this declaration removes nothing`);
801
+ if (removal !== undefined) {
802
+ const { role } = requireOrg(c, org.id);
803
+ if (role !== "owner" && !isAdmin(c))
804
+ throw new HttpError(403, `owner role required: this declaration removes ${removal}`);
805
+ if (request.removes?.trim() !== removal)
806
+ throw new HttpError(400, `this declaration removes ${removal}; a declaration that removes messages says so with "removes"`);
807
+ }
808
+ const changes = before
809
+ ? SettingNames.flatMap((s) => String(before[s] ?? "the broker's") === String(settings[s] ?? "the broker's")
810
+ ? []
811
+ : [{ setting: s, from: String(before[s] ?? "the broker's"), to: String(settings[s] ?? "the broker's") }])
812
+ : [];
583
813
  const contract = request.contract
584
814
  ? { name: String(request.contract.name), schema: request.contract.schema, fingerprint: fakeFingerprint(request.contract.schema) }
585
815
  : undefined;
586
- project.topics.set(name, { ...(project.topics.get(name) ?? {}), partitions, compacted: request.compacted === true, contract });
816
+ const same = current !== undefined && current.partitions === partitions && changes.length === 0 &&
817
+ (current.contract?.fingerprint ?? null) === (contract?.fingerprint ?? null);
818
+ if (same)
819
+ return "done";
820
+ project.topics.set(name, {
821
+ ...(current ?? {}),
822
+ partitions,
823
+ compacted: settings.cleanup !== "delete",
824
+ contract,
825
+ settings,
826
+ defaulted,
827
+ });
828
+ project.history = [
829
+ { kind: current ? "topic-changed" : "topic-declared", topic: name, actor: actor(c), at: now(), changes },
830
+ ...(project.history ?? []),
831
+ ].slice(0, 50);
587
832
  return "done";
588
833
  });
589
834
  // The schema a topic's contract was declared with (feature 037); 404 without a contract.
@@ -616,6 +861,16 @@ export async function fakeControlPlane(options = {}) {
616
861
  compacted: t.compacted ?? false,
617
862
  contract: t.contract ? { name: t.contract.name, fingerprint: t.contract.fingerprint } : null,
618
863
  checks: t.contract ? checks.filter((c) => c.topic === name).map(checkOnTheWire) : [],
864
+ settings: t.settings
865
+ ? {
866
+ ...t.settings,
867
+ copies: t.settings.copies ?? null,
868
+ minInSync: t.settings.minInSync ?? null,
869
+ defaulted: (t.defaulted ?? []).filter((s) => SettingNames.includes(s)),
870
+ }
871
+ : null,
872
+ copiesHeld: t.copiesHeld ?? t.settings?.copies ?? null,
873
+ brokerNodes: null,
619
874
  }));
620
875
  });
621
876
  // Declared brokers (feature 037): the control plane's rules, and the secret's entries checked by name.
@@ -665,11 +920,517 @@ export async function fakeControlPlane(options = {}) {
665
920
  .sort(([a], [b]) => a.localeCompare(b))
666
921
  .map(([name, b]) => ({ name, bootstrap: b.bootstrap, shape: b.shape, secret: b.secret, declaredAt: b.declaredAt }));
667
922
  });
923
+ // Backups and recovery (feature 041). The fake's project database is backed up from a day ago to
924
+ // now, and a restore is verified the moment it is asked for, holding every service's database.
925
+ const restoreView = (project, r) => ({
926
+ name: r.name,
927
+ line: r.line,
928
+ moment: r.moment,
929
+ phase: [...services.values()].some((s) => s.projectId === project.id && s.databaseCluster === r.name) ? "InUse" : "Verified",
930
+ requestedBy: r.requestedBy,
931
+ requestedAt: r.requestedAt,
932
+ reachedAt: r.moment,
933
+ services: [...services.values()]
934
+ .filter((s) => s.projectId === project.id)
935
+ .map((s) => ({ name: s.name, present: true, journalRows: 1, highestSequence: 1 })),
936
+ });
937
+ const remember = (project, kind, c, detail) => {
938
+ project.databaseHistory ??= [];
939
+ project.databaseHistory.unshift({ kind, by: c.name ?? c.subject, at: now(), detail });
940
+ };
941
+ let hold;
942
+ route("GET", "/installation/restore", () => hold
943
+ ? { held: hold.releasedAt === undefined, targetTime: hold.targetTime, restoredAt: hold.targetTime, releasedAt: hold.releasedAt, releasedBy: hold.releasedBy, services: hold.releasedAt ? [] : (hold.services ?? []) }
944
+ : { held: false });
945
+ route("POST", "/installation/restore/release", (c) => {
946
+ if (!isAdmin(c))
947
+ throw new HttpError(403, "only a platform administrator releases a held control plane");
948
+ if (hold && hold.releasedAt === undefined)
949
+ hold = { ...hold, releasedAt: now(), releasedBy: c.name ?? c.subject };
950
+ return hold ? { held: false, targetTime: hold.targetTime, releasedAt: hold.releasedAt, releasedBy: hold.releasedBy } : { held: false };
951
+ });
952
+ route("GET", "/projects/{projectId}/restores", (c, p) => {
953
+ const { project } = requireProject(c, p.projectId);
954
+ return (project.restores ?? []).map((r) => restoreView(project, r));
955
+ });
956
+ route("POST", "/projects/{projectId}/backups/credential", (c, p) => {
957
+ const { project, org } = requireProject(c, p.projectId);
958
+ requireOwner(c, org.id);
959
+ project.credentialGeneration = (project.credentialGeneration ?? 0) + 1;
960
+ remember(project, "backup-credential-reissued", c, `generation ${project.credentialGeneration}`);
961
+ return { project: project.id, generation: project.credentialGeneration };
962
+ });
963
+ // One history, newest first: the database's (feature 041) and the topics' (feature 043).
964
+ route("GET", "/projects/{projectId}/history", (c, p) => {
965
+ const { project } = requireProject(c, p.projectId);
966
+ const topics = (project.history ?? []).map((h) => ({
967
+ kind: h.kind,
968
+ by: h.actor ? (h.actor.display ?? h.actor.subject) : undefined,
969
+ at: h.at,
970
+ topic: h.topic,
971
+ changes: h.changes,
972
+ }));
973
+ return [...(project.databaseHistory ?? []), ...topics].sort((a, b) => (b.at ?? "").localeCompare(a.at ?? ""));
974
+ });
975
+ route("GET", "/projects/{projectId}/database", (c, p) => {
976
+ const { project } = requireProject(c, p.projectId);
977
+ return project.database ?? { replicas: 0, synchronous: false };
978
+ });
979
+ route("PUT", "/projects/{projectId}/database", (c, p, body) => {
980
+ const { project, org } = requireProject(c, p.projectId);
981
+ requireWrite(org);
982
+ const b = body;
983
+ const setting = { replicas: b.replicas ?? 0, synchronous: b.synchronous ?? false, retentionDays: b.retentionDays, rehearse: b.rehearse };
984
+ const problems = [];
985
+ if (setting.replicas < 0 || setting.replicas > 4)
986
+ problems.push(`replicas must be between 0 and 4, not ${setting.replicas}`);
987
+ if (setting.synchronous && setting.replicas === 0)
988
+ problems.push("a synchronous database needs at least one replica to wait for");
989
+ if (setting.retentionDays !== undefined && setting.retentionDays < 30)
990
+ problems.push(`backups are kept at least 30 days on this installation, not ${setting.retentionDays}`);
991
+ if (problems.length > 0)
992
+ throw new HttpError(400, problems.join("; "));
993
+ project.database = setting;
994
+ remember(project, "database-set", c, `${setting.replicas} replica${setting.replicas === 1 ? "" : "s"}${setting.synchronous ? ", synchronous" : ""}`);
995
+ return setting;
996
+ });
997
+ const minuteName = (prefix) => `ankka-db-${prefix}${new Date().toISOString().replace(/[^0-9]/g, "").slice(0, 12)}`;
998
+ route("GET", "/projects/{projectId}/status", (c, p) => {
999
+ const { project } = requireProject(c, p.projectId);
1000
+ const day = new Date(Date.now() - 86_400_000).toISOString();
1001
+ const onCluster = (name) => [...services.values()].filter((s) => s.projectId === project.id && (s.databaseCluster ?? "ankka-db") === name).map((s) => s.name);
1002
+ const restores = project.restores ?? [];
1003
+ return {
1004
+ id: project.id,
1005
+ backedUp: true,
1006
+ target: "object-store",
1007
+ lines: [{ line: "ankka-db", cluster: "ankka-db", phase: "backing up", lastBaseBackup: day, firstRestorable: day, lastRestorable: now() }],
1008
+ database: { cluster: "ankka-db", instances: 1, readyInstances: 1, primary: "ankka-db-1" },
1009
+ clusters: [
1010
+ { name: "ankka-db", line: "ankka-db", phase: onCluster("ankka-db").length === 0 && restores.length > 0 ? "left" : "live", services: onCluster("ankka-db") },
1011
+ ...restores.map((r) => ({ name: r.name, line: r.line, phase: "restore", services: onCluster(r.name), since: r.requestedAt })),
1012
+ ],
1013
+ restores: restores.map((r) => restoreView(project, r)),
1014
+ };
1015
+ });
1016
+ route("POST", "/projects/{projectId}/restores", (c, p, body) => {
1017
+ const { project, org } = requireProject(c, p.projectId);
1018
+ requireOwner(c, org.id);
1019
+ const request = body;
1020
+ const moment = String(request.moment ?? "");
1021
+ if (Number.isNaN(Date.parse(moment)) || Date.parse(moment) > Date.now() || Date.parse(moment) < Date.now() - 86_400_000)
1022
+ throw new HttpError(400, `${project.id} can be restored between ${new Date(Date.now() - 86_400_000).toISOString()} and ${now()}`);
1023
+ project.restores ??= [];
1024
+ const restore = { name: minuteName("r"), line: request.line ?? "ankka-db", moment, requestedBy: c.name ?? c.subject, requestedAt: now() };
1025
+ if (project.restores.some((r) => r.name === restore.name))
1026
+ throw new HttpError(409, `a restore of ${project.id} is in progress: ${restore.name}`);
1027
+ project.restores.push(restore);
1028
+ remember(project, "restore-requested", c, `${restore.name}, of ${restore.line} at ${moment}`);
1029
+ return { ...restoreView(project, restore), phase: "Restoring", services: [] };
1030
+ });
1031
+ route("GET", "/projects/{projectId}/restores/{name}", (c, p) => {
1032
+ const { project } = requireProject(c, p.projectId);
1033
+ const restore = project.restores?.find((r) => r.name === p.name);
1034
+ if (!restore)
1035
+ throw new HttpError(404, `${project.id} has no restore named ${p.name}`);
1036
+ return restoreView(project, restore);
1037
+ });
1038
+ route("POST", "/projects/{projectId}/rehearsals", (c, p) => {
1039
+ const { project, org } = requireProject(c, p.projectId);
1040
+ requireWrite(org);
1041
+ project.rehearsals ??= [];
1042
+ const rehearsal = { name: minuteName("x"), line: "ankka-db", moment: now(), requestedBy: c.name ?? c.subject, requestedAt: now() };
1043
+ project.rehearsals.push(rehearsal);
1044
+ remember(project, "rehearsal-requested", c, rehearsal.name);
1045
+ return { ...rehearsal, outcome: "Running" };
1046
+ });
1047
+ route("GET", "/projects/{projectId}/rehearsals", (c, p) => {
1048
+ const { project } = requireProject(c, p.projectId);
1049
+ return (project.rehearsals ?? []).map((r) => ({ ...r, outcome: "Completed", elapsedSeconds: 118 }));
1050
+ });
1051
+ route("POST", "/services/{projectId}/{name}/switch", (c, p, body) => {
1052
+ const { service, org } = requireService(c, p.projectId, p.name);
1053
+ requireOwner(c, org.id);
1054
+ const cluster = String(body.cluster ?? "");
1055
+ const project = projects.get(p.projectId);
1056
+ if (cluster !== "ankka-db" && !project.restores?.some((r) => r.name === cluster))
1057
+ throw new HttpError(400, `${cluster} is not a restore of ${p.projectId} that can be switched to`);
1058
+ const next = cluster === "ankka-db" ? undefined : cluster;
1059
+ if (next !== service.databaseCluster) {
1060
+ service.databaseCluster = next;
1061
+ service.generation += 1;
1062
+ }
1063
+ return serviceStatus(service);
1064
+ });
1065
+ // ── Grants (feature 040): the control plane's rules and words ─────────────
1066
+ const display = (c) => c.name ?? c.email ?? c.subject;
1067
+ const noSuchProject = (id) => new HttpError(404, `no such project '${id}'`);
1068
+ /** A project's grants are read by its organization's members and written by its owners; an outsider is told there is no project. */
1069
+ function grantProject(c, id, owner) {
1070
+ const project = projects.get(id);
1071
+ if (!project)
1072
+ throw noSuchProject(id);
1073
+ const org = organizations.get(project.organizationId);
1074
+ const role = org?.members.get(c.subject)?.role;
1075
+ if (!org || (!role && !isAdmin(c)))
1076
+ throw noSuchProject(id);
1077
+ if (owner) {
1078
+ if (role !== "owner" && !isAdmin(c))
1079
+ throw new HttpError(403, `owner role required in organization '${org.id}'`);
1080
+ requireWrite(org);
1081
+ }
1082
+ return { project, org };
1083
+ }
1084
+ const parseGrantee = (text) => {
1085
+ const m = /^(service|machine):([^/]+)\/([^/]+)$/.exec(text);
1086
+ return m ? { kind: m[1], scope: m[2], name: m[3] } : null;
1087
+ };
1088
+ const HttpMethods = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
1089
+ const GrpcMethod = /^[A-Za-z_][A-Za-z0-9_.]*\/[A-Za-z_][A-Za-z0-9_]*$/;
1090
+ const MachineNameRule = "lowercase letters, digits and '-', starting with a letter";
1091
+ const machineNameProblems = (name) => name === "" ? ["a machine needs a name"] : NameRule.test(name) ? [] : [`machine name '${name}' is invalid: ${MachineNameRule}`];
1092
+ /** Everything wrong with the grantee and the target, all at once, as `GrantRules.problems` words it. */
1093
+ function grantProblems(grantee, t) {
1094
+ const problems = [];
1095
+ const g = parseGrantee(grantee);
1096
+ if (!g)
1097
+ problems.push(`grantee '${grantee}' is not "service:<project>/<service>" or "machine:<organization>/<name>"`);
1098
+ else if (g.kind === "service") {
1099
+ if (!NameRule.test(g.scope))
1100
+ problems.push(`grantee: project id '${g.scope}' is invalid: lowercase letters, digits and '-', starting with a letter`);
1101
+ if (!NameRule.test(g.name))
1102
+ problems.push(`grantee: service name '${g.name}' is invalid`);
1103
+ }
1104
+ else {
1105
+ if (!NameRule.test(g.scope))
1106
+ 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`);
1107
+ problems.push(...machineNameProblems(g.name).map((p) => `grantee: ${p}`));
1108
+ }
1109
+ const none = (field) => (t[field] !== undefined ? [`a ${t.kind} target has no ${field}`] : []);
1110
+ switch (t.kind) {
1111
+ case "route":
1112
+ if (!t.service)
1113
+ problems.push("a route target needs a service");
1114
+ if (!t.method || !HttpMethods.includes(t.method))
1115
+ problems.push(`a route target's method is one of ${HttpMethods.join(", ")}`);
1116
+ if (!t.path?.startsWith("/"))
1117
+ problems.push("a route target's path is a template beginning with '/'");
1118
+ problems.push(...none("topic"), ...none("right"));
1119
+ break;
1120
+ case "method":
1121
+ if (!t.service)
1122
+ problems.push("a method target needs a service");
1123
+ if (!t.method || !GrpcMethod.test(t.method))
1124
+ problems.push('a method target\'s method is a gRPC method, "<Service>/<Method>"');
1125
+ problems.push(...none("path"), ...none("topic"), ...none("right"));
1126
+ break;
1127
+ case "topic":
1128
+ if (!t.topic)
1129
+ problems.push("a topic target needs a topic");
1130
+ else if (!TopicName.test(t.topic))
1131
+ problems.push(`topic '${t.topic}': ${TopicNameRule}`);
1132
+ if (t.right !== "consume" && t.right !== "produce")
1133
+ problems.push("a topic target's right is one of consume, produce");
1134
+ problems.push(...none("service"), ...none("method"), ...none("path"));
1135
+ break;
1136
+ case "erasure":
1137
+ problems.push(...none("service"), ...none("method"), ...none("path"), ...none("topic"), ...none("right"));
1138
+ break;
1139
+ default:
1140
+ problems.push(`target kind '${t.kind}' is not one of route, method, topic, erasure`);
1141
+ }
1142
+ if (t.service !== undefined && !NameRule.test(t.service))
1143
+ problems.push(`target: service name '${t.service}' is invalid`);
1144
+ if (t.decrypt && !(t.kind === "topic" && t.right === "consume"))
1145
+ problems.push("only a grant to consume a topic may allow decryption");
1146
+ return problems;
1147
+ }
1148
+ /** The target as the wire carries it: only the fields it has, and `decrypt` always. */
1149
+ const targetOnTheWire = (t) => ({
1150
+ kind: t.kind,
1151
+ ...(t.service === undefined ? {} : { service: t.service }),
1152
+ ...(t.method === undefined ? {} : { method: t.method }),
1153
+ ...(t.path === undefined ? {} : { path: t.path }),
1154
+ ...(t.topic === undefined ? {} : { topic: t.topic }),
1155
+ ...(t.right === undefined ? {} : { right: t.right }),
1156
+ decrypt: t.decrypt ?? false,
1157
+ });
1158
+ const sameTarget = (a, b) => JSON.stringify(targetOnTheWire(a)) === JSON.stringify(targetOnTheWire(b));
1159
+ const live = (g) => g.state === "pending" || g.state === "accepted";
1160
+ /**
1161
+ * Whether a grant opens what it names, or why not, as `GrantEffect` reads it — short of the
1162
+ * instances' topology, which the fake does not have: a service with a ready instance that is not
1163
+ * web-hosted is taken to have the route and to have mounted its grants.
1164
+ */
1165
+ function effectOf(g) {
1166
+ if (g.state !== "accepted")
1167
+ return g.state;
1168
+ if (g.target.kind === "route" || g.target.kind === "method") {
1169
+ const s = services.get(serviceKey(g.projectId, g.target.service ?? ""));
1170
+ if (!s)
1171
+ return "route not seen";
1172
+ if (s.hosting === "web")
1173
+ return "route not grantable";
1174
+ if (s.paused || s.readyInstances < 1)
1175
+ return "route not seen";
1176
+ return "in effect";
1177
+ }
1178
+ if (g.target.kind === "topic" && g.grantee.startsWith("machine:") && !options.brokerBootstrap)
1179
+ return "broker not exposed";
1180
+ return "in effect";
1181
+ }
1182
+ const grantDetail = (g, effect) => ({
1183
+ id: g.id,
1184
+ grantee: g.grantee,
1185
+ target: targetOnTheWire(g.target),
1186
+ state: g.state,
1187
+ effect,
1188
+ granted: g.granted,
1189
+ ...(g.answered ? { answered: g.answered } : {}),
1190
+ ...(g.ended ? { ended: g.ended } : {}),
1191
+ });
1192
+ const receivedDetail = (g) => {
1193
+ const granting = projects.get(g.projectId);
1194
+ const topic = g.target.kind === "topic" && g.target.topic ? granting?.topics?.get(g.target.topic) : undefined;
1195
+ return {
1196
+ id: g.id,
1197
+ grantingProject: g.projectId,
1198
+ grantingOrganization: granting?.organizationId ?? "",
1199
+ grantee: g.grantee,
1200
+ target: targetOnTheWire(g.target),
1201
+ state: g.state,
1202
+ changes: g.changes,
1203
+ ...(topic ? { topic: { partitions: topic.partitions, compacted: topic.compacted ?? false } } : {}),
1204
+ };
1205
+ };
1206
+ const byGranted = (a, b) => a.granted.at.localeCompare(b.granted.at) || a.id.localeCompare(b.id);
1207
+ function makeGrant(project, org, grantee, target, by, state) {
1208
+ const g = parseGrantee(grantee);
1209
+ const granteeOrg = g.kind === "service" ? projects.get(g.scope)?.organizationId : g.scope;
1210
+ const pending = granteeOrg !== org.id;
1211
+ const at = now();
1212
+ const grant = {
1213
+ id: randomBytes(8).toString("hex"),
1214
+ projectId: project.id,
1215
+ grantee,
1216
+ target,
1217
+ state: state ?? (pending ? "pending" : "accepted"),
1218
+ granted: { by, at },
1219
+ changes: [{ change: pending ? "offered" : "made", by, at }],
1220
+ };
1221
+ grants.set(grant.id, grant);
1222
+ return grant;
1223
+ }
1224
+ route("POST", "/projects/{projectId}/grants", (c, p, body) => {
1225
+ const { project, org } = grantProject(c, p.projectId, true);
1226
+ const b = (body ?? {});
1227
+ const grantee = String(b.grantee ?? "");
1228
+ const raw = b.target ?? { kind: "" };
1229
+ const target = {
1230
+ kind: String(raw.kind ?? ""),
1231
+ service: raw.service ?? undefined,
1232
+ method: raw.method ?? undefined,
1233
+ path: raw.path ?? undefined,
1234
+ topic: raw.topic ?? undefined,
1235
+ right: raw.right ?? undefined,
1236
+ decrypt: raw.decrypt === true,
1237
+ };
1238
+ const problems = grantProblems(grantee, target);
1239
+ if (problems.length > 0)
1240
+ throw new HttpError(400, problems.join("; "));
1241
+ const g = parseGrantee(grantee);
1242
+ if (g.kind === "service" && !projects.has(g.scope))
1243
+ throw noSuchProject(g.scope);
1244
+ if (g.kind === "machine" && !organizations.has(g.scope))
1245
+ throw new HttpError(404, `no such organization '${g.scope}'`);
1246
+ if (target.kind === "topic" && !project.topics?.has(target.topic))
1247
+ throw new HttpError(404, `project '${project.id}' has not declared the topic '${target.topic}'`);
1248
+ if (g.kind === "service" && g.scope === project.id)
1249
+ throw new HttpError(400, "a project's own services need no grant: name them in the route's ACL instead");
1250
+ // Granting what a live grant already grants answers that grant.
1251
+ const existing = [...grants.values()].find((x) => x.projectId === project.id && x.grantee === grantee && live(x) && sameTarget(x.target, target));
1252
+ const grant = existing ?? makeGrant(project, org, grantee, target, display(c));
1253
+ return grantDetail(grant, grant.state === "accepted" ? "in effect" : grant.state);
1254
+ });
1255
+ route("GET", "/projects/{projectId}/grants", (c, p) => {
1256
+ const { project } = grantProject(c, p.projectId, false);
1257
+ return [...grants.values()]
1258
+ .filter((g) => g.projectId === project.id)
1259
+ .sort(byGranted)
1260
+ .map((g) => grantDetail(g, effectOf(g)));
1261
+ });
1262
+ route("GET", "/projects/{projectId}/grants/received", (c, p) => {
1263
+ const { project } = grantProject(c, p.projectId, false);
1264
+ return [...grants.values()]
1265
+ .filter((g) => g.grantee.startsWith(`service:${project.id}/`))
1266
+ .sort((a, b) => a.id.localeCompare(b.id))
1267
+ .map(receivedDetail);
1268
+ });
1269
+ route("DELETE", "/projects/{projectId}/grants/{grantId}", (c, p) => {
1270
+ const { project } = grantProject(c, p.projectId, true);
1271
+ const g = grants.get(p.grantId);
1272
+ if (!g || g.projectId !== project.id)
1273
+ throw new HttpError(404, `project '${project.id}' has no grant '${p.grantId}'`);
1274
+ const [to, verb, applies] = g.state === "pending" ? ["withdrawn", "withdraw", "a pending"] : ["revoked", "revoke", "an accepted"];
1275
+ if (g.state !== "pending" && g.state !== "accepted")
1276
+ throw new HttpError(409, `grant '${g.id}' is ${g.state}; ${verb} applies to ${applies} grant`);
1277
+ const at = now();
1278
+ g.state = to;
1279
+ g.ended = { by: display(c), at };
1280
+ g.changes.push({ change: to, by: display(c), at });
1281
+ return "done";
1282
+ });
1283
+ /** The grants naming one of the organization's machines, or a service of one of its projects. */
1284
+ function offeredTo(org) {
1285
+ const own = new Set([...projects.values()].filter((x) => x.organizationId === org.id).map((x) => x.id));
1286
+ return [...grants.values()]
1287
+ .filter((g) => {
1288
+ const grantee = parseGrantee(g.grantee);
1289
+ return grantee !== null && (grantee.kind === "machine" ? grantee.scope === org.id : own.has(grantee.scope));
1290
+ })
1291
+ .sort((a, b) => a.id.localeCompare(b.id));
1292
+ }
1293
+ route("GET", "/organizations/{organizationId}/grants", (c, p) => {
1294
+ const { org } = requireOrg(c, p.organizationId);
1295
+ return offeredTo(org).map(receivedDetail);
1296
+ });
1297
+ /**
1298
+ * An owner of the grantee's organization answers a grant: a member is 403, a grant not offered to
1299
+ * the organization 404 whoever asks (its id discloses nothing), a grant in the wrong state 409.
1300
+ */
1301
+ const answers = {
1302
+ accept: { from: "pending", to: "accepted", applies: "a pending" },
1303
+ decline: { from: "pending", to: "declined", applies: "a pending" },
1304
+ relinquish: { from: "accepted", to: "relinquished", applies: "an accepted" },
1305
+ };
1306
+ for (const [verb, rule] of Object.entries(answers)) {
1307
+ route("POST", `/organizations/{organizationId}/grants/{grantId}/${verb}`, (c, p) => {
1308
+ const org = requireOwner(c, p.organizationId);
1309
+ requireWrite(org);
1310
+ const g = offeredTo(org).find((x) => x.id === p.grantId);
1311
+ if (!g)
1312
+ throw new HttpError(404, `no grant '${p.grantId}' is offered to '${org.id}'`);
1313
+ if (g.state !== rule.from)
1314
+ throw new HttpError(409, `grant '${g.id}' is ${g.state}; ${verb} applies to ${rule.applies} grant`);
1315
+ const at = now();
1316
+ const act = { by: display(c), at };
1317
+ g.state = rule.to;
1318
+ if (verb !== "relinquish")
1319
+ g.answered = act;
1320
+ if (verb !== "accept")
1321
+ g.ended = act;
1322
+ g.changes.push({ change: rule.to, ...act });
1323
+ return "done";
1324
+ });
1325
+ }
1326
+ // ── Machines (feature 040): an owner registers, limits and deletes; a member lists ──
1327
+ const machineKey = (o, n) => `${o}/${n}`;
1328
+ // `ankka.controlplane.machines.byte-rate-ceiling`, 32 MiB a second unless the installation says otherwise.
1329
+ const ByteRateCeiling = options.byteRateCeiling ?? 33_554_432;
1330
+ const machineSummary = (m) => ({
1331
+ name: m.name,
1332
+ clientId: `machine:${m.organizationId}/${m.name}`,
1333
+ registeredBy: m.registeredBy,
1334
+ registeredAt: m.registeredAt,
1335
+ ...(m.byteRates ? { byteRates: m.byteRates } : {}),
1336
+ });
1337
+ route("GET", "/organizations/{organizationId}/machines", (c, p) => {
1338
+ const { org } = requireOrg(c, p.organizationId);
1339
+ return [...machines.values()]
1340
+ .filter((m) => m.organizationId === org.id && !m.hidden)
1341
+ .sort((a, b) => a.name.localeCompare(b.name))
1342
+ .map(machineSummary);
1343
+ });
1344
+ route("POST", "/organizations/{organizationId}/machines", (c, p, body) => {
1345
+ const org = requireOwner(c, p.organizationId);
1346
+ requireWrite(org);
1347
+ const name = String(body?.name ?? "");
1348
+ const problems = machineNameProblems(name);
1349
+ if (problems.length > 0)
1350
+ throw new HttpError(400, problems.join("; "));
1351
+ if (machines.has(machineKey(org.id, name)))
1352
+ throw new HttpError(409, `machine '${name}' is already registered on '${org.id}'`);
1353
+ machines.set(machineKey(org.id, name), { organizationId: org.id, name, registeredBy: display(c), registeredAt: now(), hidden: hideNew });
1354
+ return {
1355
+ name,
1356
+ clientId: `machine:${org.id}/${name}`,
1357
+ // 32 random bytes as hex: the only time the secret exists outside the machine that holds it.
1358
+ clientSecret: randomBytes(32).toString("hex"),
1359
+ tokenUrl: `${base}/oauth/token`,
1360
+ ...(options.brokerBootstrap ? { brokerBootstrap: options.brokerBootstrap } : {}),
1361
+ };
1362
+ });
1363
+ route("DELETE", "/organizations/{organizationId}/machines/{name}", (c, p) => {
1364
+ const org = requireOwner(c, p.organizationId);
1365
+ requireWrite(org);
1366
+ if (!machines.delete(machineKey(org.id, p.name)))
1367
+ throw new HttpError(404, `no such machine '${machineKey(org.id, p.name)}'`);
1368
+ return "done";
1369
+ });
1370
+ route("PUT", "/organizations/{organizationId}/machines/{name}/byte-rates", (c, p, body) => {
1371
+ const org = requireOwner(c, p.organizationId);
1372
+ requireWrite(org);
1373
+ const b = (body ?? {});
1374
+ const produce = Number(b.produceBytesPerSecond ?? 0);
1375
+ const consume = Number(b.consumeBytesPerSecond ?? 0);
1376
+ const percentage = Number(b.requestPercentage ?? 0);
1377
+ if (![produce, consume, percentage].every(Number.isInteger))
1378
+ throw new HttpError(400, "byte rates and the request percentage are whole numbers");
1379
+ 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}`] : [];
1380
+ const problems = [
1381
+ ...rate("the produce byte rate", produce),
1382
+ ...rate("the consume byte rate", consume),
1383
+ ...(percentage < 1 || percentage > 100 ? ["the request percentage is from 1 to 100"] : []),
1384
+ ];
1385
+ if (problems.length > 0)
1386
+ throw new HttpError(400, problems.join("; "));
1387
+ const m = machines.get(machineKey(org.id, p.name));
1388
+ if (!m)
1389
+ throw new HttpError(404, `no such machine '${machineKey(org.id, p.name)}'`);
1390
+ m.byteRates = { produceBytesPerSecond: produce, consumeBytesPerSecond: consume, requestPercentage: percentage };
1391
+ return machineSummary(m);
1392
+ });
668
1393
  route("GET", "/services/{projectId}", (c, p) => {
669
1394
  requireProject(c, p.projectId);
670
1395
  return [...services.values()].filter((s) => s.projectId === p.projectId).map(serviceStatus);
671
1396
  });
672
- route("GET", "/services/{projectId}/{name}", (c, p) => serviceStatus(requireService(c, p.projectId, p.name).service));
1397
+ route("GET", "/services/{projectId}/{name}", (c, p) => oneService(requireService(c, p.projectId, p.name).service));
1398
+ // Custom hostnames (feature 045). A name whose first label is `unproved` stands for one that does not
1399
+ // carry the project's proof record.
1400
+ route("PUT", "/services/{projectId}/{name}/hostnames/{hostname}", (c, p) => {
1401
+ const { service: s, org } = requireService(c, p.projectId, p.name);
1402
+ requireWrite(org);
1403
+ const hostname = p.hostname.trim().toLowerCase().replace(/\.$/, "");
1404
+ const problem = hostnameProblem(p.hostname);
1405
+ if (problem !== null)
1406
+ throw new HttpError(409, problem);
1407
+ const held = s.customHostnames ?? [];
1408
+ if (held.includes(hostname))
1409
+ return oneService(s);
1410
+ if (!s.exposed)
1411
+ throw new HttpError(409, `service '${s.name}' is not exposed`);
1412
+ if (held.length >= 5)
1413
+ throw new HttpError(409, `service '${s.name}' holds 5 custom hostnames, the most a service can hold`);
1414
+ const holder = [...services.values()].find((o) => o !== s && (o.customHostnames ?? []).includes(hostname));
1415
+ if (holder)
1416
+ throw new HttpError(409, `'${hostname}' is held by service '${holder.name}' in project '${holder.projectId}'`);
1417
+ if (hostname.split(".")[0] === "unproved")
1418
+ 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}"`);
1419
+ s.customHostnames = [...held, hostname];
1420
+ s.history.push({ kind: "hostname added", generation: s.generation, actor: actor(c), at: now(), hostname });
1421
+ return oneService(s);
1422
+ });
1423
+ route("DELETE", "/services/{projectId}/{name}/hostnames/{hostname}", (c, p) => {
1424
+ const { service: s, org } = requireService(c, p.projectId, p.name);
1425
+ if (!isAdmin(c))
1426
+ requireWrite(org);
1427
+ const hostname = p.hostname.trim().toLowerCase().replace(/\.$/, "");
1428
+ if ((s.customHostnames ?? []).includes(hostname)) {
1429
+ s.customHostnames = (s.customHostnames ?? []).filter((h) => h !== hostname);
1430
+ s.history.push({ kind: isAdmin(c) ? "hostname taken away" : "hostname removed", generation: s.generation, actor: actor(c), at: now(), hostname });
1431
+ }
1432
+ return oneService(s);
1433
+ });
673
1434
  route("PUT", "/services/{projectId}/{name}", (c, p, body) => {
674
1435
  const { org } = requireProject(c, p.projectId);
675
1436
  requireWrite(org);
@@ -781,6 +1542,51 @@ export async function fakeControlPlane(options = {}) {
781
1542
  for (const op of ["pause", "resume", "restart", "expose", "unexpose"]) {
782
1543
  route("POST", `/services/{projectId}/{name}/${op}`, operation(op));
783
1544
  }
1545
+ // A service's storage (feature 039): the control plane's refusals, and the history it records.
1546
+ const moving = (s) => s.storageMove !== undefined && s.storageMove !== "moved" && s.storageMove !== "move failed";
1547
+ route("POST", "/services/{projectId}/{name}/storage-credential", (c, p) => {
1548
+ const { service: s, org } = requireService(c, p.projectId, p.name);
1549
+ requireWrite(org);
1550
+ if (!s.provisionObjectStorage)
1551
+ throw new HttpError(409, `service '${s.name}' has no storage credential to reissue: its descriptor asks for no bucket`);
1552
+ if (moving(s))
1553
+ throw new HttpError(409, `the storage of service '${s.name}' is moving`);
1554
+ s.history.push({ kind: "storage-credential-reissued", generation: s.generation, actor: actor(c), at: now() });
1555
+ return serviceStatus(s);
1556
+ });
1557
+ route("POST", "/services/{projectId}/{name}/storage/settings", (c, p) => {
1558
+ const { service: s, org } = requireService(c, p.projectId, p.name);
1559
+ requireWrite(org);
1560
+ if (!s.provisionObjectStorage || s.objectStore !== "gcs")
1561
+ throw new HttpError(409, `the bucket of service '${s.name}' is not in Google Cloud Storage; settings are reapplied there only`);
1562
+ s.history.push({ kind: "storage-settings-reapplied", generation: s.generation, actor: actor(c), at: now() });
1563
+ return serviceStatus(s);
1564
+ });
1565
+ route("POST", "/services/{projectId}/{name}/storage/move", (c, p, body) => {
1566
+ const { service: s, org } = requireService(c, p.projectId, p.name);
1567
+ requireWrite(org);
1568
+ if (objectStore !== "gcs")
1569
+ throw new HttpError(409, "the installation keeps new buckets in Garage; there is nowhere to move a bucket to");
1570
+ if (!s.provisionObjectStorage)
1571
+ throw new HttpError(409, `service '${s.name}' has no bucket to move: its descriptor asks for none`);
1572
+ if (moving(s))
1573
+ throw new HttpError(409, `the storage of service '${s.name}' is moving`);
1574
+ if (s.objectStore === "gcs")
1575
+ throw new HttpError(409, `the bucket of service '${s.name}' is in Google Cloud Storage already`);
1576
+ const bound = body?.writePauseBound ?? "10m";
1577
+ const m = /^(\d+)([smh])$/.exec(bound);
1578
+ if (!m)
1579
+ throw new HttpError(400, `writePauseBound '${bound}' is not a duration such as 90s, 10m or 2h`);
1580
+ const seconds = Number(m[1]) * (m[2] === "s" ? 1 : m[2] === "m" ? 60 : 3600);
1581
+ if (seconds < 60 || seconds > 86400)
1582
+ throw new HttpError(400, `writePauseBound '${bound}' is outside one minute to 24 hours`);
1583
+ // As the control plane says it: how long the write pause may last, from the moment it is asked.
1584
+ const unit = m[2] === "s" ? "second" : m[2] === "m" ? "minute" : "hour";
1585
+ const words = `${m[1]} ${unit}${m[1] === "1" ? "" : "s"}`;
1586
+ s.storageMove = `waiting for its bucket in Google Cloud Storage; its write pause may last ${words} at most`;
1587
+ s.history.push({ kind: "storage-moved", generation: s.generation, actor: actor(c), at: now() });
1588
+ return serviceStatus(s);
1589
+ });
784
1590
  route("GET", "/services/{projectId}/{name}/logs", (c, p, _body, url) => {
785
1591
  const { service: s } = requireService(c, p.projectId, p.name);
786
1592
  if (s.paused || s.readyInstances === 0 && s.logs.size === 0)
@@ -978,9 +1784,17 @@ export async function fakeControlPlane(options = {}) {
978
1784
  o.hidden = false;
979
1785
  for (const p of projects.values())
980
1786
  p.hidden = false;
1787
+ for (const m of machines.values())
1788
+ m.hidden = false;
981
1789
  },
982
- state: { organizations, projects, services, tokens, tombstones },
1790
+ state: { organizations, projects, services, tokens, tombstones, grants, machines },
983
1791
  seed(seed) {
1792
+ if (seed.objectStore)
1793
+ objectStore = seed.objectStore;
1794
+ if (seed.cloud !== undefined)
1795
+ cloud = seed.cloud;
1796
+ if (seed.held)
1797
+ hold = { ...seed.held, releasedAt: undefined, releasedBy: undefined };
984
1798
  for (const o of seed.organizations ?? []) {
985
1799
  const members = new Map();
986
1800
  for (const s of o.owners ?? [])
@@ -1031,6 +1845,9 @@ export async function fakeControlPlane(options = {}) {
1031
1845
  provisionObjectStorage: s.provisionObjectStorage,
1032
1846
  exposeObjectStorage: s.exposeObjectStorage,
1033
1847
  ownObjectStore: s.ownObjectStore,
1848
+ objectStore: s.objectStore,
1849
+ bucketLocation: s.bucketLocation,
1850
+ buckets: s.buckets,
1034
1851
  });
1035
1852
  }
1036
1853
  for (const sec of seed.secrets ?? []) {
@@ -1061,8 +1878,24 @@ export async function fakeControlPlane(options = {}) {
1061
1878
  detail: t.detail,
1062
1879
  compacted: t.compacted,
1063
1880
  contract: t.contract ? { ...t.contract, fingerprint: fakeFingerprint(t.contract.schema) } : undefined,
1881
+ settings: t.settings ? { ...TopicDefaults, ...t.settings } : undefined,
1882
+ defaulted: t.settings ? SettingNames.filter((s) => t.settings[s] === undefined) : undefined,
1064
1883
  });
1065
1884
  }
1885
+ for (const m of seed.machines ?? []) {
1886
+ machines.set(machineKey(m.organizationId, m.name), { organizationId: m.organizationId, name: m.name, registeredBy: "seed", registeredAt: now(), byteRates: m.byteRates, hidden: false });
1887
+ }
1888
+ for (const g of seed.grants ?? []) {
1889
+ const project = projects.get(g.projectId);
1890
+ const org = project && organizations.get(project.organizationId);
1891
+ if (!project || !org)
1892
+ throw new Error(`no project ${g.projectId}`);
1893
+ if (!parseGrantee(g.grantee))
1894
+ throw new Error(`grantee ${g.grantee} is not service:<project>/<name> or machine:<organization>/<name>`);
1895
+ const grant = makeGrant(project, org, g.grantee, g.target, g.by ?? "seed", g.state);
1896
+ if (g.state && g.state !== "pending" && g.state !== "accepted")
1897
+ grant.ended = { by: g.by ?? "seed", at: grant.granted.at };
1898
+ }
1066
1899
  },
1067
1900
  close: () => new Promise((resolve) => {
1068
1901
  server.closeAllConnections();