amigrind 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
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