anbaric-cloud-hosting 1.60.0 → 1.61.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/src/main.ts CHANGED
@@ -33,6 +33,8 @@ import {ConsumerRegistry} from "./queuing/ConsumerRegistry";
33
33
  import {Dispatcher} from "./queuing/Dispatcher";
34
34
  import {HostingServer} from "./hosting/HostingServer";
35
35
  import {PluginLoader} from "./plugins/PluginLoader";
36
+ import {UsageReporter} from "./usage/UsageReporter";
37
+ import {HttpSubdomains} from "./subdomains/HttpSubdomains";
36
38
 
37
39
  const pool = new Pool({ connectionString: process.env.ANBARIC_DATABASE_URL });
38
40
  await ensureSchema(pool);
@@ -108,6 +110,13 @@ const memberships = process.env.ANBARIC_CLI_KEY_LOOKUP_URL && process.env.ANBARI
108
110
  ? new HttpMembershipService(process.env.ANBARIC_CLI_KEY_LOOKUP_URL, process.env.ANBARIC_CLI_KEY_LOOKUP_SECRET ?? "", process.env.ANBARIC_TENANT)
109
111
  : undefined;
110
112
 
113
+ /* Where this tenant's apps answer. The addresses are the control plane's to
114
+ keep, because one has to be unique across every tenant; a platform that has
115
+ no control plane to ask has no addresses and serves its apps by path. */
116
+ const subdomains = process.env.ANBARIC_CLI_KEY_LOOKUP_URL && process.env.ANBARIC_TENANT
117
+ ? new HttpSubdomains(process.env.ANBARIC_CLI_KEY_LOOKUP_URL, process.env.ANBARIC_CLI_KEY_LOOKUP_SECRET ?? "", process.env.ANBARIC_TENANT)
118
+ : undefined;
119
+
111
120
  /* Files are owned by an app, like secrets: on the hosted platform each app
112
121
  gets its own key prefix in the tenant's bucket, and locally its own folder
113
122
  under the storage root. A caller with no app - the console - gets the whole
@@ -126,13 +135,37 @@ const server = new HostingServer(jobs, queue, registry, buildLayer,
126
135
  tokenAuthenticator, process.env.ANBARIC_TENANT, new PostgresAuditRecordStore(pool), plugins,
127
136
  new PostgresJobRunSchedulePersistence(pool), await loadNotifier(process.env.ANBARIC_NOTIFIER_MODULE),
128
137
  new PostgresEntitlementStore(pool), new PostgresUserDirectory(pool), memberships,
129
- (appId) => new PostgresPromptManager(pool, appId), fileStorageFor);
138
+ (appId) => new PostgresPromptManager(pool, appId), fileStorageFor, subdomains);
130
139
  const port = await server.listen(hostingPort);
131
140
  const internal = await server.listenInternal(internalPort);
132
141
 
133
142
  const dispatcher = new Dispatcher(queue, registry, Number(process.env.ANBARIC_DISPATCH_INTERVAL_MS ?? 1000));
134
143
  dispatcher.start();
135
144
 
145
+ /* Tells the control plane how many apps this tenant runs, which is what it is
146
+ billed for. Only a deployed tenant has anywhere to report to; a platform run
147
+ locally simply does not. Apps that are running or draining count - one being
148
+ built has not run yet, and one that is stopped or failed is not running. */
149
+ if (buildLayer && process.env.ANBARIC_TENANT && process.env.ANBARIC_CLI_KEY_LOOKUP_URL) {
150
+ new UsageReporter(
151
+ process.env.ANBARIC_CLI_KEY_LOOKUP_URL,
152
+ process.env.ANBARIC_CLI_KEY_LOOKUP_SECRET ?? "",
153
+ process.env.ANBARIC_TENANT,
154
+ () => buildLayer.list().filter(app => app.status === "running" || app.status === "draining").length,
155
+ ).start();
156
+ }
157
+
158
+ /* Makes sure every app this platform is running has an address. Allocation is
159
+ idempotent, so this costs one call per app and changes nothing in the
160
+ ordinary case - but it is what gives an address to an app deployed before
161
+ addresses existed, and what repairs one the control plane never managed to
162
+ publish to the edge. Cheaper and harder to forget than a migration script. */
163
+ if (buildLayer && subdomains) {
164
+ void buildLayer.ensureHydrated()
165
+ .then(() => Promise.all(buildLayer.list().map(app => subdomains.allocate(app.appName))))
166
+ .catch(error => console.warn(`[subdomains] could not reconcile app addresses: ${error instanceof Error ? error.message : error}`));
167
+ }
168
+
136
169
  // Jobs whose long-running step stopped saying it was alive are marked, not touched.
137
170
  new StallSweep(jobs).start();
138
171
 
@@ -0,0 +1,91 @@
1
+ import {AppAddress, Subdomains} from "./Subdomains";
2
+
3
+ type FetchFn = (url : string, init : RequestInit) => Promise<Response>;
4
+
5
+ /* Asks the control plane for this tenant's app addresses, with the same
6
+ central api key the platform already holds for key lookups and memberships.
7
+
8
+ Allocation is idempotent at the other end, so asking twice for one app is
9
+ not a mistake - it is how the platform makes sure every app it is running
10
+ has an address, every time it boots. A failure to allocate is reported as
11
+ nothing rather than thrown: an app that is running and reachable by path
12
+ should not be held up because its address could not be arranged, and the
13
+ next boot will ask again. Setting one, which a person did on purpose and is
14
+ waiting on, does throw. */
15
+ const LIST_CACHE_TTL_MS = 60_000;
16
+
17
+ class HttpSubdomains implements Subdomains {
18
+
19
+ private listed? : { addresses : Array<AppAddress>, expires : number };
20
+
21
+ constructor(private centralUrl : string, private secret : string, private tenantSlug : string,
22
+ private fetchFn : FetchFn = (url, init) => fetch(url, init),
23
+ private cacheTtlMs : number = LIST_CACHE_TTL_MS) {}
24
+
25
+ async allocate(appName : string) : Promise<AppAddress | undefined> {
26
+ try {
27
+ const response = await this.call("POST", `/${encodeURIComponent(appName)}/subdomain`);
28
+ this.listed = undefined;
29
+ return await response.json() as AppAddress;
30
+ } catch (error) {
31
+ console.warn(`[subdomains] could not get an address for "${appName}": ${this.because(error)}`);
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ async set(appName : string, subdomain : string) : Promise<AppAddress> {
37
+ const response = await this.call("PUT", `/${encodeURIComponent(appName)}/subdomain`, { subdomain });
38
+ this.listed = undefined;
39
+ return await response.json() as AppAddress;
40
+ }
41
+
42
+ async release(appName : string) : Promise<void> {
43
+ try {
44
+ await this.call("DELETE", `/${encodeURIComponent(appName)}/subdomain`, undefined, [404]);
45
+ } catch (error) {
46
+ console.warn(`[subdomains] could not release the address for "${appName}": ${this.because(error)}`);
47
+ }
48
+ this.listed = undefined;
49
+ }
50
+
51
+ /* The console asks for the app list on every page load, and an address
52
+ only changes when this platform changes it - so the answer is held until
53
+ it does, or until a minute has passed and another platform of the same
54
+ tenant might have. */
55
+ async all() : Promise<Array<AppAddress>> {
56
+ if (this.listed && this.listed.expires > Date.now()) return this.listed.addresses;
57
+
58
+ try {
59
+ const response = await this.call("GET", "");
60
+ const addresses = await response.json() as Array<AppAddress>;
61
+ this.listed = { addresses, expires: Date.now() + this.cacheTtlMs };
62
+ return addresses;
63
+ } catch (error) {
64
+ console.warn(`[subdomains] could not list app addresses: ${this.because(error)}`);
65
+ return this.listed?.addresses ?? [];
66
+ }
67
+ }
68
+
69
+ private because(error : unknown) : string {
70
+ return error instanceof Error ? error.message : String(error);
71
+ }
72
+
73
+ private async call(method : string, path : string, body? : unknown,
74
+ tolerated : Array<number> = []) : Promise<Response> {
75
+ const headers : Record<string, string> = { "x-anbaric-central-key": this.secret };
76
+ if (body !== undefined) headers["content-type"] = "application/json";
77
+
78
+ const response = await this.fetchFn(
79
+ `${this.centralUrl.replace(/\/$/, "")}/tenants/${encodeURIComponent(this.tenantSlug)}/apps${path}`,
80
+ { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
81
+
82
+ if (! response.ok && ! tolerated.includes(response.status)) {
83
+ const problem = await response.json().catch(() => ({})) as { error? : string };
84
+ throw new Error(problem.error ?? `The request to the central login failed with status ${response.status}`);
85
+ }
86
+ return response;
87
+ }
88
+
89
+ }
90
+
91
+ export { HttpSubdomains };
@@ -0,0 +1,30 @@
1
+ type AppAddress = {
2
+
3
+ appName : string,
4
+ subdomain : string,
5
+ published : boolean,
6
+
7
+ };
8
+
9
+ /* Where this tenant's apps answer. The addresses themselves belong to the
10
+ control plane, because one has to be unique across every tenant and no
11
+ single platform can know that; a platform asks for one and is told.
12
+
13
+ A platform that is not part of Anbaric Cloud has no control plane to ask, so
14
+ it has no addresses either and serves its apps by path. That is the whole
15
+ difference, and it is why this is an interface with a do-nothing
16
+ implementation rather than something the rest of the platform must check
17
+ for. */
18
+ interface Subdomains {
19
+
20
+ allocate(appName : string) : Promise<AppAddress | undefined>;
21
+
22
+ set(appName : string, subdomain : string) : Promise<AppAddress>;
23
+
24
+ release(appName : string) : Promise<void>;
25
+
26
+ all() : Promise<Array<AppAddress>>;
27
+
28
+ }
29
+
30
+ export type { Subdomains, AppAddress };
@@ -0,0 +1,63 @@
1
+ type FetchFn = (url : string, init : RequestInit) => Promise<Response>;
2
+
3
+ type CountApps = () => number;
4
+
5
+ const REPORT_INTERVAL_MS = 60 * 60_000;
6
+
7
+ /* Tells the control plane how many apps this tenant is running, hourly.
8
+ Hourly rather than daily because a day is recorded as the most apps seen on
9
+ it: an app deployed at noon has still run that day, and a platform that only
10
+ spoke at midnight would never have seen it.
11
+
12
+ It reports a number and learns nothing back. What an app costs, what the
13
+ tenant's allowance covers and when it is billed are the control plane's
14
+ business - so pricing can change without redeploying a single customer's
15
+ platform. A report that fails is simply the next hour's problem: the day is
16
+ the highest count that reached central, so one lost hour costs nothing
17
+ unless every hour of the day is lost. */
18
+ class UsageReporter {
19
+
20
+ private ticker? : NodeJS.Timeout;
21
+
22
+ constructor(private centralUrl : string, private secret : string, private tenantSlug : string,
23
+ private countApps : CountApps,
24
+ private fetchFn : FetchFn = (url, init) => fetch(url, init),
25
+ private intervalMs : number = REPORT_INTERVAL_MS) {}
26
+
27
+ start() : void {
28
+ if (this.ticker) return;
29
+ void this.report();
30
+ this.ticker = setInterval(() => void this.report(), this.intervalMs);
31
+ this.ticker.unref?.();
32
+ }
33
+
34
+ async report() : Promise<boolean> {
35
+ try {
36
+ const response = await this.fetchFn(`${this.centralUrl}/usage`, {
37
+ method: "POST",
38
+ headers: { "content-type": "application/json", "x-anbaric-central-key": this.secret },
39
+ body: JSON.stringify({ slug: this.tenantSlug, apps: this.countApps(), day: this.today() }),
40
+ });
41
+ if (! response.ok) console.warn(`[usage] central answered ${response.status} to the app count`);
42
+ return response.ok;
43
+ } catch (error) {
44
+ console.warn(`[usage] could not report the app count: ${error instanceof Error ? error.message : error}`);
45
+ return false;
46
+ }
47
+ }
48
+
49
+ // The day in UTC, so every tenant's day starts at the same moment however
50
+ // the platform's clock is set.
51
+ private today() : string {
52
+ return new Date().toISOString().slice(0, 10);
53
+ }
54
+
55
+ async cleanUp() : Promise<void> {
56
+ if (this.ticker) clearInterval(this.ticker);
57
+ this.ticker = undefined;
58
+ }
59
+
60
+ }
61
+
62
+ export { UsageReporter };
63
+ export type { CountApps };