conductor

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

admin-cli.js (10848B)


      1 // src/admin-cli.js - local administration
      2 //
      3 // Usage:
      4 //   node src/admin-cli.js project:add <id> <repo-url> [--name n] [--branch b]
      5 //                                                     [--config p] [--source archive|clone]
      6 //                                                     [--secret s]
      7 //   node src/admin-cli.js project:list
      8 //   node src/admin-cli.js project:secret <id> [secret]
      9 //   node src/admin-cli.js project:remove <id>
     10 //   node src/admin-cli.js token:add <name>
     11 //   node src/admin-cli.js token:list
     12 //   node src/admin-cli.js token:remove <id>
     13 //   node src/admin-cli.js var:set <project> <NAME> <value> [--visible]
     14 //   node src/admin-cli.js var:list <project>
     15 //   node src/admin-cli.js var:remove <project> <NAME>
     16 //   node src/admin-cli.js user:add <username> <password> [--role admin|user]
     17 //   node src/admin-cli.js user:list
     18 //   node src/admin-cli.js user:passwd <username> <password>
     19 //   node src/admin-cli.js user:remove <username>
     20 //   node src/admin-cli.js job:trigger <project> <sha> [--ref r] [--base b]
     21 //   node src/admin-cli.js job:cancel <job-id>
     22 //
     23 // This runs against the database directly, so it works before the first
     24 // account exists and when the server is down.
     25 
     26 import crypto from 'node:crypto';
     27 import { loadConfig } from './lib/config.js';
     28 import { createServices } from './conductor/app.js';
     29 
     30 const [command, ...rest] = process.argv.slice(2);
     31 
     32 // Splits --flag value pairs out of the positional arguments.
     33 function parseArgs(argv) {
     34   const flags = {};
     35   const positional = [];
     36   for (let i = 0; i < argv.length; i += 1) {
     37     if (argv[i].startsWith('--')) {
     38       const name = argv[i].slice(2);
     39       const next = argv[i + 1];
     40       if (next === undefined || next.startsWith('--')) {
     41         flags[name] = true;
     42       } else {
     43         flags[name] = next;
     44         i += 1;
     45       }
     46     } else {
     47       positional.push(argv[i]);
     48     }
     49   }
     50   return { flags, positional };
     51 }
     52 
     53 const { flags, positional } = parseArgs(rest);
     54 
     55 function usage(message) {
     56   if (message) console.error(`error: ${message}\n`);
     57   console.error(
     58     [
     59       'usage:',
     60       '  project:add <id> <repo-url> [--name n] [--branch b] [--config p] [--source archive|clone] [--secret s]',
     61       '  project:list',
     62       '  project:secret <id> [secret]',
     63       '  project:remove <id>',
     64       '  token:add <name>',
     65       '  token:list',
     66       '  token:remove <id>',
     67       '  var:set <project> <NAME> <value> [--visible]',
     68       '  var:list <project>',
     69       '  var:remove <project> <NAME>',
     70       '  user:add <username> <password> [--role admin|user]',
     71       '  user:list',
     72       '  user:passwd <username> <password>',
     73       '  user:remove <username>',
     74       '  job:trigger <project> <sha> [--ref r] [--base b]',
     75       '  job:cancel <job-id>',
     76     ].join('\n')
     77   );
     78   process.exit(message ? 1 : 0);
     79 }
     80 
     81 if (!command || command === 'help' || command === '--help') usage();
     82 
     83 const cfg = loadConfig();
     84 // No bootstrap account is created here: the CLI already has full access,
     85 // and creating one as a side effect of running a command would be a
     86 // surprise.
     87 const services = await createServices(cfg, { migrationLogger: () => {}, bootstrap: false });
     88 const { projects, workerTokens, variables, users, scheduler, db } = services;
     89 
     90 function table(rows, columns) {
     91   if (rows.length === 0) {
     92     console.log('(none)');
     93     return;
     94   }
     95   const widths = columns.map((c) => Math.max(c.length, ...rows.map((r) => String(r[c] ?? '').length)));
     96   console.log(columns.map((c, i) => c.padEnd(widths[i])).join('  '));
     97   for (const row of rows) {
     98     console.log(columns.map((c, i) => String(row[c] ?? '').padEnd(widths[i])).join('  '));
     99   }
    100 }
    101 
    102 const when = (ms) => (ms ? new Date(ms).toISOString().replace('T', ' ').slice(0, 19) : '');
    103 
    104 try {
    105   switch (command) {
    106     case 'project:add': {
    107       const [id, repoUrl] = positional;
    108       if (!id || !repoUrl) usage('project:add needs an id and a repository url');
    109 
    110       // Generated when not supplied, since an unsigned trigger endpoint is
    111       // rarely what anyone actually wants.
    112       const secret = flags.secret === true || flags.secret === undefined
    113         ? crypto.randomBytes(24).toString('hex')
    114         : String(flags.secret);
    115 
    116       const project = await projects.create({
    117         id,
    118         name: flags.name ? String(flags.name) : id,
    119         repo_url: repoUrl,
    120         default_branch: flags.branch ? String(flags.branch) : 'main',
    121         config_path: flags.config ? String(flags.config) : '.conductor.yml',
    122         trigger_secret: secret,
    123       });
    124 
    125       console.log(`created project ${project.id}`);
    126       console.log(`  repository:  ${project.repo_url}`);
    127       console.log(`  pipeline:    ${project.config_path}`);
    128       console.log(`  trigger url: ${cfg.server.public_url.replace(/\/+$/, '')}/api/v1/projects/${project.id}/trigger`);
    129       console.log(`  secret:      ${secret}`);
    130       break;
    131     }
    132 
    133     case 'project:list': {
    134       const rows = (await projects.list()).map((p) => ({
    135         id: p.id,
    136         repo_url: p.repo_url,
    137         branch: p.default_branch,
    138         config: p.config_path,
    139         enabled: p.enabled ? 'yes' : 'no',
    140         jobs: p.job_counter,
    141       }));
    142       table(rows, ['id', 'repo_url', 'branch', 'config', 'source', 'enabled', 'jobs']);
    143       break;
    144     }
    145 
    146     case 'project:secret': {
    147       const [id, value] = positional;
    148       if (!id) usage('project:secret needs a project id');
    149       if (!(await projects.get(id))) usage(`unknown project ${id}`);
    150       const secret = value ?? crypto.randomBytes(24).toString('hex');
    151       await projects.setTriggerSecret(id, secret);
    152       console.log(`trigger secret for ${id}: ${secret}`);
    153       break;
    154     }
    155 
    156     case 'project:remove': {
    157       const [id] = positional;
    158       if (!id) usage('project:remove needs a project id');
    159       await projects.remove(id);
    160       console.log(`removed project ${id}`);
    161       break;
    162     }
    163 
    164     case 'token:add': {
    165       const [name] = positional;
    166       if (!name) usage('token:add needs a name');
    167       const created = await workerTokens.create(name);
    168       console.log(`created worker token ${created.id} for ${created.name}`);
    169       console.log(`  token: ${created.token}`);
    170       console.log('  This is shown once. Store it in the worker configuration now.');
    171       break;
    172     }
    173 
    174     case 'token:list': {
    175       const rows = (await workerTokens.list()).map((t) => ({
    176         id: t.id,
    177         name: t.name,
    178         enabled: t.enabled ? 'yes' : 'no',
    179         created: when(t.created_at),
    180         last_seen: when(t.last_seen_at),
    181         last_ip: t.last_ip ?? '',
    182       }));
    183       table(rows, ['id', 'name', 'enabled', 'created', 'last_seen', 'last_ip']);
    184       break;
    185     }
    186 
    187     case 'token:remove': {
    188       const [id] = positional;
    189       if (!id) usage('token:remove needs a token id');
    190       console.log(await workerTokens.remove(id) ? `removed token ${id}` : `no such token ${id}`);
    191       break;
    192     }
    193 
    194     case 'var:set': {
    195       const [projectId, name, ...valueParts] = positional;
    196       const value = valueParts.join(' ');
    197       if (!projectId || !name || valueParts.length === 0) usage('var:set needs a project, a name and a value');
    198       if (!(await projects.get(projectId))) usage(`unknown project ${projectId}`);
    199       if (!cfg.secrets.encryption_key) {
    200         console.warn('warning: secrets.encryption_key is not set, so this is stored in the clear');
    201       }
    202       await variables.set(projectId, name, value, { masked: flags.visible !== true });
    203       console.log(`set ${name} for ${projectId}${flags.visible === true ? '' : ' (masked in logs)'}`);
    204       break;
    205     }
    206 
    207     case 'var:list': {
    208       const [projectId] = positional;
    209       if (!projectId) usage('var:list needs a project');
    210       const rows = (await variables.list(projectId)).map((v) => ({
    211         name: v.name,
    212         masked: v.masked ? 'yes' : 'no',
    213         at_rest: v.plaintext_at_rest ? 'PLAINTEXT' : 'encrypted',
    214         created: when(v.created_at),
    215       }));
    216       table(rows, ['name', 'masked', 'at_rest', 'created']);
    217       break;
    218     }
    219 
    220     case 'var:remove': {
    221       const [projectId, name] = positional;
    222       if (!projectId || !name) usage('var:remove needs a project and a name');
    223       console.log(await variables.remove(projectId, name) ? `removed ${name}` : `no such variable ${name}`);
    224       break;
    225     }
    226 
    227     case 'user:add': {
    228       const [username, password] = positional;
    229       if (!username || !password) usage('user:add needs a username and a password');
    230       const user = await users.create({
    231         username,
    232         password,
    233         role: flags.role ? String(flags.role) : 'user',
    234       });
    235       console.log(`created user ${user.username} with role ${user.role}`);
    236       break;
    237     }
    238 
    239     case 'user:list': {
    240       const rows = (await users.list()).map((u) => ({
    241         username: u.username,
    242         role: u.role,
    243         enabled: u.disabled ? 'no' : 'yes',
    244         created: when(u.created_at),
    245         last_login: when(u.last_login_at),
    246       }));
    247       table(rows, ['username', 'role', 'enabled', 'created', 'last_login']);
    248       break;
    249     }
    250 
    251     case 'user:passwd': {
    252       const [username, password] = positional;
    253       if (!username || !password) usage('user:passwd needs a username and a password');
    254       const existing = await users.byUsername(username);
    255       if (!existing) usage(`unknown user ${username}`);
    256       await users.setPassword(existing.id, password);
    257       console.log(`password updated for ${username}`);
    258       break;
    259     }
    260 
    261     case 'user:remove': {
    262       const [username] = positional;
    263       if (!username) usage('user:remove needs a username');
    264       const existing = await users.byUsername(username);
    265       if (!existing) usage(`unknown user ${username}`);
    266       await users.remove(existing.id);
    267       console.log(`removed user ${username}`);
    268       break;
    269     }
    270 
    271     case 'job:trigger': {
    272       const [projectId, sha] = positional;
    273       if (!projectId || !sha) usage('job:trigger needs a project and a commit');
    274       const project = await projects.get(projectId);
    275       if (!project) usage(`unknown project ${projectId}`);
    276 
    277       const result = await scheduler.createJob(project, {
    278         headSha: sha,
    279         ref: flags.ref ? String(flags.ref) : null,
    280         baseSha: flags.base ? String(flags.base) : null,
    281         trigger: 'manual',
    282         actor: 'admin-cli',
    283       });
    284       console.log(`created job ${result.jobId} with ${result.taskCount} task(s)`);
    285       break;
    286     }
    287 
    288     case 'job:cancel': {
    289       const [jobId] = positional;
    290       if (!jobId) usage('job:cancel needs a job id');
    291       const result = await scheduler.cancelJob(jobId);
    292       console.log(result.ok ? `cancelled ${jobId}` : `could not cancel: ${result.reason}`);
    293       break;
    294     }
    295 
    296     default:
    297       usage(`unknown command ${command}`);
    298   }
    299 } catch (e) {
    300   console.error(`error: ${e.message}`);
    301   process.exitCode = 1;
    302 } finally {
    303   await db.close();
    304 }