From 0906e02a8538b698ed8bd7c72e6a09d3e809b67a Mon Sep 17 00:00:00 2001 From: Anton Aksola Date: Fri, 28 Aug 2015 11:05:18 +0300 Subject: Initial implementation of the hook feature The current implementation is modular and allows users to define hooks in several ways: * Use one of the built-in hook types (currently only 'exec') * Define their own Hook classes inside ~/.config/oxidized/hook Exec hook type runs a user defined command with or without shell. It populates a bunch of environment variables with metadata. The command can either be run as synchronous or asynchronous. The default is synchronous. --- lib/oxidized/config.rb | 3 +- lib/oxidized/core.rb | 2 + lib/oxidized/hook.rb | 88 +++++++++++++++++++++++++++++++++++++++++++ lib/oxidized/hook/exec.rb | 84 +++++++++++++++++++++++++++++++++++++++++ lib/oxidized/hook/noophook.rb | 9 +++++ lib/oxidized/manager.rb | 11 +++++- lib/oxidized/worker.rb | 6 +++ oxidized.gemspec | 5 ++- 8 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 lib/oxidized/hook.rb create mode 100644 lib/oxidized/hook/exec.rb create mode 100644 lib/oxidized/hook/noophook.rb diff --git a/lib/oxidized/config.rb b/lib/oxidized/config.rb index 96c8fbf..f45004a 100644 --- a/lib/oxidized/config.rb +++ b/lib/oxidized/config.rb @@ -9,10 +9,11 @@ module Oxidized OutputDir = File.join Directory, %w(lib oxidized output) ModelDir = File.join Directory, %w(lib oxidized model) SourceDir = File.join Directory, %w(lib oxidized source) + HookDir = File.join Directory, %w(lib oxidized hook) Sleep = 1 end class << self - attr_accessor :mgr + attr_accessor :mgr, :Hooks end CFGS = Asetus.new :name=>'oxidized', :load=>false, :key_to_s=>true CFGS.default.username = 'username' diff --git a/lib/oxidized/core.rb b/lib/oxidized/core.rb index 71267dd..b70443c 100644 --- a/lib/oxidized/core.rb +++ b/lib/oxidized/core.rb @@ -6,6 +6,7 @@ module Oxidized require 'oxidized/worker' require 'oxidized/nodes' require 'oxidized/manager' + require 'oxidized/hook' class << self def new *args Core.new args @@ -15,6 +16,7 @@ module Oxidized class Core def initialize args Oxidized.mgr = Manager.new + Oxidized.Hooks = HookManager.from_config CFG nodes = Nodes.new @worker = Worker.new nodes trap('HUP') { nodes.load } diff --git a/lib/oxidized/hook.rb b/lib/oxidized/hook.rb new file mode 100644 index 0000000..2763c4f --- /dev/null +++ b/lib/oxidized/hook.rb @@ -0,0 +1,88 @@ +module Oxidized +class HookManager + class << self + def from_config cfg + mgr = new + cfg.hooks.each do |name,h_cfg| + h_cfg.events.each do |event| + mgr.register event.to_sym, name, h_cfg.type, h_cfg + end + end + mgr + end + end + + # HookContext is passed to each hook. It can contain anything related to the + # event in question. At least it contains the event name + class HookContext < OpenStruct; end + + # RegisteredHook is a container for a Hook instance + class RegisteredHook < Struct.new(:name, :hook); end + + Events = [ + :node_success, + :node_fail, + :post_store, + ] + attr_reader :registered_hooks + + def initialize + @registered_hooks = Hash.new {|h,k| h[k] = []} + end + + def register event, name, hook_type, cfg + unless Events.include? event + raise ArgumentError, + "unknown event #{event}, available: #{Events.join ','}" + end + + Oxidized.mgr.add_hook hook_type + begin + hook = Oxidized.mgr.hook.fetch(hook_type).new + rescue KeyError + raise KeyError, "cannot find hook #{hook_type.inspect}" + end + + hook.cfg = cfg + + @registered_hooks[event] << RegisteredHook.new(name, hook) + Log.debug "Hook #{name.inspect} registered #{hook.class} for event #{event.inspect}" + end + + def handle event, **ctx_params + ctx = HookContext.new ctx_params + ctx.event = event + + @registered_hooks[event].each do |r_hook| + begin + r_hook.hook.run_hook ctx + rescue => e + Log.error "Hook #{r_hook.name} (#{r_hook.hook}) failed " + + "(#{e.inspect}) for event #{event.inspect}" + end + end + end +end + +# Hook abstract base class +class Hook + attr_accessor :cfg + + def initialize + end + + def cfg=(cfg) + @cfg = cfg + validate_cfg! if self.respond_to? :validate_cfg! + end + + def run_hook ctx + raise NotImplementedError + end + + def log(msg, level=:info) + Log.send(level, "#{self.class.name}: #{msg}") + end + +end +end diff --git a/lib/oxidized/hook/exec.rb b/lib/oxidized/hook/exec.rb new file mode 100644 index 0000000..eb71466 --- /dev/null +++ b/lib/oxidized/hook/exec.rb @@ -0,0 +1,84 @@ +class Exec < Oxidized::Hook + include Process + + def initialize + super + @timeout = 60 + @async = false + end + + def validate_cfg! + # Syntax check + if cfg.has_key? "timeout" + @timeout = cfg.timeout + raise "invalid timeout value" unless @timeout.is_a?(Integer) && + @timeout > 0 + end + + if cfg.has_key? "async" + @async = !!cfg.async + end + + if cfg.has_key? "cmd" + @cmd = cfg.cmd + raise "invalid cmd value" unless @cmd.is_a?(String) || @cmd.is_a?(Array) + end + + rescue RuntimeError => e + raise ArgumentError, + "#{self.class.name}: configuration invalid: #{e.message}" + end + + def run_hook ctx + env = make_env ctx + log "Execute: #{@cmd.inspect}", :debug + th = Thread.new do + begin + run_cmd! env + rescue => e + raise e unless @async + end + end + th.join unless @async + end + + def run_cmd! env + pid, status = nil, nil + Timeout.timeout(@timeout) do + pid = spawn env, @cmd , :unsetenv_others => true + pid, status = wait2 pid + unless status.exitstatus.zero? + msg = "#{@cmd.inspect} failed with exit value #{status.exitstatus}" + log msg, :error + raise msg + end + end + rescue TimeoutError + kill "TERM", pid + msg = "#{@cmd} timed out" + log msg, :error + raise TimeoutError, msg + end + + def make_env ctx + env = { + "OX_EVENT" => ctx.event.to_s + } + if ctx.node + env.merge!( + "OX_NODE_NAME" => ctx.node.name.to_s, + "OX_NODE_FROM" => ctx.node.from.to_s, + "OX_NODE_MSG" => ctx.node.msg.to_s, + "OX_NODE_GROUP" => ctx.node.group.to_s, + "OX_EVENT" => ctx.event.to_s, + ) + end + if ctx.job + env.merge!( + "OX_JOB_STATUS" => ctx.job.status.to_s, + "OX_JOB_TIME" => ctx.job.time.to_s, + ) + end + env + end +end diff --git a/lib/oxidized/hook/noophook.rb b/lib/oxidized/hook/noophook.rb new file mode 100644 index 0000000..d4673ba --- /dev/null +++ b/lib/oxidized/hook/noophook.rb @@ -0,0 +1,9 @@ +class NoopHook < Oxidized::Hook + def validate_cfg! + log "Validate config" + end + + def run_hook ctx + log "Run hook with context: #{ctx}" + end +end diff --git a/lib/oxidized/manager.rb b/lib/oxidized/manager.rb index b4eaecd..bf28ae7 100644 --- a/lib/oxidized/manager.rb +++ b/lib/oxidized/manager.rb @@ -23,12 +23,13 @@ module Oxidized end end end - attr_reader :input, :output, :model, :source + attr_reader :input, :output, :model, :source, :hook def initialize @input = {} @output = {} @model = {} @source = {} + @hook = {} end def add_input method method = Manager.load Config::InputDir, method @@ -53,5 +54,13 @@ module Oxidized return false if _source.empty? @source.merge! _source end + def add_hook _hook + return nil if @hook.key? _hook + name = _hook + _hook = Manager.load File.join(Config::Root, 'hook'), name + _hook = Manager.load Config::HookDir, name if _hook.empty? + return false if _hook.empty? + @hook.merge! _hook + end end end diff --git a/lib/oxidized/worker.rb b/lib/oxidized/worker.rb index 6bb2a22..eea747e 100644 --- a/lib/oxidized/worker.rb +++ b/lib/oxidized/worker.rb @@ -34,12 +34,16 @@ module Oxidized @jobs.duration job.time node.running = false if job.status == :success + Oxidized.Hooks.handle :node_success, :node => node, + :job => job msg = "update #{node.name}" msg += " from #{node.from}" if node.from msg += " with message '#{node.msg}'" if node.msg if node.output.new.store node.name, job.config, :msg => msg, :user => node.user, :group => node.group Log.info "Configuration updated for #{node.group}/#{node.name}" + Oxidized.Hooks.handle :post_store, :node => node, + :job => job end node.reset else @@ -51,6 +55,8 @@ module Oxidized else msg += ", retries exhausted, giving up" node.retry = 0 + Oxidized.Hooks.handle :node_fail, :node => node, + :job => job end Log.warn msg end diff --git a/oxidized.gemspec b/oxidized.gemspec index ae069cf..4ad6e92 100644 --- a/oxidized.gemspec +++ b/oxidized.gemspec @@ -3,8 +3,8 @@ Gem::Specification.new do |s| s.version = '0.7.2' s.licenses = %w( Apache-2.0 ) s.platform = Gem::Platform::RUBY - s.authors = [ 'Saku Ytti', 'Samer Abdel-Hafez' ] - s.email = %w( saku@ytti.fi sam@arahant.net ) + s.authors = [ 'Saku Ytti', 'Samer Abdel-Hafez', 'Anton Aksola' ] + s.email = %w( saku@ytti.fi sam@arahant.net aakso@iki.fi) s.homepage = 'http://github.com/ytti/oxidized' s.summary = 'feeble attempt at rancid' s.description = 'software to fetch configuration from network devices and store them' @@ -18,4 +18,5 @@ Gem::Specification.new do |s| s.add_runtime_dependency 'slop', '~> 3.5' s.add_runtime_dependency 'net-ssh', '~> 2.8' s.add_runtime_dependency 'rugged', '~> 0.21', '>= 0.21.4' + s.add_development_dependency 'pry' end -- cgit v1.2.1 From 323f5968e3593539eca0477779114caddbfc751d Mon Sep 17 00:00:00 2001 From: Anton Aksola Date: Mon, 31 Aug 2015 12:12:54 +0300 Subject: Add configuration docs for hook system --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/README.md b/README.md index 85619c1..554ab60 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,57 @@ model_map: juniper: junos ``` +# Hooks +You can define arbitrary number of hooks that subscribe different events. The hook system is modular and different kind of hook types can be enabled. + +## Configuration +Following configuration keys need to be defined for all hooks: + + * `events`: which events to subscribe. Needs to be an array. See below for the list of available events. + * `type`: what hook class to use. See below for the list of available hook types. + +### Events + * `node_success`: triggered when configuration is succesfully pulled from a node and right before storing the configuration. + * `node_fail`: triggered after `retries` amount of failed node pulls. + * `post_store`: triggered after node configuration is stored. + +## Hook type: exec +The `exec` hook type allows users to run an arbitrary shell command or a binary when triggered. + +The command is executed on a separate child process either in synchronous or asynchronous fashion. Non-zero exit values cause errors to be logged. STDOUT and STDERR are currently not collected. + +Command is executed with the following environment: +``` +OX_EVENT +OX_NODE_NAME +OX_NODE_FROM +OX_NODE_MSG +OX_NODE_GROUP +OX_JOB_STATUS +OX_JOB_TIME +``` + +Exec hook recognizes following configuration keys: + + * `timeout`: hard timeout for the command execution. SIGTERM will be sent to the child process after the timeout has elapsed. Default: 60 + * `async`: influences whether main thread will wait for the command execution. Set this true for long running commands so node pull is not blocked. Default: false + * `cmd`: command to run. + + +## Hook configuration example +``` +hooks: + name_for_example_hook1: + type: exec + events: [node_success] + cmd: 'echo "Node success $OX_NODE_NAME" >> /tmp/ox_node_success.log' + name_for_example_hook2: + type: exec + events: [post_store, node_fail] + cmd: 'echo "Doing long running stuff for $OX_NODE_NAME" >> /tmp/ox_node_stuff.log; sleep 60' + async: true + timeout: 120 +``` # Ruby API -- cgit v1.2.1