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