conductor

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

client.js (3898B)


      1 // src/worker/client.js - talking to the conductor
      2 //
      3 // Built on fetch, so there is nothing to install. Every call carries the
      4 // worker token. Network errors are surfaced rather than retried here; the
      5 // agent decides what a failure means, since a failed claim and a failed log
      6 // append want very different handling.
      7 
      8 export class ConductorError extends Error {
      9   constructor(message, { status = 0, body = null } = {}) {
     10     super(message);
     11     this.name = 'ConductorError';
     12     this.status = status;
     13     this.body = body;
     14   }
     15 }
     16 
     17 export function createClient(cfg) {
     18   const base = cfg.conductor_url;
     19   const auth = { authorization: `Bearer ${cfg.token}` };
     20 
     21   async function readError(res, action) {
     22     let body = null;
     23     let detail = '';
     24     try {
     25       const text = await res.text();
     26       detail = text.slice(0, 512);
     27       body = JSON.parse(text);
     28     } catch {
     29       // A non JSON body is still worth reporting as text.
     30     }
     31     return new ConductorError(`${action} failed: ${res.status} ${res.statusText} ${detail}`.trim(), {
     32       status: res.status,
     33       body,
     34     });
     35   }
     36 
     37   return {
     38     // Returns a task, or null when there is nothing to do. This is the only
     39     // URL the worker knows; everything else it needs arrives with the task.
     40     async claim({ arches, features, name }) {
     41       const res = await fetch(`${base}/api/v1/tasks/claim`, {
     42         method: 'POST',
     43         headers: { ...auth, 'content-type': 'application/json' },
     44         body: JSON.stringify({ arches, features, name }),
     45       });
     46       if (res.status === 204) return null;
     47       if (!res.ok) throw await readError(res, 'claim');
     48       return (await res.json()).task;
     49     },
     50 
     51     // The tree at the task's commit. Returned as a web stream so it can be
     52     // piped straight into tar without buffering.
     53     async source(url) {
     54       const res = await fetch(url, { headers: auth });
     55       if (!res.ok) throw await readError(res, 'source download');
     56       return res.body;
     57     },
     58 
     59     // Returns the new log size. A 409 means the conductor and the worker
     60     // disagree about the offset, and carries the offset to resume from.
     61     async appendLog(url, chunk, offset) {
     62       const res = await fetch(url, {
     63         method: 'POST',
     64         headers: { ...auth, 'content-type': 'application/octet-stream', 'x-log-offset': String(offset) },
     65         body: chunk,
     66       });
     67 
     68       if (res.status === 409) {
     69         const body = await res.json().catch(() => ({}));
     70         return { conflict: true, expected: body.expected_offset ?? 0 };
     71       }
     72       if (!res.ok) throw await readError(res, 'log append');
     73       return await res.json();
     74     },
     75 
     76     async uploadArtifact(url, relPath, body, size) {
     77       const res = await fetch(url, {
     78         method: 'POST',
     79         headers: {
     80           ...auth,
     81           'content-type': 'application/octet-stream',
     82           'content-length': String(size),
     83           'x-artifact-path': relPath,
     84         },
     85         body,
     86         duplex: 'half',
     87       });
     88       if (!res.ok) throw await readError(res, `artifact upload (${relPath})`);
     89       return await res.json();
     90     },
     91 
     92     // Reports that the task is alive, and learns whether it should stop.
     93     async heartbeat(url) {
     94       const res = await fetch(url, {
     95         method: 'POST',
     96         headers: { ...auth, 'content-type': 'application/json' },
     97         body: '{}',
     98       });
     99       if (res.status === 404) return { cancelled: true, gone: true };
    100       if (!res.ok) throw await readError(res, 'heartbeat');
    101       return await res.json();
    102     },
    103 
    104     async done(url, { success, exitCode, error }) {
    105       const res = await fetch(url, {
    106         method: 'POST',
    107         headers: { ...auth, 'content-type': 'application/json' },
    108         body: JSON.stringify({ success, exit_code: exitCode ?? null, error: error ?? null }),
    109       });
    110       if (!res.ok) throw await readError(res, 'done');
    111       return await res.json();
    112     },
    113   };
    114 }