linecook-gem 0.6.10 → 0.7.0

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,45 @@
1
+ require 'yaml'
2
+ require 'json'
3
+
4
+ module Linecook
5
+ def self.config
6
+ config = Config.config
7
+ config
8
+ end
9
+
10
+
11
+ module Config
12
+ extend self
13
+
14
+ CONFIG_PATH = File.join(Dir.pwd, 'linecook.yml').freeze
15
+ SECRETS_PATH = File.join(Dir.pwd, 'secrets.ejson').freeze
16
+ LINECOOK_HOME = File.expand_path('~/.linecook').freeze
17
+ DEFAULT_CONFIG_PATH = File.join(LINECOOK_HOME, 'config.yml').freeze
18
+
19
+ def config
20
+ @config ||= begin
21
+ config_path = ENV['LINECOOK_CONFIG_PATH'] || CONFIG_PATH
22
+ config = {}
23
+ config ||= YAML.load(File.read(DEFAULT_CONFIG_PATH)) if File.exist?(DEFAULT_CONFIG_PATH)
24
+ config.deep_merge!(YAML.load(File.read(config_path))) if File.exist?(config_path)
25
+ (config || {}).deep_symbolize_keys!
26
+ config.deep_merge!(secrets)
27
+ end
28
+ end
29
+
30
+ private
31
+ def secrets
32
+ @secrets ||= begin
33
+ secrets_path = ENV['LINECOOK_SECRETS_PATH'] || SECRETS_PATH
34
+ if File.exists?(secrets_path)
35
+ ejson_path = File.join(Gem::Specification.find_by_name('ejson').gem_dir, 'build', 'linux-amd64', 'ejson' )
36
+ command = "#{ejson_path} decrypt #{secrets_path}"
37
+ secrets = JSON.load(`sudo #{command}`)
38
+ secrets.deep_symbolize_keys
39
+ else
40
+ {}
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,77 @@
1
+ require 'fileutils'
2
+ require 'tmpdir'
3
+
4
+ require 'linecook-gem/image/s3'
5
+ require 'linecook-gem/image/crypt'
6
+ require 'linecook-gem/util/downloader'
7
+
8
+ module Linecook
9
+ class Image
10
+
11
+ include Downloader
12
+ include Crypto
13
+
14
+ IMAGE_PATH = File.join(Config::LINECOOK_HOME, 'images').freeze
15
+
16
+ attr_reader :path, :id, :name, :group, :tag
17
+
18
+
19
+ def initialize(name, group, tag)
20
+ @name = name
21
+ @group = group ? "#{name}-#{group.gsub(/-|\//,'_')}" : name
22
+ @tag = tag
23
+ @id = if @tag == 'latest'
24
+ id = File.basename(latest).split('.')[0]
25
+ @tag = id.split('-').last
26
+ id
27
+ elsif tag
28
+ "#{@group}-#{@tag}"
29
+ else
30
+ @group
31
+ end
32
+ @path = image_path
33
+ end
34
+
35
+ def fetch
36
+ return if File.exists?(@path)
37
+
38
+ Dir.mktmpdir("#{@id}-download") do |tmpdir|
39
+ tmppath = File.join(tmpdir, File.basename(@path))
40
+ download(url, tmppath)
41
+ FileUtils.mkdir_p(File.dirname(@path))
42
+ decrypt(tmppath, dest: @path)
43
+ FileUtils.rm_f(tmppath)
44
+ end
45
+
46
+ end
47
+
48
+ def upload
49
+ puts "Encrypting and uploading image #{@path}"
50
+ encrypted = encrypt(@path)
51
+ provider.upload(encrypted, group: @group)
52
+ FileUtils.rm_f(encrypted)
53
+ end
54
+
55
+ def url
56
+ provider.url(@id, group: @group)
57
+ end
58
+
59
+ def list
60
+ provider.list(group: @group)
61
+ end
62
+
63
+ def latest
64
+ provider.latest(@group)
65
+ end
66
+
67
+ private
68
+
69
+ def image_path
70
+ File.join([IMAGE_PATH, @group, "#{@id}.tar.xz"].compact)
71
+ end
72
+
73
+ def provider
74
+ S3Manager
75
+ end
76
+ end
77
+ end
@@ -1,60 +1,27 @@
1
- require 'tempfile'
2
-
3
1
  require 'rbnacl/libsodium'
4
2
 
5
- require 'linecook-gem/image/manager'
6
- require 'linecook-gem/util/executor'
7
- require 'linecook-gem/util/config'
8
-
9
3
  module Linecook
10
- class Crypto
11
- include Executor
12
-
13
- def initialize(remote: nil)
14
- @remote = remote
15
- load_key
16
- end
4
+ module Crypto
17
5
 
18
- def encrypt_image(image)
19
- image_path = File.join(Linecook::ImageManager::IMAGE_PATH,File.basename(image))
20
- encrypt_file(image_path)
6
+ def self.keygen
7
+ RbNaCl::Random.random_bytes(RbNaCl::SecretBox.key_bytes).unpack('H*').first
21
8
  end
22
9
 
23
- def encrypt_file(source, dest: nil)
10
+ def encrypt(source, dest: nil)
24
11
  dest ||= "/tmp/#{File.basename(source)}"
25
12
  File.write(dest, box.encrypt(IO.binread(source)))
26
13
  dest
27
14
  end
28
15
 
29
- def decrypt_file(source, dest: nil)
16
+ def decrypt(source, dest: nil)
30
17
  dest ||= "/tmp/#{File.basename(source)}-decrypted"
31
- if @remote
32
- script = "/tmp/decrypt-#{SecureRandom.hex(4)}"
33
- @remote.upload(decryptor_script(source, dest), script)
34
- @remote.run("bash #{script}")
35
- @remote.run("rm #{script}")
36
- else
37
- File.write(dest, box.decrypt(IO.binread(source)))
38
- end
18
+ File.write(dest, box.decrypt(IO.binread(source)))
39
19
  dest
40
20
  end
41
21
 
42
- def self.keygen
43
- RbNaCl::Random.random_bytes(RbNaCl::SecretBox.key_bytes).unpack('H*').first
44
- end
45
-
46
22
  private
47
-
48
- def decryptor_script(source, dest)
49
- "ruby -e \"require 'rbnacl/libsodium'; box = RbNaCl::SimpleBox.from_secret_key(['#{@secret_key}'].pack('H*')); File.write('#{dest}', box.decrypt(File.read('#{source}')))\""
50
- end
51
-
52
23
  def box
53
- @box ||= RbNaCl::SimpleBox.from_secret_key([@secret_key].pack('H*'))
54
- end
55
-
56
- def load_key
57
- @secret_key = Linecook.config[:imagekey]
24
+ @box ||= RbNaCl::SimpleBox.from_secret_key([Linecook.config[:imagekey]].pack('H*'))
58
25
  end
59
26
  end
60
27
  end
@@ -5,29 +5,29 @@ module Linecook
5
5
  module S3Manager
6
6
  extend self
7
7
  EXPIRY = 20
8
- PREFIX = 'builds'
8
+ PREFIX = 'built-images'
9
9
 
10
- def url(name, type: nil)
10
+ def url(id, group: nil)
11
11
  client
12
12
  s3 = Aws::S3::Resource.new
13
- obj = s3.bucket(Linecook.config[:aws][:s3][:bucket]).object(File.join([PREFIX, type, name].compact))
13
+ obj = s3.bucket(Linecook.config[:aws][:s3][:bucket]).object(File.join([PREFIX, group, "#{id}.tar.xz"].compact))
14
14
  obj.presigned_url(:get, expires_in: EXPIRY * 60)
15
15
  end
16
16
 
17
- def list(type: nil)
18
- list_objects(type: type).map{ |x| x.key if x.key =~ /squashfs$/ }.compact
17
+ def list(group: nil)
18
+ list_objects(group: group).map{ |x| x.key if x.key =~ /\.tar\.xz/ }.compact
19
19
  end
20
20
 
21
- def latest(type)
22
- objects = list_objects(type: type).sort! { |a,b| a.last_modified <=> b.last_modified }
21
+ def latest(group)
22
+ objects = list_objects(group: group).sort! { |a,b| a.last_modified <=> b.last_modified }
23
23
  key = objects.last ? objects.last.key : nil
24
24
  end
25
25
 
26
- def upload(path, type: nil)
26
+ def upload(path, group: nil)
27
27
  File.open(path, 'rb') do |file|
28
- fname = File.basename(path)
29
- pbar = ProgressBar.create(title: fname, total: file.size)
30
- common_opts = { bucket: Linecook.config[:aws][:s3][:bucket], key: File.join([PREFIX, type, fname].compact) }
28
+ fid = File.basename(path)
29
+ pbar = ProgressBar.create(title: fid, total: file.size)
30
+ common_opts = { bucket: Linecook.config[:aws][:s3][:bucket], key: File.join([PREFIX, group, fid].compact) }
31
31
  resp = client.create_multipart_upload(storage_class: 'REDUCED_REDUNDANCY', server_side_encryption: 'AES256', **common_opts)
32
32
  id = resp.upload_id
33
33
  part = 0
@@ -39,7 +39,7 @@ module Linecook
39
39
  parts << { etag: resp.etag, part_number: part }
40
40
  total += content.length
41
41
  pbar.progress = total
42
- pbar.title = "#{fname} - (#{((total.to_f/file.size.to_f)*100.0).round(2)}%)"
42
+ pbar.title = "#{fid} - (#{((total.to_f/file.size.to_f)*100.0).round(2)}%)"
43
43
  end
44
44
  client.complete_multipart_upload(upload_id: id, multipart_upload: { parts: parts }, **common_opts)
45
45
  end
@@ -47,8 +47,8 @@ module Linecook
47
47
 
48
48
  private
49
49
 
50
- def list_objects(type: nil)
51
- client.list_objects(bucket: Linecook.config[:aws][:s3][:bucket], prefix: File.join([PREFIX, type].compact)).contents
50
+ def list_objects(group: nil)
51
+ client.list_objects(bucket: Linecook.config[:aws][:s3][:bucket], prefix: File.join([PREFIX, group].compact)).contents
52
52
  end
53
53
 
54
54
  def client
@@ -0,0 +1,30 @@
1
+
2
+ require 'linecook-gem/image'
3
+ require 'linecook-gem/packager/packer'
4
+ require 'linecook-gem/packager/squashfs'
5
+ require 'kitchen/configurable'
6
+
7
+
8
+ module Linecook
9
+ module Packager
10
+ extend self
11
+
12
+ def package(image, name: 'packer')
13
+ image.fetch
14
+ provider(name.to_sym).package(image)
15
+ end
16
+
17
+ private
18
+ def provider(name)
19
+ config = Linecook.config[:packager][name]
20
+ case name
21
+ when :packer
22
+ Linecook::AmiPacker.new(**config)
23
+ when :squashfs
24
+ Linecook::Squashfs.new(**config)
25
+ else
26
+ fail "No packager implemented for for #{name}"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,249 @@
1
+ require 'json'
2
+ require 'mkmf'
3
+ require 'fileutils'
4
+ require 'open-uri'
5
+ require 'tempfile'
6
+ require 'tmpdir'
7
+ require 'pty'
8
+
9
+ require 'linecook-gem/image'
10
+ require 'linecook-gem/util/downloader'
11
+ require 'linecook-gem/packager/route53'
12
+
13
+ module Linecook
14
+ class AmiPacker
15
+
16
+ include Downloader
17
+
18
+ SOURCE_URL = 'https://releases.hashicorp.com/packer/'
19
+ PACKER_VERSION = '0.8.6'
20
+ PACKER_PATH = File.join(Linecook::Config::LINECOOK_HOME, 'bin', 'packer')
21
+
22
+ BUILDER_CONFIG = {
23
+ type: 'amazon-chroot',
24
+ access_key: '{{ user `aws_access_key` }}',
25
+ secret_key: '{{ user `aws_secret_key` }}',
26
+ source_ami: "{{user `source_ami`}}",
27
+ ami_name: 'packer-image.{{ user `image_name` }} {{timestamp}}',
28
+ device_path: '/dev/{{ user `ebs_device` }}'
29
+ }.freeze
30
+
31
+ PROVISIONER_COMMANDS = [
32
+ 'umount /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }}/proc/sys/fs/binfmt_misc',
33
+ 'umount /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }}/proc',
34
+ 'umount /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }}/sys',
35
+ 'umount /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }}/dev/pts',
36
+ 'umount /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }}/dev',
37
+ 'mv /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }}/etc/network /tmp/{{ user `ebs_device`}}-network',
38
+ 'umount /dev/{{ user `ebs_device` }}1',
39
+ 'mkfs.ext4 /dev/{{ user `ebs_device` }}1',
40
+ 'tune2fs -L cloudimg-rootfs /dev/{{ user `ebs_device` }}1',
41
+ 'mount /dev/{{ user `ebs_device` }}1 /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }}',
42
+ 'tar -C /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }} -xpf {{ user `source_image_path` }}',
43
+ 'cp /etc/resolv.conf /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }}/etc',
44
+ 'echo "LABEL=cloudimg-rootfs / ext4 defaults,discard 0 0" > /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }}/etc/fstab',
45
+ 'mount -t proc none /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }}/proc',
46
+ 'mount -o bind /sys /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }}/sys',
47
+ 'mount -o bind /dev /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }}/dev',
48
+ # Sadly we need to install grub inside the image, and this implementation is Ubunut specific. This can be patched eventually when needed
49
+ 'chroot /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }} apt-get update',
50
+ 'chroot /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }} apt-get -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" -y --force-yes --no-upgrade install grub-pc grub-legacy-ec2',
51
+ 'chroot /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }} update-grub',
52
+ 'grub-install --root-directory=/mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }} /dev/{{ user `ebs_device` }}',
53
+ 'rm -rf /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }}/etc/network',
54
+ 'mv /tmp/{{ user `ebs_device`}}-network /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }}/etc/network',
55
+ 'rm -f /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }}/etc/init/fake-container-events.conf', # HACK
56
+ 'umount /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }}/proc',
57
+ 'umount /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }}/sys',
58
+ 'umount /mnt/packer-amazon-chroot-volumes/{{ user `ebs_device` }}/dev'
59
+ ]
60
+
61
+ def initialize(config)
62
+ system("#{packer_path} --version")
63
+ @hvm = config[:hvm] || true
64
+ @root_size = config[:root_size] || 10
65
+ @region = config[:region] || 'us-east-1'
66
+ @copy_regions = config[:copy_regions] || []
67
+ @accounts = config[:account_ids] || []
68
+ @source_ami = config[:source_ami] || find_ami
69
+ @write_txt = Linecook.config[:packager] && Linecook.config[:packager][:ami] && Linecook.config[:packager][:ami][:update_txt]
70
+ end
71
+
72
+ def package(image)
73
+ @image = image
74
+ conf_file = Tempfile.new("#{@image.id}-packer.json")
75
+ config = generate_config
76
+ conf_file.write(config)
77
+ conf_file.close
78
+ output = []
79
+ PTY.spawn("sudo #{PACKER_PATH} build -machine-readable #{conf_file.path}") do |stdout, _, _|
80
+ begin
81
+ stdout.each do |line|
82
+ output << line if line =~ /artifact/
83
+ tokens = line.split(',')
84
+ if tokens.length > 4
85
+ out = tokens[4].gsub('%!(PACKER_COMMA)', ',')
86
+ time = DateTime.strptime(tokens[0], '%s').strftime('%c')
87
+ puts "#{time} | #{out}"
88
+ else
89
+ puts "unexpected output format"
90
+ puts tokens
91
+ end
92
+ end
93
+ rescue Errno::EIO
94
+ puts "Packer finshed executing"
95
+ end
96
+ end
97
+ extract_amis_from_output(output)
98
+ ensure
99
+ conf_file.close
100
+ conf_file.unlink
101
+ end
102
+
103
+ private
104
+
105
+ # TO DO:
106
+ # support for multiple accounts, multiple regions
107
+ # code to extract ami name(s) from output
108
+ # amis = `grep "amazon-ebs,artifact,0,id" packer.log`.chomp.split(',')[5].split('%!(PACKER_COMMA)')
109
+ # route53 TXT record integration
110
+
111
+ def generate_config
112
+ config = {
113
+ variables: {
114
+ aws_access_key: Linecook.config[:aws][:access_key],
115
+ aws_secret_key: Linecook.config[:aws][:secret_key],
116
+ ebs_device: free_device,
117
+ source_ami: @source_ami,
118
+ image_name: "linecook-#{@image.id}",
119
+ source_image_path: @image.path
120
+ },
121
+ builders: [
122
+ BUILDER_CONFIG.merge(
123
+ ami_users: @accounts,
124
+ ami_regions: @copy_regions,
125
+ ami_virtualization_type: virt_type,
126
+ root_volume_size: @root_size
127
+ )
128
+ ],
129
+ provisioners: build_provisioner(PROVISIONER_COMMANDS)
130
+ }
131
+ JSON.pretty_generate(config)
132
+ end
133
+
134
+ def build_provisioner(commands)
135
+ provisioner = []
136
+ commands.each do |cmd|
137
+ provisioner << {
138
+ type: 'shell-local',
139
+ command: cmd
140
+ }
141
+ end
142
+ provisioner
143
+ end
144
+
145
+ def packer_path
146
+ @path ||= begin
147
+ found = File.exists?(PACKER_PATH) ? PACKER_PATH : find_executable('packer')
148
+ path = if found
149
+ version = `#{found} --version`
150
+ Gem::Version.new(version) >= Gem::Version.new(PACKER_VERSION) ? found : nil
151
+ end
152
+ path ||= get_packer
153
+ end
154
+ end
155
+
156
+ def get_packer
157
+ puts "packer too old (<#{PACKER_VERSION}) or not present, getting latest packer"
158
+ arch = 1.size == 8 ? 'amd64' : '386'
159
+ path = File.join(File.dirname(PACKER_PATH), 'packer.zip')
160
+ url = File.join(SOURCE_URL, PACKER_VERSION, "packer_#{PACKER_VERSION}_linux_#{arch}.zip")
161
+ download(url, path)
162
+ unzip(path)
163
+ PACKER_PATH
164
+ end
165
+
166
+ def free_device
167
+ lock('device_scan')
168
+ free = nil
169
+ prefix = device_prefix
170
+ ('f'..'zzz').to_a.each do |suffix|
171
+ device = "#{prefix}#{suffix}"
172
+ if free_device?(device)
173
+ lock(device)
174
+ at_exit do
175
+ clear_lock(device)
176
+ end
177
+ free = device
178
+ break
179
+ end
180
+ end
181
+ unlock('device_scan')
182
+ return free
183
+ end
184
+
185
+ def free_device?(device)
186
+ !File.exists?("/dev/#{device}") && !File.exists?(lock_path(device))
187
+ end
188
+
189
+ def lock(name)
190
+ lockfile(name).flock(File::LOCK_EX)
191
+ end
192
+
193
+ def unlock(name)
194
+ lockfile(name).flock(File::LOCK_UN)
195
+ lockfile(name).close
196
+ end
197
+
198
+ def clear_lock(name)
199
+ unlock(name)
200
+ FileUtils.rm_f(lockfile(name))
201
+ end
202
+
203
+ def lockfile(suffix)
204
+ @locks ||= {}
205
+ path = lock_path(suffix)
206
+ @locks[path] = @locks[path] || File.open(path, File::RDWR|File::CREAT, 0644)
207
+ end
208
+
209
+ def lock_path(suffix)
210
+ "/tmp/free_device_lock_#{suffix.gsub('/','_')}"
211
+ end
212
+
213
+ def device_prefix
214
+ prefixes = ['xvd', 'sd']
215
+ `sudo ls -1 /sys/block`.lines.each do |dev| # FIXME
216
+ prefixes.each do |prefix|
217
+ return prefix if dev =~ /^#{prefix}/
218
+ end
219
+ end
220
+ return prefixes.first
221
+ end
222
+
223
+ def extract_amis_from_output(output)
224
+ amis = output.grep(/amazon-chroot,artifact,0,id/).first.chomp.split(',')[5].split('%!(PACKER_COMMA)')
225
+ amis.each do |info_str|
226
+ ami_info = info_str.split(':')
227
+ ami_region = ami_info[0]
228
+ ami_id = ami_info[1]
229
+ puts "Built #{ami_id} for #{ami_region}"
230
+ Linecook::Route53.upsert_record(@image.name, ami_id, ami_region) if @write_txt
231
+ end
232
+ end
233
+
234
+ def virt_type
235
+ @hvm ? 'hvm' : 'paravirtual'
236
+ end
237
+
238
+ def find_ami
239
+ url = "http://uec-images.ubuntu.com/query/trusty/server/released.current.txt"
240
+ data = open(url).read.split("\n").map{|l| l.split}.detect do |ary|
241
+ ary[4] == 'ebs' &&
242
+ ary[5] == 'amd64' &&
243
+ ary[6] == @region &&
244
+ ary.last == virt_type
245
+ end
246
+ data[7]
247
+ end
248
+ end
249
+ end