UNPKG

@cocalc/server

Version:

CoCalc server functionality: functions used by either the hub and the next.js server

228 lines (205 loc) 6.94 kB
/* Compute client for use in Kubernetes cluster by the hub. This **modifies the database** to get "something out there (manage-actions) to" start and stop the project, copy files between projects, etc. */ import { BaseProject, CopyOptions, ProjectStatus, ProjectState, getProject, } from "./base"; import { db } from "@cocalc/database"; import { callback2 } from "@cocalc/util/async-utils"; import { is_valid_uuid_string, uuid } from "@cocalc/util/misc"; import getLogger from "@cocalc/backend/logger"; const winston = getLogger("project-control-kucalc"); class Project extends BaseProject { constructor(project_id: string) { super(project_id); } private async get(columns: string[]): Promise<{ [field: string]: any }> { return await callback2(db().get_project, { project_id: this.project_id, columns, }); } async state(): Promise<ProjectState> { return (await this.get(["state"]))?.state ?? {}; } async status(): Promise<ProjectStatus> { const status = (await this.get(["status"]))?.status ?? {}; // In KuCalc the ports for various services are hardcoded constants, // and not actually storted in the database, so we put them here. // This is also hardcoded in kucalc's addons/project/image/init/init.sh (!) status["hub-server.port"] = 6000; status["browser-server.port"] = 6001; return status; } async start(): Promise<void> { if (this.stateChanging != null) return; winston.info(`start ${this.project_id}`); if ((await this.state()).state == "running") { winston.debug("start -- already running"); return; } try { this.stateChanging = { state: "starting" }; await this.siteLicenseHook(); await this.actionRequest("start"); await this.waitUntilProject( (project) => project.state?.state == "running" || project.action_request?.finished, 120 ); } finally { this.stateChanging = undefined; } } async stop(): Promise<void> { if (this.stateChanging != null) return; winston.info("stop ", this.project_id); if ((await this.state()).state != "running") { return; } try { this.stateChanging = { state: "stopping" }; await this.actionRequest("stop"); await this.waitUntilProject( (project) => (project.state != null && project.state != "running" && project.state != "stopping") || project.action_request?.finished, 60 ); } finally { this.stateChanging = undefined; } } async copyPath(opts: CopyOptions): Promise<string> { const dbg = this.dbg("copyPath"); dbg(JSON.stringify(opts)); if (opts.path == null) { throw Error("path must be specified"); } opts.target_project_id = opts.target_project_id ? opts.target_project_id : this.project_id; opts.target_path = opts.target_path ? opts.target_path : opts.path; // check UUID are valid if (!is_valid_uuid_string(opts.target_project_id)) { throw Error(`target_project_id=${opts.target_project_id} is invalid`); } const copyID = uuid(); dbg(`copyID=${copyID}`); if (opts.scheduled && opts.scheduled instanceof Date) { // We have to remove the timezone info, b/c the PostgreSQL field is without timezone. // Ideally though, this is always UTC, e.g. "2019-08-08T18:34:49". const d = new Date(opts.scheduled); const offset = d.getTimezoneOffset() / 60; opts.scheduled = new Date(d.getTime() - offset); opts.wait_until_done = false; dbg(`opts.scheduled = ${opts.scheduled}`); } dbg("write query requesting the copy to happen to the database"); await callback2(db()._query, { query: "INSERT INTO copy_paths", values: { "id ::UUID": copyID, "time ::TIMESTAMP": new Date(), "source_project_id ::UUID": this.project_id, "source_path ::TEXT": opts.path, "target_project_id ::UUID": opts.target_project_id, "target_path ::TEXT": opts.target_path, "overwrite_newer ::BOOLEAN": opts.overwrite_newer, "public ::BOOLEAN": opts.public, "delete_missing ::BOOLEAN": opts.delete_missing, "backup ::BOOLEAN": opts.backup, "bwlimit ::TEXT": opts.bwlimit, "timeout ::NUMERIC": opts.timeout, "scheduled ::TIMESTAMP": opts.scheduled, "exclude ::TEXT[]": opts.exclude, }, }); if (opts.wait_until_done == true) { dbg("waiting for the copy request to complete..."); await this.waitUntilCopyFinished(copyID, 60 * 4); dbg("finished"); return ""; } else { dbg("NOT waiting for copy to complete"); return copyID; } } private getProjectSynctable(): any { // this is all in coffeescript, hence the any type above. return db().synctable({ table: "projects", columns: ["state", "action_request"], where: { "project_id = $::UUID": this.project_id }, // where_function is a fast easy test for matching: where_function: (project_id) => project_id == this.project_id, }); } private async actionRequest(action: "start" | "stop"): Promise<void> { await callback2(db()._query, { query: "UPDATE projects", where: { "project_id = $::UUID": this.project_id }, jsonb_set: { action_request: { action, time: new Date(), started: undefined, finished: undefined, }, }, }); } private async waitUntilProject( until: (obj) => boolean, timeout: number // in seconds ): Promise<void> { let synctable: any = undefined; try { synctable = this.getProjectSynctable(); await callback2(synctable.wait, { until: () => until(synctable.get(this.project_id)?.toJS() ?? {}), timeout, }); } finally { synctable?.close(); } } private getCopySynctable(copyID: string): any { return db().synctable({ table: "copy_paths", columns: ["started", "error", "finished"], where: { "id = $::UUID": copyID }, where_function: (id) => id == copyID, }); } private async waitUntilCopyFinished( copyID: string, timeout: number // in seconds ): Promise<void> { let synctable: any = undefined; try { synctable = this.getCopySynctable(copyID); await callback2(synctable.wait, { until: () => synctable.getIn([copyID, "finished"]), timeout, }); const err = synctable.getIn([copyID, "error"]); if (err) { throw Error(err); } } finally { synctable?.close(); } } } export default function get(project_id: string): Project { return (getProject(project_id) as Project) ?? new Project(project_id); }