anbaric-cloud-hosting 1.59.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/index.ts CHANGED
@@ -2,6 +2,7 @@ export * from "./app-management/BuildLayer";
2
2
  export * from "./auditing/AuditRecordStore";
3
3
  export * from "./auditing/InMemoryAuditRecordStore";
4
4
  export * from "./data-store/PostgresAuditRecordStore";
5
+ export * from "./data-store/StallSweep";
5
6
  export * from "./auth/Authenticator";
6
7
  export * from "./auth/CliAuthorizer";
7
8
  export * from "./auth/CliKey";
package/src/main.ts CHANGED
@@ -17,6 +17,7 @@ import {SecretsManagerSecretStore} from "./data-store/SecretsManagerSecretStore"
17
17
  import {S3FileStorage} from "./data-store/S3FileStorage";
18
18
  import {PostgresJobRunSchedulePersistence} from "./data-store/PostgresJobRunSchedulePersistence";
19
19
  import {PostgresJobPersistence} from "./data-store/PostgresJobPersistence";
20
+ import {StallSweep} from "./data-store/StallSweep";
20
21
  import {PostgresJsonStore} from "./data-store/PostgresJsonStore";
21
22
  import {PostgresQueue} from "./queuing/PostgresQueue";
22
23
  import {DockerBuildLayer} from "./app-management/DockerBuildLayer";
@@ -32,6 +33,8 @@ import {ConsumerRegistry} from "./queuing/ConsumerRegistry";
32
33
  import {Dispatcher} from "./queuing/Dispatcher";
33
34
  import {HostingServer} from "./hosting/HostingServer";
34
35
  import {PluginLoader} from "./plugins/PluginLoader";
36
+ import {UsageReporter} from "./usage/UsageReporter";
37
+ import {HttpSubdomains} from "./subdomains/HttpSubdomains";
35
38
 
36
39
  const pool = new Pool({ connectionString: process.env.ANBARIC_DATABASE_URL });
37
40
  await ensureSchema(pool);
@@ -107,6 +110,13 @@ const memberships = process.env.ANBARIC_CLI_KEY_LOOKUP_URL && process.env.ANBARI
107
110
  ? new HttpMembershipService(process.env.ANBARIC_CLI_KEY_LOOKUP_URL, process.env.ANBARIC_CLI_KEY_LOOKUP_SECRET ?? "", process.env.ANBARIC_TENANT)
108
111
  : undefined;
109
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
+
110
120
  /* Files are owned by an app, like secrets: on the hosted platform each app
111
121
  gets its own key prefix in the tenant's bucket, and locally its own folder
112
122
  under the storage root. A caller with no app - the console - gets the whole
@@ -119,16 +129,44 @@ const fileStorageFor = (appId : string) : FileStorage =>
119
129
  ? new S3FileStorage(s3, filesBucket, appId ? `${appId}/` : "")
120
130
  : new LocalFileSystemStorage(join(process.env.ANBARIC_FILE_STORAGE_PATH ?? join(tmpdir(), "anbaric", "files"), appId));
121
131
 
122
- const server = new HostingServer(new PostgresJobPersistence(pool), queue, registry, buildLayer,
132
+ const jobs = new PostgresJobPersistence(pool);
133
+ const server = new HostingServer(jobs, queue, registry, buildLayer,
123
134
  (appId, collection) => new PostgresJsonStore(pool, appId, collection), secretStoreFor, authenticator, cliAuthorizer,
124
135
  tokenAuthenticator, process.env.ANBARIC_TENANT, new PostgresAuditRecordStore(pool), plugins,
125
136
  new PostgresJobRunSchedulePersistence(pool), await loadNotifier(process.env.ANBARIC_NOTIFIER_MODULE),
126
137
  new PostgresEntitlementStore(pool), new PostgresUserDirectory(pool), memberships,
127
- (appId) => new PostgresPromptManager(pool, appId), fileStorageFor);
138
+ (appId) => new PostgresPromptManager(pool, appId), fileStorageFor, subdomains);
128
139
  const port = await server.listen(hostingPort);
129
140
  const internal = await server.listenInternal(internalPort);
130
141
 
131
142
  const dispatcher = new Dispatcher(queue, registry, Number(process.env.ANBARIC_DISPATCH_INTERVAL_MS ?? 1000));
132
143
  dispatcher.start();
133
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
+
169
+ // Jobs whose long-running step stopped saying it was alive are marked, not touched.
170
+ new StallSweep(jobs).start();
171
+
134
172
  console.log(`anbaric-cloud-hosting listening on port ${port}, internal entry point on ${internal}`);
@@ -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 };