amigrind 0.1.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 (56) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.rspec +2 -0
  4. data/.rubocop.yml +72 -0
  5. data/.travis.yml +4 -0
  6. data/CODE_OF_CONDUCT.md +49 -0
  7. data/Gemfile +4 -0
  8. data/LICENSE.txt +201 -0
  9. data/README.md +112 -0
  10. data/Rakefile +6 -0
  11. data/amigrind.gemspec +36 -0
  12. data/bin/amigrind +8 -0
  13. data/lib/amigrind.rb +29 -0
  14. data/lib/amigrind/blueprints/aws_config.rb +56 -0
  15. data/lib/amigrind/blueprints/base_ami_source.rb +15 -0
  16. data/lib/amigrind/blueprints/blueprint.rb +22 -0
  17. data/lib/amigrind/blueprints/evaluator.rb +269 -0
  18. data/lib/amigrind/blueprints/parent_blueprint_source.rb +10 -0
  19. data/lib/amigrind/blueprints/provisioner.rb +20 -0
  20. data/lib/amigrind/blueprints/provisioners/file_upload.rb +29 -0
  21. data/lib/amigrind/blueprints/provisioners/local_shell.rb +28 -0
  22. data/lib/amigrind/blueprints/provisioners/remote_shell.rb +57 -0
  23. data/lib/amigrind/build/packer_runner.rb +106 -0
  24. data/lib/amigrind/build/rackerizer.rb +134 -0
  25. data/lib/amigrind/builder.rb +46 -0
  26. data/lib/amigrind/cli.rb +12 -0
  27. data/lib/amigrind/cli/_helpers.rb +49 -0
  28. data/lib/amigrind/cli/_root.rb +43 -0
  29. data/lib/amigrind/cli/blueprints/_category.rb +15 -0
  30. data/lib/amigrind/cli/blueprints/list.rb +21 -0
  31. data/lib/amigrind/cli/blueprints/show.rb +22 -0
  32. data/lib/amigrind/cli/build/_category.rb +15 -0
  33. data/lib/amigrind/cli/build/execute.rb +32 -0
  34. data/lib/amigrind/cli/build/print_packer.rb +28 -0
  35. data/lib/amigrind/cli/environments/_category.rb +15 -0
  36. data/lib/amigrind/cli/environments/list.rb +23 -0
  37. data/lib/amigrind/cli/environments/show.rb +22 -0
  38. data/lib/amigrind/cli/inventory/_category.rb +15 -0
  39. data/lib/amigrind/cli/inventory/add_to_channel.rb +28 -0
  40. data/lib/amigrind/cli/inventory/get-image.rb +34 -0
  41. data/lib/amigrind/cli/inventory/list.rb +14 -0
  42. data/lib/amigrind/cli/inventory/remove_from_channel.rb +28 -0
  43. data/lib/amigrind/cli/repo/_category.rb +15 -0
  44. data/lib/amigrind/cli/repo/init.rb +22 -0
  45. data/lib/amigrind/config.rb +89 -0
  46. data/lib/amigrind/environments/channel.rb +9 -0
  47. data/lib/amigrind/environments/environment.rb +66 -0
  48. data/lib/amigrind/environments/rb_evaluator.rb +7 -0
  49. data/lib/amigrind/repo.rb +217 -0
  50. data/lib/amigrind/version.rb +3 -0
  51. data/sample_config/config.yaml +9 -0
  52. data/sample_repo/.amigrind_root +0 -0
  53. data/sample_repo/blueprints/dependent_ubuntu.rb +21 -0
  54. data/sample_repo/blueprints/simple_ubuntu.rb +37 -0
  55. data/sample_repo/environments/development.yaml.example +15 -0
  56. metadata +288 -0
@@ -0,0 +1,10 @@
1
+ module Amigrind
2
+ module Blueprints
3
+ class ParentBlueprintSource
4
+ include Virtus.model(constructor: false, mass_assignment: false)
5
+
6
+ attribute :name, String
7
+ attribute :channel, Symbol
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,20 @@
1
+ module Amigrind
2
+ module Blueprints
3
+ class Provisioner
4
+ include Virtus.model(constructor: false, mass_assignment: false)
5
+
6
+ attribute :name, String
7
+ attribute :weight, Fixnum
8
+
9
+ def racker_name
10
+ "#{name}-#{weight}-#{self.class.name.demodulize}"
11
+ end
12
+
13
+ def to_racker_hash
14
+ raise "#{self.class.name}#to_racker_hash must be implemented."
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ Dir["#{__dir__}/provisioners/**.rb"].each { |f| require_relative f }
@@ -0,0 +1,29 @@
1
+ module Amigrind
2
+ module Blueprints
3
+ module Provisioners
4
+ class FileUpload < Amigrind::Blueprints::Provisioner
5
+ attribute :source, String
6
+ attribute :destination, String
7
+ attribute :direction_method, String
8
+
9
+ def direction=(dir)
10
+ case dir
11
+ when :download, :upload
12
+ @direction_method = dir.to_s
13
+ else
14
+ raise "unrecognized 'direction': #{dir}"
15
+ end
16
+ end
17
+
18
+ def to_racker_hash
19
+ {
20
+ type: 'file',
21
+ source: @source,
22
+ destination: @destination,
23
+ direction: @direction_method
24
+ }.delete_if { |_, v| v.nil? }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,28 @@
1
+ module Amigrind
2
+ module Blueprints
3
+ module Provisioners
4
+ class LocalShell < Amigrind::Blueprints::Provisioner
5
+ attribute :inline, Array[String]
6
+
7
+ def command=(cmd)
8
+ raise "'command' must be a String or an array of String." \
9
+ unless cmd.is_a?(String) ||
10
+ (cmd.respond_to?(:all?) && cmd.all? { |l| l.is_a?(String) })
11
+
12
+ if cmd.is_a?(String)
13
+ @inline = cmd.split("\n")
14
+ else
15
+ @inline = cmd
16
+ end
17
+ end
18
+
19
+ def to_racker_hash
20
+ {
21
+ type: 'shell-local',
22
+ command: @inline.join("\n")
23
+ }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,57 @@
1
+ module Amigrind
2
+ module Blueprints
3
+ module Provisioners
4
+ class RemoteShell < Amigrind::Blueprints::Provisioner
5
+ attribute :inline, Array[String]
6
+ attribute :scripts, Array[String]
7
+ attribute :binary, Boolean
8
+ attribute :env_vars, Hash[String => String]
9
+ attribute :execute_command, String
10
+ attribute :inline_shebang, String
11
+ attribute :remote_path, String
12
+ attribute :skip_clean, Boolean
13
+ attribute :start_retry_timeout, ActiveSupport::Duration
14
+
15
+ def run_as_root!
16
+ @execute_command = "{{ .Vars }} sudo -E -S sh '{{ .Path }}'"
17
+ end
18
+
19
+ def command=(cmd)
20
+ raise "'command' must be a String or an array of String." \
21
+ unless cmd.is_a?(String) ||
22
+ (cmd.respond_to?(:all?) && cmd.all? { |l| l.is_a?(String) })
23
+
24
+ @inline = cmd.is_a?(String) ? cmd.split("\n") : cmd
25
+ end
26
+
27
+ def environment_vars=(vars)
28
+ raise "'vars' must be a Hash with Symbol or String keys and stringable values." \
29
+ unless vars.all? { |k, v| (k.is_a?(Symbol) || k.is_a?(String)) && v.respond_to?(:to_s)}
30
+
31
+ @env_vars =
32
+ (@env_vars || {}).merge(vars.map { |k, v| [ k.to_s, v.to_s ] }.to_h)
33
+ end
34
+
35
+ def to_racker_hash
36
+ # This trims leading whitespace off of heredoc commands, but leaves
37
+ # them as inlines. They're easier to read when you have to look at the Packer
38
+ # output when you do this.
39
+ trim_count = @inline.map { |line| line[/\A */].size }.min
40
+ lines = @inline.map { |line| line[trim_count..-1] }
41
+
42
+ {
43
+ type: 'shell',
44
+ binary: @binary,
45
+ inline: lines,
46
+ scripts: @scripts,
47
+ environment_vars: (@env_vars || {}).map { |k, v| "#{k}=#{v}" },
48
+ execute_command: @execute_command,
49
+ inline_shebang: @inline_shebang,
50
+ start_retry_timeout:
51
+ @start_retry_timeout.nil? ? nil : "#{@start_retry_timeout}s"
52
+ }
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,106 @@
1
+ module Amigrind
2
+ module Build
3
+ class PackerRunner
4
+ include Virtus.model
5
+ include Amigrind::Core::Logging::Mixin
6
+
7
+ attribute :template, Hash
8
+ attribute :amigrind_client, Amigrind::Core::Client
9
+ attribute :blueprint, Amigrind::Blueprints::Blueprint
10
+ attribute :repo, Amigrind::Repo
11
+
12
+ def initialize(template, amigrind_client, blueprint, repo)
13
+ @template = template
14
+ @amigrind_client = amigrind_client
15
+ @blueprint = blueprint
16
+ @repo = repo
17
+ end
18
+
19
+ def run
20
+ credentials = @amigrind_client.credentials
21
+
22
+ aws_env_vars =
23
+ case credentials.class
24
+ when Aws::Credentials
25
+ "AWS_ACCESS_KEY_ID='#{access_key}' AWS_SECRET_ACCESS_KEY='#{secret_key}'"
26
+ {
27
+ 'AWS_ACCESS_KEY_ID' => credentials.access_key_id,
28
+ 'AWS_SECRET_ACCESS_KEY' => credentials.instance_variable_get(:@secret_access_key)
29
+ }
30
+ when Aws::SharedCredentials
31
+ {
32
+ 'AWS_PROFILE' => credentials.profile_name,
33
+ 'AWS_DEFAULT_PROFILE' => credentials.profile_name
34
+ }
35
+ else
36
+ {}
37
+ end
38
+
39
+ thread = nil
40
+ retval = {
41
+ region: @blueprint.aws.region,
42
+ spools: { stdout: [], stderr: [] }
43
+ }
44
+
45
+ Dir.chdir @repo.path do
46
+ Open3.popen3(aws_env_vars, 'packer build -machine-readable -') do |i, o, e, thr|
47
+ thread = thr
48
+ retval[:pid] = thread.pid
49
+
50
+ i.write @template
51
+ i.flush
52
+ i.close_write
53
+
54
+ streams = [ o, e ]
55
+
56
+ stream_names = { o.fileno => :stdout, e.fileno => :stderr }
57
+
58
+ until streams.find { |f| !f.eof }.nil?
59
+ ready = IO.select(streams)
60
+
61
+ if ready
62
+ readable_streams = ready[0]
63
+
64
+ readable_streams.each do |stream|
65
+ stream_name = stream_names[stream.fileno]
66
+
67
+ begin
68
+ data = stream.read_nonblock(8192).strip
69
+ debug_log data
70
+
71
+ retval[:spools][stream_name] << data
72
+ tokens = data.split(',')
73
+
74
+ unless tokens.empty?
75
+ if stream != o || tokens[2] == 'ui'
76
+ tokens.last.split('\n').each do |log_line|
77
+ info_log "packer | #{log_line.gsub('%!(PACKER_COMMA)', ',')}"
78
+ end
79
+ end
80
+
81
+ if tokens[2] == 'artifact' && tokens[4] == 'id'
82
+ retval[:amis] =
83
+ tokens[5].split('%!(PACKER_COMMA)').map { |pair| pair.split(':') }.to_h
84
+ end
85
+ end
86
+ rescue EOFError => _
87
+ debug_log "#{stream_name} eof"
88
+ streams.delete stream # this is necessary, otherwise
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+
96
+ retval[:success] = thread.value.success?
97
+ retval[:exit_code] = thread.value.exitstatus
98
+
99
+ raise "ERROR: packer returned successfully, but couldn't parse AMIs?" \
100
+ if retval[:success] && (retval[:amis].nil? || retval[:amis].empty?)
101
+
102
+ retval
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,134 @@
1
+ module Amigrind
2
+ module Build
3
+ class Rackerizer
4
+ include Amigrind::Core::Logging::Mixin
5
+ include Virtus.model(constructor: false, mass_assignment: false)
6
+
7
+ attribute :amigrind_client, Amigrind::Core::Client
8
+ attribute :blueprint, Amigrind::Blueprints::Blueprint
9
+ attribute :repo, Amigrind::Repo
10
+
11
+ def initialize(amigrind_client, blueprint, repo)
12
+ @amigrind_client = amigrind_client
13
+ @blueprint = blueprint
14
+ @repo = repo
15
+ end
16
+
17
+ def rackerize
18
+ t = Racker::Template.new
19
+
20
+ do_builder(t)
21
+ do_provisioners(t)
22
+
23
+ JSON.pretty_generate(t.to_packer)
24
+ end
25
+
26
+ private
27
+
28
+ def do_builder(t)
29
+ latest_build =
30
+ @amigrind_client.get_image_by_channel(name: @blueprint.name, channel: :latest)
31
+ build_id =
32
+ if latest_build.nil?
33
+ 1
34
+ else
35
+ latest_build.tags.find { |t| t.key == Amigrind::Core::AMIGRIND_ID_TAG }.value.to_i + 1
36
+ end
37
+
38
+ source_ami =
39
+ if @blueprint.source.is_a?(Amigrind::Blueprints::BaseAMISource)
40
+ ami_id = @blueprint.source.ids[@blueprint.aws.region]
41
+
42
+ raise "source AMI was not provided for region #{@blueprint.aws.region}." if ami_id.nil?
43
+ ami_id
44
+ elsif @blueprint.source.is_a?(Amigrind::Blueprints::ParentBlueprintSource)
45
+ parent_name = @blueprint.source.name
46
+ parent_channel = @blueprint.source.channel
47
+
48
+ parent_image =
49
+ @amigrind_client.get_image_by_channel(name: parent_name,
50
+ channel: parent_channel)
51
+
52
+ raise "parent image (#{parent_name} #{parent_channel}) not found." if parent_image.nil?
53
+
54
+ parent_image.id
55
+ else
56
+ raise "blueprint source is unrecognized (#{@blueprint.source.class})"
57
+ end
58
+
59
+ raise "source_ami is nil; check to make sure!" if source_ami.nil?
60
+
61
+ amigrind_tags = {
62
+ Amigrind::Core::AMIGRIND_NAME_TAG => @blueprint.name,
63
+ Amigrind::Core::AMIGRIND_ID_TAG => build_id
64
+ }.delete_if { |_, v| v.nil? }
65
+
66
+ unless @blueprint.build_channel.nil?
67
+ channel_tag =
68
+ Amigrind::Core::AMIGRIND_CHANNEL_TAG % { channel_name: @blueprint.build_channel }
69
+ amigrind_tags[channel_tag] = 1
70
+ end
71
+
72
+ unless parent_image.nil? # we're in a parented image
73
+ amigrind_tags[Amigrind::Core::AMIGRIND_PARENT_NAME_TAG] = @blueprint.source.name
74
+ amigrind_tags[Amigrind::Core::AMIGRIND_PARENT_ID_TAG] =
75
+ parent_image.tags.find { |t| t.key == Amigrind::Core::AMIGRIND_ID_TAG }.value
76
+ end
77
+
78
+ # Note that we do not pull in credentials here! That would fail hilariously and
79
+ # with much gnashing of teeth if we're on an instance with IAM credentials.
80
+ # Instead, when we execute Packer on this script, we pass along environment
81
+ # variables containing AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or AWS_PROFILE,
82
+ # like a boss or boss-shaped object.
83
+ #
84
+ # This also lets us avoid having sensitive information in the Packer file that
85
+ # we might, say, print to stdout.
86
+ builder = {
87
+ type: 'amazon-ebs',
88
+ ami_name: "#{@blueprint.name}-#{build_id.to_s.rjust(6, '0')}",
89
+ source_ami: source_ami,
90
+
91
+ instance_type: @blueprint.aws.instance_type,
92
+ ssh_username: @blueprint.aws.ssh_username,
93
+
94
+ region: @blueprint.aws.region,
95
+ ami_regions: @blueprint.aws.copy_regions,
96
+
97
+ ami_description: @blueprint.description,
98
+
99
+ launch_block_device_mappings: @blueprint.aws.launch_block_device_mappings,
100
+ ami_block_device_mappings: @blueprint.aws.ami_block_device_mappings,
101
+
102
+ associate_public_ip_address: @blueprint.aws.associate_public_ip_address,
103
+ ebs_optimized: @blueprint.aws.ebs_optimized,
104
+ enhanced_networking: @blueprint.aws.enhanced_networking,
105
+ force_deregister: false, # no, this will not be allowed
106
+ iam_instance_profile: @blueprint.aws.iam_instance_profile,
107
+ run_tags: @blueprint.aws.run_tags,
108
+ run_volume_tags: @blueprint.aws.run_volume_tags,
109
+ security_group_ids: @blueprint.aws.security_group_ids,
110
+ ssh_keypair_name: @blueprint.aws.ssh_keypair_name,
111
+ ssh_private_ip: @blueprint.aws.ssh_private_ip,
112
+ subnet_id: @blueprint.aws.subnet_ids.sample, # randomly select from allowed subnets
113
+ user_data: @blueprint.aws.user_data,
114
+ vpc_id: @blueprint.aws.vpc_id,
115
+
116
+ tags: amigrind_tags
117
+ }
118
+ builder[:windows_password_timeout] = "#{@blueprint.aws.windows_password_timeout}s" \
119
+ unless @blueprint.aws.windows_password_timeout.nil?
120
+
121
+ t.builders['amigrind'] = builder.delete_if { |_, v| [ nil, [], {} ].include?(v) }.deep_stringify_keys
122
+ end
123
+
124
+ def do_provisioners(t)
125
+ @blueprint.provisioners.each do |provisioner|
126
+ t.provisioners[provisioner.weight.to_i] = {}
127
+ rh = provisioner.to_racker_hash
128
+ rh.delete_if { |k, v| v.nil? || v == [] }
129
+ t.provisioners[provisioner.weight.to_i][provisioner.racker_name] = rh
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,46 @@
1
+ module Amigrind
2
+ class Builder
3
+ include Virtus.model
4
+ include Amigrind::Core::Logging::Mixin
5
+
6
+ attribute :blueprint, Amigrind::Blueprints::Blueprint
7
+ attribute :repo, Amigrind::Repo
8
+
9
+ def initialize(aws_credentials, blueprint, repo)
10
+ @blueprint = blueprint
11
+ @repo = repo
12
+
13
+ @amigrind_client = Amigrind::Core::Client.new(@blueprint.aws.region, aws_credentials)
14
+ end
15
+
16
+ def build
17
+ lint
18
+ template = rackerize
19
+
20
+ run(template)
21
+ end
22
+
23
+ def lint
24
+ errors = []
25
+
26
+ errors << "No channel set in the blueprint; this will result in " \
27
+ "an image that can only be retrieved via :latest, which " \
28
+ "you may not want." if @blueprint.build_channel.nil?
29
+
30
+ errors.each { |e| warn_log(e) }
31
+ end
32
+
33
+ def rackerize
34
+ template = Build::Rackerizer.new(@amigrind_client, @blueprint, @repo).rackerize
35
+ end
36
+
37
+ private
38
+
39
+ def run(template)
40
+ runner = Build::PackerRunner.new(template, @amigrind_client, @blueprint, @repo)
41
+
42
+ runner.run
43
+ end
44
+
45
+ end
46
+ end
@@ -0,0 +1,12 @@
1
+ require 'amigrind'
2
+ require 'cri'
3
+
4
+ Dir["#{__dir__}/cli/**/*.rb"].each { |f| require_relative f }
5
+
6
+ module Amigrind
7
+ module CLI
8
+ def self.run(args)
9
+ ROOT.run(args)
10
+ end
11
+ end
12
+ end