Skip to content

Commit 029dd79

Browse files
committed
new lxd provisioner
1 parent cfeb84c commit 029dd79

File tree

4 files changed

+389
-1
lines changed

4 files changed

+389
-1
lines changed

lib/task_helper.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,15 @@ def get_inventory_hash(inventory_full_path)
99
if File.file?(inventory_full_path)
1010
inventory_hash_from_inventory_file(inventory_full_path)
1111
else
12-
{ 'version' => 2, 'groups' => [{ 'name' => 'docker_nodes', 'targets' => [] }, { 'name' => 'ssh_nodes', 'targets' => [] }, { 'name' => 'winrm_nodes', 'targets' => [] }] }
12+
{
13+
'version' => 2,
14+
'groups' => [
15+
{ 'name' => 'docker_nodes', 'targets' => [] },
16+
{ 'name' => 'lxd_nodes', 'targets' => [] },
17+
{ 'name' => 'ssh_nodes', 'targets' => [] },
18+
{ 'name' => 'winrm_nodes', 'targets' => [] },
19+
]
20+
}
1321
end
1422
end
1523

spec/tasks/lxd_spec.rb

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
require 'webmock/rspec'
5+
require_relative '../../tasks/lxd'
6+
require 'yaml'
7+
8+
RSpec::Matchers.define_negated_matcher :not_raise_error, :raise_error
9+
10+
RSpec.shared_context('with tmpdir') do
11+
let(:tmpdir) { @tmpdir } # rubocop:disable RSpec/InstanceVariable
12+
13+
around(:each) do |example|
14+
Dir.mktmpdir('rspec-provision_test') do |t|
15+
@tmpdir = t
16+
example.run
17+
end
18+
end
19+
end
20+
21+
describe 'provision::lxd' do
22+
let(:lxd) { LXDProvision.new }
23+
24+
let(:inventory_dir) { "#{tmpdir}/spec/fixtures" }
25+
let(:inventory_file) { "#{inventory_dir}/litmus_inventory.yaml" }
26+
let(:inventory_hash) { get_inventory_hash(inventory_file) }
27+
28+
let(:provision_input) do
29+
{
30+
action: 'provision',
31+
platform: 'images:foobar/1',
32+
inventory: tmpdir
33+
}
34+
end
35+
let(:tear_down_input) do
36+
{
37+
action: 'tear_down',
38+
node_name: container_id,
39+
inventory: tmpdir
40+
}
41+
end
42+
43+
let(:lxd_remote) { 'fake' }
44+
let(:lxd_flags) { [] }
45+
let(:lxd_platform) { nil }
46+
let(:container_id) { lxd_init_output }
47+
let(:lxd_init_output) { 'random-host' }
48+
49+
let(:provision_output) do
50+
{
51+
status: 'ok',
52+
node_name: container_id,
53+
node: {
54+
uri: container_id,
55+
config: {
56+
transport: 'lxd',
57+
lxd: {
58+
remote: lxd_remote,
59+
'shell-command': 'sh -lc'
60+
}
61+
},
62+
facts: {
63+
provisioner: 'provision::lxd',
64+
container_id: container_id,
65+
platform: lxd_platform
66+
}
67+
}
68+
}
69+
end
70+
71+
let(:tear_down_output) do
72+
{
73+
status: 'ok',
74+
removed: container_id,
75+
}
76+
end
77+
78+
include_context('with tmpdir')
79+
80+
before(:each) do
81+
FileUtils.mkdir_p(inventory_dir)
82+
end
83+
84+
describe '.run' do
85+
let(:task_input) { {} }
86+
let(:imposter) { instance_double('LXDProvision') }
87+
88+
task_tests = [
89+
[ { action: 'provision', platform: 'test' }, 'success', true ],
90+
[ { action: 'provision', node_name: 'test' }, 'do not specify node_name', false ],
91+
[ { action: 'provision' }, 'platform required', false ],
92+
[ { action: 'tear_down', node_name: 'test' }, 'success', true ],
93+
[ { action: 'tear_down' }, 'node_name required', false ],
94+
[ { action: 'tear_down', platform: 'test' }, 'do not specify platform', false ],
95+
]
96+
97+
task_tests.each do |v|
98+
it "expect arguments '#{v[0]}' return '#{v[1]}'#{v[2] ? '' : ' and raise error'}" do
99+
allow(LXDProvision).to receive(:new).and_return(imposter)
100+
allow(imposter).to receive(:task).and_return(v[1])
101+
allow($stdin).to receive(:read).and_return(v[0].to_json)
102+
if v[2]
103+
expect { LXDProvision.run }.to output(%r{#{v[1]}}).to_stdout
104+
else
105+
expect { LXDProvision.run }.to output(%r{#{v[1]}}).to_stdout.and raise_error(SystemExit)
106+
end
107+
end
108+
end
109+
end
110+
111+
describe '.task' do
112+
context 'action=provision' do
113+
let(:lxd_platform) { provision_input[:platform] }
114+
115+
before(:each) do
116+
expect(lxd).to receive(:run_local_command)
117+
.with('lxc -q remote get-default').and_return(lxd_remote)
118+
expect(lxd).to receive(:run_local_command)
119+
.with("lxc -q init #{lxd_platform} #{lxd_remote}: #{lxd_flags.join(' ')}").and_return(lxd_init_output)
120+
expect(lxd).to receive(:run_local_command)
121+
.with("lxc -q start #{lxd_remote}:#{container_id}").and_return(lxd_init_output)
122+
end
123+
124+
it 'provisions successfully' do
125+
expect(lxd).to receive(:run_local_command)
126+
.with("lxc -q exec #{lxd_remote}:#{container_id} uptime")
127+
128+
LXDProvision.new.add_node_to_group(inventory_hash, JSON.parse(provision_output[:node].to_json), 'lxd_nodes')
129+
130+
expect(File).to receive(:write).with(inventory_file, JSON.parse(inventory_hash.to_json).to_yaml)
131+
expect(lxd.task(**provision_input)).to eq(provision_output)
132+
end
133+
134+
it 'max retries then deletes the instance' do
135+
expect(lxd).to receive(:run_local_command)
136+
.exactly(3).times
137+
.with("lxc -q exec #{lxd_remote}:#{container_id} uptime").and_raise(StandardError)
138+
expect(lxd).to receive(:run_local_command)
139+
.with("lxc -q delete #{lxd_remote}:#{container_id} -f")
140+
141+
expect { lxd.task(**provision_input) }.to raise_error(StandardError, %r{Giving up waiting for #{lxd_remote}:#{container_id}})
142+
end
143+
end
144+
145+
context 'action=tear_down' do
146+
before(:each) do
147+
File.write(inventory_file, JSON.parse(inventory_hash.to_json).to_yaml)
148+
end
149+
150+
it 'tears down successfully' do
151+
expect(lxd).to receive(:run_local_command)
152+
.with("lxc -q delete #{lxd_remote}:#{container_id} -f")
153+
154+
LXDProvision.new.add_node_to_group(inventory_hash, JSON.parse(provision_output[:node].to_json), 'lxd_nodes')
155+
File.write(inventory_file, inventory_hash.to_yaml)
156+
157+
expect(lxd.task(**tear_down_input)).to eq(tear_down_output)
158+
end
159+
160+
it 'expect to raise error if no inventory' do
161+
File.delete(inventory_file)
162+
expect { lxd.task(**tear_down_input) }.to raise_error(StandardError, %r{Unable to find})
163+
end
164+
165+
it 'expect to raise error if node_name not in inventory' do
166+
expect { lxd.task(**tear_down_input) }.to raise_error(StandardError, %r{node_name #{container_id} not found in inventory})
167+
end
168+
end
169+
end
170+
end

tasks/lxd.json

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{
2+
"puppet_task_version": 1,
3+
"supports_noop": false,
4+
"description": "Provision/Tear down an instance on LXD",
5+
"parameters": {
6+
"action": {
7+
"description": "Action to perform, tear_down or provision",
8+
"type": "Enum[provision, tear_down]",
9+
"default": "provision"
10+
},
11+
"inventory": {
12+
"description": "Location of the inventory file",
13+
"type": "Optional[String[1]]"
14+
},
15+
"node_name": {
16+
"description": "The name of the instance",
17+
"type": "Optional[String[1]]"
18+
},
19+
"platform": {
20+
"description": "LXD image to use, eg images:ubuntu/22.04",
21+
"type": "Optional[String[1]]"
22+
},
23+
"profiles": {
24+
"description": "LXD Profiles to apply",
25+
"type": "Optional[Array[String[1]]]"
26+
},
27+
"storage": {
28+
"description": "LXD Storage pool name",
29+
"type": "Optional[String[1]]"
30+
},
31+
"instance_type": {
32+
"description": "LXD Instance type",
33+
"type": "Optional[String[1]]"
34+
},
35+
"vm": {
36+
"description": "Provision as a virtual-machine instead of a container",
37+
"type": "Optional[Boolean]"
38+
},
39+
"remote": {
40+
"description": "LXD remote, defaults to the LXD client configured default remote",
41+
"type": "Optional[String]"
42+
},
43+
"retries": {
44+
"description": "On provision check the instance is accepting commands, will be deleted if retries exceeded, 0 to disable",
45+
"type": "Integer",
46+
"default": 5
47+
},
48+
"vars": {
49+
"description": "YAML string of key/value pairs to add to the inventory vars section",
50+
"type": "Optional[String[1]]"
51+
}
52+
},
53+
"files": [
54+
"provision/lib/task_helper.rb"
55+
]
56+
}

tasks/lxd.rb

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require 'json'
5+
require 'yaml'
6+
require 'puppet_litmus'
7+
require_relative '../lib/task_helper'
8+
9+
# Provision and teardown instances on LXD
10+
class LXDProvision
11+
include PuppetLitmus::InventoryManipulation
12+
13+
attr_reader :node_name, :retries
14+
attr_reader :platform, :inventory, :inventory_full_path, :vars, :action, :options
15+
16+
def provision
17+
lxd_remote = options[:remote] || lxd_default_remote
18+
19+
lxd_flags = []
20+
options[:profiles]&.each { |p| lxd_flags << "--profile #{p}" }
21+
lxd_flags << "--type #{options[:instance_type]}" if options[:instance_type]
22+
lxd_flags << "--storage #{options[:storage]}" if options[:storage]
23+
lxd_flags << '--vm' if options[:vm]
24+
25+
creation_command = "lxc -q init #{platform} #{lxd_remote}: #{lxd_flags.join(' ')}"
26+
container_id = run_local_command(creation_command).chomp.split[-1]
27+
28+
begin
29+
start_command = "lxc -q start #{lxd_remote}:#{container_id}"
30+
run_local_command(start_command)
31+
32+
# wait here for a bit until instance can accept commands
33+
state_command = "lxc -q exec #{lxd_remote}:#{container_id} uptime"
34+
attempt = 0
35+
begin
36+
run_local_command(state_command)
37+
rescue StandardError => e
38+
raise "Giving up waiting for #{lxd_remote}:#{container_id} to enter running state. Got error: #{e.message}" if attempt > retries
39+
40+
attempt += 1
41+
sleep 2**attempt
42+
retry
43+
end
44+
rescue StandardError
45+
run_local_command("lxc -q delete #{lxd_remote}:#{container_id} -f")
46+
raise
47+
end
48+
49+
facts = {
50+
provisioner: 'provision::lxd',
51+
container_id: container_id,
52+
platform: platform
53+
}
54+
55+
options.each do |option|
56+
facts[:"lxd_#{option[0]}"] = option[1] unless option[1].to_s.empty?
57+
end
58+
59+
node = {
60+
uri: container_id,
61+
config: {
62+
transport: 'lxd',
63+
lxd: {
64+
remote: lxd_remote,
65+
'shell-command': 'sh -lc'
66+
}
67+
},
68+
facts: facts
69+
}
70+
71+
node[:vars] = vars unless vars.nil?
72+
73+
add_node_to_group(inventory, node, 'lxd_nodes')
74+
save_inventory
75+
76+
{ status: 'ok', node_name: container_id, node: node }
77+
end
78+
79+
def tear_down
80+
config = config_from_node(inventory, node_name)
81+
node_facts = facts_from_node(inventory, node_name)
82+
83+
raise "node_name #{node_name} not found in inventory" unless config
84+
85+
run_local_command("lxc -q delete #{config['lxd']['remote']}:#{node_facts['container_id']} -f")
86+
87+
remove_node(inventory, node_name)
88+
save_inventory
89+
90+
{ status: 'ok', removed: node_name }
91+
end
92+
93+
def save_inventory
94+
File.write(inventory_full_path, JSON.parse(inventory.to_json).to_yaml)
95+
end
96+
97+
def task(**params)
98+
finalize_params!(params)
99+
100+
@action = params.delete(:action)
101+
@retries = params.delete(:retries)&.to_i || 1
102+
@platform = params.delete(:platform)
103+
@node_name = params.delete(:node_name)
104+
@vars = YAML.safe_load(params.delete(:vars) || '~')
105+
106+
@inventory_full_path = File.join(sanitise_inventory_location(params.delete(:inventory)), 'spec/fixtures/litmus_inventory.yaml')
107+
raise "Unable to find '#{@inventory_full_path}'" unless (action == 'provision') || File.file?(@inventory_full_path)
108+
109+
@inventory = get_inventory_hash(@inventory_full_path)
110+
111+
@options = params.reject { |k, _v| k.start_with? '_' }
112+
method(action).call
113+
end
114+
115+
def lxd_default_remote
116+
@lxd_default_remote ||= run_local_command('lxc -q remote get-default').chomp
117+
@lxd_default_remote
118+
end
119+
120+
# add environment provided parameters (puppet litmus)
121+
def finalize_params!(params)
122+
['remote', 'profiles', 'storage', 'instance_type', 'vm'].each do |p|
123+
params[p] = YAML.safe_load(ENV.fetch("LXD_#{p.upcase}", '~')) if params[p].to_s.empty?
124+
end
125+
params.compact!
126+
end
127+
128+
class << self
129+
def run
130+
params = JSON.parse($stdin.read, symbolize_names: true)
131+
132+
case params[:action]
133+
when 'tear_down'
134+
raise 'do not specify platform when tearing down' if params[:platform]
135+
raise 'node_name required when tearing down' unless params[:node_name]
136+
when 'provision'
137+
raise 'do not specify node_name when provisioning' if params[:node_name]
138+
raise 'platform required, when provisioning' unless params[:platform]
139+
else
140+
raise "invalid action: #{params[:action]}" if params[:action]
141+
142+
raise 'must specify a valid action'
143+
end
144+
145+
result = new.task(**params)
146+
puts result.to_json
147+
rescue StandardError => e
148+
puts({ _error: { kind: 'provision/lxd_failure', msg: e.message, details: { backtraces: e.backtrace } } }.to_json)
149+
exit 1
150+
end
151+
end
152+
end
153+
154+
LXDProvision.run if __FILE__ == $PROGRAM_NAME

0 commit comments

Comments
 (0)