├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── MIT-LICENSE ├── README.md ├── Rakefile ├── VERSION ├── ansible_module.gemspec ├── lib └── ansible_module.rb └── spec ├── ansible_module_spec.rb ├── classes └── calc.rb └── spec_helper.rb /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | ## 0.9.2 (2014-08-31) 5 | 6 | * Add initial RSpec examples. 7 | 8 | ## 0.9.1 (2014-08-28) 9 | 10 | * Add private method `AnsibleModule#invalid_json` to improve the message on 11 | validation failure. 12 | 13 | ## 0.9.0 (2014-08-27) 14 | 15 | * Implement basic instance methods: `main`, `run`, `exit_json`, `fail_json` 16 | * Implement basic class methods: `instance`, `params` 17 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | ansible_module (0.9.2) 5 | activemodel (>= 4.1.5) 6 | activesupport (>= 4.1.5) 7 | json (>= 1.8.1) 8 | virtus (>= 1.0.3) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | activemodel (5.2.4.1) 14 | activesupport (= 5.2.4.1) 15 | activesupport (5.2.4.1) 16 | concurrent-ruby (~> 1.0, >= 1.0.2) 17 | i18n (>= 0.7, < 2) 18 | minitest (~> 5.1) 19 | tzinfo (~> 1.1) 20 | axiom-types (0.1.1) 21 | descendants_tracker (~> 0.0.4) 22 | ice_nine (~> 0.11.0) 23 | thread_safe (~> 0.3, >= 0.3.1) 24 | coercible (1.0.0) 25 | descendants_tracker (~> 0.0.1) 26 | concurrent-ruby (1.1.5) 27 | descendants_tracker (0.0.4) 28 | thread_safe (~> 0.3, >= 0.3.1) 29 | diff-lcs (1.3) 30 | equalizer (0.0.11) 31 | i18n (1.8.2) 32 | concurrent-ruby (~> 1.0) 33 | ice_nine (0.11.2) 34 | json (2.3.0) 35 | minitest (5.14.0) 36 | rspec (3.9.0) 37 | rspec-core (~> 3.9.0) 38 | rspec-expectations (~> 3.9.0) 39 | rspec-mocks (~> 3.9.0) 40 | rspec-core (3.9.1) 41 | rspec-support (~> 3.9.1) 42 | rspec-expectations (3.9.0) 43 | diff-lcs (>= 1.2.0, < 2.0) 44 | rspec-support (~> 3.9.0) 45 | rspec-mocks (3.9.1) 46 | diff-lcs (>= 1.2.0, < 2.0) 47 | rspec-support (~> 3.9.0) 48 | rspec-support (3.9.2) 49 | thread_safe (0.3.6) 50 | tzinfo (1.2.6) 51 | thread_safe (~> 0.1) 52 | virtus (1.0.5) 53 | axiom-types (~> 0.1) 54 | coercible (~> 1.0) 55 | descendants_tracker (~> 0.0, >= 0.0.3) 56 | equalizer (~> 0.0, >= 0.0.9) 57 | 58 | PLATFORMS 59 | ruby 60 | 61 | DEPENDENCIES 62 | ansible_module! 63 | rspec (>= 3.0.0) 64 | 65 | BUNDLED WITH 66 | 2.1.4 67 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Tsutomu Kuroda 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | AnsibleModule 2 | ============= 3 | 4 | [![Gem Version](https://badge.fury.io/rb/ansible_module.svg)](http://badge.fury.io/rb/ansible_module) 5 | 6 | AnsibleModule is a Ruby class that provides basic functionalities as an [Ansible](http://ansible.com) module. 7 | 8 | It is distributed as a gem package under the [MIT-LICENSE](MIT-LICENSE). 9 | 10 | Installation 11 | ------------ 12 | 13 | ### Manual Installation 14 | 15 | Install Ruby (2.0 or later) and `ansible_module` gem on your *remote* hosts. 16 | 17 | The following is a typical procedure on Ubuntu Server 14.04: 18 | 19 | ``` 20 | $ sudo add-apt-repository -y ppa:brightbox/ruby-ng 21 | $ sudo apt-get update 22 | $ sudo apt-get -y install ruby2.1 ruby2.1-dev 23 | $ sudo gem install ansible_module 24 | ``` 25 | 26 | ### Installation with Ansible 27 | 28 | Create an Ansible [playbook](http://docs.ansible.com/playbooks.html) to install Ruby and `ansible_module` gem. 29 | 30 | The following is an example for Ubuntu Server 14.04: 31 | 32 | ```yaml 33 | - hosts: servers 34 | sudo: yes 35 | tasks: 36 | - name: Add ppa for ruby 37 | apt_repository: repo='ppa:brightbox/ruby-ng' state=present 38 | - name: Install ruby 2.1 39 | apt: name=ruby2.1 state=present 40 | - name: Install ruby 2.1 headers 41 | apt: name=ruby2.1-dev state=present 42 | - name: Install ansible_module gem 43 | gem: name=ansible_module user_install=false state=present 44 | ``` 45 | 46 | If you named this file `ruby_environment.yml`, then run the following command on your *local* host: 47 | 48 | ``` 49 | $ ansible-playbook -i hosts ruby_environment.yml 50 | ``` 51 | 52 | In the above example, the `hosts` file is an [inventory](http://docs.ansible.com/intro_inventory.html) which lists up host names or IP addresses. 53 | 54 | 55 | Example (1) -- Simple Calculation 56 | --------------------------------- 57 | 58 | ### Module 59 | 60 | Create a file named `calc` on the `library` directory as follows: 61 | 62 | ```ruby 63 | #!/usr/bin/ruby 64 | # WANT_JSON 65 | 66 | require 'ansible_module' 67 | 68 | class Calc < AnsibleModule 69 | attribute :x, Integer 70 | attribute :y, Integer 71 | 72 | validates :x, :y, presence: true, numericality: { only_integer: true } 73 | 74 | def main 75 | sum = x + y 76 | 77 | exit_json(x: x, y: y, sum: sum, changed: true) 78 | end 79 | end 80 | 81 | Calc.instance.run 82 | ``` 83 | 84 | The values of attributes `x` and `y` are set during instantiation process by `AnsibleModule`. 85 | 86 | Note that you can validate them with `validates` class method derived from `ActiveModel`. 87 | 88 | The class method `instance` returns a singleton instance of `Calc` class, 89 | and its `run` method calls the `main` method if validations are successful. 90 | 91 | So, the author of an Ansible module must implement the `main` method at least. 92 | 93 | 94 | ### Playbook 95 | 96 | Now, you can use the `calc` module in your playbook. 97 | For example, create a file named `calc.yml` as follows: 98 | 99 | ```yaml 100 | - hosts: servers 101 | tasks: 102 | - name: Make a calculation 103 | calc: x=50 y=50 104 | register: result 105 | - debug: msg="sum = {{ result['sum'] }}" 106 | ``` 107 | 108 | Then, run the following command on your local host: 109 | 110 | ``` 111 | $ ansible-playbook -i hosts calc.yml 112 | ``` 113 | 114 | 115 | Example (2) -- MySQL 5.6 Replication Management 116 | ----------------------------------------------- 117 | 118 | ### Prerequisites 119 | 120 | Install MySQL 5.6 Server, MySQL 5.6 Client, MySQL 5.6 development files and `mysql2` gem. 121 | 122 | The following is a typical procedure on Ubuntu Server 14.04: 123 | 124 | ``` 125 | $ sudo add-apt-repository -y ppa:ondrej/mysql-5.6 126 | $ sudo apt-get update 127 | $ sudo apt-get -y install mysql-server-5.6 mysql-client-5.6 libmysqlclient-dev 128 | $ sudo gem install mysql2 129 | ``` 130 | 131 | You can also install them with the following playbook: 132 | 133 | ```yaml 134 | - hosts: servers 135 | sudo: yes 136 | tasks: 137 | - name: Add ppa for mysql 5.6 138 | apt_repository: repo='ppa:ondrej/mysql-5.6' state=present 139 | - name: Install mysql server 5.6 140 | apt: name=mysql-server-5.6 state=present 141 | - name: Install mysql client 5.6 142 | apt: name=mysql-client-5.6 state=present 143 | - name: Install libmysqlclient-dev 144 | apt: name=libmysqlclient-dev state=present 145 | - name: Install mysql2 gem 146 | gem: name=mysql2 user_install=false state=present 147 | ``` 148 | 149 | 150 | ### Module 151 | 152 | Create a file named `mysql_change_master` on the `library` directory as follows: 153 | 154 | ```ruby 155 | #!/usr/bin/ruby 156 | # WANT_JSON 157 | 158 | require 'ansible_module' 159 | require 'mysql2' 160 | 161 | class MysqlChangeMaster < AnsibleModule 162 | attribute :host, String 163 | attribute :port, Integer, default: 3306 164 | attribute :user, String 165 | attribute :password, String 166 | attribute :mysql_root_password, String 167 | 168 | validates :host, :user, :password, presence: true 169 | validates :port, inclusion: { in: 0..65535 } 170 | validates :password, maximum: 32 171 | 172 | def main 173 | done? && exit_json(changed: false) 174 | 175 | statement = %Q{ 176 | CHANGE MASTER TO 177 | MASTER_HOST='#{host}', 178 | MASTER_PORT=#{port}, 179 | MASTER_USER='#{user}', 180 | MASTER_PASSWORD='#{password}', 181 | MASTER_AUTO_POSITION=1 182 | }.squish 183 | 184 | mysql_client.query('STOP SLAVE') 185 | mysql_client.query(statement) 186 | mysql_client.query('START SLAVE') 187 | sleep(1) 188 | 189 | if done? 190 | exit_json(statement: statement, changed: true) 191 | else 192 | fail_json(msg: "Last Error: #{@last_error}") 193 | end 194 | end 195 | 196 | private 197 | 198 | def done? 199 | status = mysql_client.query('SHOW SLAVE STATUS').first || {} 200 | 201 | @last_error = [ status['Last_IO_Error'], status['Last_SQL_Error'] ] 202 | .compact.join(' ').squish 203 | 204 | status['Master_Host'] == host && 205 | status['Master_User'] == user && 206 | status['Master_Port'].to_i == port && 207 | status['Auto_Position'].to_i == 1 && 208 | status['Slave_IO_State'] != '' && 209 | status['Last_IO_Error'] == '' && 210 | status['Last_SQL_Error'] == '' 211 | end 212 | 213 | def mysql_client 214 | @client ||= Mysql2::Client.new( 215 | host: 'localhost', 216 | username: 'root', 217 | password: mysql_root_password, 218 | encoding: 'utf8' 219 | ) 220 | end 221 | end 222 | 223 | MysqlChangeMaster.instance.run 224 | ``` 225 | 226 | Note that you can use methods added by `ActiveSupport` like `String#squish`. 227 | 228 | ### Playbook 229 | 230 | Then, create a file named `replication.yml` as follows: 231 | 232 | ```yaml 233 | - hosts: mysql-slave 234 | vars_files: 235 | - shared/secret.yml 236 | tasks: 237 | - name: Change master to the db1 238 | mysql: > 239 | host="db1" 240 | user="repl" 241 | password="{{ mysql_repl_password }}" 242 | mysql_root_password="{{ mysql_root_password }}" 243 | ``` 244 | 245 | Next, create a file named `secret.yml` on the `shared` directory as follows: 246 | 247 | ```secret.yml 248 | mysql_repl_password: p@ssw0rd 249 | mysql_root_password: p@ssw0rd 250 | ``` 251 | 252 | Note that you should replace `p@ssw0rd` with real passwords. 253 | 254 | And run the following command on your local host: 255 | 256 | ``` 257 | $ ansible-playbook -i hosts replication.yml 258 | ``` 259 | 260 | You might want to encrypt the `secret.yml` with [ansible-vault](http://docs.ansible.com/playbooks_vault.html). 261 | In that case, you must add `--ask-vault-pass` option to the above command: 262 | 263 | ``` 264 | $ ansible-playbook -i hosts --ask-vault-pass replication.yml 265 | ``` 266 | 267 | 268 | License 269 | ------- 270 | 271 | AnsibleModule is distributed under the [MIT-LICENSE](MIT-LICENSE). 272 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler::GemHelper.install_tasks 3 | 4 | require 'rspec/core' 5 | require 'rspec/core/rake_task' 6 | 7 | RSpec::Core::RakeTask.new(:spec) do |spec| 8 | spec.pattern = FileList['spec/**/*_spec.rb'] 9 | end 10 | 11 | task :default => "spec" 12 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.9.4 2 | -------------------------------------------------------------------------------- /ansible_module.gemspec: -------------------------------------------------------------------------------- 1 | version = File.read(File.expand_path("VERSION", File.dirname(__FILE__))).strip 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "ansible_module" 5 | s.version = version 6 | s.authors = [ "Tsutomu KURODA" ] 7 | s.email = "t-kuroda@oiax.jp" 8 | s.homepage = "https://github.com/kuroda/ansible_module" 9 | s.description = "AnsibleModule is a Ruby class that provides basic functionalities as an Ansible module." 10 | s.summary = "A Ruby base class for Ansible module." 11 | s.license = "MIT" 12 | 13 | s.required_ruby_version = ">= 2.0.0" 14 | 15 | s.add_runtime_dependency "json", ">= 1.8.1" 16 | s.add_runtime_dependency "virtus", ">= 1.0.3" 17 | s.add_runtime_dependency "activesupport", ">= 4.1.5" 18 | s.add_runtime_dependency "activemodel", ">= 4.1.5" 19 | 20 | s.add_development_dependency "rspec", ">= 3.0.0" 21 | 22 | s.files = %w(README.md CHANGELOG.md MIT-LICENSE VERSION) + Dir.glob("lib/**/*") 23 | end 24 | -------------------------------------------------------------------------------- /lib/ansible_module.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'virtus' 3 | require 'active_support/all' 4 | require 'active_model' 5 | 6 | class AnsibleModule 7 | include Virtus.model 8 | include ActiveModel::Validations 9 | 10 | def main 11 | raise "Not implemented." 12 | end 13 | 14 | def run 15 | if valid? 16 | main 17 | else 18 | invalid_json 19 | end 20 | rescue StandardError => e 21 | fail_json(msg: "Failed: #{e.to_s}") 22 | end 23 | 24 | private 25 | 26 | def exit_json(hash) 27 | hash = ActiveSupport::HashWithIndifferentAccess.new(hash) 28 | print JSON.dump(hash) 29 | exit 0 30 | end 31 | 32 | def fail_json(hash) 33 | hash = ActiveSupport::HashWithIndifferentAccess.new(hash) 34 | hash[:failed] = true 35 | hash[:msg] ||= "No error message." 36 | print JSON.dump(hash) 37 | exit 1 38 | end 39 | 40 | def invalid_json 41 | message = 'Invalid parameters: ' 42 | message += errors.full_messages.map { |m| "#{m}." }.join(' ') 43 | fail_json(msg: message) 44 | end 45 | 46 | class << self 47 | def instance 48 | @instance ||= new(params) 49 | end 50 | 51 | def params 52 | return @params if @params 53 | @params = ActiveSupport::HashWithIndifferentAccess.new 54 | File.open(ARGV[0]) do |fh| 55 | @params.update JSON.parse(fh.read()) 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/ansible_module_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'calc' 4 | 5 | describe AnsibleModule do 6 | context 'Calc module' do 7 | before do 8 | Calc.instance_variable_set(:@instance, nil) 9 | Calc.instance_variable_set(:@params, nil) 10 | end 11 | 12 | describe '.instance' do 13 | it 'should return a singleton instance of Calc class' do 14 | instance = Calc.instance 15 | expect(instance).to be_kind_of(Calc) 16 | expect(instance).to equal(Calc.instance) 17 | end 18 | end 19 | 20 | describe '.params' do 21 | it 'should return a hash constructed from a temp file' do 22 | fh = double('File Handler', read: 'x=100 y=50') 23 | allow(File).to receive(:open).and_yield(fh) 24 | 25 | p = Calc.params 26 | 27 | expect(p).to be_kind_of(Hash) 28 | expect(p[:x]).to eq('100') 29 | expect(p[:y]).to eq('50') 30 | end 31 | end 32 | 33 | context 'Validation success' do 34 | let(:instance) { Calc.new(x: '100', y: '50') } 35 | before { allow(instance).to receive(:print) } 36 | before { allow(instance).to receive(:exit) } 37 | 38 | it 'should print sum in JSON format' do 39 | instance.run 40 | expect(instance).to have_received(:print).with(%r{"sum":150}) 41 | expect(instance).to have_received(:print).with(%r{"changed":true}) 42 | end 43 | 44 | it 'should exit with 0' do 45 | instance.run 46 | expect(instance).to have_received(:exit).with(0) 47 | end 48 | end 49 | 50 | context 'Validation failure' do 51 | let(:instance) { Calc.new(x: '', y: '50') } 52 | before { allow(instance).to receive(:print) } 53 | before { allow(instance).to receive(:exit) } 54 | 55 | it 'should print validation error message in JSON format' do 56 | instance.run 57 | expect(instance).to have_received(:print).with(%r{X can\'t be blank\.}) 58 | expect(instance).to have_received(:print).with(%r{"failed":true}) 59 | end 60 | 61 | it 'should exit with 1' do 62 | instance.run 63 | expect(instance).to have_received(:exit).with(1) 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/classes/calc.rb: -------------------------------------------------------------------------------- 1 | require 'ansible_module' 2 | 3 | class Calc < AnsibleModule 4 | attribute :x, Integer 5 | attribute :y, Integer 6 | 7 | validates :x, :y, presence: true, numericality: { only_integer: true, allow_blank: true } 8 | 9 | def main 10 | sum = x + y 11 | 12 | exit_json(x: x, y: y, sum: sum, changed: true) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH << File.expand_path('classes', File.dirname(__FILE__)) 2 | 3 | require 'bundler/setup' 4 | Bundler.setup 5 | 6 | require 'ansible_module' 7 | 8 | I18n.enforce_available_locales = false 9 | 10 | RSpec.configure do |config| 11 | end 12 | --------------------------------------------------------------------------------