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.
- package/dist/client/control-plane.d.ts +63 -1
- package/dist/client/control-plane.js +142 -1
- package/dist/client/schemas.d.ts +1312 -2
- package/dist/client/schemas.js +423 -2
- package/dist/context.d.ts +1 -1
- package/dist/context.js +1 -1
- package/dist/extensions/types.d.ts +1 -1
- package/dist/extensions/types.js +22 -0
- package/dist/routes/front.d.ts +62 -2
- package/dist/routes/front.js +37 -8
- package/dist/routes/logs.d.ts +57 -0
- package/dist/routes/machines.d.ts +78 -0
- package/dist/routes/machines.js +78 -0
- package/dist/routes/organization.d.ts +29 -1
- package/dist/routes/organization.js +27 -5
- package/dist/routes/project.d.ts +275 -1
- package/dist/routes/project.js +107 -6
- package/dist/routes/service-history.d.ts +2 -0
- package/dist/routes/service-history.js +3 -0
- package/dist/routes/service-topology.d.ts +57 -0
- package/dist/routes/service.d.ts +57 -0
- package/dist/routes/service.js +63 -4
- package/dist/routes.js +1 -0
- package/dist/runtime.d.ts +3 -1
- package/dist/runtime.js +1 -0
- package/dist/testing/fake-control-plane.d.ts +179 -1
- package/dist/testing/fake-control-plane.js +819 -7
- package/dist/ui/grants.d.ts +3 -0
- package/dist/ui/grants.js +13 -0
- package/dist/ui/shell.d.ts +1 -1
- package/dist/ui/shell.js +9 -2
- package/dist/ui/use-stream.d.ts +114 -0
- 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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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();
|