r-train 0.9.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +1 -0
  3. data/.rubocop.yml +45 -0
  4. data/.travis.yml +12 -0
  5. data/Gemfile +22 -0
  6. data/LICENSE +201 -0
  7. data/README.md +137 -0
  8. data/Rakefile +39 -0
  9. data/lib/train.rb +100 -0
  10. data/lib/train/errors.rb +23 -0
  11. data/lib/train/extras.rb +15 -0
  12. data/lib/train/extras/command_wrapper.rb +105 -0
  13. data/lib/train/extras/file_common.rb +131 -0
  14. data/lib/train/extras/linux_file.rb +74 -0
  15. data/lib/train/extras/linux_lsb.rb +60 -0
  16. data/lib/train/extras/os_common.rb +131 -0
  17. data/lib/train/extras/os_detect_darwin.rb +32 -0
  18. data/lib/train/extras/os_detect_linux.rb +126 -0
  19. data/lib/train/extras/os_detect_unix.rb +77 -0
  20. data/lib/train/extras/os_detect_windows.rb +73 -0
  21. data/lib/train/extras/stat.rb +92 -0
  22. data/lib/train/extras/windows_file.rb +85 -0
  23. data/lib/train/options.rb +80 -0
  24. data/lib/train/plugins.rb +40 -0
  25. data/lib/train/plugins/base_connection.rb +86 -0
  26. data/lib/train/plugins/transport.rb +49 -0
  27. data/lib/train/transports/docker.rb +102 -0
  28. data/lib/train/transports/local.rb +52 -0
  29. data/lib/train/transports/local_file.rb +77 -0
  30. data/lib/train/transports/local_os.rb +51 -0
  31. data/lib/train/transports/mock.rb +125 -0
  32. data/lib/train/transports/ssh.rb +163 -0
  33. data/lib/train/transports/ssh_connection.rb +216 -0
  34. data/lib/train/transports/winrm.rb +187 -0
  35. data/lib/train/transports/winrm_connection.rb +258 -0
  36. data/lib/train/version.rb +7 -0
  37. data/test/integration/.kitchen.yml +43 -0
  38. data/test/integration/Berksfile +3 -0
  39. data/test/integration/bootstrap.sh +17 -0
  40. data/test/integration/chefignore +1 -0
  41. data/test/integration/cookbooks/test/metadata.rb +1 -0
  42. data/test/integration/cookbooks/test/recipes/default.rb +101 -0
  43. data/test/integration/docker_run.rb +153 -0
  44. data/test/integration/docker_test.rb +24 -0
  45. data/test/integration/docker_test_container.rb +24 -0
  46. data/test/integration/helper.rb +58 -0
  47. data/test/integration/sudo/nopasswd.rb +16 -0
  48. data/test/integration/sudo/passwd.rb +21 -0
  49. data/test/integration/sudo/run_as.rb +12 -0
  50. data/test/integration/test-runner.yaml +24 -0
  51. data/test/integration/test_local.rb +19 -0
  52. data/test/integration/test_ssh.rb +24 -0
  53. data/test/integration/tests/path_block_device_test.rb +74 -0
  54. data/test/integration/tests/path_character_device_test.rb +74 -0
  55. data/test/integration/tests/path_file_test.rb +79 -0
  56. data/test/integration/tests/path_folder_test.rb +88 -0
  57. data/test/integration/tests/path_missing_test.rb +77 -0
  58. data/test/integration/tests/path_pipe_test.rb +78 -0
  59. data/test/integration/tests/path_symlink_test.rb +83 -0
  60. data/test/integration/tests/run_command_test.rb +28 -0
  61. data/test/unit/extras/command_wrapper_test.rb +41 -0
  62. data/test/unit/extras/file_common_test.rb +133 -0
  63. data/test/unit/extras/linux_file_test.rb +98 -0
  64. data/test/unit/extras/os_common_test.rb +258 -0
  65. data/test/unit/extras/stat_test.rb +105 -0
  66. data/test/unit/helper.rb +6 -0
  67. data/test/unit/plugins/connection_test.rb +44 -0
  68. data/test/unit/plugins/transport_test.rb +111 -0
  69. data/test/unit/plugins_test.rb +22 -0
  70. data/test/unit/train_test.rb +132 -0
  71. data/test/unit/transports/local_file_test.rb +112 -0
  72. data/test/unit/transports/local_test.rb +73 -0
  73. data/test/unit/transports/mock_test.rb +76 -0
  74. data/test/unit/transports/ssh_test.rb +95 -0
  75. data/test/unit/version_test.rb +8 -0
  76. data/train.gemspec +32 -0
  77. metadata +299 -0
@@ -0,0 +1,92 @@
1
+ # encoding: utf-8
2
+ # author: Dominik Richter
3
+ # author: Christoph Hartmann
4
+
5
+ module Train::Extras
6
+ class Stat
7
+ TYPES = {
8
+ socket: 00140000,
9
+ symlink: 00120000,
10
+ file: 00100000,
11
+ block_device: 00060000,
12
+ directory: 00040000,
13
+ character_device: 00020000,
14
+ pipe: 00010000,
15
+ }
16
+
17
+ def self.find_type(mode)
18
+ res = TYPES.find { |_, mask| mask & mode == mask }
19
+ res.nil? ? :unknown : res[0]
20
+ end
21
+
22
+ def self.stat(shell_escaped_path, backend)
23
+ return bsd_stat(shell_escaped_path, backend) if backend.os.bsd?
24
+ return linux_stat(shell_escaped_path, backend) if backend.os.unix?
25
+ # all other cases we don't handle
26
+ # TODO: print an error if we get here, as it shouldn't be invoked
27
+ # on non-unix
28
+ {}
29
+ end
30
+
31
+ def self.linux_stat(shell_escaped_path, backend)
32
+ res = backend.run_command("stat #{shell_escaped_path} 2>/dev/null --printf '%s\n%f\n%U\n%u\n%G\n%g\n%X\n%Y\n%C'")
33
+
34
+ # ignore the exit_code: it is != 0 if selinux labels are not supported
35
+ # on the system.
36
+
37
+ fields = res.stdout.split("\n")
38
+ return {} if fields.length != 9
39
+
40
+ tmask = fields[1].to_i(16)
41
+ selinux = fields[8]
42
+ selinux = nil if selinux == '?' or selinux == '(null)'
43
+
44
+ {
45
+ type: find_type(tmask),
46
+ mode: tmask & 00777,
47
+ owner: fields[2],
48
+ group: fields[4],
49
+ mtime: fields[7].to_i,
50
+ size: fields[0].to_i,
51
+ selinux_label: selinux,
52
+ }
53
+ end
54
+
55
+ def self.bsd_stat(shell_escaped_path, backend)
56
+ # From stat man page on FreeBSD:
57
+ # z The size of file in bytes (st_size).
58
+ # p File type and permissions (st_mode).
59
+ # u, g User ID and group ID of file's owner (st_uid, st_gid).
60
+ # a, m, c, B
61
+ # The time file was last accessed or modified, or when the
62
+ # inode was last changed, or the birth time of the inode
63
+ # (st_atime, st_mtime, st_ctime, st_birthtime).
64
+ #
65
+ # The special output specifier S may be used to indicate that the
66
+ # output, if applicable, should be in string format. May be used
67
+ # in combination with:
68
+ # ...
69
+ # gu Display group or user name.
70
+ res = backend.run_command(
71
+ "stat -f '%z\n%p\n%Su\n%u\n%Sg\n%g\n%a\n%m' "\
72
+ "#{shell_escaped_path}")
73
+
74
+ return {} if res.exit_status != 0
75
+
76
+ fields = res.stdout.split("\n")
77
+ return {} if fields.length != 8
78
+
79
+ tmask = fields[1].to_i(8)
80
+
81
+ {
82
+ type: find_type(tmask),
83
+ mode: tmask & 00777,
84
+ owner: fields[2],
85
+ group: fields[4],
86
+ mtime: fields[7].to_i,
87
+ size: fields[0].to_i,
88
+ selinux_label: fields[8],
89
+ }
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,85 @@
1
+ # encoding: utf-8
2
+ # author: Dominik Richter
3
+ # author: Christoph Hartmann
4
+
5
+ require 'train/extras/stat'
6
+
7
+ # PS C:\Users\Administrator> Get-Item -Path C:\test.txt | Select-Object -Property BaseName, FullName, IsReadOnly, Exists,
8
+ # LinkType, Mode, VersionInfo, Owner, Archive, Hidden, ReadOnly, System | ConvertTo-Json
9
+
10
+ module Train::Extras
11
+ class WindowsFile < FileCommon
12
+ attr_reader :path
13
+ def initialize(backend, path)
14
+ @backend = backend
15
+ @path = path
16
+ @spath = Shellwords.escape(@path)
17
+ end
18
+
19
+ def content
20
+ return @content if defined?(@content)
21
+ @content = @backend.run_command(
22
+ "Get-Content(\"#{@spath}\") | Out-String").stdout
23
+ return @content unless @content.empty?
24
+ @content = nil if directory? # or size.nil? or size > 0
25
+ @content
26
+ end
27
+
28
+ def exist?
29
+ nil
30
+ end
31
+
32
+ def link_target
33
+ nil
34
+ end
35
+
36
+ def link_path
37
+ nil
38
+ end
39
+
40
+ def mounted?
41
+ nil
42
+ end
43
+
44
+ def type
45
+ :unknown
46
+ end
47
+
48
+ %w{
49
+ mode owner group mtime size selinux_label
50
+ }.each do |field|
51
+ define_method field.to_sym do
52
+ nil
53
+ end
54
+ end
55
+
56
+ def product_version
57
+ nil
58
+ end
59
+
60
+ def file_version
61
+ nil
62
+ end
63
+
64
+ def stat
65
+ nil
66
+ end
67
+
68
+ private
69
+
70
+ def attributes
71
+ return @attributes if defined?(@attributes)
72
+ @attributes = @backend.run_command(
73
+ "(Get-ItemProperty -Path \"#{@spath}\").attributes.ToString()").stdout.chomp.split(',')
74
+ end
75
+
76
+ def target_type
77
+ if attributes.include?('Archive')
78
+ return :file
79
+ elsif attributes.include?('Directory')
80
+ return :directory
81
+ end
82
+ :unknown
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,80 @@
1
+ # encoding: utf-8
2
+ #
3
+ # Author:: Dominik Richter (<dominik.richter@gmail.com>)
4
+ # Author:: Christoph Hartmann (<chris@lollyrock.com>)
5
+
6
+ module Train
7
+ module Options
8
+ def self.attach(target)
9
+ target.class.method(:include).call(ClassOptions)
10
+ target.method(:include).call(InstanceOptions)
11
+ end
12
+
13
+ module ClassOptions
14
+ def option(name, conf = nil, &block)
15
+ d = conf || {}
16
+ unless d.is_a? Hash
17
+ fail Train::ClientError,
18
+ "The transport plugin #{self} declared an option #{name} "\
19
+ "and didn't provide a valid configuration hash."
20
+ end
21
+
22
+ if !conf.nil? and !conf[:default].nil? and block_given?
23
+ fail Train::ClientError,
24
+ "The transport plugin #{self} declared an option #{name} "\
25
+ 'with both a default value and block. Only use one of these.'
26
+ end
27
+
28
+ d[:default] = block if block_given?
29
+
30
+ default_options[name] = d
31
+ end
32
+
33
+ def default_options
34
+ @default_options = {} unless defined? @default_options
35
+ @default_options
36
+ end
37
+
38
+ def include_options(other)
39
+ unless other.respond_to?(:default_options)
40
+ fail "Trying to include options from module #{other.inspect}, "\
41
+ "which doesn't seem to support options."
42
+ end
43
+ default_options.merge!(other.default_options)
44
+ end
45
+ end
46
+
47
+ module InstanceOptions
48
+ # @return [Hash] options, which created this Transport
49
+ attr_reader :options
50
+
51
+ def default_options
52
+ self.class.default_options
53
+ end
54
+
55
+ def merge_options(base, opts)
56
+ res = base.merge(opts || {})
57
+ default_options.each do |field, hm|
58
+ next unless res[field].nil? and hm.key?(:default)
59
+ default = hm[:default]
60
+ if default.is_a? Proc
61
+ res[field] = default.call(res)
62
+ else
63
+ res[field] = default
64
+ end
65
+ end
66
+ res
67
+ end
68
+
69
+ def validate_options(opts)
70
+ default_options.each do |field, hm|
71
+ if opts[field].nil? and hm[:required]
72
+ fail Train::ClientError,
73
+ "You must provide a value for #{field.to_s.inspect}."
74
+ end
75
+ end
76
+ opts
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,40 @@
1
+ # encoding: utf-8
2
+ #
3
+ # Author:: Dominik Richter (<dominik.richter@gmail.com>)
4
+ # Author:: Christoph Hartmann (<chris@lollyrock.com>)
5
+
6
+ require 'train/errors'
7
+
8
+ module Train
9
+ class Plugins
10
+ autoload :Transport, 'train/plugins/transport'
11
+
12
+ class << self
13
+ # Retrieve the current plugin registry, containing all plugin names
14
+ # and their transport handlers.
15
+ #
16
+ # @return [Hash] map with plugin names and plugins
17
+ def registry
18
+ @registry ||= {}
19
+ end
20
+ end
21
+ end
22
+
23
+ # Create a new plugin by inheriting from the class returned by this method.
24
+ # Create a versioned plugin by providing the transport layer plugin version
25
+ # to this method. It will then select the correct class to inherit from.
26
+ #
27
+ # The plugin version determins what methods will be available to your plugin.
28
+ #
29
+ # @param [Int] version = 1 the plugin version to use
30
+ # @return [Transport] the versioned transport base class
31
+ def self.plugin(version = 1)
32
+ if version != 1
33
+ fail ClientError,
34
+ 'Only understand train plugin version 1. You are trying to '\
35
+ "initialize a train plugin #{version}, which is not supported "\
36
+ 'in the current release of train.'
37
+ end
38
+ ::Train::Plugins::Transport
39
+ end
40
+ end
@@ -0,0 +1,86 @@
1
+ # encoding: utf-8
2
+ #
3
+ # Author:: Salim Afiune (<salim@afiunemaya.com.mx>)
4
+ # Author:: Fletcher Nichol (<fnichol@nichol.ca>)
5
+ # Author:: Dominik Richter (<dominik.richter@gmail.com>)
6
+
7
+ require 'train/errors'
8
+ require 'train/extras'
9
+ require 'logger'
10
+
11
+ class Train::Plugins::Transport
12
+ # A Connection instance can be generated and re-generated, given new
13
+ # connection details such as connection port, hostname, credentials, etc.
14
+ # This object is responsible for carrying out the actions on the remote
15
+ # host such as executing commands, transferring files, etc.
16
+ #
17
+ # @author Fletcher Nichol <fnichol@nichol.ca>
18
+ class BaseConnection
19
+ include Train::Extras
20
+
21
+ # Create a new Connection instance.
22
+ #
23
+ # @param options [Hash] connection options
24
+ # @yield [self] yields itself for block-style invocation
25
+ def initialize(options = nil)
26
+ @options = options || {}
27
+ @logger = @options.delete(:logger) || Logger.new(STDOUT)
28
+ end
29
+
30
+ # Closes the session connection, if it is still active.
31
+ def close
32
+ # this method may be left unimplemented if that is applicable
33
+ end
34
+
35
+ # Execute a command using this connection.
36
+ #
37
+ # @param command [String] command string to execute
38
+ # @return [CommandResult] contains the result of running the command
39
+ def run_command(_command)
40
+ fail Train::ClientError, "#{self.class} does not implement #run_command()"
41
+ end
42
+
43
+ # Get information on the operating system which this transport connects to.
44
+ #
45
+ # @return [OSCommon] operating system information
46
+ def os
47
+ fail Train::ClientError, "#{self.class} does not implement #os()"
48
+ end
49
+
50
+ # Interact with files on the target. Read, write, and get metadata
51
+ # from files via the transport.
52
+ #
53
+ # @param [String] path which is being inspected
54
+ # @return [FileCommon] file object that allows for interaction
55
+ def file(_path, *_args)
56
+ fail Train::ClientError, "#{self.class} does not implement #file(...)"
57
+ end
58
+
59
+ # Builds a LoginCommand which can be used to open an interactive
60
+ # session on the remote host.
61
+ #
62
+ # @return [LoginCommand] array of command line tokens
63
+ def login_command
64
+ fail Train::ClientError, "#{self.class} does not implement #run_command()"
65
+ end
66
+
67
+ # Block and return only when the remote host is prepared and ready to
68
+ # execute command and upload files. The semantics and details will
69
+ # vary by implementation, but a round trip through the hosted
70
+ # service is preferred to simply waiting on a socket to become
71
+ # available.
72
+ def wait_until_ready
73
+ # this method may be left unimplemented if that is applicablelog
74
+ end
75
+
76
+ private
77
+
78
+ # @return [Logger] logger for reporting information
79
+ # @api private
80
+ attr_reader :logger
81
+
82
+ # @return [Hash] connection options
83
+ # @api private
84
+ attr_reader :options
85
+ end
86
+ end
@@ -0,0 +1,49 @@
1
+ # encoding: utf-8
2
+ #
3
+ # Author:: Dominik Richter (<dominik.richter@gmail.com>)
4
+ # Author:: Christoph Hartmann (<chris@lollyrock.com>)
5
+
6
+ require 'logger'
7
+ require 'train/errors'
8
+ require 'train/extras'
9
+ require 'train/options'
10
+
11
+ class Train::Plugins
12
+ class Transport
13
+ include Train::Extras
14
+ Train::Options.attach(self)
15
+
16
+ autoload :BaseConnection, 'train/plugins/base_connection'
17
+
18
+ # Initialize a new Transport object
19
+ #
20
+ # @param [Hash] config = nil the configuration for this transport
21
+ # @return [Transport] the transport object
22
+ def initialize(options = {})
23
+ @options = merge_options({}, options || {})
24
+ @logger = @options[:logger] || Logger.new(STDOUT)
25
+ end
26
+
27
+ # Create a connection to the target. Options may be provided
28
+ # for additional configuration.
29
+ #
30
+ # @param [Hash] _options = nil provide optional configuration params
31
+ # @return [Connection] the connection for this configuration
32
+ def connection(_options = nil)
33
+ fail Train::ClientError, "#{self.class} does not implement #connect()"
34
+ end
35
+
36
+ # Register the inheriting class with as a train plugin using the
37
+ # provided name.
38
+ #
39
+ # @param [String] name of the plugin, by which it will be found
40
+ def self.name(name)
41
+ Train::Plugins.registry[name] = self
42
+ end
43
+
44
+ private
45
+
46
+ # @return [Logger] logger for reporting information
47
+ attr_reader :logger
48
+ end
49
+ end
@@ -0,0 +1,102 @@
1
+ # encoding: utf-8
2
+ #
3
+ # Author:: Dominik Richter
4
+ # Author:: Christoph Hartmann
5
+
6
+ require 'docker'
7
+
8
+ module Train::Transports
9
+ class Docker < Train.plugin(1)
10
+ name 'docker'
11
+
12
+ include_options Train::Extras::CommandWrapper
13
+ option :host, required: true
14
+
15
+ def connection(state = {}, &block)
16
+ opts = merge_options(options, state || {})
17
+ validate_options(opts)
18
+
19
+ if @connection && @connection_options == opts
20
+ reuse_connection(&block)
21
+ else
22
+ create_new_connection(opts, &block)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ # Creates a new Docker connection instance and save it for potential future
29
+ # reuse.
30
+ #
31
+ # @param options [Hash] connection options
32
+ # @return [Docker::Connection] a Docker connection instance
33
+ # @api private
34
+ def create_new_connection(options, &block)
35
+ if @connection
36
+ logger.debug("[Docker] shutting previous connection #{@connection}")
37
+ @connection.close
38
+ end
39
+
40
+ @connection_options = options
41
+ @connection = Connection.new(options, &block)
42
+ end
43
+
44
+ # Return the last saved Docker connection instance.
45
+ #
46
+ # @return [Docker::Connection] a Docker connection instance
47
+ # @api private
48
+ def reuse_connection
49
+ logger.debug("[Docker] reusing existing connection #{@connection}")
50
+ yield @connection if block_given?
51
+ @connection
52
+ end
53
+ end
54
+ end
55
+
56
+ class Train::Transports::Docker
57
+ class Connection < BaseConnection
58
+ def initialize(conf)
59
+ super(conf)
60
+ @id = options[:host]
61
+ @container = ::Docker::Container.get(@id) ||
62
+ fail("Can't find Docker container #{@id}")
63
+ @files = {}
64
+ @cmd_wrapper = nil
65
+ @cmd_wrapper = CommandWrapper.load(self, @options)
66
+ self
67
+ end
68
+
69
+ def close
70
+ # nothing to do at the moment
71
+ end
72
+
73
+ def os
74
+ @os ||= OS.new(self)
75
+ end
76
+
77
+ def file(path)
78
+ @files[path] ||= LinuxFile.new(self, path)
79
+ end
80
+
81
+ def run_command(cmd)
82
+ cmd = @cmd_wrapper.run(cmd) unless @cmd_wrapper.nil?
83
+ stdout, stderr, exit_status = @container.exec([
84
+ '/bin/sh', '-c', cmd
85
+ ])
86
+ CommandResult.new(stdout.join, stderr.join, exit_status)
87
+ rescue ::Docker::Error::DockerError => _
88
+ raise
89
+ rescue => _
90
+ # @TODO: differentiate any other error
91
+ raise
92
+ end
93
+
94
+ class OS < OSCommon
95
+ def initialize(backend)
96
+ # hardcoded to unix/linux for now, until other operating systems
97
+ # are supported
98
+ super(backend, { family: 'unix' })
99
+ end
100
+ end
101
+ end
102
+ end