anbaric-cloud-hosting 1.68.3 → 1.69.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anbaric-cloud-hosting",
3
- "version": "1.68.3",
3
+ "version": "1.69.0",
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.68.3",
22
- "anbaric-plugins": "^1.68.3",
23
- "anbaric-tsapi": "^1.68.3",
24
- "anbaric-web": "^1.68.3",
21
+ "anbaric-data-store": "^1.69.0",
22
+ "anbaric-plugins": "^1.69.0",
23
+ "anbaric-tsapi": "^1.69.0",
24
+ "anbaric-web": "^1.69.0",
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.68.3",
32
- "anbaric-state-machine": "^1.68.3",
31
+ "anbaric-impl-cloud": "^1.69.0",
32
+ "anbaric-state-machine": "^1.69.0",
33
33
  "tsx": "^4.20.0",
34
34
  "typescript": "^7.0.2"
35
35
  },
@@ -3,7 +3,7 @@ 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 {AppSize, BuildLayer, DeploymentStatus, DeploymentSummary} from "./BuildLayer";
6
+ import {AppSize, BuildLayer, DeploymentStatus, DeploymentSummary, DeployOptions} from "./BuildLayer";
7
7
 
8
8
  type Deployment = {
9
9
  appName : string,
@@ -14,6 +14,8 @@ type Deployment = {
14
14
  adminPort : number,
15
15
  consumerPort : number,
16
16
  log : Array<string>,
17
+ // Whether this deploy keeps the version it replaces up until it answers.
18
+ zeroDowntime : boolean,
17
19
  process? : ChildProcess,
18
20
  replaces? : Deployment,
19
21
  draining? : { inFlight : number, since : number },
@@ -47,7 +49,7 @@ abstract class BaseBuildLayer implements BuildLayer {
47
49
 
48
50
  constructor(protected appsDir : string, private consumerPortBase : number = 8800,
49
51
  private probe : Probe = adminProbe,
50
- private livenessTimeoutMs : number = LIVENESS_TIMEOUT_MS,
52
+ protected livenessTimeoutMs : number = LIVENESS_TIMEOUT_MS,
51
53
  private drainTimeoutMs : number = DRAIN_TIMEOUT_MS,
52
54
  private drainPollIntervalMs : number = DRAIN_POLL_INTERVAL_MS) {}
53
55
 
@@ -63,7 +65,7 @@ abstract class BaseBuildLayer implements BuildLayer {
63
65
 
64
66
  protected async rehydrate() : Promise<void> {}
65
67
 
66
- deploy(appName : string, appPort : number, tarball : Buffer) : DeploymentSummary {
68
+ deploy(appName : string, appPort : number, tarball : Buffer, options : DeployOptions = {}) : DeploymentSummary {
67
69
  const existing = this.deployments.get(appName);
68
70
 
69
71
  const deployment : Deployment = {
@@ -76,6 +78,7 @@ abstract class BaseBuildLayer implements BuildLayer {
76
78
  adminPort: APP_ADMIN_PORT,
77
79
  consumerPort: existing?.consumerPort ?? this.consumerPortBase + this.nextAppIndex++,
78
80
  log: [],
81
+ zeroDowntime: options.zeroDowntime ?? false,
79
82
  replaces: existing,
80
83
  };
81
84
  this.deployments.set(appName, deployment);
@@ -247,24 +250,43 @@ abstract class BaseBuildLayer implements BuildLayer {
247
250
  const manifest = JSON.parse(await readFile(join(appDir, "package.json"), "utf8"));
248
251
  await this.linkWorkspacePackages(appDir, manifest);
249
252
 
250
- // The version being replaced finishes its work first; the build above
251
- // ran while it carried on, so the drain is the only pause it sees.
253
+ /* The version being replaced finishes its work first; the build above
254
+ ran while it carried on, so the drain is the only pause it sees. A
255
+ layer that can roll in place keeps it up past that, still serving,
256
+ until the new version has taken over. */
252
257
  if (deployment.replaces) {
253
258
  if (deployment.replaces.status === "running") await this.drain(deployment, deployment.replaces);
254
259
  if (!this.isCurrent(deployment)) return;
255
- await this.stop(deployment.replaces);
260
+ if (! this.rollsInPlace(deployment)) await this.stop(deployment.replaces);
256
261
  }
257
262
 
258
263
  if (!this.isCurrent(deployment)) return;
259
264
  await this.start(deployment, appDir, manifest.main);
260
- await this.awaitLive(deployment);
265
+ if (this.rollsInPlace(deployment)) {
266
+ await this.awaitRolled(deployment);
267
+ } else {
268
+ await this.awaitLive(deployment);
269
+ }
261
270
  }
262
271
 
263
272
  protected isCurrent(deployment : Deployment) : boolean {
264
273
  return this.deployments.get(deployment.appName) === deployment && deployment.status === "building";
265
274
  }
266
275
 
267
- private async awaitLive(deployment : Deployment) : Promise<void> {
276
+ /* Whether this deploy replaces the old version without stopping it first.
277
+ Only a layer whose backend can run two versions of one app side by side
278
+ and swap between them says yes; the default cannot, and the option on
279
+ the deploy is then simply not honoured. */
280
+ protected rollsInPlace(_deployment : Deployment) : boolean {
281
+ return false;
282
+ }
283
+
284
+ // Waits for a roll-in-place to finish; a layer that rolls must say how.
285
+ protected async awaitRolled(deployment : Deployment) : Promise<void> {
286
+ await this.awaitLive(deployment);
287
+ }
288
+
289
+ protected async awaitLive(deployment : Deployment) : Promise<void> {
268
290
  const deadline = Date.now() + this.livenessTimeoutMs;
269
291
 
270
292
  while (Date.now() < deadline) {
@@ -27,6 +27,15 @@ type DeploymentSummary = {
27
27
  draining? : Draining,
28
28
  };
29
29
 
30
+ /* How a deploy replaces the running version. By default the old version is
31
+ drained and stopped, then the new one started, so there is a gap. With
32
+ zeroDowntime the new version is brought up beside the old once the drain is
33
+ done and the old is let go only when the new answers - on a build layer that
34
+ can do that; one that cannot ignores it. */
35
+ type DeployOptions = {
36
+ zeroDowntime? : boolean,
37
+ };
38
+
30
39
  interface BuildLayer {
31
40
 
32
41
  /* Moves a running app onto a different size of instance. The app is rolled
@@ -35,7 +44,7 @@ interface BuildLayer {
35
44
  resize(appName : string, size : AppSize) : Promise<DeploymentSummary | undefined>;
36
45
 
37
46
  ensureHydrated() : Promise<void>;
38
- deploy(appName : string, appPort : number, tarball : Buffer) : DeploymentSummary;
47
+ deploy(appName : string, appPort : number, tarball : Buffer, options? : DeployOptions) : DeploymentSummary;
39
48
  status(appName : string) : (DeploymentSummary & { log : Array<string> }) | undefined;
40
49
  list() : Array<DeploymentSummary>;
41
50
  ping(appName : string) : Promise<boolean>;
@@ -61,5 +70,5 @@ interface BuildLayer {
61
70
 
62
71
  }
63
72
 
64
- export type { BuildLayer, DeploymentStatus, DeploymentSummary, Draining, AppSize };
73
+ export type { BuildLayer, DeploymentStatus, DeploymentSummary, Draining, AppSize, DeployOptions };
65
74
  export { APP_SIZES, isAppSize };
@@ -35,6 +35,10 @@ type FargateBuildLayerOptions = {
35
35
  sqlDatabaseUrlSecretArn? : string,
36
36
  sqlSchema? : string,
37
37
  docGenerator? : DocGenerator,
38
+ /* Put on every task definition and service this layer makes, and from
39
+ there on every task, so the cost of a tenant's apps can be told apart
40
+ on the bill. Empty means untagged. */
41
+ tags? : Record<string, string>,
38
42
  };
39
43
 
40
44
  type AwsClients = {
@@ -55,6 +59,7 @@ const defaultClients = (region : string) : AwsClients => ({
55
59
 
56
60
  const BUILD_TIMEOUT_MS = 900_000;
57
61
  const FARGATE_LIVENESS_TIMEOUT_MS = 420_000;
62
+ const ROLL_POLL_INTERVAL_MS = 5_000;
58
63
 
59
64
  /* What each size is on Fargate, in the units ECS wants. The memory is what
60
65
  the console describes as "2× the RAM"; the CPU is not advertised, only felt. */
@@ -65,6 +70,22 @@ const TASK_SIZES : Record<AppSize, { cpu : string, memory : string }> = {
65
70
 
66
71
  const sizeOfTask = (memory? : string) : AppSize => memory === TASK_SIZES.large.memory ? "large" : "small";
67
72
 
73
+ /* What makes a roll zero-downtime: ECS only retires the old task once the
74
+ new one is healthy, and without a health check "healthy" means "started",
75
+ before the app inside is listening. This asks the app's admin port the
76
+ same `ping` the platform's own probe sends. Only a task with zero-downtime
77
+ deploys carries it, so nothing already running is touched. The start
78
+ period is Fargate's longest, for apps that build at boot. */
79
+ const adminHealthCheck = (adminPort : number) => ({
80
+ command: ["CMD-SHELL", `node -e "const s=require('net').connect(${adminPort},'127.0.0.1');`
81
+ + `s.setTimeout(3000,()=>process.exit(1));s.on('connect',()=>s.write('ping\\n'));`
82
+ + `s.on('data',d=>process.exit(String(d).startsWith('ok')?0:1));s.on('error',()=>process.exit(1))"`],
83
+ interval: 10,
84
+ timeout: 5,
85
+ retries: 3,
86
+ startPeriod: 300,
87
+ });
88
+
68
89
  class FargateBuildLayer extends BaseBuildLayer {
69
90
 
70
91
  private aws : AwsClients;
@@ -135,6 +156,50 @@ class FargateBuildLayer extends BaseBuildLayer {
135
156
  await this.upsertService(deployment, taskDefinition, registryArn);
136
157
  }
137
158
 
159
+ /* ECS can run the new task beside the old one and swap the service over
160
+ once the new one is up, which is what a zero-downtime deploy buys: the
161
+ old version, already drained, keeps answering until then. */
162
+ protected rollsInPlace(deployment : Deployment) : boolean {
163
+ return deployment.zeroDowntime && deployment.replaces !== undefined;
164
+ }
165
+
166
+ /* The roll is done when ECS says so, not when something answers on the
167
+ app's name: during the roll that name resolves to the old task as
168
+ readily as the new, so a probe would call the deploy live too soon.
169
+ ECS reports one deployment, completed, once the old task is gone. */
170
+ protected async awaitRolled(deployment : Deployment) : Promise<void> {
171
+ const deadline = Date.now() + this.livenessTimeoutMs;
172
+
173
+ while (Date.now() < deadline) {
174
+ if (deployment.status !== "building") return;
175
+ if (await this.rolled(deployment.appName)) {
176
+ deployment.status = "running";
177
+ this.log(deployment, "app is live: the service has rolled over to the new version");
178
+ return;
179
+ }
180
+ await new Promise(resolve => setTimeout(resolve, ROLL_POLL_INTERVAL_MS));
181
+ }
182
+
183
+ /* The old version is still serving - ECS keeps it until the new one
184
+ is healthy - so nothing is torn down; the deploy is reported as
185
+ failed and the app's log says why it never came up. */
186
+ deployment.status = "failed";
187
+ this.log(deployment, `the service did not roll over within ${this.livenessTimeoutMs / 1000}s; the previous version is still serving`);
188
+ for (const line of await this.diagnostics(deployment).catch(() => [])) this.log(deployment, ` ${line}`);
189
+ }
190
+
191
+ private async rolled(appName : string) : Promise<boolean> {
192
+ const {services} = await this.aws.ecs.send(new DescribeServicesCommand({
193
+ cluster: this.options.cluster,
194
+ services: [this.serviceNameFor(appName)],
195
+ }));
196
+ const deployments : Array<{ status? : string, rolloutState? : string, runningCount? : number }> = services?.[0]?.deployments ?? [];
197
+ const primary = deployments.find(each => each.status === "PRIMARY");
198
+
199
+ return deployments.length === 1 && primary !== undefined
200
+ && (primary.rolloutState === "COMPLETED" || (primary.rolloutState === undefined && (primary.runningCount ?? 0) > 0));
201
+ }
202
+
138
203
  protected async stop(deployment : Deployment) : Promise<void> {
139
204
  if (this.deployments.get(deployment.appName) !== deployment) return;
140
205
 
@@ -192,6 +257,7 @@ class FargateBuildLayer extends BaseBuildLayer {
192
257
  const registered = await this.aws.ecs.send(new RegisterTaskDefinitionCommand({
193
258
  family: this.serviceNameFor(deployment.appName),
194
259
  requiresCompatibilities: ["FARGATE"],
260
+ ...this.taggedWith(),
195
261
  networkMode: "awsvpc",
196
262
  ...TASK_SIZES[deployment.size],
197
263
  executionRoleArn: this.options.appExecutionRoleArn,
@@ -203,6 +269,7 @@ class FargateBuildLayer extends BaseBuildLayer {
203
269
  // The most Fargate allows between SIGTERM and SIGKILL, so an
204
270
  // app signalled with steps still in hand gets to finish them.
205
271
  stopTimeout: 120,
272
+ ...(deployment.zeroDowntime ? { healthCheck: adminHealthCheck(deployment.adminPort) } : {}),
206
273
  portMappings: [
207
274
  { containerPort: deployment.appPort, protocol: "tcp" },
208
275
  { containerPort: deployment.adminPort, protocol: "tcp" },
@@ -280,6 +347,7 @@ class FargateBuildLayer extends BaseBuildLayer {
280
347
  taskDefinition,
281
348
  desiredCount: 1,
282
349
  forceNewDeployment: true,
350
+ ...this.propagatingTags(),
283
351
  }));
284
352
  return;
285
353
  }
@@ -291,6 +359,8 @@ class FargateBuildLayer extends BaseBuildLayer {
291
359
  taskDefinition,
292
360
  desiredCount: 1,
293
361
  launchType: "FARGATE",
362
+ ...this.taggedWith(),
363
+ ...this.propagatingTags(),
294
364
  networkConfiguration: {
295
365
  awsvpcConfiguration: {
296
366
  subnets: this.options.subnets,
@@ -358,6 +428,7 @@ class FargateBuildLayer extends BaseBuildLayer {
358
428
  appPort,
359
429
  appHost: this.appHostFor(appName),
360
430
  size: sizeOfTask(taskDefinition.memory),
431
+ zeroDowntime: false,
361
432
  adminPort: port("ANBARIC_ADMIN_PORT", 8791),
362
433
  consumerPort: port("ANBARIC_CONSUMER_PORT", 0),
363
434
  log: [],
@@ -368,6 +439,16 @@ class FargateBuildLayer extends BaseBuildLayer {
368
439
  return `anbaric-app-${appName}`;
369
440
  }
370
441
 
442
+ private taggedWith() : { tags? : Array<{ key : string, value : string }> } {
443
+ const entries = Object.entries(this.options.tags ?? {});
444
+ return entries.length === 0 ? {} : { tags: entries.map(([key, value]) => ({ key, value })) };
445
+ }
446
+
447
+ // The task definition's tags become each task's, which is where the cost is.
448
+ private propagatingTags() : { propagateTags? : "TASK_DEFINITION" } {
449
+ return Object.keys(this.options.tags ?? {}).length === 0 ? {} : { propagateTags: "TASK_DEFINITION" };
450
+ }
451
+
371
452
  private buildKeyFor(appName : string) : string {
372
453
  return `builds/${appName}.tar.gz`;
373
454
  }
@@ -0,0 +1,30 @@
1
+ /* What a builder has chosen for an app beyond its code: the things set in
2
+ the console that a redeploy must not forget. Kept by the platform rather
3
+ than inferred from the running infrastructure, so changing one never
4
+ restarts anything. */
5
+ type AppSettings = {
6
+ appName : string,
7
+ /* A deploy brings the new version up beside the old and only then lets
8
+ the old go, instead of stopping the old first. Priced, and off unless
9
+ the builder turns it on. */
10
+ zeroDowntime : boolean,
11
+ };
12
+
13
+ const DEFAULT_SETTINGS : Omit<AppSettings, "appName"> = { zeroDowntime: false };
14
+
15
+ interface AppSettingsStore {
16
+
17
+ // The defaults for an app nobody has configured.
18
+ get(appName : string) : Promise<AppSettings>;
19
+
20
+ set(appName : string, settings : Partial<Omit<AppSettings, "appName">>) : Promise<AppSettings>;
21
+
22
+ list() : Promise<Array<AppSettings>>;
23
+
24
+ // Forgotten with the app, so a later app of the same name starts afresh.
25
+ remove(appName : string) : Promise<void>;
26
+
27
+ }
28
+
29
+ export type { AppSettings, AppSettingsStore };
30
+ export { DEFAULT_SETTINGS };
@@ -0,0 +1,27 @@
1
+ import {AppSettings, AppSettingsStore, DEFAULT_SETTINGS} from "./AppSettingsStore";
2
+
3
+ class InMemoryAppSettingsStore implements AppSettingsStore {
4
+
5
+ private settings = new Map<string, AppSettings>();
6
+
7
+ async get(appName : string) : Promise<AppSettings> {
8
+ return { ...(this.settings.get(appName) ?? { appName, ...DEFAULT_SETTINGS }) };
9
+ }
10
+
11
+ async set(appName : string, settings : Partial<Omit<AppSettings, "appName">>) : Promise<AppSettings> {
12
+ const updated = { ...(await this.get(appName)), ...settings, appName };
13
+ this.settings.set(appName, updated);
14
+ return { ...updated };
15
+ }
16
+
17
+ async list() : Promise<Array<AppSettings>> {
18
+ return [...this.settings.values()].map(settings => ({ ...settings }));
19
+ }
20
+
21
+ async remove(appName : string) : Promise<void> {
22
+ this.settings.delete(appName);
23
+ }
24
+
25
+ }
26
+
27
+ export { InMemoryAppSettingsStore };
@@ -262,6 +262,17 @@ const PLATFORM_MIGRATIONS : Array<Migration> = [
262
262
  "CREATE INDEX IF NOT EXISTS audit_records_at ON anbaric_system.audit_records (at)",
263
263
  ),
264
264
 
265
+ /* What a builder has set for an app in the console, kept apart from the
266
+ app's running infrastructure so that changing a setting never restarts
267
+ it. An app with no row has the defaults. */
268
+ statements("007-app-settings",
269
+ `CREATE TABLE IF NOT EXISTS app_settings (
270
+ app_name TEXT PRIMARY KEY,
271
+ zero_downtime BOOLEAN NOT NULL DEFAULT false,
272
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
273
+ )`,
274
+ ),
275
+
265
276
  ];
266
277
 
267
278
  export { PLATFORM_MIGRATIONS };
@@ -0,0 +1,40 @@
1
+ import {Pool} from "pg";
2
+ import {AppSettings, AppSettingsStore, DEFAULT_SETTINGS} from "./AppSettingsStore";
3
+
4
+ const rowToSettings = (row : any) : AppSettings => ({
5
+ appName: row.app_name,
6
+ zeroDowntime: row.zero_downtime,
7
+ });
8
+
9
+ class PostgresAppSettingsStore implements AppSettingsStore {
10
+
11
+ constructor(private pool : Pool) {}
12
+
13
+ async get(appName : string) : Promise<AppSettings> {
14
+ const result = await this.pool.query(
15
+ "SELECT app_name, zero_downtime FROM app_settings WHERE app_name = $1", [appName]);
16
+ return result.rows[0] ? rowToSettings(result.rows[0]) : { appName, ...DEFAULT_SETTINGS };
17
+ }
18
+
19
+ async set(appName : string, settings : Partial<Omit<AppSettings, "appName">>) : Promise<AppSettings> {
20
+ const merged = { ...(await this.get(appName)), ...settings };
21
+ const result = await this.pool.query(
22
+ `INSERT INTO app_settings (app_name, zero_downtime) VALUES ($1, $2)
23
+ ON CONFLICT (app_name) DO UPDATE SET zero_downtime = EXCLUDED.zero_downtime, updated_at = now()
24
+ RETURNING app_name, zero_downtime`,
25
+ [appName, merged.zeroDowntime]);
26
+ return rowToSettings(result.rows[0]);
27
+ }
28
+
29
+ async list() : Promise<Array<AppSettings>> {
30
+ const result = await this.pool.query("SELECT app_name, zero_downtime FROM app_settings ORDER BY app_name");
31
+ return result.rows.map(rowToSettings);
32
+ }
33
+
34
+ async remove(appName : string) : Promise<void> {
35
+ await this.pool.query("DELETE FROM app_settings WHERE app_name = $1", [appName]);
36
+ }
37
+
38
+ }
39
+
40
+ export { PostgresAppSettingsStore };
@@ -13,6 +13,7 @@ import {MyAppsHandler} from "./handlers/MyAppsHandler";
13
13
  import {MembersHandler} from "./handlers/auth/MembersHandler";
14
14
  import {BillingHandler} from "./handlers/BillingHandler";
15
15
  import {CentralBilling} from "../billing/CentralBilling";
16
+ import {AppSettingsStore} from "../data-store/AppSettingsStore";
16
17
  import {Subdomains} from "../subdomains/Subdomains";
17
18
  import {AuditsHandler} from "./handlers/AuditsHandler";
18
19
  import {AuthorizeCliHandler} from "./handlers/auth/AuthorizeCliHandler";
@@ -81,7 +82,8 @@ class HostingServer {
81
82
  promptManagerFor? : (appId : string) => PromptManager,
82
83
  fileStorageFor? : (appId : string) => FileStorage,
83
84
  subdomains? : Subdomains,
84
- billing? : CentralBilling) {
85
+ billing? : CentralBilling,
86
+ appSettings? : AppSettingsStore) {
85
87
  const pages = new PagesHandler();
86
88
  const ping = new PingHandler(tenant);
87
89
  const jobs = new JobsHandler(persistence);
@@ -130,7 +132,7 @@ class HostingServer {
130
132
  buildLayer.consumerUrlsFor = appName =>
131
133
  [...new Set(registry.list().filter(consumer => consumer.appId === appName).map(consumer => consumer.url))];
132
134
  buildLayer.appRemoved = appName => void subdomains?.release(appName);
133
- publicRouter.registerApi("apps", new AppsHandler(buildLayer, tenant, subdomains));
135
+ publicRouter.registerApi("apps", new AppsHandler(buildLayer, tenant, subdomains, appSettings));
134
136
  publicRouter.register("app", appProxy);
135
137
 
136
138
  // The directory a person who is not a builder sees instead of the
@@ -6,11 +6,12 @@ import {appAddress} from "../../app-management/appAddress";
6
6
  import {redactEmails} from "../../app-management/redactEmails";
7
7
  import {RequestHandler} from "../RequestHandler";
8
8
  import {Subdomains} from "../../subdomains/Subdomains";
9
+ import {AppSettingsStore} from "../../data-store/AppSettingsStore";
9
10
 
10
11
  class AppsHandler implements RequestHandler {
11
12
 
12
13
  constructor(private buildLayer : BuildLayer, private tenant? : string,
13
- private subdomains? : Subdomains) {}
14
+ private subdomains? : Subdomains, private settings? : AppSettingsStore) {}
14
15
 
15
16
  async handle(request : Request) : Promise<void> {
16
17
  // Changing what is deployed is a builder's job; a USER may look.
@@ -35,6 +36,9 @@ class AppsHandler implements RequestHandler {
35
36
  case "size":
36
37
  if (request.id) return this.handleSize(request, request.id);
37
38
  break;
39
+ case "zero-downtime":
40
+ if (request.id) return this.handleZeroDowntime(request, request.id);
41
+ break;
38
42
  case undefined:
39
43
  if (request.id) return this.handleApp(request, request.id);
40
44
  return this.handleCollection(request);
@@ -44,10 +48,30 @@ class AppsHandler implements RequestHandler {
44
48
 
45
49
  private changesApps(request : Request) : boolean {
46
50
  if (request.subresource === "deploy" || request.subresource === "docs") return true;
47
- if (request.subresource === "subdomain" || request.subresource === "size") return request.method === "PUT";
51
+ if (["subdomain", "size", "zero-downtime"].includes(request.subresource ?? "")) return request.method === "PUT";
48
52
  return request.subresource === undefined && request.method === "DELETE";
49
53
  }
50
54
 
55
+ /* Whether the app's deploys keep the old version up until the new one
56
+ answers. A builder's choice, priced per app per month as the console
57
+ says; it takes effect from the next deploy and restarts nothing now. */
58
+ private async handleZeroDowntime(request : Request, appName : string) : Promise<void> {
59
+ if (request.method !== "PUT") return request.notFound();
60
+ if (! this.settings) return request.reply(409, { error: "This platform does not offer zero-downtime deploys" });
61
+ if (! this.buildLayer.status(appName)) return request.reply(404, { error: `No app named "${appName}"` });
62
+
63
+ const body = await request.body() as { on? : unknown } | undefined;
64
+ if (typeof body?.on !== "boolean") return request.reply(400, { error: "Expected a body of { on : boolean }" });
65
+
66
+ const settings = await this.settings.set(appName, { zeroDowntime: body.on });
67
+ return request.reply(200, { appName, zeroDowntime: settings.zeroDowntime });
68
+ }
69
+
70
+ private async zeroDowntimeOf(appName : string) : Promise<boolean | undefined> {
71
+ if (! this.settings) return undefined;
72
+ return (await this.settings.get(appName)).zeroDowntime;
73
+ }
74
+
51
75
  /* How big the app's instance is, which is a builder's to change and costs
52
76
  what the console told them it would. The app is rolled onto the new
53
77
  size; what it is doing is drained first, as any deploy does. */
@@ -110,7 +134,7 @@ class AppsHandler implements RequestHandler {
110
134
  const tarball = await request.rawBody();
111
135
  if (tarball.length === 0) return request.reply(400, { error: "Expected a gzipped tarball body" });
112
136
 
113
- const summary = this.buildLayer.deploy(appName, appPort, tarball);
137
+ const summary = this.buildLayer.deploy(appName, appPort, tarball, { zeroDowntime: await this.zeroDowntimeOf(appName) });
114
138
 
115
139
  /* An address is asked for alongside the build rather than
116
140
  after it: allocation is idempotent, so a redeploy costs
@@ -134,11 +158,13 @@ class AppsHandler implements RequestHandler {
134
158
  if (!status) return request.reply(404, { error: `No app named "${appName}"` });
135
159
  const live = await this.buildLayer.ping(appName);
136
160
  const subdomain = await this.addressOf(appName);
137
- return request.reply(200, { ...status, live, subdomain, url: appAddress(appName, subdomain) });
161
+ const zeroDowntime = await this.zeroDowntimeOf(appName);
162
+ return request.reply(200, { ...status, live, subdomain, url: appAddress(appName, subdomain), zeroDowntime });
138
163
  }
139
164
  case "DELETE": {
140
165
  const outcome = await this.buildLayer.teardown(appName);
141
166
  if (!outcome) return request.reply(404, { error: `No app named "${appName}"` });
167
+ await this.settings?.remove(appName);
142
168
 
143
169
  // The address is let go by the build layer's appRemoved hook,
144
170
  // which an app that drains reaches minutes after this returns.
@@ -192,9 +218,11 @@ class AppsHandler implements RequestHandler {
192
218
  switch (request.method) {
193
219
  case "GET": {
194
220
  const addresses = new Map((await this.addresses()).map(held => [held.appName, held.subdomain]));
221
+ const settings = new Map((await this.settings?.list() ?? []).map(each => [each.appName, each]));
195
222
  return request.reply(200, this.buildLayer.list().map(app => {
196
223
  const subdomain = addresses.get(app.appName);
197
- return { ...app, subdomain, url: appAddress(app.appName, subdomain) };
224
+ const zeroDowntime = this.settings ? settings.get(app.appName)?.zeroDowntime ?? false : undefined;
225
+ return { ...app, subdomain, url: appAddress(app.appName, subdomain), zeroDowntime };
198
226
  }));
199
227
  }
200
228
  }