CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/database/postgres/site-license/hook.ts
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
import { Map } from "immutable";
7
import { isEqual, sortBy } from "lodash";
8
9
import getLogger from "@cocalc/backend/logger";
10
import { callback2 } from "@cocalc/util/async-utils";
11
import { is_valid_uuid_string, len } from "@cocalc/util/misc";
12
import { SiteLicenseQuota } from "@cocalc/util/types/site-licenses";
13
import { TypedMap } from "@cocalc/util/types/typed-map";
14
import {
15
isSiteLicenseQuotaSetting,
16
LicenseStatus,
17
licenseToGroupKey,
18
QuotaSetting,
19
quota_with_reasons as compute_total_quota_with_reasons,
20
Reasons,
21
SiteLicenseQuotaSetting,
22
SiteLicenses,
23
siteLicenseSelectionKeys,
24
SiteSettingsQuotas,
25
} from "@cocalc/util/upgrades/quota";
26
import { query } from "../query";
27
import { PostgreSQL } from "../types";
28
import { number_of_running_projects_using_license } from "./analytics";
29
import { getQuotaSiteSettings } from "./quota-site-settings";
30
31
type QuotaMap = TypedMap<SiteLicenseQuota>;
32
33
const LOGGER_NAME = "site-license-hook";
34
35
const ORDERING_GROUP_KEYS = Array.from(siteLicenseSelectionKeys());
36
37
// this will hold a synctable for all valid licenses
38
let LICENSES: any = undefined;
39
40
interface License {
41
id: string;
42
title?: string;
43
expires?: Date;
44
activates?: Date;
45
upgrades?: Map<string, number>;
46
quota?: QuotaMap;
47
run_limit?: number;
48
}
49
50
type LicenseMap = TypedMap<License>;
51
52
// used to throttle lase_used updates per license
53
const LAST_USED: { [license_id: string]: number } = {};
54
55
/**
56
* Call this any time about to *start* the project.
57
*
58
* Check for site licenses, then set the site_license field for this project.
59
* The *value* for each key records what the license provides and whether or
60
* not it is actually being used by the project.
61
*
62
* If the license provides nothing new compared to what is already provided
63
* by already applied **licenses** and upgrades, then the license is *not*
64
* applied.
65
*
66
* related issues about it's heuristic:
67
* - https://github.com/sagemathinc/cocalc/issues/4979 -- do not apply a license if it does not provide upgrades
68
* - https://github.com/sagemathinc/cocalc/pull/5490 -- remove a license if it is expired
69
* - https://github.com/sagemathinc/cocalc/issues/5635 -- do not completely remove a license if it is still valid
70
*/
71
export async function site_license_hook(
72
db: PostgreSQL,
73
project_id: string,
74
paygoActive: boolean
75
): Promise<void> {
76
try {
77
const slh = new SiteLicenseHook(db, project_id, paygoActive);
78
await slh.process();
79
} catch (err) {
80
const L = getLogger(LOGGER_NAME);
81
L.warn(`ERROR -- ${err}`);
82
throw err;
83
}
84
}
85
/**
86
* This encapulates the logic for applying site licenses to projects.
87
* Use the convenience function site_license_hook() to call this.
88
*/
89
class SiteLicenseHook {
90
private readonly db: PostgreSQL;
91
private readonly project_id: string;
92
private readonly paygoActive: boolean;
93
private readonly dbg: ReturnType<typeof getLogger>;
94
95
private projectSiteLicenses: SiteLicenses = {};
96
private nextSiteLicense: SiteLicenses = {};
97
private site_settings: SiteSettingsQuotas | undefined;
98
private project: { site_license: any; settings: any; users: any };
99
100
constructor(db: PostgreSQL, project_id: string, paygoActive: boolean) {
101
this.db = db;
102
this.project_id = project_id;
103
this.paygoActive = paygoActive;
104
this.dbg = getLogger(`${LOGGER_NAME}:${project_id}`);
105
}
106
107
/**
108
* returns the cached synctable holding all licenses
109
*
110
* TODO: filter on expiration...
111
*/
112
private async getAllValidLicenses(): Promise<Map<string, LicenseMap>> {
113
if (LICENSES == null) {
114
LICENSES = await callback2(this.db.synctable.bind(this.db), {
115
table: "site_licenses",
116
columns: [
117
"title",
118
"expires",
119
"activates",
120
"upgrades",
121
"quota",
122
"run_limit",
123
],
124
// TODO: Not bothing with the where condition will be fine up to a few thousand (?) site
125
// licenses, but after that it could take nontrivial time/memory during hub startup.
126
// So... this is a ticking time bomb.
127
//, where: { expires: { ">=": new Date() }, activates: { "<=": new Date() } }
128
});
129
}
130
return LICENSES.get();
131
}
132
133
/**
134
* Basically, if the combined license config for this project changes, set it for the project.
135
*/
136
async process() {
137
this.dbg.verbose("checking for site licenses");
138
this.project = await this.getProject();
139
this.site_settings = await getQuotaSiteSettings();
140
this.dbg.verbose("site_settings_quotas=", this.site_settings);
141
142
if (
143
this.project.site_license == null ||
144
typeof this.project.site_license != "object"
145
) {
146
this.dbg.verbose("no site licenses set for this project.");
147
return;
148
}
149
150
// just to make sure we don't touch it
151
this.projectSiteLicenses = Object.freeze(this.project.site_license);
152
this.nextSiteLicense = await this.computeNextSiteLicense();
153
await this.setProjectSiteLicense();
154
await this.updateLastUsed();
155
}
156
157
private async getProject() {
158
const project = await query({
159
db: this.db,
160
select: ["site_license", "settings", "users"],
161
table: "projects",
162
where: { project_id: this.project_id },
163
one: true,
164
});
165
this.dbg.verbose(`project=${JSON.stringify(project)}`);
166
return project;
167
}
168
169
/**
170
* If there is a change in licensing, set it for the project.
171
*/
172
private async setProjectSiteLicense() {
173
const dbg = this.dbg.extend("setProjectSiteLicense");
174
if (!isEqual(this.projectSiteLicenses, this.nextSiteLicense)) {
175
// Now set the site license since something changed.
176
dbg.info(
177
`setup a modified site license=${JSON.stringify(this.nextSiteLicense)}`
178
);
179
await query({
180
db: this.db,
181
query: "UPDATE projects",
182
where: { project_id: this.project_id },
183
jsonb_set: { site_license: this.nextSiteLicense },
184
});
185
} else {
186
dbg.info("no change");
187
}
188
}
189
190
/**
191
* We have to order the site licenses by their priority.
192
* Otherwise, the method of applying them one-by-one does lead to issues, because if a lower priority
193
* license is considered first (and applied), and then a higher priority license is considered next,
194
* the quota algorithm will only pick the higher priority license in the second iteration, causing the
195
* effective quotas to be different, and hence actually both licenses seem to be applied but they are not.
196
*
197
* additionally (march 2022): start with regular licenses, then boost licenses
198
*/
199
private orderedSiteLicenseIDs(validLicenses): string[] {
200
const ids = Object.keys(this.projectSiteLicenses).filter((id) => {
201
return validLicenses.get(id) != null;
202
});
203
204
const orderedIds: string[] = [];
205
206
// first, pick the "dedicated licenses", in particular dedicated VM.
207
// otherwise: regular quota upgrade licenses are picked and registered as valid,
208
// while in fact later on, when incrementally applying more licenses in computeNextSiteLicense,
209
// those will become ineffective.
210
211
for (let idx = 0; idx < ids.length; idx++) {
212
const id = ids[idx];
213
const val = validLicenses.get(id).toJS();
214
if (isSiteLicenseQuotaSetting(val)) {
215
const vm = val.quota.dedicated_vm;
216
if (vm != null && vm !== false) {
217
orderedIds.push(id);
218
ids.splice(idx, 1);
219
}
220
}
221
}
222
223
for (let idx = 0; idx < ids.length; idx++) {
224
const id = ids[idx];
225
const val = validLicenses.get(id).toJS();
226
if (isSiteLicenseQuotaSetting(val)) {
227
const disk = val.quota.dedicated_disk;
228
if (disk != null) {
229
orderedIds.push(id);
230
ids.splice(idx, 1);
231
}
232
}
233
}
234
235
// then all regular licenses (boost == false), then the boost licenses
236
for (const boost of [false, true]) {
237
const idsPartition = ids.filter((id) => {
238
const val = validLicenses.get(id).toJS();
239
// one group is every license, while the other are those where quota.boost is true
240
const isBoost =
241
isSiteLicenseQuotaSetting(val) && (val.quota.boost ?? false);
242
return isBoost === boost;
243
});
244
orderedIds.push(
245
...sortBy(idsPartition, (id) => {
246
const val = validLicenses.get(id).toJS();
247
const key = licenseToGroupKey(val);
248
return ORDERING_GROUP_KEYS.indexOf(key);
249
})
250
);
251
}
252
253
return orderedIds;
254
}
255
256
private computeQuotas(licenses) {
257
return compute_total_quota_with_reasons(
258
this.project.settings,
259
this.project.users,
260
licenses,
261
this.site_settings
262
);
263
}
264
265
/**
266
* Calculates the next site license situation, replacing whatever the project is currently licensed as.
267
* A particular site license will only be used if it actually causes the upgrades to increase.
268
*/
269
private async computeNextSiteLicense(): Promise<SiteLicenses> {
270
// Next we check the keys of site_license to see what they contribute,
271
// and fill that in.
272
const nextLicense: SiteLicenses = {};
273
const allValidLicenses = await this.getAllValidLicenses();
274
const reasons: Reasons = {};
275
276
// it's important to start testing with regular licenses by decreasing priority
277
for (const license_id of this.orderedSiteLicenseIDs(allValidLicenses)) {
278
if (!is_valid_uuid_string(license_id)) {
279
// The site_license is supposed to be a map from uuid's to settings...
280
// We could put some sort of error here in case, though I don't know what
281
// we would do with it.
282
this.dbg.info(`skipping invalid license ${license_id} -- invalid UUID`);
283
continue;
284
}
285
const license = allValidLicenses.get(license_id);
286
const status = await this.checkLicense({ license, license_id });
287
288
if (status === "valid") {
289
const upgrades: QuotaSetting = this.extractUpgrades(license);
290
291
this.dbg.verbose(`computing run quotas by adding ${license_id}...`);
292
const { quota: run_quota } = this.computeQuotas(nextLicense);
293
const { quota: run_quota_with_license, reasons: newReasons } =
294
this.computeQuotas({
295
...nextLicense,
296
...{ [license_id]: upgrades },
297
});
298
299
Object.assign(reasons, newReasons);
300
301
this.dbg.silly(`run_quota=${JSON.stringify(run_quota)}`);
302
this.dbg.silly(
303
`run_quota_with_license=${JSON.stringify(
304
run_quota_with_license
305
)} | reason=${JSON.stringify(newReasons)}`
306
);
307
if (!isEqual(run_quota, run_quota_with_license)) {
308
this.dbg.info(
309
`License "${license_id}" provides an effective upgrade ${JSON.stringify(
310
upgrades
311
)}.`
312
);
313
nextLicense[license_id] = { ...upgrades, status: "active" };
314
} else {
315
this.dbg.info(
316
`Found a valid license "${license_id}", but it provides nothing new so not using it (reason: ${newReasons[license_id]})`
317
);
318
nextLicense[license_id] = {
319
status: "ineffective",
320
reason: reasons[license_id],
321
};
322
}
323
} else {
324
// license is not valid, all other cases:
325
// Note: in an earlier version we did delete an expired license. We don't do this any more,
326
// but instead record that it is expired and tell the user about it.
327
this.dbg.info(`Disabling license "${license_id}" -- status=${status}`);
328
nextLicense[license_id] = { status, reason: status }; // no upgrades or quotas!
329
}
330
}
331
return nextLicense;
332
}
333
334
/**
335
* get the upgrade provided by a given license
336
*/
337
private extractUpgrades(license): QuotaSetting {
338
if (license == null) throw new Error("bug");
339
// Licenses can specify what they do in two distinct ways: upgrades and quota.
340
const upgrades = (license.get("upgrades")?.toJS() ?? {}) as QuotaSetting;
341
if (upgrades == null) {
342
// This is to make typescript happy since QuotaSetting may be null
343
// (though I don't think upgrades ever could be).
344
throw Error("bug");
345
}
346
const quota = license.get("quota");
347
if (quota) {
348
upgrades["quota"] = quota.toJS() as SiteLicenseQuotaSetting;
349
}
350
// remove any zero values to make frontend client code simpler and avoid waste/clutter.
351
// NOTE: I do assume these 0 fields are removed in some client code, so don't just not do this!
352
for (const field in upgrades) {
353
if (!upgrades[field]) {
354
delete upgrades[field];
355
}
356
}
357
return upgrades;
358
}
359
360
/**
361
* A license can be in in one of these four states:
362
* - valid: the license is valid and provides upgrades
363
* - expired: the license is expired and should be removed
364
* - disabled: the license is disabled and should not provide any upgrades
365
* - future: the license is valid but not yet and should not provide any upgrades as well
366
*/
367
private async checkLicense({ license, license_id }): Promise<LicenseStatus> {
368
this.dbg.info(
369
`considering license ${license_id}: ${JSON.stringify(license?.toJS())}`
370
);
371
if (license == null) {
372
this.dbg.info(`License "${license_id}" does not exist.`);
373
return "expired";
374
} else {
375
const expires = license.get("expires");
376
const activates = license.get("activates");
377
const run_limit = license.get("run_limit");
378
if (expires != null && expires <= new Date()) {
379
this.dbg.info(`License "${license_id}" expired ${expires}.`);
380
return "expired";
381
} else if (activates == null || activates > new Date()) {
382
this.dbg.info(
383
`License "${license_id}" has not been explicitly activated yet ${activates}.`
384
);
385
return "future";
386
} else if (await this.aboveRunLimit(run_limit, license_id)) {
387
this.dbg.info(
388
`License "${license_id}" won't be applied since it would exceed the run limit ${run_limit}.`
389
);
390
return "exhausted";
391
} else {
392
if (this.paygoActive && this.disallowUnderPAYGO(license)) {
393
this.dbg.info(`due to PAYGO, license ${license_id} is ineffective`);
394
return "ineffective";
395
} else {
396
this.dbg.info(`license ${license_id} is valid`);
397
return "valid";
398
}
399
}
400
}
401
}
402
403
// Return true, if the license is not a dedicated disk license.
404
private disallowUnderPAYGO(license: LicenseMap): boolean {
405
const quota = license.get("quota");
406
if (quota == null) return true;
407
// there are some exceptions. dedicated disks do work under PAYGO.
408
const hasDisk = quota.get("dedicated_disk") != null;
409
// ext_rw and patch are for CoCalc OnPrem, adding them just in case...
410
const hasExtRW = quota.get("ext_rw") === true;
411
const hasPatch = quota.get("patch") != null;
412
if (hasDisk || hasExtRW || hasPatch) return false;
413
return true;
414
}
415
416
/**
417
* Returns true, if using that license would exceed the run limit.
418
*/
419
private async aboveRunLimit(run_limit, license_id): Promise<boolean> {
420
if (typeof run_limit !== "number") return false;
421
const usage = await number_of_running_projects_using_license(
422
this.db,
423
license_id
424
);
425
this.dbg.verbose(`run_limit=${run_limit} usage=${usage}`);
426
return usage >= run_limit;
427
}
428
429
/**
430
* Check for each license involved if the "last_used" field should be updated
431
*/
432
private async updateLastUsed() {
433
for (const license_id in this.nextSiteLicense) {
434
// this checks if the given license is actually not deactivated
435
if (len(this.nextSiteLicense[license_id]) > 0) {
436
await this._updateLastUsed(license_id);
437
}
438
}
439
}
440
441
private async _updateLastUsed(license_id: string): Promise<void> {
442
const dbg = this.dbg.extend(`_updateLastUsed("${license_id}")`);
443
const now = Date.now();
444
if (
445
LAST_USED[license_id] != null &&
446
now - LAST_USED[license_id] <= 60 * 1000
447
) {
448
dbg.info("recently updated so waiting");
449
// If we updated this entry in the database already within a minute, don't again.
450
return;
451
}
452
LAST_USED[license_id] = now;
453
dbg.info("did NOT recently update, so updating in database");
454
await callback2(this.db._query.bind(this.db), {
455
query: "UPDATE site_licenses",
456
set: { last_used: "NOW()" },
457
where: { id: license_id },
458
});
459
}
460
}
461
462