ankka-console 0.11.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.
@@ -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,11 @@ 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
+ if (s.provisionObjectStorage && !s.objectStore) {
169
+ s.objectStore = objectStore;
170
+ if (objectStore === "gcs")
171
+ s.bucketLocation = projects.get(s.projectId)?.location ?? "europe-west2";
172
+ }
111
173
  s.desiredInstances = d.service?.resources?.autoscaling?.minInstances ?? 1;
112
174
  s.lifecycle = s.paused ? "Paused" : "UpdateInProgress";
113
175
  s.descriptor = descriptor;
@@ -141,6 +203,7 @@ export async function fakeControlPlane(options = {}) {
141
203
  suspended,
142
204
  paused: s.paused,
143
205
  hosting: s.hosting ?? "embedded",
206
+ databaseCluster: s.databaseCluster ?? null,
144
207
  protocol: null,
145
208
  mounts: s.mounts ?? [],
146
209
  callers: s.callers ?? [],
@@ -149,13 +212,49 @@ export async function fakeControlPlane(options = {}) {
149
212
  undeclaredTopics: s.undeclaredTopics ?? null,
150
213
  topicChecks: s.topicChecks ? s.topicChecks.map(checkOnTheWire) : null,
151
214
  topicSources: s.topicSources ? s.topicSources.map(topicSourceOnTheWire) : null,
215
+ warnings: warningsOf(s),
152
216
  objectStorage: s.provisionObjectStorage ? "provisioned" : s.ownObjectStore ? "supplied" : null,
153
217
  bucket: s.provisionObjectStorage ? `${s.projectId}.${s.name}` : null,
154
218
  bucketAddress: s.provisionObjectStorage && s.exposeObjectStorage
155
219
  ? `https://storage.${options.baseDomain ?? "example.test"}/${s.projectId}.${s.name}`
156
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
+ })),
157
234
  };
158
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
+ };
159
258
  // ── Authorization, the control plane's rules ──────────────────────────────
160
259
  const isAdmin = (c) => c.admin;
161
260
  function claimInvitations(c) {
@@ -178,6 +277,29 @@ export async function fakeControlPlane(options = {}) {
178
277
  throw new HttpError(404, `organization '${id}' not found`);
179
278
  return { org, role: member?.role };
180
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
+ }
181
303
  function requireWrite(org) {
182
304
  if (org.disabled)
183
305
  throw new HttpError(409, `organization '${org.id}' is disabled`);
@@ -231,6 +353,26 @@ export async function fakeControlPlane(options = {}) {
231
353
  .map((o) => ({ id: o.id, name: o.name, role: o.members.get(c.subject).role })),
232
354
  };
233
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
+ });
234
376
  route("GET", "/organizations", (c) => {
235
377
  claimInvitations(c);
236
378
  return [...organizations.values()]
@@ -498,6 +640,28 @@ export async function fakeControlPlane(options = {}) {
498
640
  project.registry = undefined;
499
641
  return "done";
500
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
+ });
501
665
  // Project secrets: the control plane's rules, its merge, and names only in what it answers.
502
666
  const SecretName = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
503
667
  const EntryName = /^[A-Za-z0-9._-]{1,253}$/;
@@ -560,12 +724,30 @@ export async function fakeControlPlane(options = {}) {
560
724
  requireWrite(org);
561
725
  const name = p.name;
562
726
  const request = body;
563
- const partitions = Number(request.partitions);
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);
564
732
  const problems = [];
565
733
  if (!TopicName.test(name))
566
734
  problems.push(`topic '${name}': ${TopicNameRule}`);
567
735
  if (!Number.isInteger(partitions) || partitions < 1 || partitions > 1000)
568
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}`);
569
751
  // A contract's own rules: its name, and a schema that is a JSON document (feature 037).
570
752
  if (request.contract !== undefined && request.contract !== null) {
571
753
  const contractName = String(request.contract.name ?? "");
@@ -576,14 +758,57 @@ export async function fakeControlPlane(options = {}) {
576
758
  }
577
759
  if (problems.length > 0)
578
760
  throw new HttpError(400, problems.join("; "));
579
- project.topics ??= new Map();
580
- const has = project.topics.get(name)?.partitions;
761
+ const has = current?.partitions;
581
762
  if (has !== undefined && has > partitions)
582
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
+ : [];
583
793
  const contract = request.contract
584
794
  ? { name: String(request.contract.name), schema: request.contract.schema, fingerprint: fakeFingerprint(request.contract.schema) }
585
795
  : undefined;
586
- project.topics.set(name, { ...(project.topics.get(name) ?? {}), partitions, compacted: request.compacted === true, contract });
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);
587
812
  return "done";
588
813
  });
589
814
  // The schema a topic's contract was declared with (feature 037); 404 without a contract.
@@ -616,6 +841,16 @@ export async function fakeControlPlane(options = {}) {
616
841
  compacted: t.compacted ?? false,
617
842
  contract: t.contract ? { name: t.contract.name, fingerprint: t.contract.fingerprint } : null,
618
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,
619
854
  }));
620
855
  });
621
856
  // Declared brokers (feature 037): the control plane's rules, and the secret's entries checked by name.
@@ -665,11 +900,517 @@ export async function fakeControlPlane(options = {}) {
665
900
  .sort(([a], [b]) => a.localeCompare(b))
666
901
  .map(([name, b]) => ({ name, bootstrap: b.bootstrap, shape: b.shape, secret: b.secret, declaredAt: b.declaredAt }));
667
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
+ });
668
1373
  route("GET", "/services/{projectId}", (c, p) => {
669
1374
  requireProject(c, p.projectId);
670
1375
  return [...services.values()].filter((s) => s.projectId === p.projectId).map(serviceStatus);
671
1376
  });
672
- 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
+ });
673
1414
  route("PUT", "/services/{projectId}/{name}", (c, p, body) => {
674
1415
  const { org } = requireProject(c, p.projectId);
675
1416
  requireWrite(org);
@@ -781,6 +1522,51 @@ export async function fakeControlPlane(options = {}) {
781
1522
  for (const op of ["pause", "resume", "restart", "expose", "unexpose"]) {
782
1523
  route("POST", `/services/{projectId}/{name}/${op}`, operation(op));
783
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
+ });
784
1570
  route("GET", "/services/{projectId}/{name}/logs", (c, p, _body, url) => {
785
1571
  const { service: s } = requireService(c, p.projectId, p.name);
786
1572
  if (s.paused || s.readyInstances === 0 && s.logs.size === 0)
@@ -978,9 +1764,17 @@ export async function fakeControlPlane(options = {}) {
978
1764
  o.hidden = false;
979
1765
  for (const p of projects.values())
980
1766
  p.hidden = false;
1767
+ for (const m of machines.values())
1768
+ m.hidden = false;
981
1769
  },
982
- state: { organizations, projects, services, tokens, tombstones },
1770
+ state: { organizations, projects, services, tokens, tombstones, grants, machines },
983
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 };
984
1778
  for (const o of seed.organizations ?? []) {
985
1779
  const members = new Map();
986
1780
  for (const s of o.owners ?? [])
@@ -1031,6 +1825,8 @@ export async function fakeControlPlane(options = {}) {
1031
1825
  provisionObjectStorage: s.provisionObjectStorage,
1032
1826
  exposeObjectStorage: s.exposeObjectStorage,
1033
1827
  ownObjectStore: s.ownObjectStore,
1828
+ objectStore: s.objectStore,
1829
+ bucketLocation: s.bucketLocation,
1034
1830
  });
1035
1831
  }
1036
1832
  for (const sec of seed.secrets ?? []) {
@@ -1061,8 +1857,24 @@ export async function fakeControlPlane(options = {}) {
1061
1857
  detail: t.detail,
1062
1858
  compacted: t.compacted,
1063
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,
1064
1862
  });
1065
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
+ }
1066
1878
  },
1067
1879
  close: () => new Promise((resolve) => {
1068
1880
  server.closeAllConnections();