conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

projects.js (7630B)


      1 // src/lib/projects.js - project records
      2 //
      3 // A project pairs a repository with the settings needed to turn a push into
      4 // a run, and with an owner.
      5 //
      6 // Ownership is what makes it safe for people to bring their own hardware. A
      7 // user registers their own projects and their own workers; their workers are
      8 // only ever offered their own projects. A project with no owner belongs to
      9 // the installation and is administered centrally.
     10 //
     11 // The trigger secret is the only sensitive field, and it has to be
     12 // recoverable rather than hashed because HMAC verification needs the
     13 // original value, so it is sealed with the secret box.
     14 
     15 import { newProjectId, slugify } from './ids.js';
     16 import { parseWorkdir } from './pipeline/parse.js';
     17 import { Problems } from './pipeline/schema.js';
     18 
     19 export const VISIBILITIES = ['public', 'private'];
     20 
     21 // Validated with the same rule the pipeline uses, so a project and a
     22 // repository cannot disagree about what a usable working directory is.
     23 export function assertWorkdir(value) {
     24   const problems = new Problems();
     25   const parsed = parseWorkdir(problems, 'workdir', value);
     26   if (problems.length > 0) {
     27     throw new Error(`workdir ${problems.items[0].message}`);
     28   }
     29   return parsed;
     30 }
     31 
     32 const COLUMNS = `
     33   id, name, repo_url, default_branch, config_path, workdir,
     34   trigger_secret, enabled, job_counter, owner_id, visibility,
     35   artifact_keep_jobs, artifact_keep_days, log_keep_days,
     36   created_at, updated_at
     37 `;
     38 
     39 export function createProjects({ db, secrets }) {
     40   function aad(projectId) {
     41     return `project:${projectId}/trigger_secret`;
     42   }
     43 
     44   return {
     45     async get(id) {
     46       return db.get(`SELECT ${COLUMNS} FROM projects WHERE id = {id}`, { id });
     47     },
     48 
     49     async list() {
     50       return db.all(`SELECT ${COLUMNS} FROM projects ORDER BY name`);
     51     },
     52 
     53     // What a given user is allowed to see. Administrators see everything;
     54     // everyone else sees what they own plus anything public.
     55     async listVisible(user) {
     56       if (user && user.role === 'admin') return this.list();
     57       if (user) {
     58         return db.all(
     59           `SELECT ${COLUMNS} FROM projects
     60             WHERE owner_id = {owner} OR visibility = 'public'
     61             ORDER BY name`,
     62           { owner: user.id }
     63         );
     64       }
     65       return db.all(`SELECT ${COLUMNS} FROM projects WHERE visibility = 'public' ORDER BY name`);
     66     },
     67 
     68     async listOwnedBy(ownerId) {
     69       return db.all(`SELECT ${COLUMNS} FROM projects WHERE owner_id = {owner} ORDER BY name`, { owner: ownerId });
     70     },
     71 
     72     async create(input) {
     73       const id = input.id ? slugify(input.id) : (slugify(input.name) || newProjectId());
     74       const now = Date.now();
     75 
     76       if (input.visibility && !VISIBILITIES.includes(input.visibility)) {
     77         throw new Error(`visibility must be one of ${VISIBILITIES.join(', ')}`);
     78       }
     79       if (!input.repo_url) throw new Error('repo_url is required');
     80 
     81       if (await this.get(id)) throw new Error(`project ${id} already exists`);
     82 
     83       await db.run(
     84         `INSERT INTO projects
     85            (id, name, repo_url, default_branch, config_path, workdir,
     86             trigger_secret, enabled, job_counter, owner_id, visibility,
     87             created_at, updated_at)
     88          VALUES
     89            ({id}, {name}, {repo_url}, {branch}, {config_path}, {workdir},
     90             {secret}, {enabled}, 0, {owner}, {visibility},
     91             {now}, {now})`,
     92         {
     93           id,
     94           name: input.name || id,
     95           repo_url: input.repo_url,
     96           branch: input.default_branch || 'main',
     97           config_path: input.config_path || '.conductor.yml',
     98           workdir: input.workdir ? assertWorkdir(input.workdir) : null,
     99           secret: input.trigger_secret ? secrets.seal(input.trigger_secret, aad(id)) : null,
    100           enabled: input.enabled === undefined ? 1 : (input.enabled ? 1 : 0),
    101           owner: input.owner_id ?? null,
    102           visibility: input.visibility || 'private',
    103           now,
    104         }
    105       );
    106 
    107       return this.get(id);
    108     },
    109 
    110     triggerSecret(project) {
    111       if (!project.trigger_secret) return null;
    112       return secrets.open(project.trigger_secret, aad(project.id));
    113     },
    114 
    115     async setTriggerSecret(id, value) {
    116       await db.run(
    117         'UPDATE projects SET trigger_secret = {secret}, updated_at = {now} WHERE id = {id}',
    118         { id, secret: value === null ? null : secrets.seal(value, aad(id)), now: Date.now() }
    119       );
    120     },
    121 
    122     async setEnabled(id, enabled) {
    123       await db.run(
    124         'UPDATE projects SET enabled = {enabled}, updated_at = {now} WHERE id = {id}',
    125         { id, enabled: enabled ? 1 : 0, now: Date.now() }
    126       );
    127     },
    128 
    129     async setVisibility(id, visibility) {
    130       if (!VISIBILITIES.includes(visibility)) {
    131         throw new Error(`visibility must be one of ${VISIBILITIES.join(', ')}`);
    132       }
    133       await db.run(
    134         'UPDATE projects SET visibility = {visibility}, updated_at = {now} WHERE id = {id}',
    135         { id, visibility, now: Date.now() }
    136       );
    137     },
    138 
    139     // Retention overrides. Null hands the decision back to the server
    140     // default; zero means keep forever, and has to stay distinguishable
    141     // from null or a project could not opt out of a default that deletes.
    142     async setRetention(id, values) {
    143       const columns = ['artifact_keep_jobs', 'artifact_keep_days', 'log_keep_days'];
    144       const updates = [];
    145       const params = { id, now: Date.now() };
    146 
    147       for (const column of columns) {
    148         if (!Object.hasOwn(values, column)) continue;
    149         const value = values[column];
    150 
    151         if (value !== null && (!Number.isInteger(value) || value < 0)) {
    152           throw new Error(`${column} must be a whole number of zero or more, or null to follow the server default`);
    153         }
    154 
    155         updates.push(`${column} = {${column}}`);
    156         params[column] = value;
    157       }
    158 
    159       if (updates.length === 0) return;
    160 
    161       await db.run(
    162         `UPDATE projects SET ${updates.join(', ')}, updated_at = {now} WHERE id = {id}`,
    163         params
    164       );
    165     },
    166 
    167     // Null hands the decision back to the repository, and failing that
    168     // to the server default.
    169     async setWorkdir(id, workdir) {
    170       await db.run(
    171         'UPDATE projects SET workdir = {workdir}, updated_at = {now} WHERE id = {id}',
    172         { id, workdir: workdir ? assertWorkdir(workdir) : null, now: Date.now() }
    173       );
    174     },
    175 
    176     async setOwner(id, ownerId) {
    177       await db.run(
    178         'UPDATE projects SET owner_id = {owner}, updated_at = {now} WHERE id = {id}',
    179         { id, owner: ownerId ?? null, now: Date.now() }
    180       );
    181     },
    182 
    183     async remove(id) {
    184       await db.run('DELETE FROM projects WHERE id = {id}', { id });
    185     },
    186 
    187     // Allocates the next job number. Must run inside the same transaction
    188     // as the job insert, or two concurrent pushes can collide on the
    189     // unique (project_id, number) index.
    190     async nextJobNumber(tx, projectId) {
    191       await tx.run('UPDATE projects SET job_counter = job_counter + 1 WHERE id = {id}', { id: projectId });
    192       const row = await tx.get('SELECT job_counter FROM projects WHERE id = {id}', { id: projectId });
    193       return row.job_counter;
    194     },
    195   };
    196 }
    197 
    198 // An unowned project is administered centrally; an owned one is the
    199 // owner's, and administrators may act on either.
    200 export function canManageProject(user, project) {
    201   if (!user || !project) return false;
    202   if (user.role === 'admin') return true;
    203   return project.owner_id !== null && project.owner_id === user.id;
    204 }
    205 
    206 export function canViewProject(user, project) {
    207   if (!project) return false;
    208   if (project.visibility === 'public') return true;
    209   return canManageProject(user, project);
    210 }