conductor

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

projects.js (8367B)


      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 { paginateOffset } from './page.js';
     17 import { parseWorkdir } from './pipeline/parse.js';
     18 import { Problems } from './pipeline/schema.js';
     19 import { canManage, canView, visibleSql } from './visibility.js';
     20 
     21 export const VISIBILITIES = ['public', 'private'];
     22 
     23 // A git remote selects a transport: ext:: execs, file:// reads local disk,
     24 // http:// reaches anything routable from the conductor.
     25 const REPO_SCHEMES = new Set(['https:', 'ssh:', 'git:']);
     26 const SCP_LIKE = /^[A-Za-z0-9_.+-]+@[A-Za-z0-9_.-]+:(?!\/)/;
     27 
     28 // allowLocalPath: admin CLI only, which already has host access.
     29 export function assertRepoUrl(value, { allowLocalPath = false } = {}) {
     30   if (typeof value !== 'string' || value.trim().length === 0) {
     31     throw new Error('repo_url is required');
     32   }
     33   const url = value.trim();
     34   if (url.startsWith('-')) throw new Error('repo_url must not start with -');
     35   if (allowLocalPath && url.startsWith('/')) return url;
     36   if (SCP_LIKE.test(url)) return url;
     37 
     38   let parsed;
     39   try {
     40     parsed = new URL(url);
     41   } catch {
     42     throw new Error(`repo_url is not a valid url: ${JSON.stringify(url)}`);
     43   }
     44   if (!REPO_SCHEMES.has(parsed.protocol)) {
     45     throw new Error(
     46       `repo_url scheme ${JSON.stringify(parsed.protocol)} is not allowed; `
     47       + `use one of ${[...REPO_SCHEMES].join(', ')}`
     48     );
     49   }
     50   return url;
     51 }
     52 
     53 // Validated with the same rule the pipeline uses, so a project and a
     54 // repository cannot disagree about what a usable working directory is.
     55 export function assertWorkdir(value) {
     56   const problems = new Problems();
     57   const parsed = parseWorkdir(problems, 'workdir', value);
     58   if (problems.length > 0) {
     59     throw new Error(`workdir ${problems.items[0].message}`);
     60   }
     61   return parsed;
     62 }
     63 
     64 const COLUMNS = `
     65   id, name, repo_url, default_branch, config_path, workdir,
     66   trigger_secret, enabled, job_counter, owner_id, visibility,
     67   artifact_keep_jobs, artifact_keep_days, log_keep_days,
     68   created_at, updated_at
     69 `;
     70 
     71 export function createProjects({ db, secrets }) {
     72   function aad(projectId) {
     73     return `project:${projectId}/trigger_secret`;
     74   }
     75 
     76   return {
     77     async get(id) {
     78       return db.get(`SELECT ${COLUMNS} FROM projects WHERE id = {id}`, { id });
     79     },
     80 
     81     async list() {
     82       return db.all(`SELECT ${COLUMNS} FROM projects ORDER BY name`);
     83     },
     84 
     85     async listOwnedBy(ownerId) {
     86       return db.all(`SELECT ${COLUMNS} FROM projects WHERE owner_id = {owner} ORDER BY name`, { owner: ownerId });
     87     },
     88 
     89     async pageVisible(user, { offset, limit }) {
     90       return paginateOffset(db, {
     91         select: COLUMNS,
     92         from: 'projects',
     93         ...visibleSql(user, { visibility: 'visibility', owner: 'owner_id' }),
     94         order: 'name',
     95         offset,
     96         limit,
     97       });
     98     },
     99 
    100     async create(input, { allowLocalPath = false } = {}) {
    101       const id = input.id ? slugify(input.id) : (slugify(input.name) || newProjectId());
    102       const now = Date.now();
    103 
    104       if (input.visibility && !VISIBILITIES.includes(input.visibility)) {
    105         throw new Error(`visibility must be one of ${VISIBILITIES.join(', ')}`);
    106       }
    107       const repoUrl = assertRepoUrl(input.repo_url, { allowLocalPath });
    108 
    109       if (await this.get(id)) throw new Error(`project ${id} already exists`);
    110 
    111       await db.run(
    112         `INSERT INTO projects
    113            (id, name, repo_url, default_branch, config_path, workdir,
    114             trigger_secret, enabled, job_counter, owner_id, visibility,
    115             created_at, updated_at)
    116          VALUES
    117            ({id}, {name}, {repo_url}, {branch}, {config_path}, {workdir},
    118             {secret}, {enabled}, 0, {owner}, {visibility},
    119             {now}, {now})`,
    120         {
    121           id,
    122           name: input.name || id,
    123           repo_url: repoUrl,
    124           branch: input.default_branch || 'main',
    125           config_path: input.config_path || '.conductor.yml',
    126           workdir: input.workdir ? assertWorkdir(input.workdir) : null,
    127           secret: input.trigger_secret ? secrets.seal(input.trigger_secret, aad(id)) : null,
    128           enabled: input.enabled === undefined ? 1 : (input.enabled ? 1 : 0),
    129           owner: input.owner_id ?? null,
    130           visibility: input.visibility || 'private',
    131           now,
    132         }
    133       );
    134 
    135       return this.get(id);
    136     },
    137 
    138     triggerSecret(project) {
    139       if (!project.trigger_secret) return null;
    140       return secrets.open(project.trigger_secret, aad(project.id));
    141     },
    142 
    143     async setTriggerSecret(id, value) {
    144       await db.run(
    145         'UPDATE projects SET trigger_secret = {secret}, updated_at = {now} WHERE id = {id}',
    146         { id, secret: value === null ? null : secrets.seal(value, aad(id)), now: Date.now() }
    147       );
    148     },
    149 
    150     async setEnabled(id, enabled) {
    151       await db.run(
    152         'UPDATE projects SET enabled = {enabled}, updated_at = {now} WHERE id = {id}',
    153         { id, enabled: enabled ? 1 : 0, now: Date.now() }
    154       );
    155     },
    156 
    157     async setVisibility(id, visibility) {
    158       if (!VISIBILITIES.includes(visibility)) {
    159         throw new Error(`visibility must be one of ${VISIBILITIES.join(', ')}`);
    160       }
    161       await db.run(
    162         'UPDATE projects SET visibility = {visibility}, updated_at = {now} WHERE id = {id}',
    163         { id, visibility, now: Date.now() }
    164       );
    165     },
    166 
    167     // Retention overrides. Null hands the decision back to the server
    168     // default; zero means keep forever, and has to stay distinguishable
    169     // from null or a project could not opt out of a default that deletes.
    170     async setRetention(id, values) {
    171       const columns = ['artifact_keep_jobs', 'artifact_keep_days', 'log_keep_days'];
    172       const updates = [];
    173       const params = { id, now: Date.now() };
    174 
    175       for (const column of columns) {
    176         if (!Object.hasOwn(values, column)) continue;
    177         const value = values[column];
    178 
    179         if (value !== null && (!Number.isInteger(value) || value < 0)) {
    180           throw new Error(`${column} must be a whole number of zero or more, or null to follow the server default`);
    181         }
    182 
    183         updates.push(`${column} = {${column}}`);
    184         params[column] = value;
    185       }
    186 
    187       if (updates.length === 0) return;
    188 
    189       await db.run(
    190         `UPDATE projects SET ${updates.join(', ')}, updated_at = {now} WHERE id = {id}`,
    191         params
    192       );
    193     },
    194 
    195     // Null hands the decision back to the repository, and failing that
    196     // to the server default.
    197     async setWorkdir(id, workdir) {
    198       await db.run(
    199         'UPDATE projects SET workdir = {workdir}, updated_at = {now} WHERE id = {id}',
    200         { id, workdir: workdir ? assertWorkdir(workdir) : null, now: Date.now() }
    201       );
    202     },
    203 
    204     async setOwner(id, ownerId) {
    205       await db.run(
    206         'UPDATE projects SET owner_id = {owner}, updated_at = {now} WHERE id = {id}',
    207         { id, owner: ownerId ?? null, now: Date.now() }
    208       );
    209     },
    210 
    211     async remove(id) {
    212       await db.run('DELETE FROM projects WHERE id = {id}', { id });
    213     },
    214 
    215     // Allocates the next job number. Must run inside the same transaction
    216     // as the job insert, or two concurrent pushes can collide on the
    217     // unique (project_id, number) index.
    218     async nextJobNumber(tx, projectId) {
    219       await tx.run('UPDATE projects SET job_counter = job_counter + 1 WHERE id = {id}', { id: projectId });
    220       const row = await tx.get('SELECT job_counter FROM projects WHERE id = {id}', { id: projectId });
    221       return row.job_counter;
    222     },
    223   };
    224 }
    225 
    226 export function canManageProject(user, project) {
    227   if (!project) return false;
    228   return canManage(user, project.owner_id);
    229 }
    230 
    231 export function canViewProject(user, project) {
    232   if (!project) return false;
    233   return canView(user, { visibility: project.visibility, ownerId: project.owner_id });
    234 }