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 }