conductor

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

git.js (9237B)


      1 // src/lib/git.js - repository mirrors
      2 //
      3 // The conductor keeps one bare mirror per project. Everything it needs from
      4 // a push is read out of that mirror: the pipeline file at the pushed commit,
      5 // the commit subject, and the source tarball handed to workers.
      6 //
      7 // Serving the source from here rather than having workers clone means an
      8 // untrusted worker needs no repository credentials and cannot reach any ref
      9 // other than the commit it was given work for. It also means the worker host
     10 // does not need git installed.
     11 //
     12 // Every value interpolated into a git invocation is validated first. Refs
     13 // and object ids arrive from webhooks, and a value beginning with a hyphen
     14 // would otherwise be read as an option.
     15 
     16 import fs from 'node:fs/promises';
     17 import path from 'node:path';
     18 import { execFile, spawn } from 'node:child_process';
     19 import { existsSync } from 'node:fs';
     20 import { promisify } from 'node:util';
     21 
     22 const execFileAsync = promisify(execFile);
     23 
     24 export const SHA_PATTERN = /^[0-9a-f]{40}$/;
     25 export const SHORT_SHA_PATTERN = /^[0-9a-f]{7,40}$/;
     26 
     27 // Reading a repository owned by somebody else is the normal case here, not
     28 // a suspicious one: project paths belong to whoever put them on disk, and
     29 // the conductor is a different user again once it runs in a container.
     30 // Git refuses that by default with "detected dubious ownership", which is
     31 // the right default for a shell and the wrong one for this.
     32 //
     33 // Applied through the environment rather than a global git config, so it
     34 // covers both spawn and execFile below and affects nothing outside this
     35 // process. Anything already set in the environment is preserved, since
     36 // the indices have to be contiguous for git to read them.
     37 function gitEnv(env = process.env) {
     38   const count = Number.parseInt(env.GIT_CONFIG_COUNT ?? '0', 10);
     39   const base = Number.isInteger(count) && count > 0 ? count : 0;
     40   return {
     41     ...env,
     42     // Refuses credential prompts, so a misconfigured private repository
     43     // fails promptly instead of hanging until the request times out.
     44     GIT_TERMINAL_PROMPT: '0',
     45     GIT_ASKPASS: '',
     46     GCM_INTERACTIVE: 'never',
     47     LC_ALL: 'C',
     48     GIT_CONFIG_COUNT: String(base + 1),
     49     [`GIT_CONFIG_KEY_${base}`]: 'safe.directory',
     50     [`GIT_CONFIG_VALUE_${base}`]: '*',
     51   };
     52 }
     53 
     54 const GIT_ENV = gitEnv();
     55 
     56 export { gitEnv };
     57 
     58 export class GitError extends Error {
     59   constructor(message, { stderr, code } = {}) {
     60     super(stderr ? `${message}: ${String(stderr).trim().split('\n').slice(0, 3).join('; ')}` : message);
     61     this.name = 'GitError';
     62     this.stderr = stderr;
     63     this.exitCode = code;
     64   }
     65 }
     66 
     67 export function assertSha(sha, label = 'commit') {
     68   if (typeof sha !== 'string' || !SHA_PATTERN.test(sha)) {
     69     throw new GitError(`${label} must be a full 40 character hex object id, got ${JSON.stringify(sha)}`);
     70   }
     71   return sha;
     72 }
     73 
     74 // Refs come straight from a push hook. Reject anything git itself would
     75 // consider malformed, along with leading hyphens.
     76 export function assertRef(ref) {
     77   if (typeof ref !== 'string' || ref.length === 0 || ref.length > 255) {
     78     throw new GitError(`invalid ref: ${JSON.stringify(ref)}`);
     79   }
     80   if (ref.startsWith('-') || ref.includes('..') || /[\s~^:?*[\\]/.test(ref) || ref.endsWith('.lock')) {
     81     throw new GitError(`invalid ref: ${JSON.stringify(ref)}`);
     82   }
     83   return ref;
     84 }
     85 
     86 // A path inside the repository, used for the pipeline file.
     87 export function assertRepoPath(p) {
     88   if (typeof p !== 'string' || p.length === 0 || p.length > 512) {
     89     throw new GitError(`invalid repository path: ${JSON.stringify(p)}`);
     90   }
     91   if (p.startsWith('/') || p.startsWith('-') || p.split('/').some((s) => s === '' || s === '.' || s === '..')) {
     92     throw new GitError(`invalid repository path: ${JSON.stringify(p)}`);
     93   }
     94   return p;
     95 }
     96 
     97 // Serializes work per mirror, so two pushes to one project cannot run
     98 // concurrent fetches into the same directory.
     99 const locks = new Map();
    100 
    101 async function withLock(key, fn) {
    102   const previous = locks.get(key) ?? Promise.resolve();
    103   let release;
    104   const current = new Promise((resolve) => { release = resolve; });
    105   locks.set(key, previous.then(() => current));
    106   await previous;
    107   try {
    108     return await fn();
    109   } finally {
    110     release();
    111     if (locks.get(key) === current) locks.delete(key);
    112   }
    113 }
    114 
    115 export function createGit(cfg) {
    116   const timeout = cfg.git.timeout * 1000;
    117   const fetchInterval = cfg.git.fetch_interval * 1000;
    118   const lastFetch = new Map();
    119 
    120   function mirrorPath(projectId) {
    121     return path.join(cfg.git.mirror_path, `${projectId}.git`);
    122   }
    123 
    124   // Given --git-dir explicitly, git still treats the process working
    125   // directory as a work tree. That makes the result depend on whatever
    126   // happens to sit in the conductor's own directory: asking for a file
    127   // that is absent from a commit reports "exists on disk, but not in
    128   // <sha>" rather than a plain absence, purely because a file of that
    129   // name is next to the running process. Working inside the bare mirror
    130   // takes the stray work tree out of the picture.
    131   //
    132   // Only when it is already there, since clone is told --git-dir for a
    133   // directory it is about to create.
    134   function repoCwd(args) {
    135     const i = args.indexOf('--git-dir');
    136     if (i < 0) return undefined;
    137     const dir = args[i + 1];
    138     return dir && existsSync(dir) ? dir : undefined;
    139   }
    140 
    141   async function run(args, options = {}) {
    142     try {
    143       const { stdout, stderr } = await execFileAsync('git', args, {
    144         env: GIT_ENV,
    145         cwd: options.cwd ?? repoCwd(args),
    146         timeout,
    147         maxBuffer: options.maxBuffer ?? 16 * 1024 * 1024,
    148         encoding: options.encoding ?? 'utf8',
    149       });
    150       return { stdout, stderr };
    151     } catch (e) {
    152       throw new GitError(`git ${args[0]} failed`, { stderr: e.stderr || e.message, code: e.code });
    153     }
    154   }
    155 
    156   async function exists(dir) {
    157     try {
    158       await fs.stat(path.join(dir, 'HEAD'));
    159       return true;
    160     } catch {
    161       return false;
    162     }
    163   }
    164 
    165   return {
    166     mirrorPath,
    167 
    168     // Clones on first use, then fetches at most once per fetch_interval
    169     // unless force is set. A trigger always forces, since it needs the
    170     // commit that was just pushed.
    171     async sync(project, { force = false } = {}) {
    172       const dir = mirrorPath(project.id);
    173       return withLock(dir, async () => {
    174         if (!(await exists(dir))) {
    175           await fs.mkdir(path.dirname(dir), { recursive: true });
    176           await run(['clone', '--mirror', '--quiet', '--', project.repo_url, dir]);
    177           lastFetch.set(dir, Date.now());
    178           return dir;
    179         }
    180 
    181         const since = Date.now() - (lastFetch.get(dir) ?? 0);
    182         if (!force && since < fetchInterval) return dir;
    183 
    184         await run(['--git-dir', dir, 'fetch', '--prune', '--quiet', 'origin']);
    185         lastFetch.set(dir, Date.now());
    186         return dir;
    187       });
    188     },
    189 
    190     async hasCommit(project, sha) {
    191       assertSha(sha);
    192       try {
    193         const { stdout } = await run(['--git-dir', mirrorPath(project.id), 'cat-file', '-t', sha]);
    194         return stdout.trim() === 'commit';
    195       } catch {
    196         return false;
    197       }
    198     },
    199 
    200     async resolve(project, rev) {
    201       assertRef(rev);
    202       const { stdout } = await run(['--git-dir', mirrorPath(project.id), 'rev-parse', '--verify', `${rev}^{commit}`]);
    203       return stdout.trim();
    204     },
    205 
    206     // Reads one file at a commit. Returns null when the path is absent,
    207     // which is how a repository without a pipeline is detected.
    208     async readFile(project, sha, filePath) {
    209       assertSha(sha);
    210       assertRepoPath(filePath);
    211       try {
    212         const { stdout } = await run(
    213           ['--git-dir', mirrorPath(project.id), 'cat-file', 'blob', `${sha}:${filePath}`],
    214           { maxBuffer: 4 * 1024 * 1024 }
    215         );
    216         return stdout;
    217       } catch (e) {
    218         if (/does not exist|exists on disk, but not in|not a valid object name|Not a valid object/i.test(e.stderr ?? '')) return null;
    219         throw e;
    220       }
    221     },
    222 
    223     async commitInfo(project, sha) {
    224       assertSha(sha);
    225       // A unit separator keeps the fields unambiguous when a subject
    226       // contains anything at all.
    227       const { stdout } = await run([
    228         '--git-dir', mirrorPath(project.id),
    229         'show', '--no-patch', '--format=%s%x1f%an%x1f%ae%x1f%aI', sha,
    230       ]);
    231       const [subject = '', authorName = '', authorEmail = '', authoredAt = ''] = stdout.trim().split('\x1f');
    232       return { subject, authorName, authorEmail, authoredAt };
    233     },
    234 
    235     // A gzipped tar of the tree at a commit, streamed rather than buffered.
    236     // The caller pipes this straight to the worker.
    237     archiveStream(project, sha, { prefix = '' } = {}) {
    238       assertSha(sha);
    239       const args = ['--git-dir', mirrorPath(project.id), 'archive', '--format=tar.gz'];
    240       if (prefix) args.push(`--prefix=${prefix}`);
    241       args.push(sha);
    242 
    243       const child = spawn('git', args, { env: GIT_ENV, cwd: repoCwd(args), stdio: ['ignore', 'pipe', 'pipe'] });
    244       let stderr = '';
    245       child.stderr.on('data', (chunk) => { stderr += chunk.toString().slice(0, 4096); });
    246       child.on('close', (code) => {
    247         if (code !== 0) child.stdout.destroy(new GitError('git archive failed', { stderr, code }));
    248       });
    249       return child.stdout;
    250     },
    251   };
    252 }