@cocalc/server
Version:
CoCalc server functionality: functions used by either the hub and the next.js server
228 lines (205 loc) • 6.94 kB
text/typescript
/*
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);
}