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.
@@ -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
  }
@@ -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 };