UNPKG

server-control-s3

Version:

Easy updating of Amazon AWS instances from s3 packages

670 lines (662 loc) 22.6 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var asyncEach = require('async/each'); var asyncForever = require('async/forever'); var asyncSeries = require('async/series'); var clientAutoScaling = require('@aws-sdk/client-auto-scaling'); var clientEc2 = require('@aws-sdk/client-ec2'); var child_process = require('node:child_process'); var fs = require('node:fs'); var node_path = require('node:path'); var clientS3 = require('@aws-sdk/client-s3'); var http = require('http'); var https = require('https'); var url_lib = require('url'); function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var child_process__namespace = /*#__PURE__*/_interopNamespaceDefault(child_process); var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs); const TIMEOUT = 15 * 1000; function webRequest(opts, done) { if (!opts.timeout) { opts.timeout = TIMEOUT; } const url = new url_lib.URL(opts.url); const transport = url.protocol === 'https:' ? https : http; const request_options = { method: opts.method || 'GET', timeout: opts.timeout, headers: opts.headers || {}, }; let request_body; if (opts.json) { if (request_options.method === 'GET') { Object.keys(opts.json).forEach((key) => { url.searchParams.append(key, opts.json[key]); }); } else { request_body = JSON.stringify(opts.json); request_options.headers['Content-Type'] = 'application/json'; request_options.headers['Content-Length'] = Buffer.byteLength(request_body); } } const client_req = transport.request(url, request_options, (incoming_res) => { let response_body = ''; incoming_res.on('data', (chunk) => { response_body += chunk; }); incoming_res.on('end', () => { const status_code = incoming_res.statusCode; let err = null; if (status_code < 200 || status_code > 299) { err = status_code; } const content_type = incoming_res.headers['content-type'] || ''; if (content_type.includes('application/json')) { try { response_body = JSON.parse(response_body); } catch (e) { // ignore } } done?.(err, response_body); done = undefined; }); }); client_req.on('error', (err) => { done?.(err); done = undefined; }); client_req.on('timeout', () => { client_req.destroy(); const err = new Error('Request timed out'); err.code = 'ETIMEDOUT'; done?.(err); done = undefined; }); if (request_body) { client_req.write(request_body); } client_req.end(); } function headUrl(url, opts, done) { if (typeof opts === 'function') { done = opts; opts = {}; } if (url.indexOf('http') === 0) { webRequest({ url, method: 'HEAD' }, done); } else { const parts = url.match(/s3:\/\/([^/]*)\/(.*)/); const Bucket = parts && parts[1]; const Key = parts && parts[2]; const s3 = new clientS3.S3Client({ region: opts.region }); const command = new clientS3.HeadObjectCommand({ Bucket: Bucket || '', Key: Key || '', }); s3.send(command).then((data) => done(null, data), (err) => done(err)); } } function fetchFileContents(url, opts, done) { if (typeof opts === 'function') { done = opts; opts = {}; } if (url.indexOf('http') === 0) { webRequest({ url }, done); } else { const parts = url.match(/s3:\/\/([^/]*)\/(.*)/); const Bucket = parts && parts[1]; const Key = parts && parts[2]; const s3 = new clientS3.S3Client({ region: opts.region }); const command = new clientS3.GetObjectCommand({ Bucket: Bucket || '', Key: Key || '', }); s3.send(command).then((data) => { const stream = data.Body; let body = ''; stream.on('data', (chunk) => { body += chunk.toString(); }); stream.on('end', () => { done(null, body); }); stream.on('error', (err) => { done(err); }); }, (err) => done(err)); } } var index = { init, getGitCommitHash, }; const MAX_WAIT_COUNT = 12; const SERVER_WAIT_MS = 10 * 1000; const SERVER_UPDATE_TIMEOUT = 2 * 60 * 1000; const DEFAULT_CONFIG = { secret: 'secret', routePrefix: '', updateUrlKeyName: 'SC_UPDATE_URL', restartFunction: _defaultRestartFunction, port: 80, httpProto: 'http', repoDir: process.env.PWD || '.', consoleLog: console.log, errorLog: console.error, updateLaunchDefault: true, removeOldTarget: true, }; const g_config = {}; let g_gitCommitHash = false; let g_updateHash = ''; function init(router, config) { Object.assign(g_config, DEFAULT_CONFIG, config); if (!g_config.remoteRepoPrefix) { throw 'server-control remote_repo_prefix required'; } _getAwsRegion(() => { }); getGitCommitHash(() => { }); if (g_config.removeOldTarget) { _removeOldTarget(); } router.use(_secretOrAuth); router.all('/server_data', _serverData); router.all('/group_data', _groupData); router.all('/update_group', _updateGroup); router.all('/update_server', _updateServer); } function _secretOrAuth(req, res, next) { if (req.headers && req.headers['x-sc-secret'] === g_config.secret) { next(); } else if (req.body?.secret === g_config.secret) { next(); } else if (req.cookies?.secret === g_config.secret) { next(); } else if (g_config.authMiddleware) { g_config.authMiddleware(req, res, next); } else { res.sendStatus(403); } } function _serverData(req, res) { res.header('Cache-Control', 'no-cache, no-store, must-revalidate'); getGitCommitHash((err, git_commit_hash) => { const body = { git_commit_hash, uptime: process.uptime(), }; if (err) { res.status(500); body.err = err; } res.send(body); }); } function _groupData(req, res) { res.header('Cache-Control', 'no-cache, no-store, must-revalidate'); _getGroupData((err, result) => { const body = { LATEST: result.latest || 'unknown', InstanceId: result.InstanceId || 'unknown', InstanceList: result.instance_list, }; if (result.auto_scale_group) { body.AutoScaleGroup = { AutoScalingGroupName: result.auto_scale_group.AutoScalingGroupName, LaunchTemplate: result.auto_scale_group.LaunchTemplate, }; if (result.launch_template) { body.LaunchTemplate = result.launch_template; } } if (err) { res.status(500).send({ err, body }); } else { res.send(body); } }); } function _getGroupData(done) { const autoscaling = _getAutoscaling(); const ec2 = _getEC2(); let latest = false; let InstanceId = false; let asg = false; let instance_list = []; let launch_template = false; asyncSeries([ (done) => { _getLatest((err, result) => { if (err) { _errorLog('_getGroupData: latest err:', err); } latest = result || false; done(); }); }, (done) => { const opts = { url: 'http://169.254.169.254/latest/meta-data/instance-id', ...(g_config.metadataOpts || {}), }; webRequest(opts, (err, results) => { if (err) { _errorLog('_getGroupData: Failed to get instance id:', err); } InstanceId = results || ''; done(); }); }, (done) => { const command = new clientAutoScaling.DescribeAutoScalingGroupsCommand({}); autoscaling.send(command).then((data) => { asg = data.AutoScalingGroups.find((group) => { return (group.AutoScalingGroupName === g_config.asgName || group.Instances.find((i) => i.InstanceId === InstanceId)); }); if (!asg) { _errorLog('_getGroupData: asg not found:', g_config.asgName); done('asg_not_found'); } else { done(); } }, (err) => { _errorLog('_getGroupData: find asg err:', err); done(err); }); }, (done) => { const opts = { InstanceIds: asg.Instances.map((i) => i.InstanceId), }; const command = new clientEc2.DescribeInstancesCommand(opts); ec2.send(command).then((results) => { instance_list = []; results.Reservations.forEach((reservation) => { reservation.Instances.forEach((i) => { instance_list.push({ InstanceId: i.InstanceId, PrivateIpAddress: i.PrivateIpAddress, PublicIpAddress: i.PublicIpAddress, LaunchTime: i.LaunchTime, ImageId: i.ImageId, InstanceType: i.InstanceType, State: i.State, }); }); }); done(); }, (err) => { _errorLog('_getGroupData: describeInstances err:', err); done(err); }); }, (done) => { const list = instance_list.filter((i) => i.State.Name === 'running'); asyncEach(list, (instance, done) => { _getServerData(instance, (err, body) => { instance.git_commit_hash = body && body.git_commit_hash; instance.uptime = body && body.uptime; done(err); }); }, done); }, (done) => { const lt = asg.LaunchTemplate || asg.MixedInstancesPolicy?.LaunchTemplate?.LaunchTemplateSpecification; const opts = { LaunchTemplateId: lt?.LaunchTemplateId, Versions: [lt?.Version], }; const command = new clientEc2.DescribeLaunchTemplateVersionsCommand(opts); ec2.send(command).then((data) => { if (data?.LaunchTemplateVersions?.length > 0) { launch_template = data.LaunchTemplateVersions[0]; const ud = launch_template.LaunchTemplateData.UserData; if (ud) { const s = Buffer.from(ud, 'base64').toString('utf8'); launch_template.LaunchTemplateData.UserData = s; } done(); } else { done('launch_template_not_found'); } }, (err) => { _errorLog('_getGroupData: launch template fetch error:', err); done(err); }); }, ], (err) => { const ret = { latest, InstanceId, auto_scale_group: asg, launch_template, instance_list, }; done(err, ret); }); } function _getServerData(instance, done) { const proto = g_config.httpProto; const ip = instance.PrivateIpAddress; const port = g_config.port; const prefix = g_config.routePrefix; const url = `${proto}://${ip}:${port}${prefix}/server_data`; const opts = { url, method: 'GET', headers: { 'x-sc-secret': g_config.secret }, json: { secret: g_config.secret }, }; webRequest(opts, (err, body) => { if (err) { _errorLog('_getServerData: request err:', err); } done(err, body); }); } function _updateServer(req, res) { res.header('Cache-Control', 'no-cache, no-store, must-revalidate'); const hash = req.body?.hash ?? req.query.hash; if (hash) { _updateSelf(hash, (err) => { if (err) { res.status(500).send(err); } else { res.send('Restarting server'); g_config.restartFunction(); } }); } else { res.status(400).send('hash is required'); } } function _updateSelf(hash, done) { const dir = g_config.repoDir; const url = `${g_config.remoteRepoPrefix}/${hash}.tar.gz`; const cmd = `cd ${dir} && ${__dirname}/../scripts/update_to_hash.sh ${url}`; child_process__namespace.exec(cmd, (err, stdout, stderr) => { if (err) { _errorLog('_updateSelf: update_to_hash.sh failed with err:', err, 'stdout:', stdout, 'stderr:', stderr); err = 'update_failed'; } else { g_updateHash = hash; } done(err); }); } function _removeOldTarget() { const dir = g_config.repoDir; const cmd = `${__dirname}/../scripts/remove_old_target.sh ${dir}`; child_process__namespace.exec(cmd, (err, stdout, stderr) => { if (err) { _errorLog('_removeOldTarget: remove_old_target.sh failed with err:', err, 'stdout:', stdout, 'stderr:', stderr); } }); } function _updateGroup(req, res) { res.header('Cache-Control', 'no-cache, no-store, must-revalidate'); const hash = req.body?.hash ?? req.query.hash; if (hash) { const url = `${g_config.remoteRepoPrefix}/${hash}.tar.gz`; const key_name = g_config.updateUrlKeyName; const ami_id = req.body?.ami_id ?? req.query.ami_id ?? false; const ec2 = _getEC2(); let group_data = false; let old_data = ''; let new_version; const server_result = {}; asyncSeries([ (done) => { _getGroupData((err, result) => { if (!err) { group_data = result; const data = result.launch_template.LaunchTemplateData.UserData; data.split('\n').forEach((line) => { if (line.length && line.indexOf(key_name) === -1) { old_data += line + '\n'; } }); } done(err); }); }, (done) => { headUrl(url, { region: g_config.region }, (err) => { if (err) { _errorLog('_updateGroup: head url:', url, 'err:', err); err = 'url_not_found'; } done(err); }); }, (done) => { const new_data = `${old_data}${key_name}=${url}\n`; const opts = { LaunchTemplateId: group_data.launch_template.LaunchTemplateId, SourceVersion: String(group_data.launch_template.VersionNumber), LaunchTemplateData: { UserData: Buffer.from(new_data, 'utf8').toString('base64'), }, }; if (ami_id) { opts.LaunchTemplateData.ImageId = ami_id; } const command = new clientEc2.CreateLaunchTemplateVersionCommand(opts); ec2.send(command).then((data) => { new_version = data.LaunchTemplateVersion.VersionNumber; done(); }, (err) => { _errorLog('_updateGroup: failed to create version, err:', err); done(err); }); }, (done) => { if (g_config.updateLaunchDefault) { const opts = { DefaultVersion: String(new_version), LaunchTemplateId: group_data.launch_template.LaunchTemplateId, }; const command = new clientEc2.ModifyLaunchTemplateCommand(opts); ec2.send(command).then(() => { done(); }, (err) => { _errorLog('_updateGroup: failed to update default, err:', err); done(err); }); } else { done(); } }, (done) => { let group_err; asyncEach(group_data.instance_list, (instance, done) => { if (instance.InstanceId === group_data.InstanceId || instance.State?.Name !== 'running') { done(); } else { _updateInstance(hash, instance, (err) => { if (err) { _errorLog('_updateGroup: update instance:', instance.InstanceId, 'err:', err); group_err = err; } server_result[instance.InstanceId] = err; done(); }); } }, () => done(group_err)); }, (done) => { _updateSelf(hash, (err) => { server_result[group_data.InstanceId] = err; done(err); }); }, ], (err) => { const body = { err, server_result, launch_template_version: new_version, }; if (err) { res.status(500).send(body); } else { body._msg = 'Successful updating all servers, restarting this server.'; res.send(body); g_config.restartFunction(); } }); } else { res.status(400).send('hash is required'); } } function _updateInstance(hash, instance, done) { asyncSeries([ (done) => { const proto = g_config.httpProto; const ip = instance.PrivateIpAddress; const port = g_config.port; const prefix = g_config.routePrefix; const url = `${proto}://${ip}:${port}${prefix}/update_server`; const opts = { url, method: 'GET', headers: { 'x-sc-secret': g_config.secret }, timeout: SERVER_UPDATE_TIMEOUT, json: { hash, secret: g_config.secret }, }; webRequest(opts, done); }, (done) => _waitForServer({ instance, hash }, done), ], done); } function _waitForServer(params, done) { const { instance, hash } = params; let count = 0; asyncForever((done) => { count++; _getServerData(instance, (err, body) => { if (!err && body && body.git_commit_hash === hash) { done('stop'); } else if (count > MAX_WAIT_COUNT) { done('too_many_tries'); } else { setTimeout(done, SERVER_WAIT_MS); } }); }, (err) => { if (err === 'stop') { err = null; } done(err); }); } function _getLatest(done) { const url = g_config.remoteRepoPrefix + '/LATEST'; fetchFileContents(url, { region: g_config.region }, (err, body) => { done(err, body && body.trim()); }); } function getGitCommitHash(done) { if (typeof g_gitCommitHash === 'string') { done && done(null, g_gitCommitHash); } else { const file = node_path.join(g_config.repoDir, '.git_commit_hash'); fs__namespace.readFile(file, 'utf8', (err, result) => { if (!err && !result) { err = 'no_result'; } if (err) { _errorLog('getGitCommitHash: err:', err, 'file:', file); } else { g_gitCommitHash = result.trim(); } done && done(err, g_gitCommitHash); }); } } function _getAwsRegion(done) { if (g_config.region) { return done(); } const opts = { url: 'http://169.254.169.254/latest/dynamic/instance-identity/document', ...(g_config.metadataOpts || {}), }; webRequest(opts, (err, results) => { if (err) { _errorLog('_getAwsRegion: metadata err:', err); } else { try { const json = JSON.parse(results); if (json && json.region) { g_config.region = json.region; } } catch (e) { _errorLog('_getAwsRegion: threw:', e); } } }); } function _getAutoscaling() { return new clientAutoScaling.AutoScalingClient({ region: g_config.region }); } function _getEC2() { return new clientEc2.EC2Client({ region: g_config.region }); } function _errorLog(...args) { g_config.errorLog(...args); } function _defaultRestartFunction() { g_config.consoleLog('server-control: updated to: ', g_updateHash, 'restarting...'); setTimeout(function () { process.exit(0); }, 100); } exports.default = index; exports.getGitCommitHash = getGitCommitHash; exports.init = init;