conductor

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

artifacts.js (2619B)


      1 // src/worker/artifacts.js - collecting and uploading task output
      2 //
      3 // Patterns are matched with the glob support built into node, so there is
      4 // nothing to install. Matches are confined to the workspace: a task can write
      5 // a symlink pointing anywhere it likes, and following one would upload a
      6 // file the task was never given.
      7 
      8 import fs from 'node:fs/promises';
      9 import path from 'node:path';
     10 import { Readable } from 'node:stream';
     11 import { createReadStream } from 'node:fs';
     12 
     13 const MAX_FILES = 2000;
     14 
     15 export async function collectArtifacts(workspace, patterns, { logger = console } = {}) {
     16   const root = await fs.realpath(workspace);
     17   const seen = new Map();
     18 
     19   for (const pattern of patterns) {
     20     let matches;
     21     try {
     22       matches = await Array.fromAsync(fs.glob(pattern, { cwd: root }));
     23     } catch (e) {
     24       logger.warn?.(`artifact pattern ${pattern} failed: ${e.message}`);
     25       continue;
     26     }
     27 
     28     for (const relative of matches) {
     29       if (seen.size >= MAX_FILES) {
     30         logger.warn?.(`artifact limit of ${MAX_FILES} files reached, ignoring the rest`);
     31         return [...seen.values()];
     32       }
     33 
     34       const normalized = relative.split(path.sep).join('/');
     35       if (seen.has(normalized)) continue;
     36 
     37       const absolute = path.join(root, relative);
     38       let resolved;
     39       let stat;
     40       try {
     41         resolved = await fs.realpath(absolute);
     42         stat = await fs.stat(resolved);
     43       } catch {
     44         // Vanished between globbing and stat, or a broken symlink.
     45         continue;
     46       }
     47 
     48       if (!stat.isFile()) continue;
     49       if (resolved !== root && !resolved.startsWith(root + path.sep)) {
     50         logger.warn?.(`refusing artifact ${normalized}: resolves outside the workspace`);
     51         continue;
     52       }
     53 
     54       seen.set(normalized, { path: normalized, absolute: resolved, size: stat.size });
     55     }
     56   }
     57 
     58   return [...seen.values()];
     59 }
     60 
     61 export function shouldCollect(when, success) {
     62   if (when === 'always') return true;
     63   if (when === 'on_failure') return !success;
     64   return success;
     65 }
     66 
     67 export async function uploadArtifacts(client, url, files, { logger = console } = {}) {
     68   const uploaded = [];
     69   for (const file of files) {
     70     try {
     71       const body = Readable.toWeb(createReadStream(file.absolute));
     72       const result = await client.uploadArtifact(url, file.path, body, file.size);
     73       uploaded.push(result);
     74     } catch (e) {
     75       // An artifact that cannot be stored is worth reporting, but the task
     76       // itself already succeeded or failed on its own merits.
     77       logger.warn?.(`could not upload ${file.path}: ${e.message}`);
     78     }
     79   }
     80   return uploaded;
     81 }