UNPKG

@cocalc/database

Version:

CoCalc: code for working with our PostgreSQL database

346 lines 15.7 kB
"use strict"; /* * This file is part of CoCalc: Copyright © 2020 Sagemath, Inc. * License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.site_license_hook = void 0; const async_utils_1 = require("@cocalc/util/async-utils"); const misc_1 = require("@cocalc/util/misc"); const quota_1 = require("@cocalc/util/upgrades/quota"); const lodash_1 = require("lodash"); const query_1 = require("../query"); const analytics_1 = require("./analytics"); const logger_1 = __importDefault(require("@cocalc/backend/logger")); const LOGGER_NAME = "site-license-hook"; const ORDERING_GROUP_KEYS = Array.from((0, quota_1.siteLicenseSelectionKeys)()); // this will hold a synctable for all valid licenses let LICENSES = undefined; // used to throttle lase_used updates per license const LAST_USED = {}; /** * Call this any time about to *start* the project. * * Check for site licenses, then set the site_license field for this project. * The *value* for each key records what the license provides and whether or * not it is actually being used by the project. * * If the license provides nothing new compared to what is already provided * by already applied **licenses** and upgrades, then the license is *not* * applied. * * related issues about it's heuristic: * - https://github.com/sagemathinc/cocalc/issues/4979 -- do not apply a license if it does not provide upgrades * - https://github.com/sagemathinc/cocalc/pull/5490 -- remove a license if it is expired * - https://github.com/sagemathinc/cocalc/issues/5635 -- do not completely remove a license if it is still valid */ async function site_license_hook(db, project_id) { try { const slh = new SiteLicenseHook(db, project_id); await slh.process(); } catch (err) { const L = (0, logger_1.default)(LOGGER_NAME); L.warn(`ERROR -- ${err}`); throw err; } } exports.site_license_hook = site_license_hook; /** * This encapulates the logic for applying site licenses to projects. * Use the convenience function site_license_hook() to call this. */ class SiteLicenseHook { constructor(db, project_id) { this.projectSiteLicenses = {}; this.nextSiteLicense = {}; this.db = db; this.project_id = project_id; this.dbg = (0, logger_1.default)(`${LOGGER_NAME}:${project_id}`); } /** * returns the cached synctable holding all licenses * * TODO: filter on expiration... */ async getAllValidLicenses() { if (LICENSES == null) { LICENSES = await (0, async_utils_1.callback2)(this.db.synctable.bind(this.db), { table: "site_licenses", columns: [ "title", "expires", "activates", "upgrades", "quota", "run_limit", ], // TODO: Not bothing with the where condition will be fine up to a few thousand (?) site // licenses, but after that it could take nontrivial time/memory during hub startup. // So... this is a ticking time bomb. //, where: { expires: { ">=": new Date() }, activates: { "<=": new Date() } } }); } return LICENSES.get(); } /** * Basically, if the combined license config for this project changes, set it for the project. */ async process() { this.dbg.verbose("checking for site licenses"); this.project = await this.getProject(); if (this.project.site_license == null || typeof this.project.site_license != "object") { this.dbg.verbose("no site licenses set for this project."); return; } // just to make sure we don't touch it this.projectSiteLicenses = Object.freeze(this.project.site_license); this.nextSiteLicense = await this.computeNextSiteLicense(); await this.setProjectSiteLicense(); await this.updateLastUsed(); } async getProject() { const project = await (0, query_1.query)({ db: this.db, select: ["site_license", "settings", "users"], table: "projects", where: { project_id: this.project_id }, one: true, }); this.dbg.verbose(`project=${JSON.stringify(project)}`); return project; } /** * If there is a change in licensing, set it for the project. */ async setProjectSiteLicense() { const dbg = this.dbg.extend("setProjectSiteLicense"); if (!(0, lodash_1.isEqual)(this.projectSiteLicenses, this.nextSiteLicense)) { // Now set the site license since something changed. dbg.info(`setup a modified site license=${JSON.stringify(this.nextSiteLicense)}`); await (0, query_1.query)({ db: this.db, query: "UPDATE projects", where: { project_id: this.project_id }, jsonb_set: { site_license: this.nextSiteLicense }, }); } else { dbg.info("no change"); } } /** * We have to order the site licenses by their priority. * Otherwise, the method of applying them one-by-one does lead to issues, because if a lower priority * license is considered first (and applied), and then a higher priority license is considered next, * the quota algorithm will only pick the higher priority license in the second iteration, causing the * effective quotas to be different, and hence actually both licenses seem to be applied but they are not. * * additionally (march 2022): start with regular licenses, then boost licenses */ orderedSiteLicenseIDs(validLicenses) { const ids = Object.keys(this.projectSiteLicenses).filter((id) => { return validLicenses.get(id) != null; }); const orderedIds = []; // first, pick the "dedicated licenses", in particular dedicated VM. // otherwise: regular quota upgrade licenses are picked and registered as valid, // while in fact later on, when incrementally applying more licenses in computeNextSiteLicense, // those will become ineffective. for (let idx = 0; idx < ids.length; idx++) { const id = ids[idx]; const val = validLicenses.get(id).toJS(); if ((0, quota_1.isSiteLicenseQuotaSetting)(val)) { const vm = val.quota.dedicated_vm; if (vm != null && vm !== false) { orderedIds.push(id); ids.splice(idx, 1); } } } for (let idx = 0; idx < ids.length; idx++) { const id = ids[idx]; const val = validLicenses.get(id).toJS(); if ((0, quota_1.isSiteLicenseQuotaSetting)(val)) { const disk = val.quota.dedicated_disk; if (disk != null) { orderedIds.push(id); ids.splice(idx, 1); } } } // then all regular licenses (boost == false), then the boost licenses for (const boost of [false, true]) { const idsPartition = ids.filter((id) => { const val = validLicenses.get(id).toJS(); // one group is every license, while the other are those where quota.boost is true const isBoost = (0, quota_1.isSiteLicenseQuotaSetting)(val) && (val.quota.boost ?? false); return isBoost === boost; }); orderedIds.push(...(0, lodash_1.sortBy)(idsPartition, (id) => { const val = validLicenses.get(id).toJS(); const key = (0, quota_1.licenseToGroupKey)(val); return ORDERING_GROUP_KEYS.indexOf(key); })); } return orderedIds; } /** * Calculates the next site license situation, replacing whatever the project is currently licensed as. * A particular site license will only be used if it actually causes the upgrades to increase. */ async computeNextSiteLicense() { // Next we check the keys of site_license to see what they contribute, // and fill that in. const nextLicense = {}; const allValidLicenses = await this.getAllValidLicenses(); const reasons = {}; // it's important to start testing with regular licenses by decreasing priority for (const license_id of this.orderedSiteLicenseIDs(allValidLicenses)) { if (!(0, misc_1.is_valid_uuid_string)(license_id)) { // The site_license is supposed to be a map from uuid's to settings... // We could put some sort of error here in case, though I don't know what // we would do with it. this.dbg.info(`skipping invalid license ${license_id} -- invalid UUID`); continue; } const license = allValidLicenses.get(license_id); const status = await this.checkLicense({ license, license_id }); if (status === "valid") { const upgrades = this.extractUpgrades(license); this.dbg.verbose(`computing run quotas by adding ${license_id}...`); const { quota: run_quota } = (0, quota_1.quota_with_reasons)(this.project.settings, this.project.users, nextLicense); const { quota: run_quota_with_license, reasons: newReasons } = (0, quota_1.quota_with_reasons)(this.project.settings, this.project.users, { ...nextLicense, ...{ [license_id]: upgrades }, }); Object.assign(reasons, newReasons); this.dbg.silly(`run_quota=${JSON.stringify(run_quota)}`); this.dbg.silly(`run_quota_with_license=${JSON.stringify(run_quota_with_license)} | reason=${JSON.stringify(newReasons)}`); if (!(0, lodash_1.isEqual)(run_quota, run_quota_with_license)) { this.dbg.info(`License "${license_id}" provides an effective upgrade ${JSON.stringify(upgrades)}.`); nextLicense[license_id] = { ...upgrades, status: "active" }; } else { this.dbg.info(`Found a valid license "${license_id}", but it provides nothing new so not using it (reason: ${newReasons[license_id]})`); nextLicense[license_id] = { status: "ineffective", reason: reasons[license_id], }; } } else { // license is not valid, all other cases: // Note: in an earlier version we did delete an expired license. We don't do this any more, // but instead record that it is expired and tell the user about it. this.dbg.info(`Disabling license "${license_id}" -- status=${status}`); nextLicense[license_id] = { status, reason: status }; // no upgrades or quotas! } } return nextLicense; } /** * get the upgrade provided by a given license */ extractUpgrades(license) { if (license == null) throw new Error("bug"); // Licenses can specify what they do in two distinct ways: upgrades and quota. const upgrades = (license.get("upgrades")?.toJS() ?? {}); if (upgrades == null) { // This is to make typescript happy since QuotaSetting may be null // (though I don't think upgrades ever could be). throw Error("bug"); } const quota = license.get("quota"); if (quota) { upgrades["quota"] = quota.toJS(); } // remove any zero values to make frontend client code simpler and avoid waste/clutter. // NOTE: I do assume these 0 fields are removed in some client code, so don't just not do this! for (const field in upgrades) { if (!upgrades[field]) { delete upgrades[field]; } } return upgrades; } /** * A license can be in in one of these four states: * - valid: the license is valid and provides upgrades * - expired: the license is expired and should be removed * - disabled: the license is disabled and should not provide any upgrades * - future: the license is valid but not yet and should not provide any upgrades as well */ async checkLicense({ license, license_id }) { this.dbg.info(`considering license ${license_id}: ${JSON.stringify(license?.toJS())}`); if (license == null) { this.dbg.info(`License "${license_id}" does not exist.`); return "expired"; } else { const expires = license.get("expires"); const activates = license.get("activates"); const run_limit = license.get("run_limit"); if (expires != null && expires <= new Date()) { this.dbg.info(`License "${license_id}" expired ${expires}.`); return "expired"; } else if (activates == null || activates > new Date()) { this.dbg.info(`License "${license_id}" has not been explicitly activated yet ${activates}.`); return "future"; } else if (await this.aboveRunLimit(run_limit, license_id)) { this.dbg.info(`License "${license_id}" won't be applied since it would exceed the run limit ${run_limit}.`); return "exhausted"; } else { this.dbg.info(`license ${license_id} is valid`); return "valid"; } } } /** * Returns true, if using that license would exceed the run limit. */ async aboveRunLimit(run_limit, license_id) { if (typeof run_limit !== "number") return false; const usage = await (0, analytics_1.number_of_running_projects_using_license)(this.db, license_id); this.dbg.verbose(`run_limit=${run_limit} usage=${usage}`); return usage >= run_limit; } /** * Check for each license involved if the "last_used" field should be updated */ async updateLastUsed() { for (const license_id in this.nextSiteLicense) { // this checks if the given license is actually not deactivated if ((0, misc_1.len)(this.nextSiteLicense[license_id]) > 0) { await this._updateLastUsed(license_id); } } } async _updateLastUsed(license_id) { const dbg = this.dbg.extend(`_updateLastUsed("${license_id}")`); const now = Date.now(); if (LAST_USED[license_id] != null && now - LAST_USED[license_id] <= 60 * 1000) { dbg.info("recently updated so waiting"); // If we updated this entry in the database already within a minute, don't again. return; } LAST_USED[license_id] = now; dbg.info("did NOT recently update, so updating in database"); await (0, async_utils_1.callback2)(this.db._query.bind(this.db), { query: "UPDATE site_licenses", set: { last_used: "NOW()" }, where: { id: license_id }, }); } } //# sourceMappingURL=hook.js.map