anbaric-cloud-hosting 1.0.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/package.json +35 -0
- package/src/app-management/BaseBuildLayer.ts +178 -0
- package/src/app-management/BuildLayer.ts +19 -0
- package/src/app-management/DockerBuildLayer.ts +84 -0
- package/src/app-management/FargateBuildLayer.ts +232 -0
- package/src/auth/Authenticator.ts +18 -0
- package/src/auth/AuthenticatorLoader.ts +13 -0
- package/src/auth/CliAuthorizer.ts +54 -0
- package/src/auth/CliKey.ts +20 -0
- package/src/auth/CliKeyStore.ts +12 -0
- package/src/auth/InMemoryCliKeyStore.ts +27 -0
- package/src/auth/KeyPair.ts +13 -0
- package/src/auth/Role.ts +11 -0
- package/src/auth/Tenant.ts +11 -0
- package/src/auth/TokenAuthenticator.ts +62 -0
- package/src/auth/User.ts +22 -0
- package/src/data-store/PostgresCliKeyStore.ts +44 -0
- package/src/data-store/PostgresJobPersistence.ts +77 -0
- package/src/data-store/PostgresJsonStore.ts +41 -0
- package/src/data-store/Schema.ts +55 -0
- package/src/data-store/SecretsManagerSecretStore.ts +57 -0
- package/src/hosting/HostingServer.ts +130 -0
- package/src/hosting/Router.ts +306 -0
- package/src/hosting/pages/platform-ui.html +59 -0
- package/src/index.ts +24 -0
- package/src/main.ts +72 -0
- package/src/queuing/ConfirmableQueue.ts +9 -0
- package/src/queuing/ConsumerRegistry.ts +19 -0
- package/src/queuing/Dispatcher.ts +64 -0
- package/src/queuing/PostgresQueue.ts +54 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import {readFile} from "node:fs/promises";
|
|
2
|
+
import {IncomingMessage, ServerResponse} from "node:http";
|
|
3
|
+
import {JobPersistence, JsonStore, SecretStore, deserializeJob, serializeJob} from "anbaric-tsapi";
|
|
4
|
+
import {BuildLayer} from "../app-management/BuildLayer";
|
|
5
|
+
import {CliAuthorizer} from "../auth/CliAuthorizer";
|
|
6
|
+
import {Tenant} from "../auth/Tenant";
|
|
7
|
+
import {User} from "../auth/User";
|
|
8
|
+
import {ConfirmableQueue} from "../queuing/ConfirmableQueue";
|
|
9
|
+
import {ConsumerRegistry} from "../queuing/ConsumerRegistry";
|
|
10
|
+
|
|
11
|
+
const readRawBody = (request : IncomingMessage) : Promise<Buffer> =>
|
|
12
|
+
new Promise((resolve, reject) => {
|
|
13
|
+
const chunks : Array<Buffer> = [];
|
|
14
|
+
request.on("data", chunk => chunks.push(chunk));
|
|
15
|
+
request.on("error", reject);
|
|
16
|
+
request.on("end", () => resolve(Buffer.concat(chunks)));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const readBody = async (request : IncomingMessage) : Promise<any> => {
|
|
20
|
+
const raw = (await readRawBody(request)).toString();
|
|
21
|
+
return raw.length === 0 ? undefined : JSON.parse(raw);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
class Router {
|
|
25
|
+
|
|
26
|
+
constructor(private persistence : JobPersistence, private queue : ConfirmableQueue,
|
|
27
|
+
private registry : ConsumerRegistry,
|
|
28
|
+
private buildLayer? : BuildLayer,
|
|
29
|
+
private documentStoreFor? : (collection : string) => JsonStore,
|
|
30
|
+
private secretStore? : SecretStore,
|
|
31
|
+
private cliAuthorizer? : CliAuthorizer,
|
|
32
|
+
private tenant? : string) {}
|
|
33
|
+
|
|
34
|
+
async route(request : IncomingMessage, response : ServerResponse, user? : User, sessionTenant? : Tenant) : Promise<void> {
|
|
35
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
36
|
+
const [resource, id, subresource] = url.pathname.split("/").filter(Boolean);
|
|
37
|
+
const method = request.method ?? "GET";
|
|
38
|
+
|
|
39
|
+
if (!resource && method === "GET") {
|
|
40
|
+
return this.servePage(response);
|
|
41
|
+
}
|
|
42
|
+
if (resource === "authorize-cli" && id && this.cliAuthorizer) {
|
|
43
|
+
return this.handleAuthorizeCli(method, id, subresource, request, response, user, sessionTenant);
|
|
44
|
+
}
|
|
45
|
+
if (resource === "manage-keys" && method === "GET" && !id && this.cliAuthorizer) {
|
|
46
|
+
return this.servePage(response);
|
|
47
|
+
}
|
|
48
|
+
if (resource === "keys" && this.cliAuthorizer) {
|
|
49
|
+
return this.handleKeys(method, id, response, user);
|
|
50
|
+
}
|
|
51
|
+
if (resource === "whoami" && method === "GET" && !id) {
|
|
52
|
+
if (!user) return this.reply(response, 404, { error: "Not found" });
|
|
53
|
+
return this.reply(response, 200, { id: user.id, roles: user.roles.map(role => role.id) });
|
|
54
|
+
}
|
|
55
|
+
if (resource === "jobs") return this.handleJobs(method, id, subresource, url, request, response);
|
|
56
|
+
if (resource === "queue" && method === "POST" && !subresource) return this.handleQueue(id, request, response);
|
|
57
|
+
if (resource === "consumers" && method === "POST" && !id) {
|
|
58
|
+
const { workflowId, url: consumerUrl } = await readBody(request);
|
|
59
|
+
this.registry.register(workflowId, consumerUrl);
|
|
60
|
+
return this.reply(response, 204);
|
|
61
|
+
}
|
|
62
|
+
if (resource === "apps" && this.buildLayer) {
|
|
63
|
+
return this.handleApps(method, id, subresource, url, request, response);
|
|
64
|
+
}
|
|
65
|
+
if (resource === "state-machines" && method === "GET" && !id) {
|
|
66
|
+
return this.reply(response, 200, this.registry.list());
|
|
67
|
+
}
|
|
68
|
+
if (resource === "documents" && id && this.documentStoreFor) {
|
|
69
|
+
return this.handleDocuments(method, this.documentStoreFor(id), subresource, url, request, response);
|
|
70
|
+
}
|
|
71
|
+
if (resource === "secrets" && this.secretStore && !subresource) {
|
|
72
|
+
return this.handleSecrets(method, id, request, response);
|
|
73
|
+
}
|
|
74
|
+
if (resource && this.buildLayer) {
|
|
75
|
+
const app = this.buildLayer.status(resource);
|
|
76
|
+
if (app && app.status === "running") {
|
|
77
|
+
return this.forwardToApp(app.appHost, app.appPort, resource, method, url, request, response);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
this.reply(response, 404, { error: "Not found" });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private async handleAuthorizeCli(method : string, requestId : string, subresource : string | undefined,
|
|
85
|
+
request : IncomingMessage, response : ServerResponse, user? : User,
|
|
86
|
+
sessionTenant? : Tenant) : Promise<void> {
|
|
87
|
+
if (method === "GET" && subresource === "poll") {
|
|
88
|
+
const keyPair = this.cliAuthorizer!.collect(requestId);
|
|
89
|
+
if (!keyPair) return this.reply(response, 202, { status: "pending" });
|
|
90
|
+
return this.reply(response, 200, keyPair);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (method === "GET" && !subresource) {
|
|
94
|
+
return this.servePage(response);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (method === "POST" && !subresource) {
|
|
98
|
+
const { clientName } = await readBody(request);
|
|
99
|
+
if (typeof clientName !== "string" || clientName.trim().length === 0) {
|
|
100
|
+
return this.reply(response, 400, { error: "Expected a body of { clientName : string }" });
|
|
101
|
+
}
|
|
102
|
+
await this.cliAuthorizer!.approve(requestId, clientName.trim(), user ?? new User("local"),
|
|
103
|
+
sessionTenant?.id ?? this.tenant);
|
|
104
|
+
return this.reply(response, 204);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.reply(response, 404, { error: "Not found" });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async handleKeys(method : string, id : string | undefined,
|
|
111
|
+
response : ServerResponse, user? : User) : Promise<void> {
|
|
112
|
+
const owner = user ?? new User("local");
|
|
113
|
+
|
|
114
|
+
if (method === "GET" && !id) {
|
|
115
|
+
const keys = await this.cliAuthorizer!.keysFor(owner.id);
|
|
116
|
+
return this.reply(response, 200, keys.map(key => ({
|
|
117
|
+
id: key.id,
|
|
118
|
+
clientName: key.clientName,
|
|
119
|
+
createdAt: key.createdAt.toISOString(),
|
|
120
|
+
})));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (method === "DELETE" && id) {
|
|
124
|
+
await this.cliAuthorizer!.revoke(id, owner.id);
|
|
125
|
+
return this.reply(response, 204);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this.reply(response, 404, { error: "Not found" });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async servePage(response : ServerResponse) : Promise<void> {
|
|
132
|
+
try {
|
|
133
|
+
const page = await readFile(new URL("./pages/platform-ui.html", import.meta.url));
|
|
134
|
+
response.writeHead(200, { "content-type": "text/html" });
|
|
135
|
+
response.end(page);
|
|
136
|
+
} catch {
|
|
137
|
+
this.reply(response, 501, { error: "The platform UI has not been built - run npm run build in anbaric-cloud-hosting/ui" });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async forwardToApp(appHost : string, appPort : number, appName : string, method : string, url : URL,
|
|
142
|
+
request : IncomingMessage, response : ServerResponse) : Promise<void> {
|
|
143
|
+
const appPath = url.pathname.slice(`/${appName}`.length) || "/";
|
|
144
|
+
const body = method === "GET" || method === "HEAD" ? undefined : await readRawBody(request);
|
|
145
|
+
|
|
146
|
+
const upstream = await fetch(`http://${appHost}:${appPort}${appPath}${url.search}`, {
|
|
147
|
+
method,
|
|
148
|
+
headers: { "content-type": String(request.headers["content-type"] ?? "application/json") },
|
|
149
|
+
body: body && body.length > 0 ? new Uint8Array(body) : undefined,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const payload = Buffer.from(await upstream.arrayBuffer());
|
|
153
|
+
response.writeHead(upstream.status, { "content-type": upstream.headers.get("content-type") ?? "application/octet-stream" });
|
|
154
|
+
response.end(payload);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async handleSecrets(method : string, name : string | undefined,
|
|
158
|
+
request : IncomingMessage, response : ServerResponse) : Promise<void> {
|
|
159
|
+
if (!name && method === "GET") {
|
|
160
|
+
return this.reply(response, 200, await this.secretStore!.list());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (name) {
|
|
164
|
+
if (method === "PUT") {
|
|
165
|
+
const { value } = await readBody(request);
|
|
166
|
+
if (typeof value !== "string") return this.reply(response, 400, { error: "Expected a body of { value : string }" });
|
|
167
|
+
await this.secretStore!.save(name, value);
|
|
168
|
+
return this.reply(response, 204);
|
|
169
|
+
}
|
|
170
|
+
if (method === "GET") {
|
|
171
|
+
return this.reply(response, 200, { value: await this.secretStore!.retrieve(name) });
|
|
172
|
+
}
|
|
173
|
+
if (method === "DELETE") {
|
|
174
|
+
await this.secretStore!.delete(name);
|
|
175
|
+
return this.reply(response, 204);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
this.reply(response, 404, { error: "Not found" });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private async handleDocuments(method : string, store : JsonStore, documentId : string | undefined,
|
|
183
|
+
url : URL, request : IncomingMessage, response : ServerResponse) : Promise<void> {
|
|
184
|
+
if (!documentId && method === "GET") {
|
|
185
|
+
const pageSize = Number(url.searchParams.get("pageSize") ?? 100);
|
|
186
|
+
const page = Number(url.searchParams.get("page") ?? 0);
|
|
187
|
+
return this.reply(response, 200, await store.list(pageSize, page));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (documentId) {
|
|
191
|
+
if (method === "PUT") {
|
|
192
|
+
await store.save(documentId, await readBody(request));
|
|
193
|
+
return this.reply(response, 204);
|
|
194
|
+
}
|
|
195
|
+
if (method === "GET") {
|
|
196
|
+
return this.reply(response, 200, await store.retrieve(documentId));
|
|
197
|
+
}
|
|
198
|
+
if (method === "DELETE") {
|
|
199
|
+
await store.delete(documentId);
|
|
200
|
+
return this.reply(response, 204);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
this.reply(response, 404, { error: "Not found" });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private async handleApps(method : string, appName : string | undefined, subresource : string | undefined,
|
|
208
|
+
url : URL, request : IncomingMessage, response : ServerResponse) : Promise<void> {
|
|
209
|
+
if (method === "GET" && !appName) {
|
|
210
|
+
return this.reply(response, 200, this.buildLayer!.list());
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!appName) return this.reply(response, 404, { error: "Not found" });
|
|
214
|
+
|
|
215
|
+
if (method === "POST" && subresource === "deploy") {
|
|
216
|
+
const appPort = Number(url.searchParams.get("port"));
|
|
217
|
+
if (!Number.isInteger(appPort) || appPort <= 0) {
|
|
218
|
+
return this.reply(response, 400, { error: "Expected a numeric port query parameter" });
|
|
219
|
+
}
|
|
220
|
+
const tarball = await readRawBody(request);
|
|
221
|
+
if (tarball.length === 0) return this.reply(response, 400, { error: "Expected a gzipped tarball body" });
|
|
222
|
+
return this.reply(response, 202, this.buildLayer!.deploy(appName, appPort, tarball));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (method === "GET" && !subresource) {
|
|
226
|
+
const status = this.buildLayer!.status(appName);
|
|
227
|
+
if (!status) return this.reply(response, 404, { error: `No app named "${appName}"` });
|
|
228
|
+
return this.reply(response, 200, status);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
this.reply(response, 404, { error: "Not found" });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private async handleJobs(method : string, id : string | undefined, subresource : string | undefined,
|
|
235
|
+
url : URL, request : IncomingMessage, response : ServerResponse) : Promise<void> {
|
|
236
|
+
if (!id && method === "GET") {
|
|
237
|
+
const pageSize = Number(url.searchParams.get("pageSize") ?? 100);
|
|
238
|
+
const page = Number(url.searchParams.get("page") ?? 0);
|
|
239
|
+
const jobs = await this.persistence.list(pageSize, page);
|
|
240
|
+
return this.reply(response, 200, jobs.map(serializeJob));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (id && !subresource) {
|
|
244
|
+
if (method === "PUT") {
|
|
245
|
+
await this.persistence.save(deserializeJob(await readBody(request)));
|
|
246
|
+
return this.reply(response, 204);
|
|
247
|
+
}
|
|
248
|
+
if (method === "GET") {
|
|
249
|
+
const job = await this.persistence.retrieve(id);
|
|
250
|
+
return this.reply(response, 200, serializeJob(job));
|
|
251
|
+
}
|
|
252
|
+
if (method === "DELETE") {
|
|
253
|
+
await this.persistence.delete(id);
|
|
254
|
+
return this.reply(response, 204);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (id && subresource === "properties" && method === "PATCH") {
|
|
259
|
+
const properties = new Map<string, any>(Object.entries(await readBody(request)));
|
|
260
|
+
await this.persistence.updateProperties(id, properties);
|
|
261
|
+
return this.reply(response, 204);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
this.reply(response, 404, { error: "Not found" });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private async handleQueue(operation : string | undefined, request : IncomingMessage,
|
|
268
|
+
response : ServerResponse) : Promise<void> {
|
|
269
|
+
if (operation === "enqueue") {
|
|
270
|
+
const { jobId, workflowId } = await readBody(request);
|
|
271
|
+
await this.queue.enqueue(jobId, workflowId);
|
|
272
|
+
return this.reply(response, 204);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (operation === "schedule") {
|
|
276
|
+
const { jobId, workflowId, due } = await readBody(request);
|
|
277
|
+
await this.queue.schedule(jobId, workflowId, new Date(due));
|
|
278
|
+
return this.reply(response, 204);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (operation === "dequeue") {
|
|
282
|
+
return this.reply(response, 200, { messages: await this.queue.dequeueSome() });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (operation === "confirm") {
|
|
286
|
+
const { jobId, workflowId } = await readBody(request);
|
|
287
|
+
await this.queue.confirm({ jobId, workflowId });
|
|
288
|
+
return this.reply(response, 204);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
this.reply(response, 404, { error: "Not found" });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private reply(response : ServerResponse, status : number, body? : unknown) : void {
|
|
295
|
+
if (body === undefined) {
|
|
296
|
+
response.statusCode = status;
|
|
297
|
+
response.end();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
response.writeHead(status, { "content-type": "application/json" });
|
|
301
|
+
response.end(JSON.stringify(body));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export { Router }
|