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/util/db-schema/projects.ts
Views: 687
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import { State } from "@cocalc/util/compute-states";6import { PurchaseInfo } from "@cocalc/util/licenses/purchase/types";7import { deep_copy } from "@cocalc/util/misc";8import {9ExecuteCodeOptions,10ExecuteCodeOptionsAsyncGet,11ExecuteCodeOutput,12} from "@cocalc/util/types/execute-code";13import { DEFAULT_QUOTAS } from "@cocalc/util/upgrade-spec";1415import { NOTES } from "./crm";16import { FALLBACK_COMPUTE_IMAGE } from "./defaults";17import { SCHEMA as schema } from "./index";18import { Table } from "./types";1920export const MAX_FILENAME_SEARCH_RESULTS = 100;2122Table({23name: "projects",24rules: {25primary_key: "project_id",26//# A lot depends on this being right at all times, e.g., restart state,27//# so do not use db_standby yet.28//# It is simply not robust enough.29//# db_standby : 'safer'3031pg_indexes: [32"last_edited",33"created", // TODO: this could have a fillfactor of 10034"USING GIN (users)", // so get_collaborator_ids is fast35"lti_id",36"USING GIN (state)", // so getting all running projects is fast (e.g. for site_license_usage_log... but also manage-state)37"((state #>> '{state}'))", // projecting the "state" (running, etc.) for its own index – the GIN index above still causes a scan, which we want to avoid.38"((state ->> 'state'))", // same reason as above. both syntaxes appear and we have to index both.39"((state IS NULL))", // not covered by the above40"((settings ->> 'always_running'))", // to quickly know which projects have this setting41"((run_quota ->> 'always_running'))", // same reason as above42"deleted", // in various queries we quickly fiter deleted projects43"site_license", // for queries across projects related to site_license#>>{license_id}44],4546crm_indexes: ["last_edited"],4748user_query: {49get: {50pg_where: ["last_edited >= NOW() - interval '21 days'", "projects"],51pg_where_load: ["last_edited >= NOW() - interval '2 days'", "projects"],52options: [{ limit: 100, order_by: "-last_edited" }],53options_load: [{ limit: 15, order_by: "-last_edited" }],54pg_changefeed: "projects",55throttle_changes: 2000,56fields: {57project_id: null,58name: null,59title: "",60description: "",61users: {},62invite: null, // who has been invited to this project via email63invite_requests: null, // who has requested to be invited64deleted: null,65host: null,66settings: DEFAULT_QUOTAS,67run_quota: null,68site_license: null,69status: null,70state: null,71last_edited: null,72last_active: null,73action_request: null, // last requested action -- {action:?, time:?, started:?, finished:?, err:?}74course: null,75// if the value is not set, we have to use the old default prior to summer 2020 (Ubuntu 18.04, not 20.04!)76compute_image: FALLBACK_COMPUTE_IMAGE,77created: null,78env: null,79sandbox: null,80avatar_image_tiny: null,81// do NOT add avatar_image_full here or it will get included in changefeeds, which we don't want.82// instead it gets its own virtual table.83pay_as_you_go_quotas: null,84},85},86set: {87// NOTE: for security reasons users CANNOT set the course field via a user query;88// instead use the api/v2/projects/course/set-course-field api endpoint.89fields: {90project_id: "project_write",91title: true,92name: true,93description: true,94deleted: true,95invite_requests: true, // project collabs can modify this (e.g., to remove from it once user added or rejected)96users(obj, db, account_id) {97return db._user_set_query_project_users(obj, account_id);98},99action_request: true, // used to request that an action be performed, e.g., "save"; handled by before_change100compute_image: true,101site_license: true,102env: true,103sandbox: true,104avatar_image_tiny: true,105avatar_image_full: true,106},107required_fields: {108project_id: true,109},110before_change(database, old_val, new_val, account_id, cb) {111database._user_set_query_project_change_before(112old_val,113new_val,114account_id,115cb,116);117},118119on_change(database, old_val, new_val, account_id, cb) {120database._user_set_query_project_change_after(121old_val,122new_val,123account_id,124cb,125);126},127},128},129130project_query: {131get: {132pg_where: [{ "project_id = $::UUID": "project_id" }],133fields: {134project_id: null,135title: null,136description: null,137status: null,138},139},140set: {141fields: {142project_id: "project_id",143title: true,144description: true,145status: true,146},147},148},149},150fields: {151project_id: {152type: "uuid",153desc: "The project id, which is the primary key that determines the project.",154},155name: {156type: "string",157pg_type: "VARCHAR(100)",158desc: "The optional name of this project. Must be globally unique (up to case) across all projects with a given *owner*. It can be between 1 and 100 characters from a-z A-Z 0-9 period and dash.",159render: { type: "text", maxLen: 100, editable: true },160},161title: {162type: "string",163desc: "The short title of the project. Should use no special formatting, except hashtags.",164render: { type: "project_link", project_id: "project_id" },165},166description: {167type: "string",168desc: "A longer textual description of the project. This can include hashtags and should be formatted using markdown.",169render: {170type: "markdown",171maxLen: 1024,172editable: true,173},174}, // markdown rendering possibly not implemented175users: {176title: "Collaborators",177type: "map",178desc: "This is a map from account_id's to {hide:bool, group:'owner'|'collaborator', upgrades:{memory:1000, ...}, ssh:{...}}.",179render: { type: "usersmap", editable: true },180},181invite: {182type: "map",183desc: "Map from email addresses to {time:when invite sent, error:error message if there was one}",184date: ["time"],185},186invite_requests: {187type: "map",188desc: "This is a map from account_id's to {timestamp:?, message:'i want to join because...'}.",189date: ["timestamp"],190},191deleted: {192type: "boolean",193desc: "Whether or not this project is deleted.",194render: { type: "boolean", editable: true },195},196host: {197type: "map",198desc: "This is a map {host:'hostname_of_server', assigned:timestamp of when assigned to that server}.",199date: ["assigned"],200},201settings: {202type: "map",203desc: 'This is a map that defines the free base quotas that a project has. It is of the form {cores: 1.5, cpu_shares: 768, disk_quota: 1000, memory: 2000, mintime: 36000000, network: 0, ephemeral_state:0, ephemeral_disk:0, always_running:0}. WARNING: some of the values are strings not numbers in the database right now, e.g., disk_quota:"1000".',204},205site_license: {206type: "map",207desc: "This is a map that defines upgrades (just when running the project) that come from a site license, and also the licenses that are applied to this project. The format is {license_id:{memory:?, mintime:?, ...}} where the target of the license_id is the same as for the settings field. The license_id is the uuid of the license that contributed these upgrades. To tell cocalc to use a license for a project, a user sets site_license to {license_id:{}}, and when it is requested to start the project, the backend decides what allocation license_id provides and changes the field accordingly, i.e., changes {license_id:{},...} to {license_id:{memory:?,...},...}",208},209status: {210type: "map",211desc: "This is a map computed by the status command run inside a project, and slightly enhanced by the compute server, which gives extensive status information about a project. See the exported ProjectStatus interface defined in the code here.",212},213state: {214type: "map",215desc: 'Info about the state of this project of the form {error: "", state: "running" (etc), time: timestamp, ip?:"ip address where project is"}, where time is when the state was last computed. See COMPUTE_STATES in the compute-states file for state.state and the ProjectState interface defined below in code.',216date: ["time"],217},218last_edited: {219type: "timestamp",220desc: "The last time some file was edited in this project. This is the last time that the file_use table was updated for this project.",221},222last_started: {223type: "timestamp",224desc: "The last time the project started running.",225},226last_active: {227type: "map",228desc: "Map from account_id's to the timestamp of when the user with that account_id touched this project.",229date: "all",230},231created: {232type: "timestamp",233desc: "When the project was created.",234},235action_request: {236type: "map",237desc: "Request state change action for project: {action:['start', 'stop'], started:timestamp, err:?, finished:timestamp}",238date: ["started", "finished"],239},240storage: {241type: "map",242desc: "(DEPRECATED) This is a map {host:'hostname_of_server', assigned:when first saved here, saved:when last saved here}.",243date: ["assigned", "saved"],244},245last_backup: {246type: "timestamp",247desc: "(DEPRECATED) Timestamp of last off-disk successful backup using bup to Google cloud storage",248},249storage_request: {250type: "map",251desc: "(DEPRECATED) {action:['save', 'close', 'move', 'open'], requested:timestap, pid:?, target:?, started:timestamp, finished:timestamp, err:?}",252date: ["started", "finished", "requested"],253},254course: {255type: "map",256desc: "{project_id:[id of project that contains .course file], path:[path to .course file], pay:?, payInfo:?, email_address:[optional email address of student -- used if account_id not known], account_id:[account id of student]}, where pay is either not set (or equals falseish) or is a timestamp by which the students must pay. If payInfo is set, it specifies the parameters of the license the students should purchase.",257date: ["pay"],258},259storage_server: {260type: "integer",261desc: "(DEPRECATED) Number of the Kubernetes storage server with the data for this project: one of 0, 1, 2, ...",262},263storage_ready: {264type: "boolean",265desc: "(DEPRECATED) Whether storage is ready to be used on the storage server. Do NOT try to start project until true; this gets set by storage daemon when it notices that run is true.",266},267disk_size: {268type: "integer",269desc: "Size in megabytes of the project disk.",270},271resources: {272type: "map",273desc: 'Object of the form {requests:{memory:"30Mi",cpu:"5m"}, limits:{memory:"100Mi",cpu:"300m"}} which is passed to the k8s resources section for this pod.',274},275preemptible: {276type: "boolean",277desc: "If true, allow to run on preemptible nodes.",278},279idle_timeout: {280type: "integer",281desc: "If given and nonzero, project will be killed if it is idle for this many **minutes**, where idle *means* that last_edited has not been updated.",282},283run_quota: {284type: "map",285desc: "If project is running, this is the quota that it is running with.",286},287compute_image: {288type: "string",289desc: "Specify the name of the underlying (kucalc) compute image.",290},291addons: {292type: "map",293desc: "Configure (kucalc specific) addons for projects. (e.g. academic software, license keys, ...)",294},295lti_id: {296type: "array",297pg_type: "TEXT[]",298desc: "This is a specific ID derived from an LTI context",299},300lti_data: {301type: "map",302desc: "extra information related to LTI",303},304env: {305type: "map",306desc: "Additional environment variables (TS: {[key:string]:string})",307render: { type: "json", editable: true },308},309sandbox: {310type: "boolean",311desc: "If set to true, then any user who attempts to access this project is automatically added as a collaborator to it. Only the project owner can change this setting.",312render: { type: "boolean", editable: true },313},314avatar_image_tiny: {315title: "Image",316type: "string",317desc: "tiny (32x32) visual image associated with the project. Suitable to include as part of changefeed, since about 3kb.",318render: { type: "image" },319},320avatar_image_full: {321title: "Image",322type: "string",323desc: "A visual image associated with the project. Could be 150kb. NOT include as part of changefeed of projects, since potentially big (e.g., 200kb x 1000 projects = 200MB!).",324render: { type: "image" },325},326pay_as_you_go_quotas: {327type: "map",328desc: "Pay as you go quotas that users set so that when they run this project, it gets upgraded to at least what is specified here, and user gets billed later for what is used. Any changes to this table could result in money being spent, so should only be done via the api. This is a map from the account_id of the user that set the quota to the value of the quota spec (which is purchase-quotas.ProjectQuota).",329render: { type: "json", editable: false },330},331notes: NOTES,332},333});334335export interface ApiKeyInfo {336name: string;337trunc: string;338hash?: string;339used?: number;340}341342// Same query above, but without the last_edited time constraint.343schema.projects_all = deep_copy(schema.projects);344if (345schema.projects_all.user_query?.get == null ||346schema.projects.user_query?.get == null347) {348throw Error("make typescript happy");349}350schema.projects_all.user_query.get.options = [];351schema.projects_all.virtual = "projects";352schema.projects_all.user_query.get.pg_where = ["projects"];353354// Table that provides extended read info about a single project355// but *ONLY* for admin.356Table({357name: "projects_admin",358fields: schema.projects.fields,359rules: {360primary_key: schema.projects.primary_key,361virtual: "projects",362user_query: {363get: {364admin: true, // only admins can do get queries on this table365// (without this, users who have read access could read)366pg_where: [{ "project_id = $::UUID": "project_id" }],367fields: schema.projects.user_query.get.fields,368},369},370},371});372373/*374Table that enables set queries to the course field of a project. Only375project owners are allowed to use this table. The point is that this makes376it possible for the owner of the project to set things, but not for the377collaborators to set those things.378**wARNING:** right now we're not using this since when multiple people add379students to a course and the 'course' field doesn't get properly set,380much confusion and misery arises.... and it is very hard to fix.381In theory a malicous student could not pay via this. But if they could382mess with their client, they could easily not pay anyways.383*/384Table({385name: "projects_owner",386rules: {387virtual: "projects",388user_query: {389set: {390fields: {391project_id: "project_owner",392course: true,393},394},395},396},397fields: {398project_id: true,399course: true,400},401});402403/*404405Table that enables any signed-in user to set an invite request.406Later: we can make an index so that users can see all outstanding requests they have made easily.407How to test this from the browser console:408project_id = '4e0f5bfd-3f1b-4d7b-9dff-456dcf8725b8' // id of a project you have409invite_requests = {}; invite_requests[smc.client.account_id] = {timestamp:new Date(), message:'please invite me'}410smc.client.query({cb:console.log, query:{project_invite_requests:{project_id:project_id, invite_requests:invite_requests}}}) // set it411smc.redux.getStore('projects').get_project(project_id).invite_requests // see requests for this project412413CURRENTLY NOT USED, but probably will be...414415database._user_set_query_project_invite_requests(old_val, new_val, account_id, cb)416For now don't check anything -- this is how we will make it secure later.417This will:418- that user setting this is signed in419- ensure user only modifies their own entry (for their own id).420- enforce some hard limit on number of outstanding invites (say 30).421- enforce limit on size of invite message.422- sanity check on timestamp423- with an index as mentioned above we could limit the number of projects424to which a single user has requested to be invited.425426*/427Table({428name: "project_invite_requests",429rules: {430virtual: "projects",431primary_key: "project_id",432user_query: {433set: {434fields: {435project_id: true,436invite_requests: true,437},438before_change(_database, _old_val, _new_val, _account_id, cb) {439cb();440},441},442},443}, // actual function will be database._user...444fields: {445project_id: true,446invite_requests: true,447}, // {account_id:{timestamp:?, message:?}, ...}448});449450/*451Virtual table to get project avatar_images.452We don't put this in the main projects table,453since we don't want the avatar_image_full to be454the projects queries or changefeeds, since it455is big, and by default all get fields appear there.456*/457458Table({459name: "project_avatar_images",460rules: {461virtual: "projects",462primary_key: "project_id",463user_query: {464get: {465pg_where: ["projects"],466fields: {467project_id: null,468avatar_image_full: null,469},470},471},472},473fields: {474project_id: true,475avatar_image_full: true,476},477});478479/*480Table to get/set the datastore config in addons.481482The main idea is to set/update/delete entries in the dict addons.datastore.[key] = {...}483*/484Table({485name: "project_datastore",486rules: {487virtual: "projects",488primary_key: "project_id",489user_query: {490set: {491// this also deals with delete requests492fields: {493project_id: true,494addons: true,495},496async instead_of_change(497db,498_old_value,499new_val,500account_id,501cb,502): Promise<void> {503try {504// to delete an entry, pretend to set the datastore = {delete: [name]}505if (typeof new_val.addons.datastore.delete === "string") {506await db.project_datastore_del(507account_id,508new_val.project_id,509new_val.addons.datastore.delete,510);511cb(undefined);512} else {513// query should set addons.datastore.[new key] = config, such that we see here514// new_val = {"project_id":"...","addons":{"datastore":{"key3":{"type":"xxx", ...}}}}515// which will be merged into the existing addons.datastore dict516const res = await db.project_datastore_set(517account_id,518new_val.project_id,519new_val.addons.datastore,520);521cb(undefined, res);522}523} catch (err) {524cb(`${err}`);525}526},527},528get: {529fields: {530project_id: true,531addons: true,532},533async instead_of_query(db, opts, cb): Promise<void> {534if (opts.multi) {535throw Error("'multi' is not implemented");536}537try {538// important: the config dicts for each key must not expose secret credentials!539// check if opts.query.addons === null ?!540const data = await db.project_datastore_get(541opts.account_id,542opts.query.project_id,543);544cb(undefined, data);545} catch (err) {546cb(`${err}`);547}548},549},550},551},552fields: {553project_id: true,554addons: true,555},556});557558export interface ProjectStatus {559"project.pid"?: number; // pid of project server process560"hub-server.port"?: number; // port of tcp server that is listening for conn from hub561"browser-server.port"?: number; // port listening for http/websocket conn from browser client562"sage_server.port"?: number; // port where sage server is listening.563"sage_server.pid"?: number; // pid of sage server process564start_ts?: number; // timestamp, when project server started565session_id?: string; // unique identifyer566secret_token?: string; // long random secret token that is needed to communicate with local_hub567version?: number; // version number of project code568disk_MB?: number; // MB of used disk569installed?: boolean; // whether code is installed570memory?: {571count?: number;572pss?: number;573rss?: number;574swap?: number;575uss?: number;576}; // output by smem577}578579export interface ProjectState {580ip?: string; // where the project is running581error?: string;582state?: State; // running, stopped, etc.583time?: Date;584}585586Table({587name: "crm_projects",588fields: schema.projects.fields,589rules: {590primary_key: schema.projects.primary_key,591virtual: "projects",592user_query: {593get: {594admin: true, // only admins can do get queries on this table595// (without this, users who have read access could read)596pg_where: [],597fields: {598...schema.projects.user_query?.get?.fields,599notes: null,600},601},602set: {603admin: true,604fields: {605project_id: true,606name: true,607title: true,608description: true,609deleted: true,610notes: true,611},612},613},614},615});616617export type Datastore = boolean | string[] | undefined;618619// in the future, we might want to extend this to include custom environmment variables620export interface EnvVarsRecord {621inherit?: boolean;622}623export type EnvVars = EnvVarsRecord | undefined;624625export interface StudentProjectFunctionality {626disableActions?: boolean;627disableJupyterToggleReadonly?: boolean;628disableJupyterClassicServer?: boolean;629disableJupyterClassicMode?: boolean;630disableJupyterLabServer?: boolean;631disableRServer?: boolean;632disableVSCodeServer?: boolean;633disableLibrary?: boolean;634disableNetworkWarningBanner?: boolean;635disablePlutoServer?: boolean;636disableTerminals?: boolean;637disableUploads?: boolean;638disableNetwork?: boolean;639disableSSH?: boolean;640disableCollaborators?: boolean;641disableChatGPT?: boolean;642disableSharing?: boolean;643}644645export interface CourseInfo {646type: "student" | "shared" | "nbgrader";647account_id?: string; // account_id of the student that this project is for.648project_id: string; // the course project, i.e., project with the .course file649path: string; // path to the .course file in project_id650pay?: string; // iso timestamp or ""651paid?: string; // iso timestamp with *when* they paid.652payInfo?: PurchaseInfo;653email_address?: string;654datastore: Datastore;655student_project_functionality?: StudentProjectFunctionality;656envvars?: EnvVars;657}658659type ExecOptsCommon = {660project_id: string;661cb?: Function; // if given use a callback interface *instead* of async.662};663664export type ExecOptsBlocking = ExecOptsCommon & {665compute_server_id?: number; // if true, run on the compute server (if available)666filesystem?: boolean; // run in fileserver container on compute server; otherwise, runs on main compute container.667path?: string;668command: string;669args?: string[];670timeout?: number;671max_output?: number;672bash?: boolean;673aggregate?: string | number | { value: string | number };674err_on_exit?: boolean;675env?: { [key: string]: string }; // custom environment variables.676async_call?: ExecuteCodeOptions["async_call"];677};678679export type ExecOptsAsync = ExecOptsCommon & {680async_get?: ExecuteCodeOptionsAsyncGet["async_get"];681async_stats?: ExecuteCodeOptionsAsyncGet["async_stats"];682async_await?: ExecuteCodeOptionsAsyncGet["async_await"];683};684685export type ExecOpts = ExecOptsBlocking | ExecOptsAsync;686687export function isExecOptsBlocking(opts: unknown): opts is ExecOptsBlocking {688return (689typeof opts === "object" &&690typeof (opts as any).project_id === "string" &&691typeof (opts as any).command === "string"692);693}694695export type ExecOutput = ExecuteCodeOutput & {696time: number; // time in ms, from user point of view.697};698699700