anbaric-cloud-hosting 1.65.3 → 1.66.1
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 +7 -7
- package/src/app-management/BaseBuildLayer.ts +24 -1
- package/src/app-management/BuildLayer.ts +18 -1
- package/src/app-management/DockerBuildLayer.ts +4 -0
- package/src/app-management/FargateBuildLayer.ts +22 -2
- package/src/app-management/redactEmails.ts +11 -0
- package/src/billing/CentralBilling.ts +9 -2
- package/src/hosting/handlers/AppsHandler.ts +21 -3
- package/src/hosting/handlers/BillingHandler.ts +20 -0
- package/src/hosting/pages/platform-ui.html +7 -7
- package/src/main.ts +4 -1
- package/src/usage/UsageReporter.ts +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anbaric-cloud-hosting",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.66.1",
|
|
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,18 +18,18 @@
|
|
|
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.
|
|
22
|
-
"anbaric-plugins": "^1.
|
|
23
|
-
"anbaric-tsapi": "^1.
|
|
24
|
-
"anbaric-web": "^1.
|
|
21
|
+
"anbaric-data-store": "^1.66.1",
|
|
22
|
+
"anbaric-plugins": "^1.66.1",
|
|
23
|
+
"anbaric-tsapi": "^1.66.1",
|
|
24
|
+
"anbaric-web": "^1.66.1",
|
|
25
25
|
"esbuild": "^0.28.2",
|
|
26
26
|
"pg": "^8.16.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^26.2.0",
|
|
30
30
|
"@types/pg": "^8.15.0",
|
|
31
|
-
"anbaric-impl-cloud": "^1.
|
|
32
|
-
"anbaric-state-machine": "^1.
|
|
31
|
+
"anbaric-impl-cloud": "^1.66.1",
|
|
32
|
+
"anbaric-state-machine": "^1.66.1",
|
|
33
33
|
"tsx": "^4.20.0",
|
|
34
34
|
"typescript": "^7.0.2"
|
|
35
35
|
},
|
|
@@ -3,13 +3,14 @@ import {mkdir, readFile, rm, symlink, writeFile} from "node:fs/promises";
|
|
|
3
3
|
import {join} from "node:path";
|
|
4
4
|
import {adminPing} from "../app-admin/adminPing";
|
|
5
5
|
import {DocGenerator} from "../docs/DocGenerator";
|
|
6
|
-
import {BuildLayer, DeploymentStatus, DeploymentSummary} from "./BuildLayer";
|
|
6
|
+
import {AppSize, BuildLayer, DeploymentStatus, DeploymentSummary} from "./BuildLayer";
|
|
7
7
|
|
|
8
8
|
type Deployment = {
|
|
9
9
|
appName : string,
|
|
10
10
|
status : DeploymentStatus,
|
|
11
11
|
appPort : number,
|
|
12
12
|
appHost : string,
|
|
13
|
+
size : AppSize,
|
|
13
14
|
adminPort : number,
|
|
14
15
|
consumerPort : number,
|
|
15
16
|
log : Array<string>,
|
|
@@ -70,6 +71,8 @@ abstract class BaseBuildLayer implements BuildLayer {
|
|
|
70
71
|
status: "building",
|
|
71
72
|
appPort,
|
|
72
73
|
appHost: this.appHostFor(appName),
|
|
74
|
+
// A redeploy keeps the size the app was given; only the console changes it.
|
|
75
|
+
size: existing?.size ?? "small",
|
|
73
76
|
adminPort: APP_ADMIN_PORT,
|
|
74
77
|
consumerPort: existing?.consumerPort ?? this.consumerPortBase + this.nextAppIndex++,
|
|
75
78
|
log: [],
|
|
@@ -106,6 +109,23 @@ abstract class BaseBuildLayer implements BuildLayer {
|
|
|
106
109
|
yield* this.streamLogs(deployment, signal);
|
|
107
110
|
}
|
|
108
111
|
|
|
112
|
+
async resize(appName : string, size : AppSize) : Promise<DeploymentSummary | undefined> {
|
|
113
|
+
const deployment = this.deployments.get(appName);
|
|
114
|
+
if (!deployment) return undefined;
|
|
115
|
+
if (deployment.size === size) return this.summarize(deployment);
|
|
116
|
+
|
|
117
|
+
const before = deployment.size;
|
|
118
|
+
deployment.size = size;
|
|
119
|
+
this.log(deployment, `resizing to ${size}`);
|
|
120
|
+
try {
|
|
121
|
+
await this.applySize(deployment);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
deployment.size = before;
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
return this.summarize(deployment);
|
|
127
|
+
}
|
|
128
|
+
|
|
109
129
|
async teardown(appName : string) : Promise<DeploymentSummary | undefined> {
|
|
110
130
|
const deployment = this.deployments.get(appName);
|
|
111
131
|
if (!deployment) return undefined;
|
|
@@ -200,6 +220,8 @@ abstract class BaseBuildLayer implements BuildLayer {
|
|
|
200
220
|
protected abstract appHostFor(appName : string) : string;
|
|
201
221
|
protected abstract start(deployment : Deployment, appDir : string, entryPoint : string) : Promise<void>;
|
|
202
222
|
protected abstract stop(deployment : Deployment) : Promise<void>;
|
|
223
|
+
// Puts the running app onto the instance its (already changed) size names.
|
|
224
|
+
protected abstract applySize(deployment : Deployment) : Promise<void>;
|
|
203
225
|
protected abstract streamLogs(deployment : Deployment, signal : AbortSignal) : AsyncIterable<string>;
|
|
204
226
|
// Materialises the app's source into a directory for regeneration; the
|
|
205
227
|
// returned cleanup removes anything temporary the implementation created.
|
|
@@ -319,6 +341,7 @@ abstract class BaseBuildLayer implements BuildLayer {
|
|
|
319
341
|
status: deployment.status,
|
|
320
342
|
appPort: deployment.appPort,
|
|
321
343
|
appHost: deployment.appHost,
|
|
344
|
+
size: deployment.size,
|
|
322
345
|
...(deployment.draining
|
|
323
346
|
? { draining: { inFlight: deployment.draining.inFlight, since: new Date(deployment.draining.since).toISOString() } }
|
|
324
347
|
: {}),
|
|
@@ -8,16 +8,32 @@ type Draining = {
|
|
|
8
8
|
since : string,
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
+
/* How big an app's instance is. Small is what every app starts as; large is
|
|
12
|
+
faster compute and four times the memory, and costs more. The sizes are
|
|
13
|
+
named rather than numbered so the platform can change what they mean. */
|
|
14
|
+
type AppSize = "small" | "large";
|
|
15
|
+
|
|
16
|
+
const APP_SIZES : Array<AppSize> = ["small", "large"];
|
|
17
|
+
|
|
18
|
+
const isAppSize = (value : unknown) : value is AppSize =>
|
|
19
|
+
typeof value === "string" && APP_SIZES.includes(value as AppSize);
|
|
20
|
+
|
|
11
21
|
type DeploymentSummary = {
|
|
12
22
|
appName : string,
|
|
13
23
|
status : DeploymentStatus,
|
|
14
24
|
appPort : number,
|
|
15
25
|
appHost : string,
|
|
26
|
+
size : AppSize,
|
|
16
27
|
draining? : Draining,
|
|
17
28
|
};
|
|
18
29
|
|
|
19
30
|
interface BuildLayer {
|
|
20
31
|
|
|
32
|
+
/* Moves a running app onto a different size of instance. The app is rolled
|
|
33
|
+
as a deploy would roll it, so nothing in hand is lost. Returns undefined
|
|
34
|
+
for an unknown app. */
|
|
35
|
+
resize(appName : string, size : AppSize) : Promise<DeploymentSummary | undefined>;
|
|
36
|
+
|
|
21
37
|
ensureHydrated() : Promise<void>;
|
|
22
38
|
deploy(appName : string, appPort : number, tarball : Buffer) : DeploymentSummary;
|
|
23
39
|
status(appName : string) : (DeploymentSummary & { log : Array<string> }) | undefined;
|
|
@@ -45,4 +61,5 @@ interface BuildLayer {
|
|
|
45
61
|
|
|
46
62
|
}
|
|
47
63
|
|
|
48
|
-
export type { BuildLayer, DeploymentStatus, DeploymentSummary, Draining };
|
|
64
|
+
export type { BuildLayer, DeploymentStatus, DeploymentSummary, Draining, AppSize };
|
|
65
|
+
export { APP_SIZES, isAppSize };
|
|
@@ -123,6 +123,10 @@ class DockerBuildLayer extends BaseBuildLayer {
|
|
|
123
123
|
await this.removeContainer(deployment, this.appHostFor(deployment.appName));
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// A local container has whatever the machine has; the size is remembered
|
|
127
|
+
// so a later deploy to the cloud starts at it, and otherwise means nothing.
|
|
128
|
+
protected async applySize(_deployment : Deployment) : Promise<void> {}
|
|
129
|
+
|
|
126
130
|
// Locally the extracted source from the last deploy is still on disk, so
|
|
127
131
|
// regeneration reads it in place with nothing to clean up.
|
|
128
132
|
protected async sourceDir(appName : string) : Promise<{ dir : string, cleanup : () => Promise<void> }> {
|
|
@@ -11,6 +11,7 @@ import {ServiceDiscoveryClient, CreateServiceCommand as CreateDiscoveryServiceCo
|
|
|
11
11
|
ListServicesCommand} from "@aws-sdk/client-servicediscovery";
|
|
12
12
|
import {CloudWatchLogsClient, FilterLogEventsCommand, StartLiveTailCommand} from "@aws-sdk/client-cloudwatch-logs";
|
|
13
13
|
import {BaseBuildLayer, Deployment, Probe} from "./BaseBuildLayer";
|
|
14
|
+
import {AppSize} from "./BuildLayer";
|
|
14
15
|
import {DocGenerator} from "../docs/DocGenerator";
|
|
15
16
|
import {dockerfileFor} from "./DockerBuildLayer";
|
|
16
17
|
|
|
@@ -55,6 +56,15 @@ const defaultClients = (region : string) : AwsClients => ({
|
|
|
55
56
|
const BUILD_TIMEOUT_MS = 900_000;
|
|
56
57
|
const FARGATE_LIVENESS_TIMEOUT_MS = 420_000;
|
|
57
58
|
|
|
59
|
+
/* What each size is on Fargate, in the units ECS wants. The memory is what
|
|
60
|
+
the console describes as "4× the RAM"; the CPU is not advertised, only felt. */
|
|
61
|
+
const TASK_SIZES : Record<AppSize, { cpu : string, memory : string }> = {
|
|
62
|
+
small: { cpu: "256", memory: "512" },
|
|
63
|
+
large: { cpu: "512", memory: "2048" },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const sizeOfTask = (memory? : string) : AppSize => memory === TASK_SIZES.large.memory ? "large" : "small";
|
|
67
|
+
|
|
58
68
|
class FargateBuildLayer extends BaseBuildLayer {
|
|
59
69
|
|
|
60
70
|
private aws : AwsClients;
|
|
@@ -115,6 +125,16 @@ class FargateBuildLayer extends BaseBuildLayer {
|
|
|
115
125
|
await this.upsertService(deployment, taskDefinition, registryArn);
|
|
116
126
|
}
|
|
117
127
|
|
|
128
|
+
/* The image is the one already baked for this app; only the task's shape
|
|
129
|
+
changes. Registering a definition at the new size and rolling the
|
|
130
|
+
service onto it is exactly the tail of a deploy. */
|
|
131
|
+
protected async applySize(deployment : Deployment) : Promise<void> {
|
|
132
|
+
const imageUri = `${this.options.appsRepositoryUrl}:${deployment.appName}`;
|
|
133
|
+
const taskDefinition = await this.registerTaskDefinition(deployment, imageUri);
|
|
134
|
+
const registryArn = await this.discoveryServiceFor(deployment.appName);
|
|
135
|
+
await this.upsertService(deployment, taskDefinition, registryArn);
|
|
136
|
+
}
|
|
137
|
+
|
|
118
138
|
protected async stop(deployment : Deployment) : Promise<void> {
|
|
119
139
|
if (this.deployments.get(deployment.appName) !== deployment) return;
|
|
120
140
|
|
|
@@ -173,8 +193,7 @@ class FargateBuildLayer extends BaseBuildLayer {
|
|
|
173
193
|
family: this.serviceNameFor(deployment.appName),
|
|
174
194
|
requiresCompatibilities: ["FARGATE"],
|
|
175
195
|
networkMode: "awsvpc",
|
|
176
|
-
|
|
177
|
-
memory: "512",
|
|
196
|
+
...TASK_SIZES[deployment.size],
|
|
178
197
|
executionRoleArn: this.options.appExecutionRoleArn,
|
|
179
198
|
runtimePlatform: { operatingSystemFamily: "LINUX", cpuArchitecture: "X86_64" },
|
|
180
199
|
containerDefinitions: [{
|
|
@@ -338,6 +357,7 @@ class FargateBuildLayer extends BaseBuildLayer {
|
|
|
338
357
|
status: (service.runningCount ?? 0) > 0 ? "running" : "stopped",
|
|
339
358
|
appPort,
|
|
340
359
|
appHost: this.appHostFor(appName),
|
|
360
|
+
size: sizeOfTask(taskDefinition.memory),
|
|
341
361
|
adminPort: port("ANBARIC_ADMIN_PORT", 8791),
|
|
342
362
|
consumerPort: port("ANBARIC_CONSUMER_PORT", 0),
|
|
343
363
|
log: [],
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/g;
|
|
2
|
+
|
|
3
|
+
/* An app's logs are read by its builders, who are not owed the addresses of
|
|
4
|
+
the people using it. Each address keeps the first two letters of its local
|
|
5
|
+
part, enough to tell one person's line from another's, and loses the rest
|
|
6
|
+
to a fixed shape: the same number of asterisks whatever the address, so the
|
|
7
|
+
redaction gives away neither its length nor its domain. */
|
|
8
|
+
const redactEmails = (line : string) : string =>
|
|
9
|
+
line.replace(EMAIL, address => `${address.split("@")[0].slice(0, 2).toLowerCase()}****@****.**`);
|
|
10
|
+
|
|
11
|
+
export { redactEmails };
|
|
@@ -19,8 +19,15 @@ class CentralBilling {
|
|
|
19
19
|
return String((await response.json() as { url : string }).url);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
/* Ends the tenant, on the say-so of the person named, whom central checks
|
|
23
|
+
is its owner. Nothing here is undone afterwards; this platform is among
|
|
24
|
+
the things going. */
|
|
25
|
+
async terminate(userId : string) : Promise<void> {
|
|
26
|
+
await this.call("DELETE", `/tenants/${encodeURIComponent(this.tenantSlug)}`, undefined, { "x-anbaric-user": userId });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private async call(method : string, path : string, body? : unknown, extra : Record<string, string> = {}) : Promise<Response> {
|
|
30
|
+
const headers : Record<string, string> = { "x-anbaric-central-key": this.secret, ...extra };
|
|
24
31
|
if (body !== undefined) headers["content-type"] = "application/json";
|
|
25
32
|
|
|
26
33
|
const response = await this.fetchFn(`${this.centralUrl.replace(/\/$/, "")}${path}`,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {BuildLayer} from "../../app-management/BuildLayer";
|
|
1
|
+
import {BuildLayer, isAppSize} from "../../app-management/BuildLayer";
|
|
2
2
|
import {deniesBuild} from "../../auth/TenantRole";
|
|
3
3
|
import {Request} from "../Request";
|
|
4
4
|
import {hostnameObjection} from "../../app-management/appHostname";
|
|
5
5
|
import {appAddress} from "../../app-management/appAddress";
|
|
6
|
+
import {redactEmails} from "../../app-management/redactEmails";
|
|
6
7
|
import {RequestHandler} from "../RequestHandler";
|
|
7
8
|
import {Subdomains} from "../../subdomains/Subdomains";
|
|
8
9
|
|
|
@@ -31,6 +32,9 @@ class AppsHandler implements RequestHandler {
|
|
|
31
32
|
case "subdomain":
|
|
32
33
|
if (request.id) return this.handleSubdomain(request, request.id);
|
|
33
34
|
break;
|
|
35
|
+
case "size":
|
|
36
|
+
if (request.id) return this.handleSize(request, request.id);
|
|
37
|
+
break;
|
|
34
38
|
case undefined:
|
|
35
39
|
if (request.id) return this.handleApp(request, request.id);
|
|
36
40
|
return this.handleCollection(request);
|
|
@@ -40,10 +44,24 @@ class AppsHandler implements RequestHandler {
|
|
|
40
44
|
|
|
41
45
|
private changesApps(request : Request) : boolean {
|
|
42
46
|
if (request.subresource === "deploy" || request.subresource === "docs") return true;
|
|
43
|
-
if (request.subresource === "subdomain") return request.method === "PUT";
|
|
47
|
+
if (request.subresource === "subdomain" || request.subresource === "size") return request.method === "PUT";
|
|
44
48
|
return request.subresource === undefined && request.method === "DELETE";
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
/* How big the app's instance is, which is a builder's to change and costs
|
|
52
|
+
what the console told them it would. The app is rolled onto the new
|
|
53
|
+
size; what it is doing is drained first, as any deploy does. */
|
|
54
|
+
private async handleSize(request : Request, appName : string) : Promise<void> {
|
|
55
|
+
if (request.method !== "PUT") return request.notFound();
|
|
56
|
+
|
|
57
|
+
const body = await request.body() as { size? : string } | undefined;
|
|
58
|
+
if (! isAppSize(body?.size)) return request.reply(400, { error: 'Expected a body of { size : "small" | "large" }' });
|
|
59
|
+
|
|
60
|
+
const summary = await this.buildLayer.resize(appName, body.size);
|
|
61
|
+
if (! summary) return request.reply(404, { error: `No app named "${appName}"` });
|
|
62
|
+
return request.reply(200, summary);
|
|
63
|
+
}
|
|
64
|
+
|
|
47
65
|
/* Where an app answers, which is a builder's to change. The address itself
|
|
48
66
|
is the control plane's to agree, so a name already taken comes back from
|
|
49
67
|
there as a refusal and is passed on as one. */
|
|
@@ -155,7 +173,7 @@ class AppsHandler implements RequestHandler {
|
|
|
155
173
|
try {
|
|
156
174
|
for await (const line of this.buildLayer.logs(appName, controller.signal)) {
|
|
157
175
|
clearTimeout(heartbeat);
|
|
158
|
-
if (!response.write(`${line}\n`)) {
|
|
176
|
+
if (!response.write(`${redactEmails(line)}\n`)) {
|
|
159
177
|
await new Promise<void>(resolve => response.once("drain", resolve));
|
|
160
178
|
}
|
|
161
179
|
heartbeat = beat();
|
|
@@ -12,9 +12,29 @@ class BillingHandler implements RequestHandler {
|
|
|
12
12
|
async handle(request : Request) : Promise<void> {
|
|
13
13
|
if (request.method === "GET" && request.id === "summary") return this.summary(request);
|
|
14
14
|
if (request.method === "POST" && request.id === "portal") return this.portal(request);
|
|
15
|
+
if (request.method === "DELETE" && request.id === "account") return this.terminate(request);
|
|
15
16
|
request.notFound();
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
/* The end of the tenant, which only its owner may ask for, and only by
|
|
20
|
+
typing the word: the page asks for it, and this refuses without it, so
|
|
21
|
+
nothing in a script or a stray click can do it by accident. */
|
|
22
|
+
private async terminate(request : Request) : Promise<void> {
|
|
23
|
+
if (request.user?.tenantRole !== "OWNER") {
|
|
24
|
+
return request.reply(403, { error: "Only the owner of this tenant can terminate it" });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const body = await request.body() as { confirm? : string } | undefined;
|
|
28
|
+
if (body?.confirm !== "DELETE") return request.reply(400, { error: 'Terminating a tenant needs { confirm : "DELETE" }' });
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await this.billing.terminate(request.user.id);
|
|
32
|
+
request.reply(202, { terminated: true });
|
|
33
|
+
} catch (error) {
|
|
34
|
+
request.reply(409, { error: error instanceof Error ? error.message : "Could not terminate this tenant" });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
18
38
|
private async summary(request : Request) : Promise<void> {
|
|
19
39
|
if (deniesBuild(request.user?.tenantRole)) {
|
|
20
40
|
return request.reply(403, { error: "Your role in this tenant cannot see its costs" });
|