jisota 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +19 -0
  3. data/.rspec +2 -0
  4. data/Gemfile +4 -0
  5. data/Guardfile +7 -0
  6. data/LICENSE.txt +22 -0
  7. data/README.md +254 -0
  8. data/Rakefile +1 -0
  9. data/jisota.gemspec +30 -0
  10. data/lib/jisota/collection.rb +52 -0
  11. data/lib/jisota/command_script.rb +17 -0
  12. data/lib/jisota/composite_script.rb +23 -0
  13. data/lib/jisota/configuration.rb +60 -0
  14. data/lib/jisota/errors.rb +3 -0
  15. data/lib/jisota/file_script.rb +21 -0
  16. data/lib/jisota/logger.rb +74 -0
  17. data/lib/jisota/package.rb +35 -0
  18. data/lib/jisota/package_script.rb +54 -0
  19. data/lib/jisota/packages/apt.rb +17 -0
  20. data/lib/jisota/packages/ruby.rb +29 -0
  21. data/lib/jisota/param.rb +34 -0
  22. data/lib/jisota/param_parser.rb +70 -0
  23. data/lib/jisota/provisioner.rb +24 -0
  24. data/lib/jisota/role.rb +13 -0
  25. data/lib/jisota/script_block.rb +66 -0
  26. data/lib/jisota/server.rb +12 -0
  27. data/lib/jisota/ssh_engine.rb +11 -0
  28. data/lib/jisota/ssh_session.rb +48 -0
  29. data/lib/jisota/upload_file.rb +3 -0
  30. data/lib/jisota/version.rb +3 -0
  31. data/lib/jisota.rb +63 -0
  32. data/spec/acceptance/simple_script_spec.rb +56 -0
  33. data/spec/lib/jisota/collection_spec.rb +44 -0
  34. data/spec/lib/jisota/command_script_spec.rb +22 -0
  35. data/spec/lib/jisota/composite_script_spec.rb +37 -0
  36. data/spec/lib/jisota/configuration_spec.rb +82 -0
  37. data/spec/lib/jisota/file_script_spec.rb +22 -0
  38. data/spec/lib/jisota/logger_spec.rb +34 -0
  39. data/spec/lib/jisota/package_script_spec.rb +43 -0
  40. data/spec/lib/jisota/package_spec.rb +74 -0
  41. data/spec/lib/jisota/packages/apt_spec.rb +15 -0
  42. data/spec/lib/jisota/packages/ruby_spec.rb +14 -0
  43. data/spec/lib/jisota/role_spec.rb +31 -0
  44. data/spec/lib/jisota/script_block_spec.rb +51 -0
  45. data/spec/lib/jisota/server_spec.rb +37 -0
  46. data/spec/lib/jisota_spec.rb +34 -0
  47. data/spec/spec_helper.rb +21 -0
  48. data/spec/test_files/foo +1 -0
  49. metadata +221 -0
@@ -0,0 +1,54 @@
1
+ module Jisota
2
+ ##
3
+ # Part of the Script duck type
4
+ #
5
+ # Executes an entire package, which in turn might execute other packages.
6
+ # The args are parsed with the ParamParser to match args with params.
7
+ #
8
+ # If the package has a `verify_block`, that will be executed first. If the
9
+ # result of verify is success, the `run_block` will not be executed.
10
+ class PackageScript
11
+ attr_accessor :package, :args, :packages
12
+
13
+ def initialize(package, args = [], packages = Collection.new)
14
+ @package = package
15
+ @args = args
16
+ @packages = packages
17
+ end
18
+
19
+ def execute(ssh_session, logger = nil)
20
+ logger.package(self) if logger
21
+ logger.indent if logger
22
+ result = execute_verify_and_run(ssh_session, logger)
23
+ logger.outdent if logger
24
+ result
25
+ end
26
+
27
+ def to_s
28
+ "#{package.name} #{args.map(&:inspect).join(", ")}"
29
+ end
30
+
31
+ private
32
+
33
+ def execute_verify_and_run(ssh_session, logger)
34
+ parsed_params = ParamParser.new(package.params, args).parse
35
+ if package.verify_block
36
+ verify_script = package.verify_block.evaluate(parsed_params, packages)
37
+ result = verify_script.execute(ssh_session, logger)
38
+ if result
39
+ logger.package_cancelled_by_verify(self) if logger
40
+ true
41
+ else
42
+ execute_run(parsed_params, ssh_session, logger)
43
+ end
44
+ else
45
+ execute_run(parsed_params, ssh_session, logger)
46
+ end
47
+ end
48
+
49
+ def execute_run(parsed_params, ssh_session, logger)
50
+ run_script = package.run_block.evaluate(parsed_params, packages)
51
+ run_script.execute(ssh_session, logger)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,17 @@
1
+ require 'jisota'
2
+
3
+ Jisota.global_config do
4
+ package :apt do
5
+ description "Installs packages with apt-get"
6
+ param :packages, required: true, splat: true
7
+
8
+ run do
9
+ cmd "sudo apt-get install -y #{packages.join(" ")}"
10
+ end
11
+
12
+ verify do
13
+ cmd "dpkg -s #{packages.join(" ")}"
14
+ end
15
+ end
16
+ end
17
+
@@ -0,0 +1,29 @@
1
+ require 'jisota'
2
+
3
+ Jisota.global_config do
4
+ package :ruby do
5
+ description "Installs ruby from source"
6
+ param :version, required: true
7
+ param :tmp_dir, default: "~/tmp"
8
+
9
+ run do
10
+ minor_version = version.match(/\d+\.\d+/)[0]
11
+
12
+ apt *%w(libffi-dev libssl-dev zlib1g-dev libreadline-dev)
13
+ cmd %Q{
14
+ mkdir -p #{tmp_dir} &&
15
+ cd #{tmp_dir} &&
16
+ wget --continue --no-verbose http://cache.ruby-lang.org/pub/ruby/#{minor_version}/ruby-#{version}.tar.gz &&
17
+ tar -zxvf ruby-#{version}.tar.gz &&
18
+ cd ruby-#{version} &&
19
+ ./configure &&
20
+ make &&
21
+ sudo make install &&
22
+ }
23
+ end
24
+
25
+ verify do
26
+ cmd "ruby -v | grep #{version}"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,34 @@
1
+ module Jisota
2
+ ##
3
+ # A package param
4
+ #
5
+ # Options:
6
+ #
7
+ # [default] Provide a default value for the param
8
+ # [required] If true, an error is raised unless the param has a value
9
+ # [splat] Will get remaining unnamed arguments
10
+ class Param
11
+ attr_reader :name, :options
12
+
13
+ def initialize(name, options = {})
14
+ @name = name
15
+ @options = options
16
+ end
17
+
18
+ def default?
19
+ options.has_key?(:default)
20
+ end
21
+
22
+ def default
23
+ options[:default]
24
+ end
25
+
26
+ def required?
27
+ !!options[:required]
28
+ end
29
+
30
+ def splat?
31
+ !!options[:splat]
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,70 @@
1
+ module Jisota
2
+ class ParamParser
3
+ attr_reader :params, :args
4
+
5
+ def initialize(params, args)
6
+ @original_params = params.dup
7
+ @params = params.dup
8
+ @args = args.dup
9
+ end
10
+
11
+ def parse
12
+ Hash.new.tap do |result|
13
+ init_splat_params(result)
14
+ add_implicit_args(result)
15
+ add_hash_args(result)
16
+ set_remaining_params(result)
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def init_splat_params(result)
23
+ params.select(&:splat?).each do |param|
24
+ result[param.name] = []
25
+ end
26
+ end
27
+
28
+ def add_implicit_args(result)
29
+ param = nil
30
+ while args.first && !args.first.is_a?(Hash)
31
+ param = params.shift unless param && param.splat?
32
+ arg = args.shift
33
+ raise ParameterError, "No parameter for implicit argument #{arg}" unless param
34
+ if param.splat?
35
+ result[param.name] << arg
36
+ else
37
+ result[param.name] = arg
38
+ end
39
+ end
40
+ end
41
+
42
+ def add_hash_args(result)
43
+ return if args.empty?
44
+ raise ParameterError, "Hash parameters must be last in argument list" if args.size > 1
45
+ args.first.each do |key, value|
46
+ param = @original_params.select { |p| p.name == key }.first
47
+ raise ParameterError, "No parameter with name #{key.inspect}" unless param
48
+ if result.has_key?(key)
49
+ if param.splat?
50
+ raise ParameterError, "Splat parameter #{key.inspect} only accepts unnamed arguments"
51
+ else
52
+ raise ParameterError, "Parameter #{key.inspect} already set with an implicit argument"
53
+ end
54
+ end
55
+ result[key] = value
56
+ end
57
+ end
58
+
59
+ def set_remaining_params(result)
60
+ params.each do |param|
61
+ unless result.has_key?(param.name)
62
+ if param.required? && !param.default?
63
+ raise ParameterError, "Parameter #{param.name.inspect} is required"
64
+ end
65
+ result[param.name] = param.default? ? param.default : nil
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,24 @@
1
+ module Jisota
2
+ ##
3
+ # Reads configuration and runs it
4
+ class Provisioner
5
+ def run(configuration, logger)
6
+ packages = configuration.packages.merge(Jisota.global_packages) { |_, left, _| left }
7
+ configuration.each_server do |server|
8
+ run_server(server, configuration.ssh_engine, configuration.roles, packages, logger)
9
+ end
10
+ end
11
+
12
+ private
13
+
14
+ def run_server(server, ssh, roles, packages, logger)
15
+ ssh.start(user: server.user, host: server.host) do |ssh_session|
16
+ server.roles.each do |role_name|
17
+ role = roles[role_name]
18
+ script = role.script_block.evaluate({}, packages)
19
+ script.execute(ssh_session, logger)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,13 @@
1
+ module Jisota
2
+ class Role
3
+ attr_accessor :name, :script_block
4
+ alias_method :key, :name
5
+
6
+ def initialize(name = nil, &block)
7
+ @name = name
8
+ @script_block = ScriptBlock.new(&block) if block_given?
9
+ end
10
+
11
+ end
12
+ end
13
+
@@ -0,0 +1,66 @@
1
+ module Jisota
2
+ ##
3
+ # DSL for creating a CompositeScript
4
+ #
5
+ # Methods in the DSL:
6
+ #
7
+ # [cmd] Add a CommandScript
8
+ # [upload] Add a FileScript
9
+ # [<arg name>] The value of the argument
10
+ # [<package name>] Add a PackageScript
11
+ class ScriptBlock
12
+ attr_accessor :block
13
+
14
+ def initialize(options = {}, &block)
15
+ @block = block
16
+ end
17
+
18
+ def evaluate(args = {}, packages = Collection.new)
19
+ CompositeScript.new.tap do |script|
20
+ dsl = DSL.new(script, args, packages)
21
+ dsl.instance_eval(&block)
22
+ end
23
+ end
24
+
25
+ class DSL
26
+ def initialize(script, args, packages)
27
+ @script = script
28
+ @args = args
29
+ @packages = packages
30
+ end
31
+
32
+ def cmd(command)
33
+ @script.scripts << CommandScript.new(command)
34
+ end
35
+
36
+ def upload(from:, to: )
37
+ @script.scripts << FileScript.new(UploadFile.new(from, to))
38
+ end
39
+
40
+ def method_missing(method, *args, &block)
41
+ if has_argument?(method)
42
+ get_argument(method)
43
+ else
44
+ add_package_script(method, args) || super
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def has_argument?(name)
51
+ @args.has_key?(name)
52
+ end
53
+
54
+ def get_argument(name)
55
+ @args.fetch(name)
56
+ end
57
+
58
+ def add_package_script(name, args)
59
+ if @packages.has_key?(name)
60
+ package = @packages[name]
61
+ @script.scripts << PackageScript.new(package, args, @packages)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,12 @@
1
+ module Jisota
2
+ class Server
3
+ attr_accessor :host, :user, :roles
4
+
5
+ def initialize(host = nil, options = {})
6
+ @roles = []
7
+ @host = host
8
+ @user = options[:user]
9
+ @roles = Array(options[:roles]) if options[:roles]
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ require 'net/ssh'
2
+
3
+ module Jisota
4
+ class SSHEngine
5
+ def self.start(user: , host: )
6
+ Net::SSH.start(host, user) do |ssh_session|
7
+ yield SSHSession.new(ssh_session)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,48 @@
1
+ require 'net/scp'
2
+
3
+ module Jisota
4
+ class SSHSession
5
+
6
+ class FileNotFoundError < StandardError; end
7
+
8
+ def initialize(session = nil, options = {})
9
+ @session = session
10
+ @scp_engine = options.fetch(:scp_engine) { Net::SCP }
11
+ end
12
+
13
+ def command(command, logger = nil)
14
+ exit_code = nil
15
+ exit_signal = nil
16
+ error_message = ""
17
+
18
+ logger.command(command) if logger
19
+
20
+ @session.open_channel do |channel|
21
+ channel.exec(command) do
22
+ channel.on_data { |_, data| logger.info(data) if logger }
23
+ channel.on_extended_data { |_, _, data| logger.warn(data) if logger; error_message << data }
24
+ channel.on_request("exit-status") { |_, data| exit_code = data.read_long }
25
+ channel.on_request("exit-signal") { |_, data| exit_signal = data.read_long }
26
+ end
27
+ end
28
+ @session.loop
29
+
30
+ if exit_code == 0
31
+ true
32
+ else
33
+ logger.error("Error running #{command}:")
34
+ logger.error(error_message)
35
+ false
36
+ end
37
+ end
38
+
39
+ def upload(file, logger = nil)
40
+ raise FileNotFoundError, "Upload file not found: #{file.from}" unless File.exist?(file.from)
41
+ tmp_file = "tmp/jisota/#{SecureRandom.hex}"
42
+ command("mkdir -p tmp/jisota", logger)
43
+ logger.upload(from: file.from, to: tmp_file) if logger
44
+ @scp_engine.new(@session).upload!(file.from, tmp_file, recursive: false)
45
+ command("sudo mkdir -p `dirname #{file.to}` && sudo mv #{tmp_file} #{file.to}", logger)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,3 @@
1
+ module Jisota
2
+ UploadFile = Struct.new(:from, :to)
3
+ end
@@ -0,0 +1,3 @@
1
+ module Jisota
2
+ VERSION = "0.0.1"
3
+ end
data/lib/jisota.rb ADDED
@@ -0,0 +1,63 @@
1
+ module Jisota
2
+
3
+ ##
4
+ # Runs a provision with the given configuration
5
+ #
6
+ # Options allow default depenedencies to be overridden. Example:
7
+ #
8
+ # Jisota.run(config, logger: Jisota::Logger.new(verbose: true))
9
+ #
10
+ def self.run(configuration, options = {})
11
+ provisioner = options.fetch(:provisioner) { Provisioner.new }
12
+ logger = options.fetch(:logger) { Logger.new }
13
+ provisioner.run(configuration, logger)
14
+ end
15
+
16
+ ##
17
+ # Shorthand for defining a new configuration
18
+ #
19
+ # Example:
20
+ #
21
+ # config = Jisota.config do
22
+ # role :app do
23
+ # ruby version: "2.1.1"
24
+ # postgresql
25
+ # end
26
+ # server "123.456.789.000", user: "john", roles: :app
27
+ # end
28
+ def self.config(&block)
29
+ Configuration.new(&block)
30
+ end
31
+
32
+ ##
33
+ # Shorthand for defining packages in the global package manager
34
+ # The global package manager is useful when sharing a package between
35
+ # multiple projects or configurations
36
+ #
37
+ # Example:
38
+ #
39
+ # Jisota.global_config do
40
+ # package :apt do
41
+ # param :packages, splat: true, required: true
42
+ # run { cmd "sudo apt-get install -y #{packages.join(" ")}" }
43
+ # end
44
+ # end
45
+ def self.global_config(&block)
46
+ config = config(&block)
47
+ config.packages.each do |package|
48
+ global_packages << package
49
+ end
50
+ end
51
+
52
+ ##
53
+ # The global package manager
54
+ #
55
+ # All build-in packages also live here
56
+ # Local packages with the same name will have precedence over these.
57
+ def self.global_packages
58
+ @global_packages ||= Collection.new
59
+ end
60
+ end
61
+
62
+ Dir[File.expand_path("../jisota/*.rb", __FILE__)].each { |file| require file }
63
+ Dir[File.expand_path("../jisota/packages/*.rb", __FILE__)].each { |file| require file }
@@ -0,0 +1,56 @@
1
+ require 'spec_helper'
2
+
3
+ module Jisota
4
+ describe "Execute a simple script" do
5
+ let(:ssh_engine) do
6
+ class_double(SSHEngine).tap do |ssh_engine|
7
+ allow(ssh_engine).to receive(:start).and_yield(ssh_session)
8
+ end
9
+ end
10
+
11
+ let(:ssh_session) { instance_double(SSHSession, command: true, upload: true) }
12
+
13
+ example "Simple script with a package" do
14
+ run_for_real = ENV["ACCEPTANCE_TEST"] == "run_for_real"
15
+
16
+ host = run_for_real ? ENV["ACCEPTANCE_HOST"] : "test.jisota"
17
+ raise "Must set host with ACCEPTANCE_HOST=<host>" unless host
18
+
19
+ user = run_for_real ? ENV["ACCEPTANCE_USER"] : "john_doe"
20
+ raise "Must set user with ACCEPTANCE_USER=<user>" unless user
21
+
22
+ Jisota.global_config do
23
+ package :touch_global do
24
+ param :target
25
+ run { cmd "touch #{target}" }
26
+ end
27
+ end
28
+
29
+ config = Jisota.config do
30
+ package :touch do
31
+ param :target
32
+ run { cmd "touch #{target}" }
33
+ end
34
+
35
+ role :app do
36
+ cmd "touch foo"
37
+ touch "bar"
38
+ touch_global "baz"
39
+ upload from: "spec/test_files/foo", to: "uploads/foo"
40
+ end
41
+
42
+ server host, user: user, roles: :app
43
+ end
44
+
45
+ config.ssh_engine = ssh_engine unless run_for_real
46
+ Jisota.run(config)
47
+
48
+ unless run_for_real
49
+ expect(ssh_engine).to have_received(:start).with(user: "john_doe", host: "test.jisota")
50
+ expect(ssh_session).to have_received(:command).with("touch foo", anything)
51
+ expect(ssh_session).to have_received(:command).with("touch bar", anything)
52
+ expect(ssh_session).to have_received(:upload).with(UploadFile.new("spec/test_files/foo", "uploads/foo"), anything)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,44 @@
1
+ require 'spec_helper'
2
+
3
+ module Jisota
4
+ describe Collection do
5
+ describe "#add" do
6
+ it "adds the item" do
7
+ collection = Collection.new
8
+ item = double(key: :foo)
9
+ collection.add(item)
10
+
11
+ expect(collection[:foo]).to eq(item)
12
+ expect(collection.size).to eq(1)
13
+ end
14
+
15
+ it "will not allow duplicate key" do
16
+ collection = Collection.new
17
+ item = double(key: :foo)
18
+ collection.add(item)
19
+
20
+ expect { collection.add(item) }.to raise_error(Collection::DuplicateKeyError)
21
+ end
22
+ end
23
+
24
+ describe "#merge" do
25
+ it "returns a new collection with all items" do
26
+ collection_1 = Collection.new
27
+ collection_1.add(double(key: :foo, value: 1))
28
+ collection_1.add(double(key: :bar, value: 2))
29
+
30
+ collection_2 = Collection.new
31
+ collection_2.add(double(key: :bar, value: 3))
32
+ collection_2.add(double(key: :baz, value: 4))
33
+
34
+ result = collection_1.merge(collection_2) { |_, left, _| left }
35
+
36
+ expect(collection_1.size).to eq(2)
37
+ expect(collection_2.size).to eq(2)
38
+ expect(result.size).to eq(3)
39
+
40
+ expect(result.map(&:value)).to eq([1, 2, 4])
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,22 @@
1
+ require 'spec_helper'
2
+
3
+ module Jisota
4
+ describe CommandScript do
5
+ describe "#command" do
6
+ it "initializes with a command" do
7
+ expect(CommandScript.new(:foo).command).to eq(:foo)
8
+ end
9
+ end
10
+
11
+ describe "#execute" do
12
+ it "asks ssh_session to execute" do
13
+ script = CommandScript.new(:foo)
14
+ ssh_session = instance_double(SSHSession, command: true)
15
+ result = script.execute(ssh_session)
16
+
17
+ expect(ssh_session).to have_received(:command).with(:foo, nil)
18
+ expect(result).to be true
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,37 @@
1
+ require 'spec_helper'
2
+
3
+ module Jisota
4
+ describe CompositeScript do
5
+ describe "#scripts" do
6
+ it "initializes to empty array" do
7
+ expect(CompositeScript.new.scripts).to eq([])
8
+ end
9
+ end
10
+
11
+ describe "execute" do
12
+ let(:ssh_session) { instance_double(SSHSession) }
13
+ let(:script) { CompositeScript.new }
14
+ it "executes all scripts" do
15
+ inner_1 = double(execute: true)
16
+ inner_2 = double(execute: true)
17
+ script.scripts << inner_1 << inner_2
18
+ result = script.execute(ssh_session)
19
+
20
+ expect(inner_1).to have_received(:execute).with(ssh_session, nil)
21
+ expect(inner_2).to have_received(:execute).with(ssh_session, nil)
22
+ expect(result).to be true
23
+ end
24
+
25
+ it "stops execution if one script fails" do
26
+ inner_1 = double(execute: false)
27
+ inner_2 = double(execute: true)
28
+ script.scripts << inner_1 << inner_2
29
+ result = script.execute(ssh_session)
30
+
31
+ expect(inner_1).to have_received(:execute).with(ssh_session, nil)
32
+ expect(inner_2).to_not have_received(:execute).with(ssh_session, nil)
33
+ expect(result).to be false
34
+ end
35
+ end
36
+ end
37
+ end