From f5b84b530c905db164d06e7e43d16f76ff4dc5cc Mon Sep 17 00:00:00 2001 From: garrettmills Date: Thu, 13 Aug 2020 20:28:23 -0500 Subject: [PATCH] Add support for running routines via command line --- .gitignore | 1 + Units.flitter.js | 1 + app/AppUnit.js | 17 +++ app/classes/directive/Rollcall.js | 73 +++++++++++++ app/classes/directive/Routine.js | 100 ++++++++++++++++++ app/classes/git/Repository.js | 4 + app/classes/metal/Host.js | 1 + app/classes/routine/Routine.js | 15 ++- app/classes/routine/RunDirective.js | 48 +++++++++ app/classes/state/State.js | 4 + app/classes/state/fs/DirectoryState.js | 4 + app/classes/state/fs/DownloadState.js | 4 + app/classes/state/fs/FileState.js | 4 + app/classes/state/fs/OwnerState.js | 4 + app/classes/state/fs/PackState.js | 4 + app/classes/state/fs/PermissionState.js | 4 + app/classes/state/fs/UnpackState.js | 4 + app/classes/state/git/CheckoutState.js | 4 + app/classes/state/git/CloneState.js | 4 + app/classes/state/git/PullState.js | 36 +++++++ app/classes/state/git/TagState.js | 4 + app/classes/state/os/CommandState.js | 11 +- app/classes/state/os/IsAliveState.js | 4 + app/classes/state/os/PackageAbsentState.js | 4 + .../state/os/PackageCacheClearedState.js | 4 + app/classes/state/os/PackageState.js | 4 + .../state/os/ServiceDaemonReloadState.js | 4 + app/classes/state/os/ServiceRestartState.js | 4 + app/classes/state/os/ServiceState.js | 4 + app/classes/state/os/ServiceStoppedState.js | 4 + app/classes/state/os/UpdateState.js | 4 + app/services/states.service.js | 3 +- config/routines/example.config.js | 11 ++ 33 files changed, 396 insertions(+), 5 deletions(-) create mode 100644 app/AppUnit.js create mode 100644 app/classes/directive/Rollcall.js create mode 100644 app/classes/directive/Routine.js create mode 100644 app/classes/routine/RunDirective.js create mode 100644 app/classes/state/git/PullState.js diff --git a/.gitignore b/.gitignore index 0ec3cf4..881157a 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,4 @@ typings/ .dynamodb/ # End of https://www.gitignore.io/api/node +demo/* diff --git a/Units.flitter.js b/Units.flitter.js index 141d111..b0f54f0 100644 --- a/Units.flitter.js +++ b/Units.flitter.js @@ -20,6 +20,7 @@ const FlitterUnits = { 'Config' : require('./app/compat/ConfigUnit'), 'Services' : require('./app/compat/ServicesUnit'), 'Utility' : require('libflitter/utility/UtilityUnit'), + 'Misc' : require('./app/AppUnit'), 'Cli' : require('flitter-cli/CliUnit'), 'App' : require('flitter-cli/CliAppUnit'), } diff --git a/app/AppUnit.js b/app/AppUnit.js new file mode 100644 index 0000000..40ebe1d --- /dev/null +++ b/app/AppUnit.js @@ -0,0 +1,17 @@ +const { Unit } = require('libflitter') +const RunDirective = require('./classes/routine/RunDirective') +const Rollcall = require('./classes/directive/Rollcall') +const Routine = require('./classes/directive/Routine') + +class AppUnit extends Unit { + static get name() { return 'misc' } + + directives() { + return { + Rollcall, + Routine, + } + } +} + +module.exports = exports = AppUnit diff --git a/app/classes/directive/Rollcall.js b/app/classes/directive/Rollcall.js new file mode 100644 index 0000000..97feb9e --- /dev/null +++ b/app/classes/directive/Rollcall.js @@ -0,0 +1,73 @@ +const Directive = require('flitter-cli/Directive') +const path = require('path') + +class Rollcall extends Directive { + static get services() { + return [...super.services, 'configs', 'hosts'] + } + + static options() { + return [ + '--hosts -h {host file} | path to the host definition file' + ] + } + + static name() { + return 'rollcall' + } + + static help() { + return 'Ping the configured hosts to determine if we can access them' + } + + async handle(app, argv) { + const host_path = this.option('hosts') + + if ( host_path ) { + try { + // Override the host config externally + const host_conf = require(path.resolve(host_path)) + if (typeof host_conf === 'object') { + this.configs.canonical_items.hosts = host_conf + this.info('Loaded host definition file.') + } else { + this.error('Invalid host definition file!') + } + } catch (e) { + this.error('Invalid host definition file!') + this.output.debug(e) + } + } + + const successes = [] + const failures = [] + + const host_config = this.configs.get('hosts') + const hostnames = Object.keys(host_config) + for ( const name of hostnames ) { + this.info(`Pinging ${name}...`) + + try { + const host = this.hosts.get(name) + const is_alive = await host.is_alive() + if ( !is_alive ) { + failures.push(name) + this.error('Unable to ping host and verify execution.') + } else { + successes.push(name) + this.success('Connected to host and verified execution.') + } + } catch(e) { + failures.push(name) + this.error('Unable to ping host and verify execution.') + this.output.debug(e) + } + } + + this.hosts.close() + this.info(`Successfully pinged ${successes.length} host(s).`) + if ( failures.length > 0 ) this.info(`Unable to ping ${failures.length} host(s): ${failures.join(', ')}`) + } +} + +module.exports = exports = Rollcall diff --git a/app/classes/directive/Routine.js b/app/classes/directive/Routine.js new file mode 100644 index 0000000..b535048 --- /dev/null +++ b/app/classes/directive/Routine.js @@ -0,0 +1,100 @@ +const Directive = require('flitter-cli/Directive') +const path = require('path') + +class Routine extends Directive { + static get services() { + return [...super.services, 'configs', 'hosts', 'routines'] + } + + static options() { + return [ + '--hosts -h {host file} | path to the host definition file', + '--target -t {host name} | the host to run the routine on', + '--routine -r {routine file} | path to the routine definition file', + '--type -t {run type} | how to execute the routine: checks | apply' + ] + } + + static name() { + return 'routine' + } + + static help() { + return 'Run the specified routine file' + } + + async handle(app, argv) { + const host_path = this.option('hosts') + const routine_path = this.option('routine') + const target_host = this.option('target') + const run_type = this.option('type') + + if ( host_path ) { + try { + // Override the host config externally + const host_conf = require(path.resolve(host_path)) + if (typeof host_conf === 'object') { + this.configs.canonical_items.hosts = host_conf + this.info('Loaded host definition file.') + } else { + this.error('Invalid host definition file!') + } + } catch (e) { + this.error('Invalid host definition file!') + this.output.debug(e) + } + } + + // loaded_from_cli + + const host = this.hosts.get(target_host) + if ( !host ) { + this.error('Invalid host name. Unable to find configuration for host with that name.') + return + } + + if ( !['checks', 'apply'].includes(run_type) ) { + this.error('Invalid run type. Must be one of: checks, apply') + return + } + + if ( routine_path ) { + try { + // Override the routine config externally + const routine_conf = require(path.resolve(routine_path)) + if (typeof routine_conf === 'object') { + routine_conf.hosts = [target_host] + routine_conf.type = run_type + if ( Array.isArray(routine_conf.steps) ) { + routine_conf.steps = routine_conf.steps.map(step => { + step.host = target_host + return step + }) + } + this.configs.canonical_items['routines:loaded_from_cli'] = routine_conf + this.info('Loaded routine definition file.') + } else { + this.error('Invalid routine definition file!') + } + } catch (e) { + this.error('Invalid routine definition file!') + this.output.debug(e) + } + } else { + this.error('Missing required parameter: --routine') + return + } + + const routine = await this.routines.get('loaded_from_cli') + + try { + const result = await routine.execute(true) + } catch (e) { + this.output.error(e) + } + + this.hosts.close() + } +} + +module.exports = exports = Routine diff --git a/app/classes/git/Repository.js b/app/classes/git/Repository.js index c86f046..f8f73ef 100644 --- a/app/classes/git/Repository.js +++ b/app/classes/git/Repository.js @@ -68,6 +68,10 @@ class Repository extends Injectable { await this._git_cmd(`push${target ? ' '+target : ''}${ref ? ' '+ref : ''} --tags`) } + async pull({ rebase = false, target = '', ref = '' } = {}) { + await this._git_cmd(`pull${target ? ' '+target : ''}${ref ? ' '+ref : ''}${rebase ? ' --rebase' : ''}`) + } + async checkout(ref = 'master') { await this._git_cmd(`checkout "${ref}"`) } diff --git a/app/classes/metal/Host.js b/app/classes/metal/Host.js index d491418..a3c70a0 100644 --- a/app/classes/metal/Host.js +++ b/app/classes/metal/Host.js @@ -80,6 +80,7 @@ class Host extends Injectable { const result = await this.execute(`echo "${unique_id}"`) return (result.exit_code === 0 && (result.clean_out.length > 0 && result.clean_out[0] === unique_id)) } catch (e) { + this.output.debug(e) return false } } diff --git a/app/classes/routine/Routine.js b/app/classes/routine/Routine.js index 11e29e3..98c4aa4 100644 --- a/app/classes/routine/Routine.js +++ b/app/classes/routine/Routine.js @@ -22,10 +22,14 @@ class Routine extends Injectable { this._type = type } - async execute() { + async execute(with_logging = false) { const result = await this._build_result() + let step_no = 1 for ( const step of result.steps ) { + this.output.info(`(${step_no}/${result.steps.length}) ${step.step.display()}`, with_logging ? 0 : 10) + step_no += 1 + if ( this._type === 'checks' ) { step.status = (await step.step.check()) ? 'success' : 'fail' step.message = step.status === 'success' ? 'Check passed.' : step.step.check_message() @@ -41,6 +45,12 @@ class Routine extends Injectable { } else { throw new InvalidRoutineTypeError(this._type) } + + if ( step.status === 'success' ) { + this.output.success(` ${step.message}`, with_logging ? 0 : 10) + } else { + this.output.error(` ${step.message}`, with_logging ? 0 : 10) + } } result.overall_state = result.steps.every(x => x.status === 'success') ? 'success' : 'fail' @@ -50,7 +60,8 @@ class Routine extends Injectable { async _build_result() { const steps = [] for ( const step_config of this._config.steps ) { - const step = this.states.from_config(this._hosts[step_config.host], step_config) + const host = this._hosts[step_config.host] + const step = this.states.from_config(host, step_config) const result = new StepResult(this, step) steps.push(result) } diff --git a/app/classes/routine/RunDirective.js b/app/classes/routine/RunDirective.js new file mode 100644 index 0000000..611ccbb --- /dev/null +++ b/app/classes/routine/RunDirective.js @@ -0,0 +1,48 @@ +const Directive = require('flitter-cli/Directive') +const path = require('path') + +class RunDirective extends Directive { + static options() { + return [ + '--hosts -h {host file} | path to the host definition file', + '{routine file} | path to the routine definition file', + ] + } + + static name() { + return 'run' + } + + static help() { + return 'Run a specified routine' + } + + async handle(app, argv) { + const routine_path = this.option('routine file') + const host_path = this.option('hosts') + + if ( !host_path ) + return this.error('Missing required parameter: --hosts') + + let routine_conf + let host_conf + + try { + routine_conf = require(path.resolve(routine_path)) + } catch (e) { + this.error('Routine file is invalid.') + return this.output.debug(e) + } + + try { + host_conf = require(path.resolve(host_path)) + } catch (e) { + this.error('Host definition file is invalid.') + return this.output.debug(e) + } + + console.log({ routine_conf, host_conf }) + } +} + +module.exports = exports = RunDirective diff --git a/app/classes/state/State.js b/app/classes/state/State.js index 7b64259..3e4d691 100644 --- a/app/classes/state/State.js +++ b/app/classes/state/State.js @@ -27,6 +27,10 @@ class State extends Injectable { check_message() { throw new ImplementationError() } + + display() { + return this.constructor.name + } } module.exports = exports = State diff --git a/app/classes/state/fs/DirectoryState.js b/app/classes/state/fs/DirectoryState.js index 3510fd5..28fe149 100644 --- a/app/classes/state/fs/DirectoryState.js +++ b/app/classes/state/fs/DirectoryState.js @@ -34,6 +34,10 @@ class DirectoryState extends State { check_message() { return this.failure_message() } + + display() { + return `Ensure that the directory ${this._config.path} exists...` + } } module.exports = exports = DirectoryState diff --git a/app/classes/state/fs/DownloadState.js b/app/classes/state/fs/DownloadState.js index 9cab46f..26e8421 100644 --- a/app/classes/state/fs/DownloadState.js +++ b/app/classes/state/fs/DownloadState.js @@ -54,6 +54,10 @@ class DownloadState extends State { check_message() { return this.failure_message() } + + display() { + return `Ensure that the file ${this._config.path} is downloaded from ${this._config.source}...` + } } module.exports = exports = DownloadState diff --git a/app/classes/state/fs/FileState.js b/app/classes/state/fs/FileState.js index d7c9876..e017abb 100644 --- a/app/classes/state/fs/FileState.js +++ b/app/classes/state/fs/FileState.js @@ -39,6 +39,10 @@ class FileState extends State { check_message() { return this.failure_message() } + + display() { + return `Ensure that the file ${this._config.path} exists...` + } } module.exports = exports = FileState diff --git a/app/classes/state/fs/OwnerState.js b/app/classes/state/fs/OwnerState.js index db4159c..a5e0c09 100644 --- a/app/classes/state/fs/OwnerState.js +++ b/app/classes/state/fs/OwnerState.js @@ -46,6 +46,10 @@ class OwnerState extends State { check_message() { return this.failure_message() } + + display() { + return `Set filesystem owner of resource ${this._config.path}...` + } } module.exports = exports = OwnerState diff --git a/app/classes/state/fs/PackState.js b/app/classes/state/fs/PackState.js index 89b424b..a22ab20 100644 --- a/app/classes/state/fs/PackState.js +++ b/app/classes/state/fs/PackState.js @@ -63,6 +63,10 @@ class PackState extends State { check_message() { return this.failure_message() } + + display() { + return `Archive the contents of ${this._config.path} to ${this._config.destination}...` + } } module.exports = exports = PackState diff --git a/app/classes/state/fs/PermissionState.js b/app/classes/state/fs/PermissionState.js index 0f13d57..143b197 100644 --- a/app/classes/state/fs/PermissionState.js +++ b/app/classes/state/fs/PermissionState.js @@ -42,6 +42,10 @@ class PermissionState extends State { check_message() { return this.failure_message() } + + display() { + return `Set filesystem permissions of resource ${this._config.path}...` + } } module.exports = exports = PermissionState diff --git a/app/classes/state/fs/UnpackState.js b/app/classes/state/fs/UnpackState.js index bf92794..63585fa 100644 --- a/app/classes/state/fs/UnpackState.js +++ b/app/classes/state/fs/UnpackState.js @@ -66,6 +66,10 @@ class UnpackState extends State { check_message() { return this.failure_message() } + + display() { + return `Un-archive the contents of ${this._config.path} to ${this._config.destination}...` + } } module.exports = exports = UnpackState diff --git a/app/classes/state/git/CheckoutState.js b/app/classes/state/git/CheckoutState.js index 1de34c3..a571f47 100644 --- a/app/classes/state/git/CheckoutState.js +++ b/app/classes/state/git/CheckoutState.js @@ -40,6 +40,10 @@ class CheckoutState extends AbstractGitState { check_message() { return this.failure_message() } + + display() { + return `Check out ref ${this._config.target} in repo ${this._config.path}...` + } } module.exports = exports = CheckoutState diff --git a/app/classes/state/git/CloneState.js b/app/classes/state/git/CloneState.js index f0f75bf..edc5c9a 100644 --- a/app/classes/state/git/CloneState.js +++ b/app/classes/state/git/CloneState.js @@ -30,6 +30,10 @@ class CloneState extends AbstractGitState { check_message() { return this.failure_message() } + + display() { + return `Clone repo at ${this._config.source} to ${this._config.path}...` + } } module.exports = exports = CloneState diff --git a/app/classes/state/git/PullState.js b/app/classes/state/git/PullState.js new file mode 100644 index 0000000..df5705b --- /dev/null +++ b/app/classes/state/git/PullState.js @@ -0,0 +1,36 @@ +const AbstractGitState = require('./AbstractGitState') + +class PullState extends AbstractGitState { + static get services() { + return [...super.services, 'output'] + } + + async apply() { + if ( !(await this.check()) ) { + const repo = await this._repo() + await repo.pull(this._config.target) + } + } + + async check() { + return true // TODO support a better check here + } + + async reverse() { + this.output.warn('Pull state does not currently support automatic reversal.') + } + + failure_message() { + return `The Git repo at "${this._config.path}" on host "${this._host.name}" will be pulled.` + } + + check_message() { + return this.failure_message() + } + + display() { + return `Pull refs in repo ${this._config.path}...` + } +} + +module.exports = exports = PullState diff --git a/app/classes/state/git/TagState.js b/app/classes/state/git/TagState.js index 9ab5cf2..9f806d7 100644 --- a/app/classes/state/git/TagState.js +++ b/app/classes/state/git/TagState.js @@ -33,6 +33,10 @@ class TagState extends AbstractGitState { check_message() { return this.failure_message() } + + display() { + return `Create tag ${this._config.tag} in repo ${this._config.path}...` + } } module.exports = exports = TagState diff --git a/app/classes/state/os/CommandState.js b/app/classes/state/os/CommandState.js index 2fb8335..580d35e 100644 --- a/app/classes/state/os/CommandState.js +++ b/app/classes/state/os/CommandState.js @@ -1,17 +1,20 @@ const State = require('../State') class CommandState extends State { + #ran_once = false + static get services() { return [...super.services, 'output'] } async apply() { const cmd = `${this._config.cmd}` - await this._host.run(cmd) + const result = await this._host.run(cmd) + this.#ran_once = true } async check() { - return false + return this.#ran_once } async reverse() { @@ -30,6 +33,10 @@ class CommandState extends State { check_message() { return `The command check was not successful.` } + + display() { + return `Run the command: ${this._config.cmd}` + } } module.exports = exports = CommandState diff --git a/app/classes/state/os/IsAliveState.js b/app/classes/state/os/IsAliveState.js index e0f2981..d7fbd9d 100644 --- a/app/classes/state/os/IsAliveState.js +++ b/app/classes/state/os/IsAliveState.js @@ -20,6 +20,10 @@ class IsAliveState extends State { check_message() { return this.failure_message() } + + display() { + return `Ensure that the host is alive...` + } } module.exports = exports = IsAliveState diff --git a/app/classes/state/os/PackageAbsentState.js b/app/classes/state/os/PackageAbsentState.js index f80c3be..08d4df3 100644 --- a/app/classes/state/os/PackageAbsentState.js +++ b/app/classes/state/os/PackageAbsentState.js @@ -27,6 +27,10 @@ class PackageAbsentState extends State { check_message() { return this.failure_message() } + + display() { + return `Ensure that the package ${this._config.package} is not installed...` + } } module.exports = exports = PackageAbsentState diff --git a/app/classes/state/os/PackageCacheClearedState.js b/app/classes/state/os/PackageCacheClearedState.js index 70fdb4e..084675f 100644 --- a/app/classes/state/os/PackageCacheClearedState.js +++ b/app/classes/state/os/PackageCacheClearedState.js @@ -31,6 +31,10 @@ class PackageCacheClearedState extends State { check_message() { return `The package cache on host "${this._host.name}" has not been cleared.` } + + display() { + return `Ensure that the package cache is cleared...` + } } module.exports = exports = PackageCacheClearedState diff --git a/app/classes/state/os/PackageState.js b/app/classes/state/os/PackageState.js index 92db158..ed3bd0e 100644 --- a/app/classes/state/os/PackageState.js +++ b/app/classes/state/os/PackageState.js @@ -27,6 +27,10 @@ class PackageState extends State { check_message() { return this.failure_message() } + + display() { + return `Ensure that the package ${this._config.package} is installed...` + } } module.exports = exports = PackageState diff --git a/app/classes/state/os/ServiceDaemonReloadState.js b/app/classes/state/os/ServiceDaemonReloadState.js index 8b1ee0f..a710e36 100644 --- a/app/classes/state/os/ServiceDaemonReloadState.js +++ b/app/classes/state/os/ServiceDaemonReloadState.js @@ -31,6 +31,10 @@ class ServiceDaemonReloadState extends State { check_message() { return `The service daemon on host "${this._host.name}" has not been reloaded.` } + + display() { + return `Reload the service daemon...` + } } module.exports = exports = ServiceDaemonReloadState diff --git a/app/classes/state/os/ServiceRestartState.js b/app/classes/state/os/ServiceRestartState.js index f0ce878..23a9556 100644 --- a/app/classes/state/os/ServiceRestartState.js +++ b/app/classes/state/os/ServiceRestartState.js @@ -32,6 +32,10 @@ class ServiceRestartState extends State { check_message() { return `The service "${this._config.service}" on host "${this._host.name}" has not been restarted.` } + + display() { + return `Restart the ${this._config.service} service...` + } } module.exports = exports = ServiceRestartState diff --git a/app/classes/state/os/ServiceState.js b/app/classes/state/os/ServiceState.js index 2c91d58..0069eac 100644 --- a/app/classes/state/os/ServiceState.js +++ b/app/classes/state/os/ServiceState.js @@ -33,6 +33,10 @@ class ServiceState extends State { check_message() { return this.failure_message() } + + display() { + return `Ensure that the ${this._config.service} service is running...` + } } module.exports = exports = ServiceState diff --git a/app/classes/state/os/ServiceStoppedState.js b/app/classes/state/os/ServiceStoppedState.js index 60ea1bf..596fd40 100644 --- a/app/classes/state/os/ServiceStoppedState.js +++ b/app/classes/state/os/ServiceStoppedState.js @@ -32,6 +32,10 @@ class ServiceStoppedState extends State { check_message() { return `The service "${this._config.service}" on host "${this._host.name}" has not been stopped.` } + + display() { + return `Ensure that the ${this._config.service} service is not running...` + } } module.exports = exports = ServiceStoppedState diff --git a/app/classes/state/os/UpdateState.js b/app/classes/state/os/UpdateState.js index e299cdc..0275b4a 100644 --- a/app/classes/state/os/UpdateState.js +++ b/app/classes/state/os/UpdateState.js @@ -32,6 +32,10 @@ class UpdateState extends State { check_message() { return `There are package updates pending on the host "${this._host.name}."` } + + display() { + return `Ensure that all packages are up to date...` + } } module.exports = exports = UpdateState diff --git a/app/services/states.service.js b/app/services/states.service.js index be45d7f..3168516 100644 --- a/app/services/states.service.js +++ b/app/services/states.service.js @@ -25,6 +25,7 @@ class StatesService extends Service { 'git.clone': require('../classes/state/git/CloneState'), 'git.checkout': require('../classes/state/git/CheckoutState'), 'git.tag': require('../classes/state/git/TagState'), + 'git.pull': require('../classes/state/git/PullState'), 'os.cmd': require('../classes/state/os/CommandState'), 'os.alive': require('../classes/state/os/IsAliveState'), @@ -57,7 +58,7 @@ class StatesService extends Service { const StepClass = this.constructor.#state_map[type] if ( !StepClass ) throw new Error(`Invalid or unknown step type: ${type}`) - this.app.make(StepClass) + this.app.di().inject(StepClass) return new StepClass(host, state_config) } diff --git a/config/routines/example.config.js b/config/routines/example.config.js index 267488f..54c747b 100644 --- a/config/routines/example.config.js +++ b/config/routines/example.config.js @@ -69,6 +69,17 @@ const example_routine = { target: 'develop', // ref to check out revert_to: 'master', // ref to revert to on undo }, + { + // State: the specified repo is pulled + type: 'git.pull', + host: 'localhost', + path: '/tmp/some.dir.on.host', + target: { + target: 'origin', + ref: 'master', + rebase: true, + }, + }, { // State: the specified tag exists type: 'git.tag',