jisota 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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