linecook-gem 0.0.3 → 0.0.4

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,270 @@
1
+ require 'net/http'
2
+ require 'open-uri'
3
+ require 'timeout'
4
+ require 'tmpdir'
5
+
6
+ require 'linecook/util/ssh'
7
+ require 'linecook/util/executor'
8
+
9
+ require 'aws-sdk'
10
+
11
+ module Linecook
12
+ # Installs a linecook image on a target device
13
+ module Packager
14
+ class EBS
15
+ include Executor
16
+
17
+ def initialize(hvm: true, size: 10, region: 'us-east-1')
18
+ @hvm = hvm
19
+ @size = size
20
+ @region = region
21
+ end
22
+
23
+ def package(image)
24
+ @image = image
25
+ setup_remote unless instance_id
26
+ prepare
27
+ execute("tar -C #{@mountpoint} -cpf - . | sudo tar -C #{@root} -xpf -")
28
+ finalize
29
+ snapshot
30
+ end
31
+
32
+ private
33
+
34
+ def prepare
35
+ @mountpoint = Dir.mktmpdir
36
+ execute("mkdir -p #{@mountpoint}")
37
+ execute("mount #{@image} #{@mountpoint}")
38
+ create_volume
39
+ format_and_mount
40
+ end
41
+
42
+ def finalize
43
+ execute("echo \"UUID=\\\"$(blkid -o value -s UUID #{@rootdev})\\\" / ext4 defaults 1 2\" > /tmp/fstab")
44
+ execute("mv /tmp/fstab #{@root}/etc/fstab")
45
+ chroot_exec('apt-get update')
46
+ chroot_exec('apt-get install -y --force-yes grub-pc grub-legacy-ec2')
47
+ chroot_exec('update-grub')
48
+ execute("grub-install --root-directory=#{@root} $(echo #{@rootdev} | sed \"s/[0-9]*//g\")") if @hvm
49
+ end
50
+
51
+
52
+ def chroot_exec(command)
53
+ execute("mount -o bind /dev #{@root}/dev")
54
+ execute("mount -o bind /sys #{@root}/sys")
55
+ execute("mount -t proc none #{@root}/proc")
56
+ execute("cp /etc/resolv.conf #{@root}/etc")
57
+ execute("chroot #{@root} #{command}")
58
+ execute("umount #{@root}/dev")
59
+ execute("umount #{@root}/sys")
60
+ execute("umount #{@root}/proc")
61
+ end
62
+
63
+ def partition
64
+ execute("parted -s #{@rootdev} mklabel msdos")
65
+ execute("parted -s #{@rootdev} mkpart primary ext2 0% 100%")
66
+ @rootdev = "#{@rootdev}1"
67
+ end
68
+
69
+ def format_and_mount
70
+ partition if @hvm
71
+ execute("mkfs.ext4 #{@rootdev}")
72
+ @root = Dir.mktmpdir
73
+ execute("mkdir -p #{@root}")
74
+ execute("mount #{@rootdev} #{@root}")
75
+ end
76
+
77
+ def instance_id
78
+ @instance_id ||= metadata('instance-id')
79
+ end
80
+
81
+ def availability_zone
82
+ @availability_zone ||= metadata('placement/availability-zone')
83
+ end
84
+
85
+ def client
86
+ @client ||= begin
87
+ credentials = Aws::Credentials.new(Linecook::Config.secrets['aws_access_key'], Linecook::Config.secrets['aws_secret_key'])
88
+ Aws::EC2::Client.new(region: @region, credentials: credentials)
89
+ end
90
+ end
91
+
92
+ def create_volume
93
+ resp = client.create_volume({
94
+ size: @size,
95
+ availability_zone: availability_zone, # required
96
+ volume_type: "standard", # accepts standard, io1, gp2
97
+ })
98
+
99
+ @volume_id = resp.volume_id
100
+ rootdev = free_device
101
+
102
+ puts "Waiting for volume to become available"
103
+ wait_for_state('available', 120) do
104
+ client.describe_volumes(volume_ids: [@volume_id]).volumes.first.state
105
+ end
106
+
107
+ resp = client.attach_volume({
108
+ volume_id: @volume_id,
109
+ instance_id: instance_id,
110
+ device: rootdev,
111
+ })
112
+
113
+ puts "Waiting for volume to attach"
114
+ wait_for_state('attached', 120) do
115
+ client.describe_volumes(volume_ids: [@volume_id]).volumes.first.attachments.first.state
116
+ end
117
+ @rootdev = "/dev/#{rootdev}"
118
+ end
119
+
120
+ def snapshot
121
+ execute("umount #{@root}")
122
+ puts 'Creating snapshot'
123
+ resp = client.detach_volume(volume_id: @volume_id)
124
+ wait_for_state('available', 120) do
125
+ client.describe_volumes(volume_ids: [@volume_id]).volumes.first.state
126
+ end
127
+ resp = client.create_snapshot(volume_id: @volume_id, description: "Snapshot of #{File.basename(@image)}")
128
+ tag(resp.snapshot_id, Name: 'Linecook snapshot', image: File.basename(@image), hvm: @hvm.to_s)
129
+ client.delete_volume(volume_id: @volume_id)
130
+ end
131
+
132
+ def free_device
133
+ prefix = device_prefix
134
+ ('f'..'zzz').to_a.each do |suffix|
135
+ device = "#{prefix}#{suffix}"
136
+ if free_device?(device)
137
+ lock_device(device)
138
+ return device
139
+ end
140
+ end
141
+ return nil
142
+ end
143
+
144
+ def free_device?(device)
145
+ test("[ ! -e /dev/#{device} ]") && test("[ ! -e /run/lock/linecook-#{device} ]")
146
+ end
147
+
148
+ def lock_device(device)
149
+ execute("echo #{Process.pid} > /run/lock/linecook-#{device}")
150
+ end
151
+
152
+ def unlock_device(device)
153
+ execute("rm /run/lock/linecook-#{device}")
154
+ end
155
+
156
+ def device_prefix
157
+ prefixes = ['xvd', 'sd']
158
+ capture('ls -1 /sys/block').lines.each do |dev|
159
+ prefixes.each do |prefix|
160
+ return prefix if dev =~ /^#{prefix}/
161
+ end
162
+ end
163
+ return nil
164
+ end
165
+
166
+ def setup_remote
167
+ start_node
168
+ path = "/tmp/#{File.basename(@image)}"
169
+ @remote.run("wget '#{Linecook::ImageManager.url(File.basename(@image))}' -nv -O #{path}")
170
+ @image = Linecook::Crypto.new(remote: @remote).decrypt_file(path)
171
+ end
172
+
173
+ def start_node
174
+ resp = client.run_instances(
175
+ image_id: find_ami,
176
+ min_count: 1,
177
+ max_count: 1,
178
+ instance_type: 'c4.large',
179
+ instance_initiated_shutdown_behavior: 'terminate',
180
+ security_groups: [security_group],
181
+ key_name: key_pair
182
+ )
183
+ @instance_id = resp.instances.first.instance_id
184
+ @availability_zone = resp.instances.first.placement.availability_zone
185
+
186
+ puts 'Waiting for temporary instance to come online'
187
+ wait_for_state('running', 300) do
188
+ client.describe_instances(instance_ids: [@instance_id]).reservations.first.instances.first.state.name
189
+ end
190
+ tag(@instance_id, Name: 'linecook-temporary-installer-node')
191
+ @remote = Linecook::SSH.new(instance_ip, username: 'ubuntu', keyfile: Linecook::SSH.private_key)
192
+ @remote.upload("exec shutdown -h 60 'Delayed shutdown started'", '/tmp/delay-shutdown')
193
+ execute('mv /tmp/delay-shutdown /etc/init/delay-shutdown.conf') # ubuntism is ok, since the temporary host can always be ubuntu
194
+ execute('start delay-shutdown')
195
+ end
196
+
197
+ def find_ami
198
+ url = "http://uec-images.ubuntu.com/query/trusty/server/released.current.txt"
199
+ type = @hvm ? 'hvm' : 'paravirtual'
200
+ data = open(url).read.split("\n").map{|l| l.split}.detect do |ary|
201
+ ary[4] == 'ebs' &&
202
+ ary[5] == 'amd64' &&
203
+ ary[6] == @region &&
204
+ ary.last == type
205
+ end
206
+ data[7]
207
+ end
208
+
209
+ def instance_ip
210
+ client.describe_instances(instance_ids: [@instance_id]).reservations.first.instances.first.public_ip_address
211
+ end
212
+
213
+ def security_group
214
+ group_name = 'linecook-global-ssh'
215
+ resp = client.describe_security_groups(filters: [{name: 'group-name', values: [group_name]}])
216
+ if resp.security_groups.length < 1
217
+ resp = client.create_security_group({
218
+ group_name: group_name,
219
+ description: "Allow global ssh for linecook temporary builder instances",
220
+ })
221
+
222
+ resp = client.authorize_security_group_ingress({
223
+ group_name: group_name,
224
+ ip_protocol: "tcp",
225
+ from_port: 22,
226
+ to_port: 22,
227
+ cidr_ip: "0.0.0.0/0",
228
+ })
229
+ end
230
+ group_name
231
+ end
232
+
233
+ def tag(id, **kwargs)
234
+ resp = client.create_tags(resources: [id], tags: kwargs.map{ |k,v| {key: k, value: v } })
235
+ end
236
+
237
+ def key_pair
238
+ pubkey = Linecook::SSH.public_key
239
+ resp = client.describe_key_pairs({
240
+ filters: [ { name: 'fingerprint', values: [Linecook::SSH.sshv2_fingerprint(pubkey)] } ]
241
+ })
242
+
243
+ if resp.key_pairs.length >= 1
244
+ return resp.key_pairs.first.key_name
245
+ else
246
+ keyname = "linecook-#{SecureRandom.uuid}"
247
+ resp = client.import_key_pair({
248
+ key_name: keyname,
249
+ public_key_material: pubkey,
250
+ })
251
+ return keyname
252
+ end
253
+ end
254
+
255
+ def metadata(key)
256
+ (Timeout::timeout(1) { Net::HTTP.get(URI(File.join("http://169.254.169.254/latest/meta-data", key))) } rescue nil)
257
+ end
258
+
259
+ def wait_for_state(desired, timeout)
260
+ attempts = 0
261
+ state = nil
262
+ while attempts < timeout && state != desired
263
+ state = yield
264
+ attempts += 1
265
+ sleep(1)
266
+ end
267
+ end
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,23 @@
1
+ require 'linecook/packager/ebs'
2
+
3
+ module Linecook
4
+ module Packager
5
+ extend self
6
+
7
+ def package(image)
8
+ provider.package(image)
9
+ end
10
+
11
+ private
12
+ def provider
13
+ name = Linecook::Config.load_config[:packager][:provider]
14
+ config = Linecook::Config.load_config[:packager][name]
15
+ case name
16
+ when :ebs
17
+ Linecook::Packager::EBS.new(**config)
18
+ else
19
+ fail "No packager implemented for for #{name}"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -8,13 +8,14 @@ module Linecook
8
8
  module Chef
9
9
  extend self
10
10
 
11
- def provision(build)
11
+ def provision(build, role)
12
12
  chef_config = setup
13
+ role_config = Linecook::Config.load_config[:roles][role.to_sym]
13
14
  script = ChefProvisioner::Bootstrap.generate(
14
15
  node_name: chef_config[:node_name],
15
16
  chef_version: chef_config[:version] || nil,
16
17
  first_boot: {
17
- run_list: chef_config[:run_list]
18
+ run_list: role_config[:run_list]
18
19
  }
19
20
  )
20
21
 
@@ -0,0 +1,34 @@
1
+ require 'securerandom'
2
+
3
+ require 'linecook/builder/build'
4
+ require 'linecook/provisioner/chef-zero'
5
+ require 'linecook/provisioner/packer'
6
+
7
+ module Linecook
8
+ module Baker
9
+ extend self
10
+
11
+ def bake(name: nil, image: nil, snapshot: nil, upload: nil, package: nil, build: nil)
12
+ build_agent = Linecook::Build.new(name, image: image)
13
+ provider(name).provision(build_agent, name) if build
14
+ snapshot = build_agent.snapshot(save: true) if snapshot || upload || package
15
+ Linecook::ImageManager.upload(snapshot) if upload || package
16
+ Linecook::Packager.package(snapshot) if package
17
+ build_agent.stop
18
+ end
19
+
20
+ private
21
+
22
+ def provider(name)
23
+ provisioner = Linecook::Config.load_config[:roles][name.to_sym][:provisioner] || Linecook::Config.load_config[:provisioner][:default_provider]
24
+ case provisioner
25
+ when :chefzero
26
+ Linecook::Chef
27
+ when :packer
28
+ Linecook::Packer
29
+ else
30
+ fail "Unsupported provisioner #{provisioner}"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,82 @@
1
+ require 'json'
2
+ require 'tempfile'
3
+ require 'mkmf'
4
+ require 'fileutils'
5
+
6
+ require 'linecook/util/executor'
7
+
8
+
9
+ module Linecook
10
+ module Packer
11
+ def self.provision(build, role)
12
+ Runner.new(build, role).run
13
+ end
14
+
15
+ class Runner
16
+ SOURCE_URL = 'https://releases.hashicorp.com/packer/'
17
+ PACKER_VERSION = '0.8.6'
18
+ PACKER_PATH = File.join(Linecook::Config::LINECOOK_HOME, 'bin', 'packer')
19
+
20
+ include Executor
21
+
22
+ def initialize(build, role)
23
+ role_config = Linecook::Config.load_config[:roles][role.to_sym]
24
+ @packer = packer_path
25
+ @build = build
26
+ @template = role_config[:template_path]
27
+ end
28
+
29
+ def run
30
+ @build.start
31
+ packer_template = inject_builder
32
+ Tempfile.open('packer-linecook') do |template|
33
+ template.write(JSON.dump(packer_template))
34
+ template.flush
35
+ execute("#{@packer} build #{template.path}", sudo: false)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def packer_path
42
+ found = File.exists?(PACKER_PATH) ? PACKER_PATH : find_executable('packer')
43
+ path = if found
44
+ version = execute("#{found} --version", sudo: false, capture: true)
45
+ Gem::Version.new(version) >= Gem::Version.new(PACKER_VERSION) ? found : nil
46
+ end
47
+
48
+ path ||= get_packer
49
+ end
50
+
51
+ def get_packer
52
+ puts "packer too old (<#{PACKER_VERSION}) or not present, getting latest packer"
53
+ arch = 1.size == 8 ? 'amd64' : '386'
54
+ path = File.join(File.dirname(PACKER_PATH), 'packer.zip')
55
+ url = File.join(SOURCE_URL, PACKER_VERSION, "packer_#{PACKER_VERSION}_#{Linecook::Config.platform}_#{arch}.zip")
56
+ Linecook::Downloader.download(url, path)
57
+ Linecook::Downloader.unzip(path)
58
+ PACKER_PATH
59
+ end
60
+
61
+ def inject_builder
62
+ packer_template = JSON.load(File.read(@template)).symbolize_keys
63
+ packer_template.merge(builders: null_builder)
64
+ end
65
+
66
+ def null_builder
67
+ [ communicator.merge(type: 'null') ]
68
+ end
69
+
70
+ def communicator
71
+ {
72
+ ssh_host: @build.ssh.hostname,
73
+ ssh_username: @build.ssh.username,
74
+ ssh_private_key_file: @build.ssh.keyfile,
75
+ ssh_bastion_host: Linecook::Builder.ssh.hostname,
76
+ ssh_bastion_username: Linecook::Builder.ssh.username,
77
+ ssh_bastion_private_key_file: Linecook::Builder.ssh.keyfile,
78
+ }
79
+ end
80
+ end
81
+ end
82
+ end
@@ -1,4 +1,5 @@
1
1
  require 'yaml'
2
+ require 'json'
2
3
  require 'fileutils'
3
4
 
4
5
  require 'xhyve'
@@ -19,13 +20,52 @@ module Linecook
19
20
  username: 'ubuntu',
20
21
  password: 'ubuntu'
21
22
  },
22
- images: {
23
- live_iso: 'livesys.iso',
24
- live_image: 'livesys.squashfs',
25
- base_image: 'ubuntu-base.squashfs'
23
+ provisioner: {
24
+ default_provider: :chefzero,
25
+ default_image: :base_image,
26
+ },
27
+ image: {
28
+ provider: {
29
+ public: :github,
30
+ private: :s3,
31
+ },
32
+ images: {
33
+ live_iso: {
34
+ name: 'livesys.iso',
35
+ profile: :public,
36
+ },
37
+ live_image: {
38
+ name: 'livesys.squashfs',
39
+ profile: :public,
40
+ },
41
+ base_image: {
42
+ name: 'ubuntu-base.squashfs',
43
+ profile: :public,
44
+ }
45
+ }
46
+ },
47
+ packager: {
48
+ provider: :ebs,
49
+ ebs: {
50
+ hvm: true,
51
+ size: 10,
52
+ region: 'us-east-1'
53
+ }
54
+ },
55
+ roles: {
26
56
  }
27
57
  }
28
58
 
59
+ def secrets
60
+ @secrets ||= begin
61
+ if File.exists?('secrets.ejson')
62
+ JSON.load(`ejson decrypt secrets.ejson`)
63
+ else
64
+ {}
65
+ end
66
+ end
67
+ end
68
+
29
69
  def setup
30
70
  FileUtils.mkdir_p(LINECOOK_HOME)
31
71
  File.write(DEFAULT_CONFIG_PATH, YAML.dump(DEFAULT_CONFIG)) unless File.exist?(DEFAULT_CONFIG_PATH)
@@ -0,0 +1,35 @@
1
+ require 'open-uri'
2
+ require 'fileutils'
3
+
4
+ require 'zip'
5
+ require 'ruby-progressbar'
6
+
7
+ module Linecook
8
+ module Downloader
9
+ def self.download(url, path)
10
+ FileUtils.mkdir_p(File.dirname(path))
11
+ File.open(path, 'w') do |f|
12
+ pbar = ProgressBar.create(title: File.basename(path), total: nil)
13
+ IO.copy_stream(open(url,
14
+ content_length_proc: lambda do|t|
15
+ pbar.total = t if t && 0 < t
16
+ end,
17
+ progress_proc: lambda do|s|
18
+ pbar.progress = s
19
+ end), f)
20
+ end
21
+ end
22
+
23
+ def self.unzip(source, dest: nil)
24
+ puts "Extracting #{source}..."
25
+ dest ||= File.dirname(source)
26
+ Zip::File.open(source) do |zip_file|
27
+ zip_file.each do |f|
28
+ file_path = File.join(dest, f.name)
29
+ FileUtils.mkdir_p(File.dirname(file_path))
30
+ zip_file.extract(f, file_path)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,33 @@
1
+ module Linecook
2
+ module Executor
3
+ def capture(command, sudo: true)
4
+ execute(command, sudo: sudo, capture: true)
5
+ end
6
+
7
+ def test(check)
8
+ if @remote
9
+ return @remote.test(check)
10
+ else
11
+ `#{check}`
12
+ return $?.exitstatus == 0
13
+ end
14
+ end
15
+
16
+ def execute(command, sudo: true, capture: false)
17
+ command = "sudo #{command}" if sudo
18
+ if @remote
19
+ if capture
20
+ return @remote.capture(command)
21
+ else
22
+ @remote.run(command)
23
+ end
24
+ else
25
+ if capture
26
+ return `#{command}`
27
+ else
28
+ system(command)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,9 +1,11 @@
1
+ require 'openssl'
2
+
1
3
  require 'sshkit'
2
4
  require 'sshkit/dsl'
3
5
  require 'net/ssh'
4
6
  require 'net/ssh/proxy/command'
5
7
 
6
- require 'linecook/config'
8
+ require 'linecook/util/config'
7
9
 
8
10
  module Linecook
9
11
  class SSHKit::Formatter::Linecook < SSHKit::Formatter::Pretty
@@ -48,14 +50,40 @@ module Linecook
48
50
  end
49
51
 
50
52
  class SSH
53
+ MAX_RETRIES = 5
54
+ attr_reader :username, :password, :hostname, :keyfile
55
+
56
+ def self.private_key
57
+ userkey = File.expand_path("~/.ssh/id_rsa")
58
+ dedicated_key = File.join(Linecook::Config::LINECOOK_HOME, 'linecook_ssh.pem')
59
+ unless File.exists?(dedicated_key)
60
+ File.write(dedicated_key, SSHKey.generate.private_key)
61
+ FileUtils.chmod(0600, dedicated_key)
62
+ end
63
+ File.exists?(userkey) ? userkey : dedicated_key
64
+ end
65
+
66
+ def self.public_key(keyfile: nil)
67
+ SSHKey.new(File.read(keyfile || private_key)).ssh_public_key
68
+ end
69
+
70
+ # Generate a fingerprint for an SSHv2 key used by amazon, yes, this is ugly
71
+ def self.sshv2_fingerprint(key)
72
+ _, blob = key.split(/ /)
73
+ blob = blob.unpack("m*").first
74
+ reader = Net::SSH::Buffer.new(blob)
75
+ k=reader.read_key
76
+ OpenSSL::Digest.new('md5',k.to_der).hexdigest.scan(/../).join(":")
77
+ end
51
78
 
52
- attr_reader :username, :hostname
53
79
  def initialize(hostname, username: 'ubuntu', password: nil, keyfile: nil, proxy: nil)
54
80
  @username = username
55
81
  @password = password
56
82
  @hostname = hostname
57
83
  @keyfile = keyfile
58
84
  @proxy = proxy_command(proxy) if proxy
85
+ wait_for_connection
86
+ setup_ssh_key if @keyfile
59
87
  end
60
88
 
61
89
  def forward(local, remote:nil)
@@ -107,8 +135,37 @@ module Linecook
107
135
  end
108
136
  end
109
137
 
138
+ def download(path, local: nil)
139
+ on linecook_host do |_host|
140
+ download! path, local || File.basename(path)
141
+ end
142
+ end
143
+
110
144
  private
111
145
 
146
+ def wait_for_connection
147
+ puts "Waiting for SSH connection"
148
+ attempts = 0
149
+ while attempts < MAX_RETRIES
150
+ begin
151
+ run("echo connected")
152
+ return
153
+ rescue SSHKit::Runner::ExecuteError
154
+ puts "Retrying SSH connection"
155
+ sleep(5)
156
+ attempts += 1
157
+ end
158
+ end
159
+ end
160
+
161
+ def setup_ssh_key
162
+ pubkey = Linecook::SSH.public_key(keyfile: @keyfile)
163
+ config = Linecook::Config.load_config[:builder]
164
+ run("mkdir -p /home/#{config[:username]}/.ssh")
165
+ upload(pubkey, "/home/#{config[:username]}/.ssh/authorized_keys")
166
+ end
167
+
168
+
112
169
  def linecook_host
113
170
  @host ||= begin
114
171
  host = SSHKit::Host.new(user: @username, hostname: @hostname)
@@ -1,3 +1,3 @@
1
1
  module Linecook
2
- VERSION = '0.0.3'
2
+ VERSION = '0.0.4'
3
3
  end
data/lib/linecook.rb CHANGED
@@ -2,7 +2,8 @@ $LOAD_PATH.unshift(File.expand_path('../lib', __FILE__))
2
2
 
3
3
  require 'active_support/all'
4
4
  require 'linecook/version'
5
- require 'linecook/config'
6
- require 'linecook/build'
7
- require 'linecook/bake'
8
- require 'linecook/image'
5
+ require 'linecook/util/config'
6
+ require 'linecook/image/manager'
7
+ require 'linecook/builder/manager'
8
+ require 'linecook/provisioner/manager'
9
+ require 'linecook/packager/manager'