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 }