conductor

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

admin-cli.js (10951B)


      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         visibility: flags.visibility ? String(flags.visibility) : 'private',
    123         trigger_secret: secret,
    124       }, { allowLocalPath: true });
    125 
    126       console.log(`created project ${project.id}`);
    127       console.log(`  repository:  ${project.repo_url}`);
    128       console.log(`  pipeline:    ${project.config_path}`);
    129       console.log(`  trigger url: ${cfg.server.public_url.replace(/\/+$/, '')}/api/v1/projects/${project.id}/trigger`);
    130       console.log(`  secret:      ${secret}`);
    131       break;
    132     }
    133 
    134     case 'project:list': {
    135       const rows = (await projects.list()).map((p) => ({
    136         id: p.id,
    137         repo_url: p.repo_url,
    138         branch: p.default_branch,
    139         config: p.config_path,
    140         enabled: p.enabled ? 'yes' : 'no',
    141         jobs: p.job_counter,
    142       }));
    143       table(rows, ['id', 'repo_url', 'branch', 'config', 'source', 'enabled', 'jobs']);
    144       break;
    145     }
    146 
    147     case 'project:secret': {
    148       const [id, value] = positional;
    149       if (!id) usage('project:secret needs a project id');
    150       if (!(await projects.get(id))) usage(`unknown project ${id}`);
    151       const secret = value ?? crypto.randomBytes(24).toString('hex');
    152       await projects.setTriggerSecret(id, secret);
    153       console.log(`trigger secret for ${id}: ${secret}`);
    154       break;
    155     }
    156 
    157     case 'project:remove': {
    158       const [id] = positional;
    159       if (!id) usage('project:remove needs a project id');
    160       await projects.remove(id);
    161       console.log(`removed project ${id}`);
    162       break;
    163     }
    164 
    165     case 'token:add': {
    166       const [name] = positional;
    167       if (!name) usage('token:add needs a name');
    168       const created = await workerTokens.create(name);
    169       console.log(`created worker token ${created.id} for ${created.name}`);
    170       console.log(`  token: ${created.token}`);
    171       console.log('  This is shown once. Store it in the worker configuration now.');
    172       break;
    173     }
    174 
    175     case 'token:list': {
    176       const rows = (await workerTokens.list()).map((t) => ({
    177         id: t.id,
    178         name: t.name,
    179         enabled: t.enabled ? 'yes' : 'no',
    180         created: when(t.created_at),
    181         last_seen: when(t.last_seen_at),
    182         last_ip: t.last_ip ?? '',
    183       }));
    184       table(rows, ['id', 'name', 'enabled', 'created', 'last_seen', 'last_ip']);
    185       break;
    186     }
    187 
    188     case 'token:remove': {
    189       const [id] = positional;
    190       if (!id) usage('token:remove needs a token id');
    191       console.log(await workerTokens.remove(id) ? `removed token ${id}` : `no such token ${id}`);
    192       break;
    193     }
    194 
    195     case 'var:set': {
    196       const [projectId, name, ...valueParts] = positional;
    197       const value = valueParts.join(' ');
    198       if (!projectId || !name || valueParts.length === 0) usage('var:set needs a project, a name and a value');
    199       if (!(await projects.get(projectId))) usage(`unknown project ${projectId}`);
    200       if (!cfg.secrets.encryption_key) {
    201         console.warn('warning: secrets.encryption_key is not set, so this is stored in the clear');
    202       }
    203       await variables.set(projectId, name, value, { masked: flags.visible !== true });
    204       console.log(`set ${name} for ${projectId}${flags.visible === true ? '' : ' (masked in logs)'}`);
    205       break;
    206     }
    207 
    208     case 'var:list': {
    209       const [projectId] = positional;
    210       if (!projectId) usage('var:list needs a project');
    211       const rows = (await variables.list(projectId)).map((v) => ({
    212         name: v.name,
    213         masked: v.masked ? 'yes' : 'no',
    214         at_rest: v.plaintext_at_rest ? 'PLAINTEXT' : 'encrypted',
    215         created: when(v.created_at),
    216       }));
    217       table(rows, ['name', 'masked', 'at_rest', 'created']);
    218       break;
    219     }
    220 
    221     case 'var:remove': {
    222       const [projectId, name] = positional;
    223       if (!projectId || !name) usage('var:remove needs a project and a name');
    224       console.log(await variables.remove(projectId, name) ? `removed ${name}` : `no such variable ${name}`);
    225       break;
    226     }
    227 
    228     case 'user:add': {
    229       const [username, password] = positional;
    230       if (!username || !password) usage('user:add needs a username and a password');
    231       const user = await users.create({
    232         username,
    233         password,
    234         role: flags.role ? String(flags.role) : 'user',
    235       });
    236       console.log(`created user ${user.username} with role ${user.role}`);
    237       break;
    238     }
    239 
    240     case 'user:list': {
    241       const rows = (await users.list()).map((u) => ({
    242         username: u.username,
    243         role: u.role,
    244         enabled: u.disabled ? 'no' : 'yes',
    245         created: when(u.created_at),
    246         last_login: when(u.last_login_at),
    247       }));
    248       table(rows, ['username', 'role', 'enabled', 'created', 'last_login']);
    249       break;
    250     }
    251 
    252     case 'user:passwd': {
    253       const [username, password] = positional;
    254       if (!username || !password) usage('user:passwd needs a username and a password');
    255       const existing = await users.byUsername(username);
    256       if (!existing) usage(`unknown user ${username}`);
    257       await users.setPassword(existing.id, password);
    258       console.log(`password updated for ${username}`);
    259       break;
    260     }
    261 
    262     case 'user:remove': {
    263       const [username] = positional;
    264       if (!username) usage('user:remove needs a username');
    265       const existing = await users.byUsername(username);
    266       if (!existing) usage(`unknown user ${username}`);
    267       await users.remove(existing.id);
    268       console.log(`removed user ${username}`);
    269       break;
    270     }
    271 
    272     case 'job:trigger': {
    273       const [projectId, sha] = positional;
    274       if (!projectId || !sha) usage('job:trigger needs a project and a commit');
    275       const project = await projects.get(projectId);
    276       if (!project) usage(`unknown project ${projectId}`);
    277 
    278       const result = await scheduler.createJob(project, {
    279         headSha: sha,
    280         ref: flags.ref ? String(flags.ref) : null,
    281         baseSha: flags.base ? String(flags.base) : null,
    282         trigger: 'manual',
    283         actor: 'admin-cli',
    284       });
    285       console.log(`created job ${result.jobId} with ${result.taskCount} task(s)`);
    286       break;
    287     }
    288 
    289     case 'job:cancel': {
    290       const [jobId] = positional;
    291       if (!jobId) usage('job:cancel needs a job id');
    292       const result = await scheduler.cancelJob(jobId);
    293       console.log(result.ok ? `cancelled ${jobId}` : `could not cancel: ${result.reason}`);
    294       break;
    295     }
    296 
    297     default:
    298       usage(`unknown command ${command}`);
    299   }
    300 } catch (e) {
    301   console.error(`error: ${e.message}`);
    302   process.exitCode = 1;
    303 } finally {
    304   await db.close();
    305 }