UNPKG

antietcd

Version:

Simplistic etcd replacement based on TinyRaft

334 lines (315 loc) 10.5 kB
#!/usr/bin/env node // CLI for AntiEtcd // (c) Vitaliy Filippov, 2024 // License: Mozilla Public License 2.0 or Vitastor Network Public License 1.1 const fs = require('fs'); const fsp = require('fs').promises; const http = require('http'); const https = require('https'); const help_text = `CLI for AntiEtcd (c) Vitaliy Filippov, 2024 License: Mozilla Public License 2.0 or Vitastor Network Public License 1.1 Usage: anticli.js [OPTIONS] put <key> [<value>] anticli.js [OPTIONS] get <key> [-p|--prefix] [-v|--print-value-only] [-k|--keys-only] [--no-temp] anticli.js [OPTIONS] del <key> [-p|--prefix] anticli.js [OPTIONS] load [--with-lease] < dump.json Options: [--endpoints|-e http://node1:2379,http://node2:2379,http://node3:2379] [--cert cert.pem] [--key key.pem] [--timeout 1000] [--json] `; class AntiEtcdCli { static parse(args) { const cmd = []; const options = {}; for (let i = 2; i < args.length; i++) { const arg = args[i].toLowerCase().replace(/^--(.+)$/, (m, m1) => '--'+m1.replace(/-/g, '_')); if (arg === '-h' || arg === '--help') { process.stderr.write(help_text); process.exit(); } else if (arg == '-e' || arg == '--endpoints') { options['endpoints'] = args[++i].split(/\s*[,\s]+\s*/); } else if (arg == '-p' || arg == '--prefix') { options['prefix'] = true; } else if (arg == '-v' || arg == '--print_value_only') { options['print_value_only'] = true; } else if (arg == '-k' || arg == '--keys_only') { options['keys_only'] = true; } else if (arg == '--with_lease') { options['with_lease'] = true; } else if (arg == '--write_out' && args[i+1] == 'json') { i++; options['json'] = true; } else if (arg == '--json' || arg == '--write_out=json') { options['json'] = true; } else if (arg[0] == '-' && arg[1] !== '-') { process.stderr.write('Unknown option '+arg); process.exit(1); } else if (arg.substr(0, 2) == '--') { options[arg.substr(2)] = args[++i]; } else { cmd.push(arg); } } if (!cmd.length || cmd[0] != 'get' && cmd[0] != 'put' && cmd[0] != 'del' && cmd[0] != 'load') { process.stderr.write('Supported commands: get, put, del, load. Use --help to see details\n'); process.exit(1); } return [ cmd, options ]; } async run(cmd, options) { this.options = options; if (!this.options.endpoints) { this.options.endpoints = [ 'http://localhost:2379' ]; } if (this.options.cert && this.options.key) { this.tls = { key: await fsp.readFile(this.options.key), cert: await fsp.readFile(this.options.cert), }; } if (cmd[0] == 'get') { await this.get(cmd.slice(1)); } else if (cmd[0] == 'put') { await this.put(cmd[1], cmd.length > 2 ? cmd[2] : undefined); } else if (cmd[0] == 'del') { await this.del(cmd.slice(1)); } else if (cmd[0] == 'load') { await this.load(); } // wait until output is fully flushed await new Promise(ok => process.stdout.write('', ok)); await new Promise(ok => process.stderr.write('', ok)); process.exit(0); } async load() { const dump = JSON.parse(await new Promise((ok, no) => fs.readFile(0, { encoding: 'utf-8' }, (err, res) => err ? no(err) : ok(res)))); if (!dump.responses && !dump.kvs) { console.error('dump should be /kv/txn or /kv/range response in json format'); process.exit(1); } const success = []; for (const r of (dump.responses ? dump.responses.map(r => r.response_range).filter(r => r) : [ dump ])) { for (const kv of r.kvs) { if (kv.value == null) { console.error('dump should contain values'); process.exit(1); } success.push({ request_put: { key: kv.key, value: kv.value, lease: this.options.with_lease ? kv.lease||undefined : undefined } }); } } const res = await this.request('/v3/kv/txn', { success }); if (this.options.json) { process.stdout.write(JSON.stringify(res)); return; } if (res.succeeded) { process.stdout.write('OK, loaded '+success.length+' values\n'); } } async get(keys) { if (this.options.prefix) { keys = keys.map(k => k.replace(/\/+$/, '')); } const txn = { success: keys.map(key => ({ request_range: this.options.prefix ? { key: b64(key+'/'), range_end: b64(key+'0') } : { key: b64(key) } })) }; const res = await this.request('/v3/kv/txn', txn); if (this.options.notemp) { // Skip temporary values (values with lease) for (const r of res.responses||[]) { if (r.response_range) { r.response_range.kvs = r.response_range.kvs.filter(kv => !kv.lease); } } } if (this.options.json) { process.stdout.write(JSON.stringify(keys.length == 1 ? res.responses[0].response_range : res)); return; } for (const r of res.responses||[]) { if (r.response_range) { for (const kv of r.response_range.kvs) { if (!this.options.print_value_only) { process.stdout.write(de64(kv.key)+'\n'); } if (!this.options.keys_only) { process.stdout.write(de64(kv.value)+'\n'); } } } } } async put(key, value) { if (value === undefined) { value = await new Promise((ok, no) => fs.readFile(0, { encoding: 'utf-8' }, (err, res) => err ? no(err) : ok(res))); } const res = await this.request('/v3/kv/put', { key: b64(key), value: b64(value) }); if (res.header) { process.stdout.write('OK\n'); } } async del(keys) { if (this.options.prefix) { keys = keys.map(k => k.replace(/\/+$/, '')); } const txn = { success: keys.map(key => ({ request_delete_range: this.options.prefix ? { key: b64(key+'/'), range_end: b64(key+'0') } : { key: b64(key) } })) }; const res = await this.request('/v3/kv/txn', txn); for (const r of res.responses||[]) { if (r.response_delete_range) { process.stdout.write(r.response_delete_range.deleted+'\n'); } } } async request(path, body) { for (const url of this.options.endpoints) { const cur_url = url.replace(/\/+$/, '')+path; const res = await POST(cur_url, this.tls||{}, body, this.options.timeout||1000); if (res.json) { if (res.json.error) { process.stderr.write(cur_url+': '+res.json.error+'\n'); process.exit(1); } return res.json; } if (res.body) { process.stderr.write(cur_url+': '+res.body+'\n'); } if (res.error) { process.stderr.write(cur_url+': '+res.error+'\n'); if (!res.response || !res.response.statusCode) { // This URL is unavailable continue; } } break; } process.exit(1); } } function POST(url, options, body, timeout) { return new Promise(ok => { const body_text = Buffer.from(JSON.stringify(body)); let timer_id = timeout > 0 ? setTimeout(() => { if (req) req.abort(); req = null; ok({ error: 'timeout' }); }, timeout) : null; let req = (url.substr(0, 6).toLowerCase() == 'https://' ? https : http).request(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': body_text.length, }, timeout, ...options }, (res) => { if (!req) { return; } clearTimeout(timer_id); let res_body = ''; res.setEncoding('utf8'); res.on('error', (error) => ok({ error })); res.on('data', chunk => { res_body += chunk; }); res.on('end', () => { if (res.statusCode != 200 || !/application\/json/i.exec(res.headers['content-type'])) { ok({ response: res, body: res_body, code: res.statusCode }); return; } try { res_body = JSON.parse(res_body); ok({ response: res, json: res_body }); } catch (e) { ok({ response: res, error: e, body: res_body }); } }); }); req.on('error', (error) => ok({ error })); req.on('close', () => ok({ error: new Error('Connection closed prematurely') })); req.write(body_text); req.end(); }); } function b64(str) { return Buffer.from(str).toString('base64'); } function de64(str) { return Buffer.from(str, 'base64').toString(); } new AntiEtcdCli().run(...AntiEtcdCli.parse(process.argv)).catch(console.error);