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/package.json +7 -7
- package/src/app-management/BaseBuildLayer.ts +3 -0
- package/src/app-management/BuildLayer.ts +6 -0
- package/src/app-management/appAddress.ts +19 -0
- package/src/app-management/appHostname.ts +16 -23
- package/src/auth/HttpMembershipService.ts +2 -2
- package/src/auth/InMemoryMembershipService.ts +2 -1
- package/src/auth/MembershipService.ts +5 -1
- package/src/data-store/EntitlementStore.ts +23 -1
- package/src/data-store/InMemoryEntitlementStore.ts +25 -1
- package/src/data-store/PlatformMigrations.ts +19 -0
- package/src/data-store/PostgresEntitlementStore.ts +40 -1
- package/src/hosting/HostingServer.ts +26 -4
- package/src/hosting/handlers/AppsHandler.ts +65 -6
- package/src/hosting/handlers/MyAppsHandler.ts +48 -0
- package/src/hosting/handlers/auth/InvitationsHandler.ts +44 -4
- package/src/hosting/middleware/AuthenticationMiddleware.ts +20 -5
- package/src/hosting/pages/platform-ui.html +3 -3
- package/src/main.ts +34 -1
- package/src/subdomains/HttpSubdomains.ts +91 -0
- package/src/subdomains/Subdomains.ts +30 -0
- package/src/usage/UsageReporter.ts +63 -0
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 };
|