UNPKG

antietcd

Version:

Simplistic etcd replacement based on TinyRaft

655 lines (614 loc) 20.3 kB
#!/usr/bin/node // AntiEtcd embeddable class // (c) Vitaliy Filippov, 2024 // License: Mozilla Public License 2.0 or Vitastor Network Public License 1.1 const fsp = require('fs').promises; const { URL } = require('url'); const http = require('http'); const https = require('https'); const EventEmitter = require('events'); const ws = require('ws'); const EtcTree = require('./etctree.js'); const AntiPersistence = require('./antipersistence.js'); const AntiCluster = require('./anticluster.js'); const { runCallbacks, de64, b64, RequestError } = require('./common.js'); const VERSION = '1.1.3'; class AntiEtcd extends EventEmitter { constructor(cfg) { super(); cfg['merge_watches'] = !('merge_watches' in cfg) || is_true(cfg['merge_watches']); cfg['stale_read'] = !('stale_read' in cfg) || is_true(cfg['stale_read']); cfg['use_base64'] = !('use_base64' in cfg) || is_true(cfg['use_base64']); this.clients = {}; this.client_id = 1; this.etctree = new EtcTree(true); this.persistence = null; this.cluster = null; this.stored_term = 0; this.cfg = cfg; this.loading = false; this.stopped = false; this.inflight = 0; this.wait_inflight = []; this.api_watches = {}; } async start() { if (this.cfg.data || this.cfg.cluster) { this.etctree.set_replicate_watcher(msg => this._persistAndReplicate(msg)); } if (this.cfg.data) { this.persistence = new AntiPersistence(this); // Load data from disk await this.persistence.load(); } if (this.cfg.cluster) { this.cluster = new AntiCluster(this); } if (this.cfg.cert) { this.tls = { key: await fsp.readFile(this.cfg.key), cert: await fsp.readFile(this.cfg.cert), }; if (this.cfg.ca) { this.tls.ca = await fsp.readFile(this.cfg.ca); } if (this.cfg.client_cert_auth) { this.tls.requestCert = true; } this.server = https.createServer(this.tls, (req, res) => this._handleRequest(req, res)); } else { this.server = http.createServer((req, res) => this._handleRequest(req, res)); } this.wss = new ws.Server({ server: this.server }); // eslint-disable-next-line no-unused-vars this.wss.on('connection', (conn, req) => this._startWebsocket(conn, null)); this.server.listen(this.cfg.port || 2379, this.cfg.ip || undefined); } async stop() { if (this.stopped) { return; } this.stopped = true; // Wait until all requests complete while (this.inflight > 0) { await new Promise(ok => this.wait_inflight.push(ok)); } if (this.persistence) { await this.persistence.persist(); } } async _persistAndReplicate(msg) { let res = []; if (this.cluster) { // We have to guarantee that replication is processed sequentially // So we have to send messages without first awaiting for anything! res.push(this.cluster.replicateChange(msg)); } if (this.persistence) { res.push(this.persistence.persistChange(msg)); } if (res.length == 1) { await res[0]; } else if (res.length > 0) { let done = 0; await new Promise((allOk, allNo) => { res.map(promise => promise.then((/*r*/) => { if ((++done) == res.length) allOk(); }).catch(e => { console.error(e); allNo(e); })); }); } if (!this.cluster) { // Run deletion compaction without followers const mod_revision = this.etctree.mod_revision; if (mod_revision - this.etctree.compact_revision > (this.cfg.compact_revisions||1000)*2) { const revision = mod_revision - (this.cfg.compact_revisions||1000); this.etctree.compact(revision); } } } _handleRequest(req, res) { let data = []; req.on('data', (chunk) => data.push(chunk)); req.on('end', async () => { this.inflight++; data = Buffer.concat(data); let body = ''; let code = 200; let ctype = 'text/plain; charset=utf-8'; let reply; try { if (req.headers['content-type'] != 'application/json') { throw new RequestError(400, 'content-type should be application/json'); } body = data.toString(); try { data = data.length ? JSON.parse(data) : {}; } catch (e) { throw new RequestError(400, 'body should be valid JSON'); } if (!(data instanceof Object) || data instanceof Array) { throw new RequestError(400, 'body should be JSON object'); } reply = await this._runHandler(req, data); reply = JSON.stringify(reply); ctype = 'application/json'; } catch (e) { if (e instanceof RequestError) { code = e.code; reply = e.message+'\n'; } else { console.error(e); code = 500; reply = 'Internal error: '+e.message+'\n'; } } try { // Access log if (this.cfg.log_level > 1) { console.log( new Date().toISOString()+ ' '+(req.headers['x-forwarded-for'] || (req.socket.remoteAddress + ':' + req.socket.remotePort))+ ' '+req.method+' '+req.url+' '+code+'\n '+body.replace(/\n/g, '\\n')+ '\n '+reply.replace(/\n/g, '\\n') ); } reply = Buffer.from(reply); res.writeHead(code, { 'Content-Type': ctype, 'Content-Length': reply.length, }); res.write(reply); res.end(); } catch (e) { console.error(e); } this.inflight--; if (!this.inflight) { runCallbacks(this, 'wait_inflight', []); } }); } _startWebsocket(socket, reconnect) { const client_id = this.client_id++; this.clients[client_id] = { id: client_id, addr: socket._socket ? socket._socket.remoteAddress+':'+socket._socket.remotePort : '', socket, alive: true, watches: {}, }; socket.on('pong', () => this.clients[client_id].alive = true); socket.on('error', e => console.error(e.syscall === 'connect' ? e.message : e)); const pinger = setInterval(() => { if (!this.clients[client_id]) { return; } if (!this.clients[client_id].alive) { return socket.terminate(); } this.clients[client_id].alive = false; socket.ping(() => {}); }, this.cfg.ws_keepalive_interval||30000); socket.on('message', (msg) => { try { msg = JSON.parse(msg); } catch (e) { socket.send(JSON.stringify({ error: 'bad-json' })); return; } if (!msg) { socket.send(JSON.stringify({ error: 'empty-message' })); } else { this._handleMessage(client_id, msg, socket); } }); socket.on('close', () => { this._unsubscribeClient(client_id); clearInterval(pinger); delete this.clients[client_id]; socket.terminate(); if (reconnect) { reconnect(); } }); return client_id; } async _runHandler(req, data) { // v3/kv/txn // v3/kv/range // v3/kv/put // v3/kv/deleterange // v3/lease/grant // v3/lease/keepalive // v3/lease/revoke O_o // v3/kv/lease/revoke O_o const requestUrl = new URL(req.url, 'http://'+(req.headers.host || 'localhost')); if (requestUrl.searchParams.get('leaderonly')) { data.leaderonly = true; } if (requestUrl.searchParams.get('serializable')) { data.serializable = true; } if (requestUrl.searchParams.get('nowaitquorum')) { data.nowaitquorum = true; } try { if (requestUrl.pathname.substr(0, 4) == '/v3/') { const path = requestUrl.pathname.substr(4).replace(/\/+$/, '').replace(/\/+/g, '_'); if (req.method != 'POST') { throw new RequestError(405, 'Please use POST method'); } return await this.api(path, data); } else if (requestUrl.pathname == '/dump') { return await this.api('dump', data); } else { throw new RequestError(404, ''); } } catch (e) { if ((e instanceof RequestError) && e.code == 404) { throw new RequestError(404, 'Supported APIs: /v3/kv/txn, /v3/kv/range, /v3/kv/put, /v3/kv/deleterange, '+ '/v3/lease/grant, /v3/lease/revoke, /v3/kv/lease/revoke, /v3/lease/keepalive, /v3/maintenance/status'); } else { throw e; } } } // public generic handler async api(path, data) { if (this.stopped) { throw new RequestError(502, 'Server is stopping'); } if (this.cluster && path !== 'dump' && path != 'maintenance_status') { const res = await this.cluster.checkRaftState( path, (data.leaderonly ? AntiCluster.LEADER_ONLY : 0) | (data.serializable ? AntiCluster.READ_FROM_FOLLOWER : 0) | (data.nowaitquorum ? AntiCluster.NO_WAIT_QUORUM : 0), data ); if (res) { return res; } } const cb = this['_handle_'+path]; if (cb) { const res = cb.call(this, data); if (res instanceof Promise) { return await res; } return res; } throw new RequestError(404, 'Unsupported API'); } // public wrappers async txn(params) { return await this.api('kv_txn', params); } async range(params) { return await this.api('kv_range', params); } async put(params) { return await this.api('kv_put', params); } async deleterange(params) { return await this.api('kv_deleterange', params); } async lease_grant(params) { return await this.api('lease_grant', params); } async lease_revoke(params) { return await this.api('lease_revoke', params); } async lease_keepalive(params) { return await this.api('lease_keepalive', params); } // public watch API async create_watch(params, callback, stream_id) { const watch = this.etctree.api_create_watch(this._encodeWatch(params), (msg) => callback(this._encodeMsg(msg)), stream_id); if (!watch.created) { throw new RequestError(400, 'Requested watch revision is compacted', { compact_revision: watch.compact_revision }); } const watch_id = params.watch_id || watch.watch_id; this.api_watches[watch_id] = watch.watch_id; return watch_id; } async cancel_watch(watch_id) { const mapped_id = this.api_watches[watch_id]; if (!mapped_id) { throw new RequestError(400, 'Watch not found'); } this.etctree.api_cancel_watch(mapped_id); delete this.api_watches[watch_id]; } // internal handlers async _handle_kv_txn(data) { if (this.cfg.use_base64) { for (const item of data.compare||[]) { if (item.key != null) item.key = de64(item.key); } for (const items of [ data.success, data.failure ]) { for (const item of items||[]) { const req = item.request_range || item.requestRange || item.request_put || item.requestPut || item.request_delete_range || item.requestDeleteRange; if (req.key != null) req.key = de64(req.key); if (req.range_end != null) req.range_end = de64(req.range_end); if (req.value != null) req.value = de64(req.value); } } } const result = await this.etctree.api_txn(data); if (this.cfg.use_base64) { for (const item of result.responses||[]) { if (item.response_range) { for (const kv of item.response_range.kvs) { if (kv.key != null) kv.key = b64(kv.key); if (kv.value != null) kv.value = b64(kv.value); } } } } return result; } async _handle_kv_range(data) { const r = await this._handle_kv_txn({ success: [ { request_range: data } ] }); return { header: r.header, ...r.responses[0].response_range }; } async _handle_kv_put(data) { const r = await this._handle_kv_txn({ success: [ { request_put: data } ] }); return { header: r.header, ...r.responses[0].response_put }; } async _handle_kv_deleterange(data) { const r = await this._handle_kv_txn({ success: [ { request_delete_range: data } ] }); return { header: r.header, ...r.responses[0].response_delete_range }; } _handle_lease_grant(data) { return this.etctree.api_grant_lease(data); } _handle_lease_revoke(data) { return this.etctree.api_revoke_lease(data); } _handle_kv_lease_revoke(data) { return this.etctree.api_revoke_lease(data); } _handle_lease_keepalive(data) { return this.etctree.api_keepalive_lease(data); } _handle_maintenance_status(/*data*/) { const raft = this.cluster && this.cluster.raft; return { header: { member_id: this.cfg.node_id || undefined, revision: this.etctree.mod_revision, compact_revision: this.etctree.compact_revision || 0, raft_term: raft && raft.term || undefined, }, version: 'antietcd '+AntiEtcd.VERSION, cluster: this.cfg.cluster || undefined, leader: raft && raft.leader || undefined, followers: raft && raft.followers || undefined, raftTerm: raft && raft.term || undefined, raftState: raft && raft.state || undefined, dbSize: process.memoryUsage().heapUsed, }; } // eslint-disable-next-line no-unused-vars _handle_dump(data) { return { ...this.etctree.dump(), term: this.stored_term }; } _handleMessage(client_id, msg, socket) { const client = this.clients[client_id]; if (this.cfg.access_log) { console.log(new Date().toISOString()+' '+client.addr+' '+(client.raft_node_id || '-')+' -> '+JSON.stringify(msg)); } if (msg.create_request) { const create_request = msg.create_request; if (!create_request.watch_id || !client.watches[create_request.watch_id]) { client.send_cb = client.send_cb || (msg => socket.send(JSON.stringify(this._encodeMsg(msg)))); const watch = this.etctree.api_create_watch( this._encodeWatch(create_request), client.send_cb, (this.cfg.merge_watches ? 'C'+client_id : null) ); if (!watch.created) { socket.send(JSON.stringify({ result: { header: { revision: this.etctree.mod_revision }, watch_id: create_request.watch_id, ...watch } })); } else { create_request.watch_id = create_request.watch_id || watch.watch_id; client.watches[create_request.watch_id] = watch.watch_id; socket.send(JSON.stringify({ result: { header: { revision: this.etctree.mod_revision }, watch_id: create_request.watch_id, created: true } })); } } } else if (msg.cancel_request) { const mapped_id = client.watches[msg.cancel_request.watch_id]; if (mapped_id) { this.etctree.api_cancel_watch(mapped_id); delete client.watches[msg.cancel_request.watch_id]; socket.send(JSON.stringify({ result: { header: { revision: this.etctree.mod_revision }, watch_id: msg.cancel_request.watch_id, canceled: true } })); } } else if (msg.progress_request) { socket.send(JSON.stringify({ result: { header: { revision: this.etctree.mod_revision } } })); } else { if (!this.cluster) { return; } this.cluster.handleWsMsg(client, msg); } } _encodeWatch(create_request) { const req = { ...create_request, watch_id: null }; if (this.cfg.use_base64) { if (req.key != null) req.key = de64(req.key); if (req.range_end != null) req.range_end = de64(req.range_end); } return req; } _encodeMsg(msg) { if (this.cfg.use_base64 && msg.result && msg.result.events) { return { ...msg, result: { ...msg.result, events: msg.result.events.map(ev => ({ ...ev, kv: !ev.kv ? ev.kv : { ...ev.kv, key: b64(ev.kv.key), value: b64(ev.kv.value), }, })) } }; } return msg; } _unsubscribeClient(client_id) { if (!this.clients[client_id]) { return; } for (const watch_id in this.clients[client_id].watches) { const mapped_id = this.clients[client_id].watches[watch_id]; this.etctree.api_cancel_watch(mapped_id); } } } function is_true(s) { return s === true || s === 1 || s === '1' || s === 'yes' || s === 'true' || s === 'on'; } AntiEtcd.RequestError = RequestError; AntiEtcd.VERSION = VERSION; module.exports = AntiEtcd;