conductor

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

workers.js (5971B)


      1 // src/lib/workers.js - worker registration tokens
      2 //
      3 // A worker authenticates with a bearer token. Only the SHA-256 of the token
      4 // is stored, so the database never holds a usable credential; the plaintext
      5 // is shown once at creation and cannot be recovered afterwards.
      6 //
      7 // A token may belong to a user, in which case it is only ever offered jobs
      8 // from that user's projects. A token with no owner is shared and can run
      9 // anything, which is how an administrator provides general capacity. That
     10 // distinction is enforced in the scheduler, not here.
     11 //
     12 // A token is not a machine: several hosts may share one, so what each
     13 // advertises is kept per reported name in worker_agents.
     14 
     15 import { newToken, hashToken, newWorkerTokenId, newWorkerAgentId } from './ids.js';
     16 import { paginateOffset } from './page.js';
     17 
     18 function parseList(value) {
     19   try {
     20     const parsed = JSON.parse(value);
     21     return Array.isArray(parsed) ? parsed : [];
     22   } catch {
     23     return [];
     24   }
     25 }
     26 
     27 export function createWorkerTokens({ db }) {
     28   return {
     29     async create(name, { ownerId = null } = {}) {
     30       if (!name || typeof name !== 'string') throw new Error('worker token needs a name');
     31       const token = newToken();
     32       const id = newWorkerTokenId();
     33       await db.run(
     34         `INSERT INTO worker_tokens (id, name, token_hash, enabled, owner_id, created_at)
     35          VALUES ({id}, {name}, {hash}, 1, {owner}, {now})`,
     36         { id, name, hash: hashToken(token), owner: ownerId, now: Date.now() }
     37       );
     38       // The only time the plaintext exists outside the worker.
     39       return { id, name, token, owner_id: ownerId };
     40     },
     41 
     42     async get(id) {
     43       return db.get(
     44         'SELECT id, name, enabled, owner_id, created_at, last_seen_at, last_ip FROM worker_tokens WHERE id = {id}',
     45         { id }
     46       );
     47     },
     48 
     49     // Returns the token record, or null. Lookup is by hash, so a timing
     50     // difference cannot reveal anything beyond whether a hash exists.
     51     async verify(presented, { ip = null } = {}) {
     52       if (typeof presented !== 'string' || presented.length === 0) return null;
     53       const hash = hashToken(presented);
     54       const row = await db.get(
     55         'SELECT id, name, enabled, owner_id FROM worker_tokens WHERE token_hash = {hash}',
     56         { hash }
     57       );
     58       if (!row || row.enabled !== 1) return null;
     59 
     60       await db.run(
     61         'UPDATE worker_tokens SET last_seen_at = {now}, last_ip = {ip} WHERE id = {id}',
     62         { id: row.id, now: Date.now(), ip }
     63       );
     64       return { id: row.id, name: row.name, owner_id: row.owner_id };
     65     },
     66 
     67     // Upsert syntax is not portable, so read and write share a transaction.
     68     async recordAgent({ tokenId, name, arches = [], features = [], ip = null }) {
     69       const label = typeof name === 'string' ? name.trim().slice(0, 255) : '';
     70       if (!tokenId || !label) return;
     71 
     72       const now = Date.now();
     73       const values = { arches: JSON.stringify(arches), features: JSON.stringify(features) };
     74 
     75       await db.transaction(async (tx) => {
     76         const row = await tx.get(
     77           'SELECT id FROM worker_agents WHERE token_id = {token} AND name = {name}',
     78           { token: tokenId, name: label }
     79         );
     80         if (row) {
     81           await tx.run(
     82             `UPDATE worker_agents
     83                 SET arches = {arches}, features = {features}, last_seen_at = {now}, last_ip = {ip}
     84               WHERE id = {id}`,
     85             { id: row.id, ...values, now, ip }
     86           );
     87           return;
     88         }
     89         await tx.run(
     90           `INSERT INTO worker_agents
     91              (id, token_id, name, arches, features, first_seen_at, last_seen_at, last_ip)
     92            VALUES ({id}, {token}, {name}, {arches}, {features}, {now}, {now}, {ip})`,
     93           { id: newWorkerAgentId(), token: tokenId, name: label, ...values, now, ip }
     94         );
     95       });
     96     },
     97 
     98     async agentsFor(tokenId) {
     99       const rows = await db.all(
    100         `SELECT id, name, arches, features, first_seen_at, last_seen_at, last_ip
    101            FROM worker_agents WHERE token_id = {token} ORDER BY last_seen_at DESC`,
    102         { token: tokenId }
    103       );
    104       return rows.map((row) => ({
    105         ...row,
    106         arches: parseList(row.arches),
    107         features: parseList(row.features),
    108       }));
    109     },
    110 
    111     async list() {
    112       return db.all(
    113         `SELECT id, name, enabled, owner_id, created_at, last_seen_at, last_ip
    114            FROM worker_tokens ORDER BY created_at DESC`
    115       );
    116     },
    117 
    118     async listVisible(user) {
    119       if (user && user.role === 'admin') return this.list();
    120       if (!user) return [];
    121       return db.all(
    122         `SELECT id, name, enabled, owner_id, created_at, last_seen_at, last_ip
    123            FROM worker_tokens WHERE owner_id = {owner} ORDER BY created_at DESC`,
    124         { owner: user.id }
    125       );
    126     },
    127 
    128     // One page of listVisible. An anonymous caller sees nothing, so the
    129     // window is empty rather than a query nobody needs.
    130     async pageVisible(user, { offset, limit }) {
    131       if (!user) return { rows: [], total: 0, offset, limit };
    132       const admin = user.role === 'admin';
    133       return paginateOffset(db, {
    134         select: 'id, name, enabled, owner_id, created_at, last_seen_at, last_ip',
    135         from: 'worker_tokens',
    136         where: admin ? '1 = 1' : 'owner_id = {owner}',
    137         order: 'created_at DESC',
    138         params: admin ? {} : { owner: user.id },
    139         offset,
    140         limit,
    141       });
    142     },
    143 
    144     async setEnabled(id, enabled) {
    145       const res = await db.run(
    146         'UPDATE worker_tokens SET enabled = {enabled} WHERE id = {id}',
    147         { id, enabled: enabled ? 1 : 0 }
    148       );
    149       return res.changes > 0;
    150     },
    151 
    152     async remove(id) {
    153       const res = await db.run('DELETE FROM worker_tokens WHERE id = {id}', { id });
    154       return res.changes > 0;
    155     },
    156   };
    157 }
    158 
    159 export function canManageWorker(user, token) {
    160   if (!user || !token) return false;
    161   if (user.role === 'admin') return true;
    162   return token.owner_id !== null && token.owner_id === user.id;
    163 }