conductor

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

tasks.js (7577B)


      1 // src/conductor/routes/tasks.js - reading a task back
      2 //
      3 //   GET /tasks/:task                    a task id is unique on its own
      4 //   GET /projects/:project/tasks/:task  the same task, project asserted
      5 //
      6 // The worker protocol lives in routes/agent.js, a separate plugin so its
      7 // worker-token hook cannot reach these routes.
      8 //
      9 // A public task reads without a session; a private one needs a session that
     10 // may manage the project. Absent and forbidden are both 404.
     11 //
     12 // env, services and script are deliberately omitted: all three can carry
     13 // project variables, and a public job may be built from a private repository.
     14 
     15 import { canManageProject } from '../../lib/projects.js';
     16 import { ok } from './envelope.js';
     17 import { identifies } from './guard.js';
     18 import { UnknownTask } from '../../lib/errors.js';
     19 import { outputAad } from '../../lib/outputs.js';
     20 import { limitOf, offsetOfQuery, pageBlock, paginateOffset } from '../../lib/page.js';
     21 
     22 const LOG_WINDOW = 256 * 1024;
     23 
     24 export default async function taskRoutes(fastify, { cfg, db, logs, storage, projects, secrets, auth = null }) {
     25   const base = cfg.server.public_url.replace(/\/+$/, '');
     26 
     27   // Explicit columns rather than t.*, which would pull spec and
     28   // worker_token_id into scope and leave one careless spread away from
     29   // publishing them.
     30   const TASK_QUERY = `
     31     SELECT t.id, t.name, t.base_name, t.state, t.arch, t.image,
     32            t.attempt, t.max_attempts, t.allow_failure, t.exit_code, t.error,
     33            t.worker_name, t.timeout, t.spec, t.log_size, t.log_key, t.log_expired_at,
     34            t.created_at, t.started_at, t.finished_at,
     35            j.id AS job_id, j.number AS job_number, j.ref, j.head_sha,
     36            j.visibility, j.project_id
     37       FROM tasks t
     38       JOIN jobs j ON j.id = t.job_id`;
     39 
     40   identifies(fastify, auth);
     41 
     42   // Names always, values only where the task said they could be shown.
     43   function readOutput(row) {
     44     const masked = row.masked === 1;
     45     if (masked) return { name: row.name, masked, value: null };
     46 
     47     let value = null;
     48     try {
     49       value = secrets.open(row.value, outputAad(row.task_id, row.name));
     50     } catch {
     51       value = null;
     52     }
     53     return { name: row.name, masked, value };
     54   }
     55 
     56   function represent(reply, row) {
     57     const spec = readSpec(row.spec);
     58 
     59     return ok(reply, {
     60       task: {
     61         id: row.id,
     62         name: row.name,
     63         base_name: row.base_name,
     64         state: row.state,
     65         arch: row.arch,
     66         image: row.image,
     67         attempt: row.attempt,
     68         max_attempts: row.max_attempts,
     69         allow_failure: row.allow_failure === 1,
     70         exit_code: row.exit_code,
     71         error: row.error,
     72         worker_name: row.worker_name,
     73         timeout: row.timeout,
     74         // Settled when the job was created, so it is what the task ran in
     75         // rather than what the pipeline says today.
     76         workdir: spec.workdir,
     77         needs: spec.needs,
     78         after: spec.after,
     79         matrix: spec.matrix,
     80         // What it was told to collect, not what it managed to store.
     81         artifact_paths: spec.artifactPaths,
     82         log_size: row.log_size,
     83         log_expired: row.log_expired_at !== null,
     84         created_at: row.created_at,
     85         started_at: row.started_at,
     86         finished_at: row.finished_at,
     87         project_id: row.project_id,
     88         job_id: row.job_id,
     89         job_number: row.job_number,
     90         ref: row.ref,
     91         sha: row.head_sha,
     92         visibility: row.visibility,
     93       },
     94     });
     95   }
     96 
     97   // Naming a project asserts it rather than decorating the path.
     98   async function readableTask(req) {
     99     const row = await db.get(`${TASK_QUERY} WHERE t.id = {id}`, { id: req.params.task });
    100     if (!row) throw new UnknownTask();
    101     if (req.params.project !== undefined && req.params.project !== row.project_id) throw new UnknownTask();
    102 
    103     if (row.visibility !== 'public') {
    104       const project = await projects.get(row.project_id);
    105       if (!canManageProject(req.user, project)) throw new UnknownTask();
    106     }
    107     return row;
    108   }
    109 
    110   function both(suffix, handler) {
    111     fastify.get(`/tasks/:task${suffix}`, handler);
    112     fastify.get(`/projects/:project/tasks/:task${suffix}`, handler);
    113   }
    114 
    115   both('', async (req, reply) => represent(reply, await readableTask(req)));
    116 
    117   both('/outputs', async (req, reply) => {
    118     const task = await readableTask(req);
    119     const window = await paginateOffset(db, {
    120       select: 'task_id, name, value, masked',
    121       from: 'task_outputs',
    122       where: 'task_id = {task}',
    123       order: 'name',
    124       params: { task: task.id },
    125       offset: offsetOfQuery(req.query),
    126       limit: limitOf(req.query),
    127     });
    128 
    129     return ok(reply, window.rows.map(readOutput), pageBlock({ ...window, url: base + req.url }));
    130   });
    131 
    132   both('/artifacts', async (req, reply) => {
    133     const task = await readableTask(req);
    134     const window = await paginateOffset(db, {
    135       select: 'id, path, size, sha256, created_at, expires_at',
    136       from: 'artifacts',
    137       where: 'task_id = {task}',
    138       order: 'path',
    139       params: { task: task.id },
    140       offset: offsetOfQuery(req.query),
    141       limit: limitOf(req.query),
    142     });
    143 
    144     const rows = window.rows.map((a) => ({
    145       ...a,
    146       // A convenience, not the permission: the download route checks again.
    147       url: `${base}/api/v1/projects/${encodeURIComponent(task.project_id)}/jobs/${task.job_id}`
    148         + `/tasks/${task.id}/artifacts/${a.id}`,
    149     }));
    150 
    151     return ok(reply, rows, pageBlock({ ...window, url: base + req.url }));
    152   });
    153 
    154   both('/log', async (req, reply) => {
    155     const task = await readableTask(req);
    156     const offset = offsetOfQuery(req.query);
    157     const limit = limitOf(req.query, LOG_WINDOW, LOG_WINDOW);
    158 
    159     if (task.log_expired_at !== null) {
    160       return ok(reply, { offset: 0, size: 0, data: '', expired: true });
    161     }
    162 
    163     // The spool only exists until the task finishes; after that the log is
    164     // in storage under log_key.
    165     if (!task.log_key) {
    166       const chunk = await logs.read(task.job_id, task.id, { offset, limit });
    167       return ok(reply, {
    168         offset: chunk.offset,
    169         size: chunk.size,
    170         data: chunk.data.toString('utf8'),
    171         expired: false,
    172       });
    173     }
    174 
    175     const size = task.log_size ?? 0;
    176     if (offset >= size) return ok(reply, { offset: size, size, data: '', expired: false });
    177 
    178     const object = await storage.get(task.log_key, { range: { start: offset, end: Math.min(offset + limit, size) - 1 } });
    179     const parts = [];
    180     for await (const part of object.stream) parts.push(part);
    181 
    182     return ok(reply, {
    183       offset,
    184       size,
    185       data: Buffer.concat(parts).toString('utf8'),
    186       expired: false,
    187     });
    188   });
    189 }
    190 
    191 // The stored spec, reduced to the keys that are safe to publish. env and
    192 // services are never read: both can carry project variables.
    193 //
    194 // A spec that will not parse is not this request's problem, and reporting
    195 // the task without its detail beats refusing to report it at all.
    196 function readSpec(raw) {
    197   let spec;
    198   try {
    199     spec = typeof raw === 'string' ? JSON.parse(raw) : raw;
    200   } catch {
    201     spec = null;
    202   }
    203   if (!spec || typeof spec !== 'object') {
    204     return { workdir: null, needs: [], after: [], matrix: {}, artifactPaths: [] };
    205   }
    206 
    207   const paths = spec.artifacts?.paths;
    208   return {
    209     workdir: typeof spec.workdir === 'string' ? spec.workdir : null,
    210     needs: Array.isArray(spec.needs) ? spec.needs : [],
    211     after: Array.isArray(spec.after) ? spec.after : [],
    212     matrix: spec.matrix && typeof spec.matrix === 'object' ? spec.matrix : {},
    213     artifactPaths: Array.isArray(paths) ? paths : [],
    214   };
    215 }