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 }