lxd-common 0.4.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.
@@ -0,0 +1,12 @@
1
+ require 'nexussw/lxd/driver'
2
+ require 'nexussw/lxd/driver/mixins/cli'
3
+
4
+ module NexusSW
5
+ module LXD
6
+ class Driver
7
+ class CLI < Driver
8
+ include Mixins::CLI
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,182 @@
1
+ require 'tempfile'
2
+ require 'yaml'
3
+ require 'json'
4
+
5
+ module NexusSW
6
+ module LXD
7
+ class Driver
8
+ module Mixins
9
+ module CLI
10
+ def initialize(inner_transport, driver_options = {})
11
+ @inner_transport = inner_transport
12
+ @driver_options = driver_options || {}
13
+ end
14
+
15
+ attr_reader :inner_transport, :driver_options
16
+
17
+ def create_container(container_name, container_options = {})
18
+ if container_exists? container_name
19
+ start_container container_name # Start for Parity with the below logic (`lxc launch` auto starts)
20
+ return container_name
21
+ end
22
+ cline = "lxc launch #{image_alias(container_options)} #{container_name}"
23
+ profiles = container_options[:profiles] || []
24
+ profiles.each { |p| cline += " -p #{p}" }
25
+ configs = container_options[:config] || {}
26
+ configs.each { |k, v| cline += " -c #{k}=#{v}" }
27
+ inner_transport.execute(cline).error!
28
+ wait_for_status container_name, 'running'
29
+ container_name
30
+ end
31
+
32
+ def start_container(container_id)
33
+ return if container_status(container_id) == 'running'
34
+ inner_transport.execute("lxc start #{container_id}").error!
35
+ wait_for_status container_id, 'running'
36
+ end
37
+
38
+ def stop_container(container_id, options = {})
39
+ options ||= {} # default behavior: no timeout or retries. These functions are up to the consumer's context and not really 'sane' defaults
40
+ return if container_status(container_id) == 'stopped'
41
+ return inner_transport.execute("lxc stop #{container_id} --force", capture: false).error! if options[:force]
42
+ LXD.with_timeout_and_retries(options) do
43
+ return if container_status(container_id) == 'stopped'
44
+ timeout = " --timeout=#{options[:retry_interval]}" if options[:retry_interval]
45
+ retval = inner_transport.execute("lxc stop #{container_id}#{timeout || ''}", capture: false)
46
+ begin
47
+ retval.error!
48
+ rescue => e
49
+ return if container_status(container_id) == 'stopped'
50
+ # can't distinguish between timeout, or other error.
51
+ # but if the status call is not popping a 404, and we're not stopped, then a retry is worth it
52
+ raise Timeout::Retry.new(e) if timeout # rubocop:disable Style/RaiseArgs
53
+ raise
54
+ end
55
+ end
56
+ wait_for_status container_id, 'stopped'
57
+ end
58
+
59
+ def delete_container(container_id)
60
+ return unless container_exists? container_id
61
+ inner_transport.execute("lxc delete #{container_id} --force", capture: false).error!
62
+ end
63
+
64
+ def container_status(container_id)
65
+ STATUS_CODES[container(container_id)[:status_code].to_i]
66
+ end
67
+
68
+ def convert_keys(oldhash)
69
+ return oldhash unless oldhash.is_a?(Hash) || oldhash.is_a?(Array)
70
+ retval = {}
71
+ if oldhash.is_a? Array
72
+ retval = []
73
+ oldhash.each { |v| retval << convert_keys(v) }
74
+ else
75
+ oldhash.each do |k, v|
76
+ retval[k.to_sym] = convert_keys(v)
77
+ end
78
+ end
79
+ retval
80
+ end
81
+
82
+ # YAML is not supported until somewhere in the feature branch
83
+ # the YAML return has :state and :container at the root level
84
+ # the JSON return has no :container (:container is root)
85
+ # and has :state underneath that
86
+ # (CLI Only) and :state is only available if the container is running
87
+ def container_state(container_id)
88
+ res = inner_transport.execute("lxc list #{container_id} --format=json")
89
+ res.error!
90
+ JSON.parse(res.stdout).each do |c|
91
+ return convert_keys(c['state']) if c['name'] == container_id
92
+ end
93
+ nil
94
+ end
95
+
96
+ def container(container_id)
97
+ res = inner_transport.execute("lxc list #{container_id} --format=json")
98
+ res.error!
99
+ JSON.parse(res.stdout).each do |c|
100
+ return convert_keys(c.except('state')) if c['name'] == container_id
101
+ end
102
+ nil
103
+ end
104
+
105
+ def container_exists?(container_id)
106
+ return true if container_status(container_id)
107
+ return false
108
+ rescue
109
+ false
110
+ end
111
+
112
+ include WaitMixin
113
+
114
+ protected
115
+
116
+ def wait_for_status(container_id, newstatus)
117
+ loop do
118
+ status = container_status(container_id)
119
+ return if status == newstatus
120
+ NIO::WebSocket.logger.debug "#{container_id} status = '#{status}'. Waiting for '#{newstatus}'"
121
+ sleep 0.5
122
+ end
123
+ end
124
+
125
+ private
126
+
127
+ def remote_for!(url, protocol = 'lxd')
128
+ raise 'Protocol is required' unless protocol # protect me from accidentally slipping in a nil
129
+ # normalize the url and 'require' protocol to protect against a scenario:
130
+ # 1) user only specifies https://someimageserver.org without specifying the protocol
131
+ # 2) the rest of this function would blindly add that without saying the protocol
132
+ # 3) 'lxc remote add' would add that remote, but defaults to the lxd protocol and appends ':8443' to the saved url
133
+ # 4) the next time this function is called we would not match that same entry due to the ':8443'
134
+ # 5) ultimately resulting in us adding a new remote EVERY time this function is called
135
+ port = url.split(':', 3)[2]
136
+ url += ':8443' unless port || protocol != 'lxd'
137
+ remotes = begin
138
+ YAML.load(inner_transport.read_file('~/.config/lxc/config.yml')) || {}
139
+ rescue
140
+ {}
141
+ end
142
+ # make sure these default entries are available to us even if config.yml isn't created yet
143
+ # and i've seen instances where these defaults don't live in the config.yml
144
+ remotes = { 'remotes' => {
145
+ 'images' => { 'addr' => 'https://images.linuxcontainers.org' },
146
+ 'ubuntu' => { 'addr' => 'https://cloud-images.ubuntu.com/releases' },
147
+ 'ubuntu-daily' => { 'addr' => 'https://cloud-images.ubuntu.com/daily' },
148
+ } }.merge remotes
149
+ max = 0
150
+ remotes['remotes'].each do |remote, data|
151
+ return remote.to_s if data['addr'] == url
152
+ num = remote.to_s.split('-', 2)[1] if remote.to_s.start_with? 'images-'
153
+ max = num.to_i if num && num.to_i > max
154
+ end
155
+ remote = "images-#{max + 1}"
156
+ inner_transport.execute("lxc remote add #{remote} #{url} --accept-certificate --protocol=#{protocol}").error!
157
+ remote
158
+ end
159
+
160
+ def image(properties, remote = '')
161
+ return nil unless properties && properties.any?
162
+ cline = "lxc image list #{remote} --format=json"
163
+ properties.each { |k, v| cline += " #{k}=#{v}" }
164
+ res = inner_transport.execute cline
165
+ res.error!
166
+ res = JSON.parse(res.stdout)
167
+ return res[0]['fingerprint'] if res.any?
168
+ end
169
+
170
+ def image_alias(container_options)
171
+ remote = container_options[:server] ? remote_for!(container_options[:server], container_options[:protocol] || 'lxd') + ':' : ''
172
+ name = container_options[:alias]
173
+ name ||= container_options[:fingerprint]
174
+ name ||= image(container_options[:properties], remote)
175
+ raise 'No image parameters. One of alias, fingerprint, or properties must be specified (The CLI interface does not support empty containers)' unless name
176
+ "#{remote}#{name}"
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,128 @@
1
+ require 'hyperkit'
2
+
3
+ module NexusSW
4
+ module LXD
5
+ class Driver
6
+ module Mixins
7
+ module Rest
8
+ # PARITY note: CLI functions are on an indefinite timeout by default, yet we have a 2 minute socket read timeout
9
+ # Leaving it alone, for now, on calls that are quick in nature
10
+ # Adapting on known long running calls such as create, stop, execute
11
+ # REQUEST_TIMEOUT = 120 # upstream default: 120
12
+ def initialize(rest_endpoint, driver_options = {}, inner_driver = nil)
13
+ @rest_endpoint = rest_endpoint
14
+ @driver_options = driver_options
15
+ hkoptions = (driver_options || {}).merge(
16
+ api_endpoint: rest_endpoint,
17
+ auto_sync: true
18
+ )
19
+ @hk = inner_driver || Hyperkit::Client.new(hkoptions)
20
+ # HACK: can't otherwise get at the request timeout because sawyer is in the way
21
+ # Beware of unused function in hyperkit: reset_agent If that gets used it'll undo this timeout
22
+ # unneeded while default valued: @hk.agent.instance_variable_get(:@conn).options[:timeout] = REQUEST_TIMEOUT
23
+ end
24
+
25
+ attr_reader :hk, :rest_endpoint, :driver_options
26
+
27
+ def create_container(container_name, container_options = {})
28
+ if container_exists?(container_name)
29
+ start_container container_name # Start the container for Parity with the CLI
30
+ return container_name
31
+ end
32
+ # we'll break this apart and time it out for those with slow net (and this was my 3 minute stress test case with good net)
33
+ # parity note: CLI will run indefinitely rather than timeout hence the 0 timeout
34
+ retry_forever do
35
+ @hk.create_container(container_name, container_options.merge(sync: false))
36
+ end
37
+ start_container container_name
38
+ container_name
39
+ end
40
+
41
+ def start_container(container_id)
42
+ return if container_status(container_id) == 'running'
43
+ retry_forever do
44
+ @hk.start_container(container_id, sync: false)
45
+ end
46
+ wait_for_status container_id, 'running'
47
+ end
48
+
49
+ def stop_container(container_id, options = {})
50
+ return if container_status(container_id) == 'stopped'
51
+ return @hk.stop_container(container_id, force: true) if options[:force]
52
+ last_id = nil
53
+ use_last = false
54
+ LXD.with_timeout_and_retries({ timeout: 0 }.merge(options)) do # timeout: 0 to enable retry functionality
55
+ return if container_status(container_id) == 'stopped'
56
+ begin
57
+ unless use_last
58
+ # Keep resubmitting until the server complains (Stops will be ignored/hang if init is not yet listening for SIGPWR i.e. recently started)
59
+ begin
60
+ last_id = @hk.stop_container(container_id, sync: false)[:id] # TODO: this 'could' hang for 2 minutes? and then we'd never get a last_id if that's where the hang happens, and then it 'could' error on retry
61
+ rescue Hyperkit::BadRequest # Happens if a stop command has previously been accepted as well as other reasons. handle that on next line
62
+ raise unless last_id # if we have a last_id then a prior stop command has successfully initiated so we'll just wait on that one
63
+ use_last = true
64
+ end
65
+ end
66
+ @hk.wait_for_operation last_id # , options[:retry_interval]
67
+ rescue Faraday::TimeoutError => e
68
+ return if container_status(container_id) == 'stopped'
69
+ raise Timeout::Retry.new e # if options[:retry_interval] # rubocop:disable Style/RaiseArgs
70
+ end
71
+ end
72
+ wait_for_status container_id, 'stopped'
73
+ end
74
+
75
+ def delete_container(container_id)
76
+ return unless container_exists? container_id
77
+ stop_container container_id, force: true
78
+ @hk.delete_container(container_id)
79
+ end
80
+
81
+ def container_status(container_id)
82
+ STATUS_CODES[container(container_id)[:status_code].to_i]
83
+ end
84
+
85
+ def container_state(container_id)
86
+ return nil unless container_status(container_id) == 'running' # Parity with CLI
87
+ @hk.container_state(container_id)
88
+ end
89
+
90
+ def container(container_id)
91
+ @hk.container container_id
92
+ end
93
+
94
+ def container_exists?(container_id)
95
+ return true if container_status(container_id)
96
+ return false
97
+ rescue
98
+ false
99
+ end
100
+
101
+ include WaitMixin
102
+
103
+ protected
104
+
105
+ def wait_for_status(container_id, newstatus)
106
+ loop do
107
+ return if container_status(container_id) == newstatus
108
+ sleep 0.5
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ def retry_forever
115
+ retval = yield
116
+ LXD.with_timeout_and_retries timeout: 0 do
117
+ begin
118
+ @hk.wait_for_operation retval[:id]
119
+ rescue Faraday::TimeoutError => e
120
+ raise Timeout::Retry.new e # rubocop:disable Style/RaiseArgs
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,12 @@
1
+ require 'nexussw/lxd/driver'
2
+ require 'nexussw/lxd/driver/mixins/rest'
3
+
4
+ module NexusSW
5
+ module LXD
6
+ class Driver
7
+ class Rest < Driver
8
+ include Mixins::Rest
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,73 @@
1
+ require 'nexussw/lxd'
2
+
3
+ module NexusSW
4
+ module LXD
5
+ class Transport
6
+ class LXDExecuteResult
7
+ def initialize(command, options, exitstatus)
8
+ @command = command
9
+ @options = options || {}
10
+ @exitstatus = exitstatus
11
+ end
12
+
13
+ attr_reader :exitstatus, :options, :command
14
+
15
+ def stdout
16
+ options[:capture_options][:stdout] if options.key? :capture_options
17
+ end
18
+
19
+ def stderr
20
+ options[:capture_options][:stderr] if options.key? :capture_options
21
+ end
22
+
23
+ def error!
24
+ return self if exitstatus == 0
25
+ msg = "Error: '#{command}' failed with exit code #{exitstatus}.\n"
26
+ msg += "STDOUT: #{stdout}" if stdout && !stdout.empty?
27
+ msg += "STDERR: #{stderr}" if stderr && !stderr.empty?
28
+ raise msg
29
+ end
30
+ end
31
+
32
+ module ExecuteMixin
33
+ def execute(command, options = {}, &block)
34
+ options ||= {}
35
+ return execute_chunked(command, options) if options[:capture] == false && !block_given?
36
+
37
+ capture_options = { stdout: '', stderr: '' }
38
+ capture_options[:capture] = block if block_given?
39
+ capture_options[:capture] ||= options[:capture] if options[:capture].respond_to? :call
40
+ # capture_options[:capture] ||= options[:stream] if options[:stream].respond_to? :call
41
+ capture_options[:capture] ||= proc do |stdout_chunk, stderr_chunk|
42
+ capture_options[:stdout] += stdout_chunk if stdout_chunk
43
+ capture_options[:stderr] += stderr_chunk if stderr_chunk
44
+ end
45
+
46
+ execute_chunked(command, options.merge(capture_options: capture_options), &capture_options[:capture])
47
+ end
48
+ end
49
+
50
+ def execute(_command, _options = {})
51
+ raise 'NexusSW::LXD::Transport.execute not implemented'
52
+ end
53
+
54
+ def read_file(_path)
55
+ raise 'NexusSW::LXD::Transport.read_file not implemented'
56
+ end
57
+
58
+ def write_file(_path, _content)
59
+ raise 'NexusSW::LXD::Transport.write_file not implemented'
60
+ end
61
+
62
+ def download_file(_path, _local_path)
63
+ raise 'NexusSW::LXD::Transport.download_file not implemented'
64
+ end
65
+
66
+ def upload_file(_local_path, _path)
67
+ raise 'NexusSW::LXD::Transport.upload_file not implemented'
68
+ end
69
+ # protected
70
+ # def execute_chunked(_command, _options = {})
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,12 @@
1
+ require 'nexussw/lxd/transport'
2
+ require 'nexussw/lxd/transport/mixins/cli'
3
+
4
+ module NexusSW
5
+ module LXD
6
+ class Transport
7
+ class CLI < Transport
8
+ include Mixins::CLI
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ require 'nexussw/lxd/transport'
2
+ require 'nexussw/lxd/transport/mixins/local'
3
+
4
+ module NexusSW
5
+ module LXD
6
+ class Transport
7
+ class Local < Transport
8
+ include Mixins::Local
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,98 @@
1
+ require 'nexussw/lxd/transport/mixins/local'
2
+ require 'tempfile'
3
+ require 'pp'
4
+
5
+ module NexusSW
6
+ module LXD
7
+ class Transport
8
+ module Mixins
9
+ module CLI
10
+ def initialize(remote_transport, container_name, config = {})
11
+ @container_name = container_name
12
+ @config = config
13
+ @inner_transport = remote_transport
14
+ @punt = !inner_transport.is_a?(::NexusSW::LXD::Transport::Mixins::Local)
15
+ end
16
+ attr_reader :inner_transport, :punt, :container_name, :config
17
+
18
+ def execute(command, options = {})
19
+ mycommand = command.is_a?(Array) ? command.join(' ') : command
20
+ subcommand = options[:subcommand] || "exec #{container_name} --"
21
+ mycommand = "lxc #{subcommand} #{mycommand}"
22
+ options = options.except :subcommand if options.key? :subcommand
23
+ # We would have competing timeout logic depending on who the inner transport is
24
+ # I'll just let rest & local do the timeouts, and if inner is a chef sourced transport, they have timeout logic of their own
25
+ # with_timeout_and_retries(options) do
26
+ inner_transport.execute mycommand, options
27
+ end
28
+
29
+ def read_file(path)
30
+ tfile = inner_mktmp
31
+ retval = execute("#{@container_name}#{path} #{tfile}", subcommand: 'file pull', capture: false)
32
+ return '' if retval.exitstatus == 1
33
+ retval.error!
34
+ return inner_transport.read_file tfile
35
+ ensure
36
+ inner_transport.execute("rm -rf #{tfile}", capture: false) if tfile
37
+ end
38
+
39
+ def write_file(path, content)
40
+ tfile = inner_mktmp
41
+ inner_transport.write_file tfile, content
42
+ execute("#{tfile} #{container_name}#{path}", subcommand: 'file push', capture: false).error!
43
+ ensure
44
+ inner_transport.execute("rm -rf #{tfile}", capture: false) if tfile
45
+ end
46
+
47
+ def download_file(path, local_path)
48
+ tfile = inner_mktmp if punt
49
+ localname = tfile || local_path
50
+ execute("#{container_name}#{path} #{localname}", subcommand: 'file pull', capture: false).error!
51
+ inner_transport.download_file tfile, local_path if tfile
52
+ ensure
53
+ inner_transport.execute("rm -rf #{tfile}", capture: false) if tfile
54
+ end
55
+
56
+ def upload_file(local_path, path)
57
+ tfile = inner_mktmp if punt
58
+ localname = tfile || local_path
59
+ inner_transport.upload_file local_path, tfile if tfile
60
+ execute("#{localname} #{container_name}#{path}", subcommand: 'file push', capture: false).error!
61
+ ensure
62
+ inner_transport.execute("rm -rf #{tfile}", capture: false) if tfile
63
+ end
64
+
65
+ def add_remote(host_name)
66
+ execute("add #{host_name} --accept-certificate", subcommand: 'remote').error! unless remote? host_name
67
+ end
68
+
69
+ def linked_transport(host_name)
70
+ linked = inner_transport.linked_transport(host_name) if inner_transport.is_a?(::NexusSW::LXD::Transport::CLI)
71
+ return linked if linked
72
+ return nil unless remote?(host_name)
73
+ new(driver, inner_transport, "#{host_name}:#{container_name}", config)
74
+ end
75
+
76
+ def remote?(host_name)
77
+ result = execute 'list', subcommand: 'remote'
78
+ result.error!
79
+ result.stdout.each_line do |line|
80
+ return true if line.start_with? "| #{host_name} "
81
+ end
82
+ false
83
+ end
84
+
85
+ private
86
+
87
+ # kludge for windows environment
88
+ def inner_mktmp
89
+ tfile = Tempfile.new(container_name)
90
+ "/tmp/#{File.basename tfile.path}"
91
+ ensure
92
+ tfile.unlink
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end