r-train 0.9.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 (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