beaker 1.3.1 → 1.3.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +9 -9
- data/.travis.yml +3 -0
- data/beaker.gemspec +4 -2
- data/lib/beaker.rb +1 -1
- data/lib/beaker/cli.rb +1 -3
- data/lib/beaker/dsl/helpers.rb +4 -1
- data/lib/beaker/dsl/install_utils.rb +27 -16
- data/lib/beaker/hypervisor/vagrant.rb +23 -1
- data/lib/beaker/version.rb +5 -0
- data/spec/beaker/dsl/helpers_spec.rb +18 -18
- data/spec/beaker/dsl/install_utils_spec.rb +2 -5
- data/spec/beaker/hypervisor/vagrant_spec.rb +28 -0
- metadata +6 -5
checksums.yaml
CHANGED
@@ -1,15 +1,15 @@
|
|
1
1
|
---
|
2
2
|
!binary "U0hBMQ==":
|
3
3
|
metadata.gz: !binary |-
|
4
|
-
|
4
|
+
Y2M3OTY2ZTdlMDEyYzA1YWIyMmUwN2VlMzlkYzdlNzI5MzQzNjQ1YQ==
|
5
5
|
data.tar.gz: !binary |-
|
6
|
-
|
7
|
-
|
6
|
+
OGQ4MTNjYWJlNDExMGI0ODliMDY1OGJkOGI0YTY0NjQzOWY2MTQ4ZQ==
|
7
|
+
SHA512:
|
8
8
|
metadata.gz: !binary |-
|
9
|
-
|
10
|
-
|
11
|
-
|
9
|
+
NDJmNTcwMjFjYzhjNDM4MzQ5OTY1OTdhYWNkN2IzYTI2NjIzZjZmNjEzZjky
|
10
|
+
ODM3YjVjMWM5ZmFmY2RhNWIyZGQxZjI4OTY4YzJmZDk1OTM3NzVjY2Q5Mjhl
|
11
|
+
MzYwMTlmZjkyZTQxYWNjYzQyNWUzNTQwNGY1Yjc2YjNjNTFlMWY=
|
12
12
|
data.tar.gz: !binary |-
|
13
|
-
|
14
|
-
|
15
|
-
|
13
|
+
OTA4MDAxMjhhNTY0OTYzZjRjZjViYTNiNjlhOTZkOTUxMDZmM2YxNGVkYzYy
|
14
|
+
NTg2NDA2MTM4OWUzNTRjZWI1NjBlMTFiNWI3YjQyNzM2MzcwN2YwMDc1Mzg5
|
15
|
+
YTkwNGI1ODQ0ODMyMTdiYzY1N2ZkZWZiNzdiNzIzMmE0MTA5NjA=
|
data/.travis.yml
CHANGED
data/beaker.gemspec
CHANGED
@@ -1,8 +1,10 @@
|
|
1
1
|
# -*- encoding: utf-8 -*-
|
2
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __FILE__)
|
3
|
+
require 'beaker/version'
|
2
4
|
|
3
5
|
Gem::Specification.new do |s|
|
4
6
|
s.name = "beaker"
|
5
|
-
s.version =
|
7
|
+
s.version = Beaker::Version::STRING
|
6
8
|
s.authors = ["Puppetlabs"]
|
7
9
|
s.email = ["delivery@puppetlabs.com"]
|
8
10
|
s.homepage = "https://github.com/puppetlabs/beaker"
|
@@ -33,7 +35,7 @@ Gem::Specification.new do |s|
|
|
33
35
|
s.add_runtime_dependency 'inifile', '~> 2.0'
|
34
36
|
|
35
37
|
# Optional provisioner specific support
|
36
|
-
s.add_runtime_dependency 'rbvmomi', '1.
|
38
|
+
s.add_runtime_dependency 'rbvmomi', '1.8.1'
|
37
39
|
s.add_runtime_dependency 'blimpy', '~> 0.6'
|
38
40
|
s.add_runtime_dependency 'fission', '~> 0.4'
|
39
41
|
|
data/lib/beaker.rb
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
require 'rubygems' unless defined?(Gem)
|
2
2
|
module Beaker
|
3
3
|
|
4
|
-
%w( utils test_suite result command options network_manager cli ).each do |lib|
|
4
|
+
%w( version utils test_suite result command options network_manager cli ).each do |lib|
|
5
5
|
begin
|
6
6
|
require "beaker/#{lib}"
|
7
7
|
rescue LoadError
|
data/lib/beaker/cli.rb
CHANGED
@@ -21,9 +21,7 @@ module Beaker
|
|
21
21
|
exit
|
22
22
|
end
|
23
23
|
if @options[:version]
|
24
|
-
|
25
|
-
spec = Gem::Specification::load(GEMSPEC)
|
26
|
-
@logger.notify(VERSION_STRING % spec.version)
|
24
|
+
@logger.notify(VERSION_STRING % Beaker::Version::STRING)
|
27
25
|
exit
|
28
26
|
end
|
29
27
|
@logger.info(@options.dump)
|
data/lib/beaker/dsl/helpers.rb
CHANGED
@@ -669,7 +669,7 @@ module Beaker
|
|
669
669
|
# validation, etc.
|
670
670
|
#
|
671
671
|
def apply_manifest_on(host, manifest, opts = {}, &block)
|
672
|
-
on_options = {
|
672
|
+
on_options = {}
|
673
673
|
on_options[:acceptable_exit_codes] = Array(opts.delete(:acceptable_exit_codes))
|
674
674
|
args = ["--verbose"]
|
675
675
|
args << "--parseonly" if opts[:parseonly]
|
@@ -714,6 +714,9 @@ module Beaker
|
|
714
714
|
args << { :environment => opts[:environment]}
|
715
715
|
end
|
716
716
|
|
717
|
+
file_path = "/tmp/apply_manifest.#{rand(1000000000).to_s}.pp"
|
718
|
+
create_remote_file(host, file_path, manifest + "\n")
|
719
|
+
args << file_path
|
717
720
|
on host, puppet( 'apply', *args), on_options, &block
|
718
721
|
end
|
719
722
|
|
@@ -12,7 +12,7 @@ module Beaker
|
|
12
12
|
SourcePath = "/opt/puppet-git-repos"
|
13
13
|
|
14
14
|
# A regex to know if the uri passed is pointing to a git repo
|
15
|
-
GitURI = %r{^(git|https?|file)://|^git@}
|
15
|
+
GitURI = %r{^(git|https?|file)://|^git@|^gitmirror@}
|
16
16
|
|
17
17
|
# Github's ssh signature for cloning via ssh
|
18
18
|
GitHubSig = 'github.com,207.97.227.239 ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ=='
|
@@ -160,9 +160,12 @@ module Beaker
|
|
160
160
|
# @api private
|
161
161
|
def link_exists?(link)
|
162
162
|
require "net/http"
|
163
|
+
require "net/https"
|
163
164
|
require "open-uri"
|
164
165
|
url = URI.parse(link)
|
165
|
-
Net::HTTP.
|
166
|
+
http = Net::HTTP.new(url.host, url.port)
|
167
|
+
http.use_ssl = (url.scheme == 'https')
|
168
|
+
http.start do |http|
|
166
169
|
return http.head(url.request_uri).code == "200"
|
167
170
|
end
|
168
171
|
end
|
@@ -202,21 +205,29 @@ module Beaker
|
|
202
205
|
end
|
203
206
|
end
|
204
207
|
if local
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
208
|
+
if not File.exists?("#{path}/#{filename}#{extension}")
|
209
|
+
raise "attempting installation on #{host}, #{path}/#{filename}#{extension} does not exist"
|
210
|
+
end
|
211
|
+
scp_to host, "#{path}/#{filename}#{extension}", "#{host['working_dir']}/#{filename}#{extension}"
|
212
|
+
if extension =~ /gz/
|
213
|
+
on host, "cd #{host['working_dir']}; gunzip #{filename}#{extension}"
|
214
|
+
end
|
215
|
+
if extension =~ /tar/
|
216
|
+
on host, "cd #{host['working_dir']}; tar -xvf #{filename}.tar"
|
217
|
+
end
|
209
218
|
else
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
219
|
+
if not link_exists?("#{path}/#{filename}#{extension}")
|
220
|
+
raise "attempting installation on #{host}, #{path}/#{filename}#{extension} does not exist"
|
221
|
+
end
|
222
|
+
gunzip = ""
|
223
|
+
untar = ""
|
224
|
+
if extension =~ /gz/
|
225
|
+
gunzip = "| gunzip"
|
226
|
+
end
|
227
|
+
if extension =~ /tar/
|
228
|
+
untar = "| tar -xvf -"
|
229
|
+
end
|
230
|
+
on host, "cd #{host['working_dir']}; curl #{path}/#{filename}#{extension} #{gunzip} #{untar}"
|
220
231
|
end
|
221
232
|
end
|
222
233
|
end
|
@@ -29,7 +29,7 @@ module Beaker
|
|
29
29
|
v_file << " v.vm.box = '#{host['box']}'\n"
|
30
30
|
v_file << " v.vm.box_url = '#{host['box_url']}'\n" unless host['box_url'].nil?
|
31
31
|
v_file << " v.vm.base_mac = '#{randmac}'\n"
|
32
|
-
v_file << " v.vm.network :private_network, ip: \"#{host['ip'].to_s}\", :netmask => \"255.255.0.0\"\n"
|
32
|
+
v_file << " v.vm.network :private_network, ip: \"#{host['ip'].to_s}\", :netmask => \"#{host['netmask'] ||= "255.255.0.0"}\"\n"
|
33
33
|
v_file << " end\n"
|
34
34
|
@logger.debug "created Vagrantfile for VagrantHost #{host.name}"
|
35
35
|
end
|
@@ -82,6 +82,23 @@ module Beaker
|
|
82
82
|
@temp_files << f
|
83
83
|
end
|
84
84
|
|
85
|
+
def get_ip_from_vagrant_file(hostname)
|
86
|
+
ip = ''
|
87
|
+
if File.file?(@vagrant_file) #we should have a vagrant file available to us for reading
|
88
|
+
f = File.read(@vagrant_file)
|
89
|
+
m = /#{hostname}.*?ip:\s*('|")\s*([^'"]+)('|")/m.match(f)
|
90
|
+
if m
|
91
|
+
ip = m[2]
|
92
|
+
@logger.debug("Determined existing vagrant box #{hostname} ip to be: #{ip} ")
|
93
|
+
else
|
94
|
+
raise("Unable to determine ip for vagrant box #{hostname}")
|
95
|
+
end
|
96
|
+
else
|
97
|
+
raise("No vagrant file found (should be located at #{@vagrant_file})")
|
98
|
+
end
|
99
|
+
ip
|
100
|
+
end
|
101
|
+
|
85
102
|
def initialize(vagrant_hosts, options)
|
86
103
|
require 'tempfile'
|
87
104
|
@options = options
|
@@ -103,7 +120,12 @@ module Beaker
|
|
103
120
|
make_vfile @vagrant_hosts
|
104
121
|
|
105
122
|
vagrant_cmd("up")
|
123
|
+
else #set host ip of already up boxes
|
124
|
+
@vagrant_hosts.each do |host|
|
125
|
+
host[:ip] = get_ip_from_vagrant_file(host.name)
|
126
|
+
end
|
106
127
|
end
|
128
|
+
|
107
129
|
@logger.debug "configure vagrant boxes (set ssh-config, switch to root user, hack etc/hosts)"
|
108
130
|
@vagrant_hosts.each do |host|
|
109
131
|
default_user = host['user']
|
@@ -298,26 +298,26 @@ describe ClassMixedWithDSLHelpers do
|
|
298
298
|
|
299
299
|
describe '#apply_manifest_on' do
|
300
300
|
it 'calls puppet' do
|
301
|
+
subject.should_receive( :create_remote_file ).and_return( true )
|
301
302
|
subject.should_receive( :puppet ).
|
302
|
-
with( 'apply', '--verbose').
|
303
|
+
with( 'apply', '--verbose', /apply_manifest.\d+.pp/ ).
|
303
304
|
and_return( 'puppet_command' )
|
304
305
|
|
305
306
|
subject.should_receive( :on ).
|
306
307
|
with( 'my_host', 'puppet_command',
|
307
|
-
:acceptable_exit_codes => [0]
|
308
|
-
:stdin => "class { \"boo\": }\n" )
|
308
|
+
:acceptable_exit_codes => [0] )
|
309
309
|
|
310
310
|
subject.apply_manifest_on( 'my_host', 'class { "boo": }')
|
311
311
|
end
|
312
312
|
it 'adds acceptable exit codes with :catch_failures' do
|
313
|
+
subject.should_receive( :create_remote_file ).and_return( true )
|
313
314
|
subject.should_receive( :puppet ).
|
314
|
-
with( 'apply', '--verbose', '--trace', '--detailed-exitcodes' ).
|
315
|
+
with( 'apply', '--verbose', '--trace', '--detailed-exitcodes', /apply_manifest.\d+.pp/ ).
|
315
316
|
and_return( 'puppet_command' )
|
316
317
|
|
317
318
|
subject.should_receive( :on ).
|
318
319
|
with( 'my_host', 'puppet_command',
|
319
|
-
:acceptable_exit_codes => [0,2]
|
320
|
-
:stdin => "class { \"boo\": }\n" )
|
320
|
+
:acceptable_exit_codes => [0,2] )
|
321
321
|
|
322
322
|
subject.apply_manifest_on( 'my_host',
|
323
323
|
'class { "boo": }',
|
@@ -325,14 +325,14 @@ describe ClassMixedWithDSLHelpers do
|
|
325
325
|
:catch_failures => true )
|
326
326
|
end
|
327
327
|
it 'allows acceptable exit codes through :catch_failures' do
|
328
|
+
subject.should_receive( :create_remote_file ).and_return( true )
|
328
329
|
subject.should_receive( :puppet ).
|
329
|
-
with( 'apply', '--verbose', '--trace', '--detailed-exitcodes' ).
|
330
|
+
with( 'apply', '--verbose', '--trace', '--detailed-exitcodes', /apply_manifest.\d+.pp/ ).
|
330
331
|
and_return( 'puppet_command' )
|
331
332
|
|
332
333
|
subject.should_receive( :on ).
|
333
334
|
with( 'my_host', 'puppet_command',
|
334
|
-
:acceptable_exit_codes => [4,0,2]
|
335
|
-
:stdin => "class { \"boo\": }\n" )
|
335
|
+
:acceptable_exit_codes => [4,0,2] )
|
336
336
|
|
337
337
|
subject.apply_manifest_on( 'my_host',
|
338
338
|
'class { "boo": }',
|
@@ -341,15 +341,15 @@ describe ClassMixedWithDSLHelpers do
|
|
341
341
|
:catch_failures => true )
|
342
342
|
end
|
343
343
|
it 'enforces a 0 exit code through :catch_changes' do
|
344
|
+
subject.should_receive( :create_remote_file ).and_return( true )
|
344
345
|
subject.should_receive( :puppet ).
|
345
|
-
with( 'apply', '--verbose', '--trace', '--detailed-exitcodes' ).
|
346
|
+
with( 'apply', '--verbose', '--trace', '--detailed-exitcodes', /apply_manifest.\d+.pp/ ).
|
346
347
|
and_return( 'puppet_command' )
|
347
348
|
|
348
349
|
subject.should_receive( :on ).with(
|
349
350
|
'my_host',
|
350
351
|
'puppet_command',
|
351
|
-
:acceptable_exit_codes => [0]
|
352
|
-
:stdin => "class { \"boo\": }\n"
|
352
|
+
:acceptable_exit_codes => [0]
|
353
353
|
)
|
354
354
|
|
355
355
|
subject.apply_manifest_on(
|
@@ -360,15 +360,15 @@ describe ClassMixedWithDSLHelpers do
|
|
360
360
|
)
|
361
361
|
end
|
362
362
|
it 'enforces exit codes through :expect_failures' do
|
363
|
+
subject.should_receive( :create_remote_file ).and_return( true )
|
363
364
|
subject.should_receive( :puppet ).
|
364
|
-
with( 'apply', '--verbose', '--trace', '--detailed-exitcodes' ).
|
365
|
+
with( 'apply', '--verbose', '--trace', '--detailed-exitcodes', /apply_manifest.\d+.pp/ ).
|
365
366
|
and_return( 'puppet_command' )
|
366
367
|
|
367
368
|
subject.should_receive( :on ).with(
|
368
369
|
'my_host',
|
369
370
|
'puppet_command',
|
370
|
-
:acceptable_exit_codes => [1,4,6]
|
371
|
-
:stdin => "class { \"boo\": }\n"
|
371
|
+
:acceptable_exit_codes => [1,4,6]
|
372
372
|
)
|
373
373
|
|
374
374
|
subject.apply_manifest_on(
|
@@ -390,15 +390,15 @@ describe ClassMixedWithDSLHelpers do
|
|
390
390
|
}.to raise_error ArgumentError, /catch_failures.+expect_failures/
|
391
391
|
end
|
392
392
|
it 'enforces added exit codes through :expect_failures' do
|
393
|
+
subject.should_receive( :create_remote_file ).and_return( true )
|
393
394
|
subject.should_receive( :puppet ).
|
394
|
-
with( 'apply', '--verbose', '--trace', '--detailed-exitcodes' ).
|
395
|
+
with( 'apply', '--verbose', '--trace', '--detailed-exitcodes', /apply_manifest.\d+.pp/ ).
|
395
396
|
and_return( 'puppet_command' )
|
396
397
|
|
397
398
|
subject.should_receive( :on ).with(
|
398
399
|
'my_host',
|
399
400
|
'puppet_command',
|
400
|
-
:acceptable_exit_codes => [1,2,3,4,5,6]
|
401
|
-
:stdin => "class { \"boo\": }\n"
|
401
|
+
:acceptable_exit_codes => [1,2,3,4,5,6]
|
402
402
|
)
|
403
403
|
|
404
404
|
subject.apply_manifest_on(
|
@@ -166,8 +166,7 @@ describe ClassMixedWithDSLInstallUtils do
|
|
166
166
|
path = unixhost['pe_dir']
|
167
167
|
filename = "#{ unixhost['dist'] }"
|
168
168
|
extension = '.tar'
|
169
|
-
subject.should_receive( :on ).with( unixhost, "cd #{ unixhost['working_dir'] }; curl #{ path }/#{ filename }#{ extension } -
|
170
|
-
subject.should_receive( :on ).with( unixhost, /tar -xvf/ ).once
|
169
|
+
subject.should_receive( :on ).with( unixhost, "cd #{ unixhost['working_dir'] }; curl #{ path }/#{ filename }#{ extension } | tar -xvf -" ).once
|
171
170
|
subject.fetch_puppet( [unixhost], {} )
|
172
171
|
end
|
173
172
|
|
@@ -180,9 +179,7 @@ describe ClassMixedWithDSLInstallUtils do
|
|
180
179
|
path = unixhost['pe_dir']
|
181
180
|
filename = "#{ unixhost['dist'] }"
|
182
181
|
extension = '.tar.gz'
|
183
|
-
subject.should_receive( :on ).with( unixhost, "cd #{ unixhost['working_dir'] }; curl #{ path }/#{ filename }#{ extension }
|
184
|
-
subject.should_receive( :on ).with( unixhost, /gunzip/ ).once
|
185
|
-
subject.should_receive( :on ).with( unixhost, /tar -xvf/ ).once
|
182
|
+
subject.should_receive( :on ).with( unixhost, "cd #{ unixhost['working_dir'] }; curl #{ path }/#{ filename }#{ extension } | gunzip | tar -xvf -" ).once
|
186
183
|
subject.fetch_puppet( [unixhost], {} )
|
187
184
|
end
|
188
185
|
|
@@ -96,6 +96,34 @@ module Beaker
|
|
96
96
|
|
97
97
|
end
|
98
98
|
|
99
|
+
describe "get_ip_from_vagrant_file" do
|
100
|
+
before :each do
|
101
|
+
FakeFS.activate!
|
102
|
+
vagrant.stub( :randmac ).and_return( "0123456789" )
|
103
|
+
vagrant.make_vfile( @hosts )
|
104
|
+
end
|
105
|
+
|
106
|
+
it "can find the correct ip for the provided hostname" do
|
107
|
+
@hosts.each do |host|
|
108
|
+
expect( vagrant.get_ip_from_vagrant_file(host.name) ).to be === host[:ip]
|
109
|
+
end
|
110
|
+
|
111
|
+
end
|
112
|
+
|
113
|
+
it "raises an error if it is unable to find an ip" do
|
114
|
+
expect{ vagrant.get_ip_from_vagrant_file("unknown") }.to raise_error
|
115
|
+
|
116
|
+
end
|
117
|
+
|
118
|
+
it "raises an error if no Vagrantfile is present" do
|
119
|
+
File.delete( vagrant.instance_variable_get( :@vagrant_file ) )
|
120
|
+
@hosts.each do |host|
|
121
|
+
expect{ vagrant.get_ip_from_vagrant_file(host.name) }.to raise_error
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
end
|
126
|
+
|
99
127
|
describe "provisioning and cleanup" do
|
100
128
|
|
101
129
|
before :each do
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: beaker
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.3.
|
4
|
+
version: 1.3.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Puppetlabs
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2014-01-24 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rspec
|
@@ -170,14 +170,14 @@ dependencies:
|
|
170
170
|
requirements:
|
171
171
|
- - '='
|
172
172
|
- !ruby/object:Gem::Version
|
173
|
-
version:
|
173
|
+
version: 1.8.1
|
174
174
|
type: :runtime
|
175
175
|
prerelease: false
|
176
176
|
version_requirements: !ruby/object:Gem::Requirement
|
177
177
|
requirements:
|
178
178
|
- - '='
|
179
179
|
- !ruby/object:Gem::Version
|
180
|
-
version:
|
180
|
+
version: 1.8.1
|
181
181
|
- !ruby/object:Gem::Dependency
|
182
182
|
name: blimpy
|
183
183
|
requirement: !ruby/object:Gem::Requirement
|
@@ -335,6 +335,7 @@ files:
|
|
335
335
|
- lib/beaker/utils/repo_control.rb
|
336
336
|
- lib/beaker/utils/setup_helper.rb
|
337
337
|
- lib/beaker/utils/validator.rb
|
338
|
+
- lib/beaker/version.rb
|
338
339
|
- spec/beaker/answers_spec.rb
|
339
340
|
- spec/beaker/command_spec.rb
|
340
341
|
- spec/beaker/dsl/assertions_spec.rb
|
@@ -407,7 +408,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
407
408
|
version: '0'
|
408
409
|
requirements: []
|
409
410
|
rubyforge_project:
|
410
|
-
rubygems_version: 2.
|
411
|
+
rubygems_version: 2.2.1
|
411
412
|
signing_key:
|
412
413
|
specification_version: 4
|
413
414
|
summary: Let's test Puppet!
|