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 }