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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anbaric-cloud-hosting",
3
- "version": "1.65.3",
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.65.3",
22
- "anbaric-plugins": "^1.65.3",
23
- "anbaric-tsapi": "^1.65.3",
24
- "anbaric-web": "^1.65.3",
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.65.3",
32
- "anbaric-state-machine": "^1.65.3",
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
- cpu: "256",
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
- private async call(method : string, path : string, body? : unknown) : Promise<Response> {
23
- const headers : Record<string, string> = { "x-anbaric-central-key": this.secret };
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" });