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
|
@@ -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
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {deniesBuild} from "../../auth/TenantRole";
|
|
2
|
+
import {isSkillId, Skill, SkillStore} from "../../data-store/SkillStore";
|
|
3
|
+
import {Request} from "../Request";
|
|
4
|
+
import {RequestHandler} from "../RequestHandler";
|
|
5
|
+
|
|
6
|
+
/* The tenant's skills: guidance its builders write for the agents that work
|
|
7
|
+
on it. Anyone in the tenant may read them - an agent acting for a user
|
|
8
|
+
needs them as much as one acting for a builder - but writing them is a
|
|
9
|
+
builder's job. Served to people and their agents only, never to deployed
|
|
10
|
+
apps, which is why this is registered on the public entry point alone. */
|
|
11
|
+
class SkillsHandler implements RequestHandler {
|
|
12
|
+
|
|
13
|
+
constructor(private skills : SkillStore) {}
|
|
14
|
+
|
|
15
|
+
async handle(request : Request) : Promise<void> {
|
|
16
|
+
if (request.subresource) return request.notFound();
|
|
17
|
+
if (request.id) return this.handleSkill(request, request.id);
|
|
18
|
+
return this.handleCollection(request);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private async handleCollection(request : Request) : Promise<void> {
|
|
22
|
+
if (request.method !== "GET") return request.notFound();
|
|
23
|
+
return request.reply(200, (await this.skills.list()).map(skill => this.publicSkill(skill)));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private async handleSkill(request : Request, id : string) : Promise<void> {
|
|
27
|
+
switch (request.method) {
|
|
28
|
+
case "GET": {
|
|
29
|
+
const skill = await this.skills.get(id);
|
|
30
|
+
if (! skill) return request.reply(404, { error: `No skill with id "${id}"` });
|
|
31
|
+
return request.reply(200, this.publicSkill(skill));
|
|
32
|
+
}
|
|
33
|
+
case "PUT": {
|
|
34
|
+
if (deniesBuild(request.user?.tenantRole)) return this.refuse(request);
|
|
35
|
+
if (! isSkillId(id)) {
|
|
36
|
+
return request.reply(400, { error: `"${id}" is not a skill id: lower-case letters, digits and hyphens, up to 50 characters` });
|
|
37
|
+
}
|
|
38
|
+
const body = await request.body();
|
|
39
|
+
const draft = this.draftFrom(body);
|
|
40
|
+
if (! draft) return request.reply(400, { error: "Expected a body of { title : string, usageHints : string, body : string }, none empty" });
|
|
41
|
+
|
|
42
|
+
return request.reply(200, this.publicSkill(await this.skills.save({ id, ...draft })));
|
|
43
|
+
}
|
|
44
|
+
case "DELETE": {
|
|
45
|
+
if (deniesBuild(request.user?.tenantRole)) return this.refuse(request);
|
|
46
|
+
const removed = await this.skills.remove(id);
|
|
47
|
+
return removed ? request.reply(204) : request.reply(404, { error: `No skill with id "${id}"` });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
request.notFound();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private refuse(request : Request) : void {
|
|
54
|
+
request.reply(403, { error: "Your role in this tenant cannot manage skills" });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private draftFrom(body : unknown) : { title : string, usageHints : string, body : string } | undefined {
|
|
58
|
+
const fields = body as Record<string, unknown> | undefined;
|
|
59
|
+
const text = (value : unknown) : string | undefined =>
|
|
60
|
+
typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
61
|
+
|
|
62
|
+
const title = text(fields?.title);
|
|
63
|
+
const usageHints = text(fields?.usageHints);
|
|
64
|
+
const content = text(fields?.body);
|
|
65
|
+
return title && usageHints && content ? { title, usageHints, body: content } : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private publicSkill(skill : Skill) {
|
|
69
|
+
return {
|
|
70
|
+
id: skill.id,
|
|
71
|
+
title: skill.title,
|
|
72
|
+
usageHints: skill.usageHints,
|
|
73
|
+
body: skill.body,
|
|
74
|
+
createdAt: skill.createdAt.toISOString(),
|
|
75
|
+
updatedAt: skill.updatedAt.toISOString(),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export { SkillsHandler };
|