conductor

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

tasks.js (7243B)


      1 // src/conductor/routes/tasks.js - reading a task back
      2 //
      3 // A task is the standalone unit a worker runs, and this is how everyone who
      4 // is not that worker looks at one: its state, and the artifacts it stored.
      5 // routes/workers.js holds the protocol the worker itself speaks, behind a
      6 // worker token. These two files share a URL prefix and nothing else, which
      7 // is why they are separate plugins: the token hook over there covers its
      8 // whole scope, and must not reach these routes.
      9 //
     10 // Two paths, one answer:
     11 //
     12 //   GET /tasks/:task                    a task id is unique on its own
     13 //   GET /projects/:project/tasks/:task  the same task, said in full
     14 //
     15 // They differ only in what may reach a private task. A task id is enough to
     16 // prove nothing, so the bare path accepts a public task or a session that
     17 // may manage the project. The scoped path can look up the project's trigger
     18 // secret, so it also accepts a signature, matching the job read-back it sits
     19 // beside: one credential covers starting a build, watching the job and
     20 // reading any task in it.
     21 //
     22 // Absent and forbidden are both 404, so neither can be used to find out
     23 // which tasks exist.
     24 //
     25 // What is deliberately not here: the task's environment, and its services,
     26 // which carry an environment of their own. Both can hold project variables,
     27 // and a public job is still built from a repository that may be private. The
     28 // script is left out for the same reason. What remains describes what the
     29 // task was and how it went, which is what a caller asking after a task is
     30 // actually asking.
     31 
     32 import { canManageProject } from '../../lib/projects.js';
     33 import { verifySignature } from '../../lib/signature.js';
     34 
     35 export default async function taskRoutes(fastify, { cfg, db, projects, auth = null }) {
     36   const base = cfg.server.public_url.replace(/\/+$/, '');
     37 
     38   // Explicit columns rather than t.*, which would pull spec and
     39   // worker_token_id into scope and leave one careless spread away from
     40   // publishing them.
     41   const TASK_QUERY = `
     42     SELECT t.id, t.name, t.base_name, t.state, t.arch, t.image,
     43            t.attempt, t.max_attempts, t.allow_failure, t.exit_code, t.error,
     44            t.worker_name, t.timeout, t.spec, t.log_size,
     45            t.created_at, t.started_at, t.finished_at,
     46            j.id AS job_id, j.number AS job_number, j.ref, j.head_sha,
     47            j.visibility, j.project_id
     48       FROM tasks t
     49       JOIN jobs j ON j.id = t.job_id`;
     50 
     51   async function currentUser(req) {
     52     if (!auth) return null;
     53     try {
     54       return await auth.identify(req);
     55     } catch {
     56       return null;
     57     }
     58   }
     59 
     60   // Everything a caller is allowed to see, assembled once so the two routes
     61   // cannot drift into answering differently.
     62   async function represent(reply, row) {
     63     const spec = readSpec(row.spec);
     64 
     65     const artifacts = await db.all(
     66       `SELECT id, path, size, sha256, created_at, expires_at
     67          FROM artifacts WHERE task_id = {task} ORDER BY path`,
     68       { task: row.id }
     69     );
     70 
     71     return reply.send({
     72       task: {
     73         id: row.id,
     74         name: row.name,
     75         base_name: row.base_name,
     76         state: row.state,
     77         arch: row.arch,
     78         image: row.image,
     79         attempt: row.attempt,
     80         max_attempts: row.max_attempts,
     81         allow_failure: row.allow_failure === 1,
     82         exit_code: row.exit_code,
     83         error: row.error,
     84         worker_name: row.worker_name,
     85         timeout: row.timeout,
     86         // Settled when the job was created, so it is what the task ran in
     87         // rather than what the pipeline says today.
     88         workdir: spec.workdir,
     89         needs: spec.needs,
     90         matrix: spec.matrix,
     91         // What the task was told to collect, which is not the same as what
     92         // it managed to store. The list below is what exists.
     93         artifact_paths: spec.artifactPaths,
     94         log_size: row.log_size,
     95         created_at: row.created_at,
     96         started_at: row.started_at,
     97         finished_at: row.finished_at,
     98         project_id: row.project_id,
     99         job_id: row.job_id,
    100         job_number: row.job_number,
    101         ref: row.ref,
    102         sha: row.head_sha,
    103         visibility: row.visibility,
    104       },
    105       artifacts: artifacts.map((a) => ({
    106         id: a.id,
    107         path: a.path,
    108         size: a.size,
    109         sha256: a.sha256,
    110         created_at: a.created_at,
    111         expires_at: a.expires_at,
    112         // Ready to fetch. The download route checks visibility again for
    113         // itself, so this is a convenience and not the permission.
    114         url: `${base}/api/v1/projects/${encodeURIComponent(row.project_id)}/jobs/${row.job_id}`
    115           + `/tasks/${row.id}/artifacts/${a.id}`,
    116       })),
    117     });
    118   }
    119 
    120   // A task id is unique by itself, so the project need not be named. Without
    121   // one there is no trigger secret to check against, leaving a public task
    122   // or a session that may manage the project.
    123   fastify.get('/tasks/:task', async (req, reply) => {
    124     const row = await db.get(`${TASK_QUERY} WHERE t.id = {id}`, { id: req.params.task });
    125     if (!row) return reply.code(404).send({ error: 'unknown task' });
    126 
    127     if (row.visibility !== 'public') {
    128       const project = await projects.get(row.project_id);
    129       if (!canManageProject(await currentUser(req), project)) {
    130         return reply.code(404).send({ error: 'unknown task' });
    131       }
    132     }
    133 
    134     return represent(reply, row);
    135   });
    136 
    137   // The same task named in full. The project in the path must be the one the
    138   // task belongs to, so a guessed id cannot be read through a project that
    139   // happens to be readable.
    140   fastify.get('/projects/:project/tasks/:task', async (req, reply) => {
    141     const project = await projects.get(req.params.project);
    142     if (!project) return reply.code(404).send({ error: 'unknown task' });
    143 
    144     const row = await db.get(
    145       `${TASK_QUERY} WHERE t.id = {id} AND j.project_id = {project}`,
    146       { id: req.params.task, project: project.id }
    147     );
    148     if (!row) return reply.code(404).send({ error: 'unknown task' });
    149 
    150     if (row.visibility !== 'public' && !(await mayRead(req, project))) {
    151       return reply.code(404).send({ error: 'unknown task' });
    152     }
    153 
    154     return represent(reply, row);
    155   });
    156 
    157   async function mayRead(req, project) {
    158     const secret = projects.triggerSecret(project);
    159     if (secret && verifySignature(req, secret)) return true;
    160 
    161     return canManageProject(await currentUser(req), project);
    162   }
    163 }
    164 
    165 // The stored spec, reduced to the keys that are safe to publish. env and
    166 // services are never read: both can carry project variables.
    167 //
    168 // A spec that will not parse is not this request's problem, and reporting
    169 // the task without its detail beats refusing to report it at all.
    170 function readSpec(raw) {
    171   let spec;
    172   try {
    173     spec = typeof raw === 'string' ? JSON.parse(raw) : raw;
    174   } catch {
    175     spec = null;
    176   }
    177   if (!spec || typeof spec !== 'object') {
    178     return { workdir: null, needs: [], matrix: {}, artifactPaths: [] };
    179   }
    180 
    181   const paths = spec.artifacts?.paths;
    182   return {
    183     workdir: typeof spec.workdir === 'string' ? spec.workdir : null,
    184     needs: Array.isArray(spec.needs) ? spec.needs : [],
    185     matrix: spec.matrix && typeof spec.matrix === 'object' ? spec.matrix : {},
    186     artifactPaths: Array.isArray(paths) ? paths : [],
    187   };
    188 }