anbaric-cloud-hosting 1.68.3 → 1.70.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/InMemorySkillStore.ts +31 -0
- package/src/data-store/PlatformMigrations.ts +25 -0
- package/src/data-store/PostgresAppSettingsStore.ts +40 -0
- package/src/data-store/PostgresSkillStore.ts +47 -0
- package/src/data-store/SkillStore.ts +38 -0
- package/src/hosting/HostingServer.ts +9 -2
- package/src/hosting/handlers/AppsHandler.ts +33 -5
- package/src/hosting/handlers/SkillsHandler.ts +81 -0
- package/src/hosting/pages/platform-ui.html +1 -1
- package/src/main.ts +18 -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.70.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.70.0",
|
|
22
|
+
"anbaric-plugins": "^1.70.0",
|
|
23
|
+
"anbaric-tsapi": "^1.70.0",
|
|
24
|
+
"anbaric-web": "^1.70.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.70.0",
|
|
32
|
+
"anbaric-state-machine": "^1.70.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 };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {Skill, SkillDraft, SkillStore} from "./SkillStore";
|
|
2
|
+
|
|
3
|
+
class InMemorySkillStore implements SkillStore {
|
|
4
|
+
|
|
5
|
+
private skills = new Map<string, Skill>();
|
|
6
|
+
|
|
7
|
+
async list() : Promise<Array<Skill>> {
|
|
8
|
+
return [...this.skills.values()]
|
|
9
|
+
.sort((left, right) => left.id.localeCompare(right.id))
|
|
10
|
+
.map(skill => ({ ...skill }));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async get(id : string) : Promise<Skill | undefined> {
|
|
14
|
+
const skill = this.skills.get(id);
|
|
15
|
+
return skill && { ...skill };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async save(draft : SkillDraft) : Promise<Skill> {
|
|
19
|
+
const now = new Date();
|
|
20
|
+
const saved : Skill = { ...draft, createdAt: this.skills.get(draft.id)?.createdAt ?? now, updatedAt: now };
|
|
21
|
+
this.skills.set(draft.id, saved);
|
|
22
|
+
return { ...saved };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async remove(id : string) : Promise<boolean> {
|
|
26
|
+
return this.skills.delete(id);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export { InMemorySkillStore };
|
|
@@ -262,6 +262,31 @@ 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
|
+
|
|
276
|
+
/* Guidance the tenant's builders write for the agents that work on it:
|
|
277
|
+
usage hints say when to reach for a skill, the body says what to do.
|
|
278
|
+
A platform table rather than workflow data, since apps never see it. */
|
|
279
|
+
statements("008-skills",
|
|
280
|
+
`CREATE TABLE IF NOT EXISTS anbaric_system.skills (
|
|
281
|
+
id TEXT PRIMARY KEY,
|
|
282
|
+
title TEXT NOT NULL,
|
|
283
|
+
usage_hints TEXT NOT NULL,
|
|
284
|
+
body TEXT NOT NULL,
|
|
285
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
286
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
287
|
+
)`,
|
|
288
|
+
),
|
|
289
|
+
|
|
265
290
|
];
|
|
266
291
|
|
|
267
292
|
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 };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {Pool} from "pg";
|
|
2
|
+
import {Skill, SkillDraft, SkillStore} from "./SkillStore";
|
|
3
|
+
|
|
4
|
+
const COLUMNS = "id, title, usage_hints, body, created_at, updated_at";
|
|
5
|
+
|
|
6
|
+
const rowToSkill = (row : any) : Skill => ({
|
|
7
|
+
id: row.id,
|
|
8
|
+
title: row.title,
|
|
9
|
+
usageHints: row.usage_hints,
|
|
10
|
+
body: row.body,
|
|
11
|
+
createdAt: row.created_at,
|
|
12
|
+
updatedAt: row.updated_at,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
class PostgresSkillStore implements SkillStore {
|
|
16
|
+
|
|
17
|
+
constructor(private pool : Pool) {}
|
|
18
|
+
|
|
19
|
+
async list() : Promise<Array<Skill>> {
|
|
20
|
+
const result = await this.pool.query(`SELECT ${COLUMNS} FROM anbaric_system.skills ORDER BY id`);
|
|
21
|
+
return result.rows.map(rowToSkill);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async get(id : string) : Promise<Skill | undefined> {
|
|
25
|
+
const result = await this.pool.query(`SELECT ${COLUMNS} FROM anbaric_system.skills WHERE id = $1`, [id]);
|
|
26
|
+
return result.rows[0] && rowToSkill(result.rows[0]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async save(draft : SkillDraft) : Promise<Skill> {
|
|
30
|
+
const result = await this.pool.query(
|
|
31
|
+
`INSERT INTO anbaric_system.skills (id, title, usage_hints, body)
|
|
32
|
+
VALUES ($1, $2, $3, $4)
|
|
33
|
+
ON CONFLICT (id) DO UPDATE
|
|
34
|
+
SET title = EXCLUDED.title, usage_hints = EXCLUDED.usage_hints, body = EXCLUDED.body, updated_at = now()
|
|
35
|
+
RETURNING ${COLUMNS}`,
|
|
36
|
+
[draft.id, draft.title, draft.usageHints, draft.body]);
|
|
37
|
+
return rowToSkill(result.rows[0]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async remove(id : string) : Promise<boolean> {
|
|
41
|
+
const result = await this.pool.query("DELETE FROM anbaric_system.skills WHERE id = $1", [id]);
|
|
42
|
+
return (result.rowCount ?? 0) > 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export { PostgresSkillStore };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/* A skill is guidance a tenant writes for the agents that build on it: when to
|
|
2
|
+
reach for it (the usage hints, which an agent reads before deciding) and
|
|
3
|
+
what to do (the body, which it reads once it has decided). Kept by the
|
|
4
|
+
platform, managed from the console, and never handed to deployed apps. */
|
|
5
|
+
type Skill = {
|
|
6
|
+
id : string,
|
|
7
|
+
title : string,
|
|
8
|
+
usageHints : string,
|
|
9
|
+
body : string,
|
|
10
|
+
createdAt : Date,
|
|
11
|
+
updatedAt : Date,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type SkillDraft = Omit<Skill, "createdAt" | "updatedAt">;
|
|
15
|
+
|
|
16
|
+
/* The id ends up as the tail of a tool name an agent sees, after a fixed
|
|
17
|
+
prefix, and tool names are capped at 64 characters: hence the shape and
|
|
18
|
+
the length. */
|
|
19
|
+
const SKILL_ID_SHAPE = /^[a-z0-9][a-z0-9-]{0,49}$/;
|
|
20
|
+
|
|
21
|
+
const isSkillId = (value : unknown) : value is string =>
|
|
22
|
+
typeof value === "string" && SKILL_ID_SHAPE.test(value);
|
|
23
|
+
|
|
24
|
+
interface SkillStore {
|
|
25
|
+
|
|
26
|
+
list() : Promise<Array<Skill>>;
|
|
27
|
+
|
|
28
|
+
get(id : string) : Promise<Skill | undefined>;
|
|
29
|
+
|
|
30
|
+
// Creates or replaces the skill with that id.
|
|
31
|
+
save(draft : SkillDraft) : Promise<Skill>;
|
|
32
|
+
|
|
33
|
+
remove(id : string) : Promise<boolean>;
|
|
34
|
+
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type { Skill, SkillDraft, SkillStore };
|
|
38
|
+
export { SKILL_ID_SHAPE, isSkillId };
|
|
@@ -13,6 +13,9 @@ 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";
|
|
17
|
+
import {SkillStore} from "../data-store/SkillStore";
|
|
18
|
+
import {SkillsHandler} from "./handlers/SkillsHandler";
|
|
16
19
|
import {Subdomains} from "../subdomains/Subdomains";
|
|
17
20
|
import {AuditsHandler} from "./handlers/AuditsHandler";
|
|
18
21
|
import {AuthorizeCliHandler} from "./handlers/auth/AuthorizeCliHandler";
|
|
@@ -81,7 +84,9 @@ class HostingServer {
|
|
|
81
84
|
promptManagerFor? : (appId : string) => PromptManager,
|
|
82
85
|
fileStorageFor? : (appId : string) => FileStorage,
|
|
83
86
|
subdomains? : Subdomains,
|
|
84
|
-
billing? : CentralBilling
|
|
87
|
+
billing? : CentralBilling,
|
|
88
|
+
appSettings? : AppSettingsStore,
|
|
89
|
+
skills? : SkillStore) {
|
|
85
90
|
const pages = new PagesHandler();
|
|
86
91
|
const ping = new PingHandler(tenant);
|
|
87
92
|
const jobs = new JobsHandler(persistence);
|
|
@@ -117,6 +122,8 @@ class HostingServer {
|
|
|
117
122
|
if (memberships) publicRouter.registerApi("invitations", new InvitationsHandler(memberships, entitlements));
|
|
118
123
|
if (memberships) publicRouter.registerApi("members", new MembersHandler(memberships, entitlements));
|
|
119
124
|
if (billing) publicRouter.registerApi("billing", new BillingHandler(billing));
|
|
125
|
+
// People and their agents only: a deployed app has no business reading skills.
|
|
126
|
+
if (skills) publicRouter.registerApi("skills", new SkillsHandler(skills));
|
|
120
127
|
const prompts = promptManagerFor && new PromptsHandler(promptManagerFor);
|
|
121
128
|
if (prompts) publicRouter.registerApi("prompts", prompts);
|
|
122
129
|
if (cliAuthorizer) {
|
|
@@ -130,7 +137,7 @@ class HostingServer {
|
|
|
130
137
|
buildLayer.consumerUrlsFor = appName =>
|
|
131
138
|
[...new Set(registry.list().filter(consumer => consumer.appId === appName).map(consumer => consumer.url))];
|
|
132
139
|
buildLayer.appRemoved = appName => void subdomains?.release(appName);
|
|
133
|
-
publicRouter.registerApi("apps", new AppsHandler(buildLayer, tenant, subdomains));
|
|
140
|
+
publicRouter.registerApi("apps", new AppsHandler(buildLayer, tenant, subdomains, appSettings));
|
|
134
141
|
publicRouter.register("app", appProxy);
|
|
135
142
|
|
|
136
143
|
// The directory a person who is not a builder sees instead of the
|