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