anbaric-cloud-hosting 1.16.0 → 1.16.2
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 +6 -6
- package/src/app-management/BaseBuildLayer.ts +13 -0
- package/src/app-management/BuildLayer.ts +1 -0
- package/src/app-management/FargateBuildLayer.ts +62 -0
- package/src/hosting/handlers/AppProxyHandler.ts +47 -11
- package/src/hosting/handlers/AppsHandler.ts +2 -1
- package/src/hosting/pages/platform-ui.html +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anbaric-cloud-hosting",
|
|
3
|
-
"version": "1.16.
|
|
3
|
+
"version": "1.16.2",
|
|
4
4
|
"description": "Anbaric Cloud hosting service: Postgres-backed job persistence and queuing exposed over an HTTP API",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "chris@anbaric.ai",
|
|
@@ -18,17 +18,17 @@
|
|
|
18
18
|
"@aws-sdk/client-s3": "^3.1110.0",
|
|
19
19
|
"@aws-sdk/client-secrets-manager": "^3.700.0",
|
|
20
20
|
"@aws-sdk/client-servicediscovery": "^3.1110.0",
|
|
21
|
-
"anbaric-data-store": "^1.16.
|
|
22
|
-
"anbaric-plugins": "^1.16.
|
|
23
|
-
"anbaric-tsapi": "^1.16.
|
|
21
|
+
"anbaric-data-store": "^1.16.2",
|
|
22
|
+
"anbaric-plugins": "^1.16.2",
|
|
23
|
+
"anbaric-tsapi": "^1.16.2",
|
|
24
24
|
"esbuild": "^0.28.2",
|
|
25
25
|
"pg": "^8.16.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^26.2.0",
|
|
29
29
|
"@types/pg": "^8.15.0",
|
|
30
|
-
"anbaric-impl-cloud": "^1.16.
|
|
31
|
-
"anbaric-state-machine": "^1.16.
|
|
30
|
+
"anbaric-impl-cloud": "^1.16.2",
|
|
31
|
+
"anbaric-state-machine": "^1.16.2",
|
|
32
32
|
"tsx": "^4.20.0",
|
|
33
33
|
"typescript": "^7.0.2"
|
|
34
34
|
},
|
|
@@ -31,11 +31,24 @@ abstract class BaseBuildLayer implements BuildLayer {
|
|
|
31
31
|
|
|
32
32
|
protected deployments = new Map<string, Deployment>();
|
|
33
33
|
private nextAppIndex = 0;
|
|
34
|
+
private hydration? : Promise<void>;
|
|
34
35
|
|
|
35
36
|
constructor(protected appsDir : string, private consumerPortBase : number = 8800,
|
|
36
37
|
private probe : Probe = adminProbe,
|
|
37
38
|
private livenessTimeoutMs : number = LIVENESS_TIMEOUT_MS) {}
|
|
38
39
|
|
|
40
|
+
/* The deployments map is rebuilt from the durable backend the first time the
|
|
41
|
+
app registry is read, so apps survive a platform restart. A failed
|
|
42
|
+
rehydration is not cached, so a later request retries. */
|
|
43
|
+
ensureHydrated() : Promise<void> {
|
|
44
|
+
return this.hydration ??= this.rehydrate().catch(error => {
|
|
45
|
+
this.hydration = undefined;
|
|
46
|
+
console.warn(`Could not rehydrate the app registry: ${error instanceof Error ? error.message : error}`);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
protected async rehydrate() : Promise<void> {}
|
|
51
|
+
|
|
39
52
|
deploy(appName : string, appPort : number, tarball : Buffer) : DeploymentSummary {
|
|
40
53
|
const existing = this.deployments.get(appName);
|
|
41
54
|
|
|
@@ -9,6 +9,7 @@ type DeploymentSummary = {
|
|
|
9
9
|
|
|
10
10
|
interface BuildLayer {
|
|
11
11
|
|
|
12
|
+
ensureHydrated() : Promise<void>;
|
|
12
13
|
deploy(appName : string, appPort : number, tarball : Buffer) : DeploymentSummary;
|
|
13
14
|
status(appName : string) : (DeploymentSummary & { log : Array<string> }) | undefined;
|
|
14
15
|
list() : Array<DeploymentSummary>;
|
|
@@ -2,6 +2,7 @@ import {readFile, writeFile} from "node:fs/promises";
|
|
|
2
2
|
import {join} from "node:path";
|
|
3
3
|
import {CodeBuildClient, BatchGetBuildsCommand, StartBuildCommand} from "@aws-sdk/client-codebuild";
|
|
4
4
|
import {ECSClient, CreateServiceCommand, DeleteServiceCommand, DescribeServicesCommand,
|
|
5
|
+
DescribeTaskDefinitionCommand, ListServicesCommand as ListEcsServicesCommand,
|
|
5
6
|
RegisterTaskDefinitionCommand, UpdateServiceCommand} from "@aws-sdk/client-ecs";
|
|
6
7
|
import {S3Client, PutObjectCommand} from "@aws-sdk/client-s3";
|
|
7
8
|
import {ServiceDiscoveryClient, CreateServiceCommand as CreateDiscoveryServiceCommand,
|
|
@@ -255,6 +256,67 @@ class FargateBuildLayer extends BaseBuildLayer {
|
|
|
255
256
|
}));
|
|
256
257
|
}
|
|
257
258
|
|
|
259
|
+
/* ECS is the durable record of what is deployed, so on the first read after
|
|
260
|
+
a restart the registry is rebuilt from the running anbaric-app-* services
|
|
261
|
+
and their task definitions (ports come from the container environment the
|
|
262
|
+
platform set at deploy time). */
|
|
263
|
+
protected async rehydrate() : Promise<void> {
|
|
264
|
+
const serviceArns = await this.appServiceArns();
|
|
265
|
+
|
|
266
|
+
for (let batch = 0; batch < serviceArns.length; batch += 10) {
|
|
267
|
+
const {services} = await this.aws.ecs.send(new DescribeServicesCommand({
|
|
268
|
+
cluster: this.options.cluster,
|
|
269
|
+
services: serviceArns.slice(batch, batch + 10),
|
|
270
|
+
}));
|
|
271
|
+
for (const service of services ?? []) {
|
|
272
|
+
if (service.status !== "ACTIVE" || !service.serviceName?.startsWith("anbaric-app-")) continue;
|
|
273
|
+
const appName = service.serviceName.slice("anbaric-app-".length);
|
|
274
|
+
if (this.deployments.has(appName)) continue;
|
|
275
|
+
const deployment = await this.deploymentFromService(appName, service);
|
|
276
|
+
if (deployment) this.deployments.set(appName, deployment);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private async appServiceArns() : Promise<Array<string>> {
|
|
282
|
+
const arns : Array<string> = [];
|
|
283
|
+
let nextToken : string | undefined;
|
|
284
|
+
do {
|
|
285
|
+
const page = await this.aws.ecs.send(new ListEcsServicesCommand({ cluster: this.options.cluster, nextToken }));
|
|
286
|
+
for (const arn of page.serviceArns ?? []) {
|
|
287
|
+
if (arn.includes("/anbaric-app-")) arns.push(arn);
|
|
288
|
+
}
|
|
289
|
+
nextToken = page.nextToken;
|
|
290
|
+
} while (nextToken);
|
|
291
|
+
return arns;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private async deploymentFromService(appName : string, service : { taskDefinition : string, runningCount? : number }) : Promise<Deployment | undefined> {
|
|
295
|
+
const {taskDefinition} = await this.aws.ecs.send(new DescribeTaskDefinitionCommand({ taskDefinition: service.taskDefinition }));
|
|
296
|
+
const container = taskDefinition?.containerDefinitions?.[0];
|
|
297
|
+
if (!container) return undefined;
|
|
298
|
+
|
|
299
|
+
const environment = new Map<string, string>(
|
|
300
|
+
(container.environment ?? []).map((entry : { name : string, value : string }) => [entry.name, entry.value]),
|
|
301
|
+
);
|
|
302
|
+
const port = (name : string, fallback : number) => {
|
|
303
|
+
const value = Number(environment.get(name));
|
|
304
|
+
return Number.isInteger(value) ? value : fallback;
|
|
305
|
+
};
|
|
306
|
+
const appPort = Number(environment.get("PORT"));
|
|
307
|
+
if (!Number.isInteger(appPort)) return undefined;
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
appName,
|
|
311
|
+
status: (service.runningCount ?? 0) > 0 ? "running" : "stopped",
|
|
312
|
+
appPort,
|
|
313
|
+
appHost: this.appHostFor(appName),
|
|
314
|
+
adminPort: port("ANBARIC_ADMIN_PORT", 8791),
|
|
315
|
+
consumerPort: port("ANBARIC_CONSUMER_PORT", 0),
|
|
316
|
+
log: [],
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
258
320
|
private serviceNameFor(appName : string) : string {
|
|
259
321
|
return `anbaric-app-${appName}`;
|
|
260
322
|
}
|
|
@@ -1,9 +1,21 @@
|
|
|
1
|
+
import {IncomingHttpHeaders, OutgoingHttpHeaders, request as httpRequest} from "node:http";
|
|
1
2
|
import {BuildLayer} from "../../app-management/BuildLayer";
|
|
2
3
|
import {Request} from "../Request";
|
|
3
4
|
import {RequestHandler} from "../RequestHandler";
|
|
4
5
|
|
|
5
|
-
/*
|
|
6
|
-
|
|
6
|
+
/* Headers that describe a single hop and must not be forwarded across the
|
|
7
|
+
proxy in either direction. */
|
|
8
|
+
const HOP_BY_HOP = new Set([
|
|
9
|
+
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
|
10
|
+
"te", "trailer", "transfer-encoding", "upgrade",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
/* The router's fallback: any unregistered top-level path naming a running app
|
|
14
|
+
is reverse-proxied to it. The app is exposed at /<appName>; that prefix is
|
|
15
|
+
stripped before forwarding (the app sees the sub-path) and surfaced to the
|
|
16
|
+
app as X-Forwarded-Prefix so it can rebuild public URLs. Request and response
|
|
17
|
+
headers pass through both ways - notably cookies, Set-Cookie and Location - so
|
|
18
|
+
sessions and redirects work from app-served HTML. */
|
|
7
19
|
class AppProxyHandler implements RequestHandler {
|
|
8
20
|
|
|
9
21
|
constructor(private buildLayer : BuildLayer) {}
|
|
@@ -16,17 +28,41 @@ class AppProxyHandler implements RequestHandler {
|
|
|
16
28
|
const appPath = request.url.pathname.slice(`/${appName}`.length) || "/";
|
|
17
29
|
const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.rawBody();
|
|
18
30
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
await new Promise<void>((resolve, reject) => {
|
|
32
|
+
const upstream = httpRequest({
|
|
33
|
+
host: app.appHost,
|
|
34
|
+
port: app.appPort,
|
|
35
|
+
method: request.method,
|
|
36
|
+
path: `${appPath}${request.url.search}`,
|
|
37
|
+
headers: this.forwardHeaders(request, appName),
|
|
38
|
+
}, response => {
|
|
39
|
+
request.rawResponse.writeHead(response.statusCode ?? 502, this.passThrough(response.headers));
|
|
40
|
+
response.pipe(request.rawResponse);
|
|
41
|
+
response.on("end", resolve);
|
|
42
|
+
response.on("error", reject);
|
|
43
|
+
});
|
|
44
|
+
upstream.on("error", reject);
|
|
45
|
+
if (body && body.length > 0) upstream.write(body);
|
|
46
|
+
upstream.end();
|
|
23
47
|
});
|
|
48
|
+
}
|
|
24
49
|
|
|
25
|
-
|
|
26
|
-
request.
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
request.
|
|
50
|
+
private forwardHeaders(request : Request, appName : string) : OutgoingHttpHeaders {
|
|
51
|
+
const headers = this.passThrough(request.raw.headers);
|
|
52
|
+
delete headers.host;
|
|
53
|
+
headers["x-forwarded-prefix"] = `/${appName}`;
|
|
54
|
+
headers["x-forwarded-host"] = request.raw.headers.host;
|
|
55
|
+
headers["x-forwarded-proto"] = "https";
|
|
56
|
+
return headers;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private passThrough(headers : IncomingHttpHeaders) : OutgoingHttpHeaders {
|
|
60
|
+
const kept : OutgoingHttpHeaders = {};
|
|
61
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
62
|
+
if (value === undefined || HOP_BY_HOP.has(name)) continue;
|
|
63
|
+
kept[name] = value;
|
|
64
|
+
}
|
|
65
|
+
return kept;
|
|
30
66
|
}
|
|
31
67
|
|
|
32
68
|
}
|
|
@@ -7,6 +7,7 @@ class AppsHandler implements RequestHandler {
|
|
|
7
7
|
constructor(private buildLayer : BuildLayer) {}
|
|
8
8
|
|
|
9
9
|
async handle(request : Request) : Promise<void> {
|
|
10
|
+
await this.buildLayer.ensureHydrated();
|
|
10
11
|
switch (request.subresource) {
|
|
11
12
|
case "deploy":
|
|
12
13
|
if (request.id) return this.handleDeploy(request, request.id);
|
|
@@ -26,7 +27,7 @@ class AppsHandler implements RequestHandler {
|
|
|
26
27
|
case "POST": {
|
|
27
28
|
const appPort = Number(request.query("port"));
|
|
28
29
|
if (!Number.isInteger(appPort) || appPort <= 0) {
|
|
29
|
-
return request.reply(400, { error: "Expected a numeric port query parameter" });
|
|
30
|
+
return request.reply(400, { error: "Expected a numeric ?port query parameter - the app's internal port (\"internalPort\" in .anbaric/app-config.json)" });
|
|
30
31
|
}
|
|
31
32
|
const tarball = await request.rawBody();
|
|
32
33
|
if (tarball.length === 0) return request.reply(400, { error: "Expected a gzipped tarball body" });
|