@cocalc/server
Version:
CoCalc server functionality: functions used by either the hub and the next.js server
132 lines (117 loc) • 4.26 kB
text/typescript
/*
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
* License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details
*/
import { getLogger } from "@cocalc/backend/logger";
import type { PostgreSQL } from "@cocalc/database/postgres/types";
import { PurchaseInfo } from "@cocalc/util/licenses/purchase/types";
import { adjustDateRangeEndOnSameDay } from "@cocalc/util/stripe/timecalcs";
import { getDedicatedDiskKey, PRICES } from "@cocalc/util/upgrades/dedicated";
import { v4 as uuid } from "uuid";
const logger = getLogger("createLicense");
// ATTN: for specific intervals, the activates/expires start/end dates should be at the start/end of the day in the user's timezone.
// this is done while selecting the time interval – here, server side, we no longer know the user's time zone.
export default async function createLicense(
database: PostgreSQL,
account_id: string,
info: PurchaseInfo
): Promise<string> {
const license_id = await getUUID(database, info);
logger.debug("creating a license...", license_id, info);
const [start, end] =
info.type !== "disk"
? adjustDateRangeEndOnSameDay([info.start, info.end])
: [info.start, undefined];
const values: { [key: string]: any } = {
"id::UUID": license_id,
"info::JSONB": {
purchased: { account_id, ...info },
},
"activates::TIMESTAMP":
info.subscription != "no"
? new Date(new Date().valueOf() - 60000) // one minute in past to avoid any funny confusion.
: start,
"created::TIMESTAMP": new Date(),
"managers::TEXT[]": [account_id],
"quota::JSONB": await getQuota(info, license_id),
"title::TEXT": info.title,
"description::TEXT": info.description,
"run_limit::INTEGER": info.quantity,
};
if (info.type !== "disk" && end != null) {
values["expires::TIMESTAMP"] = end;
}
await database.async_query({
query: "INSERT INTO site_licenses",
values,
});
return license_id;
}
// this constructs the "quota" object for the license,
// while it also sanity checks all fields. Last chance to find a problem!
async function getQuota(info: PurchaseInfo, license_id: string) {
switch (info.type) {
case "quota":
return {
user: info.user,
ram: info.custom_ram,
cpu: info.custom_cpu,
dedicated_ram: info.custom_dedicated_ram,
dedicated_cpu: info.custom_dedicated_cpu,
disk: info.custom_disk,
always_running: info.custom_uptime === "always_running",
idle_timeout: info.custom_uptime,
member: info.custom_member,
boost: info.boost ?? false,
};
case "vm":
const { machine } = info.dedicated_vm;
if (PRICES.vms[machine] == null) {
throw new Error(`VM type ${machine} does not exist`);
}
return {
dedicated_vm: {
machine,
name: uuid2name(license_id),
},
};
case "disk":
if (info.dedicated_disk === false) {
throw new Error(`info.dedicated_disk cannot be false`);
}
const diskID = getDedicatedDiskKey(info.dedicated_disk);
if (PRICES.disks[diskID] == null) {
throw new Error(`Disk type ${diskID} does not exist`);
}
return {
dedicated_disk: info.dedicated_disk,
};
}
}
const VM_NAME_EXISTS = `
SELECT EXISTS(
SELECT 1 FROM site_licenses WHERE quota -> 'dedicated_vm' ->> 'name' = $1
) AS exists`;
async function getUUID(database: PostgreSQL, info: PurchaseInfo) {
// in the case of type == 'vm', we derive the "name" from the UUID
// and double check that this is a unique name.
if (info.type !== "vm") return uuid();
// we try up to 10 times
for (let i = 0; i < 10; i++) {
const id = uuid();
// use the last part of the UUID id after the last dash
const name = uuid2name(id);
const res = await database.async_query({
query: VM_NAME_EXISTS,
params: [name],
});
if (res.rows[0]?.exists === false) {
return id;
}
}
throw new Error(`Unable to generate a unique name for VM`);
}
// last part of the UUID, we show this to users even if they're not license managers. hence no leak of information.
function uuid2name(id: string) {
return id.split("-").pop();
}