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