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 +7 -7
- package/src/app-management/BaseBuildLayer.ts +30 -8
- package/src/app-management/BuildLayer.ts +11 -2
- package/src/app-management/FargateBuildLayer.ts +81 -0
- package/src/data-store/AppSettingsStore.ts +30 -0
- package/src/data-store/InMemoryAppSettingsStore.ts +27 -0
- package/src/data-store/PlatformMigrations.ts +11 -0
- package/src/data-store/PostgresAppSettingsStore.ts +40 -0
- package/src/hosting/HostingServer.ts +4 -2
- package/src/hosting/handlers/AppsHandler.ts +33 -5
- package/src/hosting/pages/platform-ui.html +1 -1
- package/src/main.ts +17 -3
- package/src/usage/UsageReporter.ts +10 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anbaric-cloud-hosting",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
22
|
-
"anbaric-plugins": "^1.
|
|
23
|
-
"anbaric-tsapi": "^1.
|
|
24
|
-
"anbaric-web": "^1.
|
|
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.
|
|
32
|
-
"anbaric-state-machine": "^1.
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|