OSDN Git Service

Add ntpdate and cout-init setting.
[osdn-codes/image-creator.git] / create-image
1 #!/usr/bin/ruby
2 require 'pp'
3 require 'shellwords'
4 require 'tmpdir'
5 require 'yaml'
6 require 'json'
7
8 class SyncDirDef
9   DEFAULT_EXCLUDE = %w[/proc/* /sys/* /dev/mqueue /dev/hugepages /run/* /var/lib/os-prober/mount /swap /dev/shm/* /var/lib/lxcfs/*]
10   attr_accessor :path, :size, :exclude, :size, :srcpath, :fs_features, :device, :fs_uuid
11   def initialize(path: '/', size: 8, exclude: DEFAULT_EXCLUDE, srcpath: nil, fs_features: nil)
12     @path = path
13     @size = size.to_f
14     @exclude = (DEFAULT_EXCLUDE + [*exclude]).uniq
15     @srcpath = srcpath || path
16     @fs_features = fs_features
17     @device = nil
18     @fs_uuid = nil
19   end
20 end
21
22 class ImageCreator
23   attr_accessor :name, :dirs, :src_host, :img_path_base, :run_cmds
24   MiB = 1024 ** 2
25   GiB = 1024 ** 3
26
27   def initialize(name, dirs, src_host: nil, run_cmds: nil)
28     @name = name
29     @dirs = dirs
30     @src_host = src_host || name
31     @run_cmds = run_cmds
32     @img_path_base = "#{name}_#{Time.now.strftime '%FT%T%z'}"
33   end
34
35   def imgpath(idx)
36     "#{img_path_base}_#{idx}.img"
37   end
38
39   def create_disk
40     dirs.each_with_index do |di, idx|
41       _create_disk imgpath(idx), di, idx != 0
42     end
43   end
44
45   def _create_disk path, di, use_gpt = false
46     size_gb = di.size
47     raise "Disk image #{path} is already exists!" if File.exists? path
48     puts "Creating disk image #{path} (#{'%5.2f' % size_gb.to_f} GiB)..."
49     File.open(path, "w") do |f|
50       f.truncate(size_gb * GiB)
51     end
52     system("parted", "-s", path, "mklabel", use_gpt ? 'gpt' : 'msdos') or raise "Failed to create partition label"
53     system("parted", "-s", path, "mkpart", "primary", "1MiB", "#{size_gb * 1024 - 1}MiB") or raise "Failed to create partition"
54     if !use_gpt
55       system("parted", "-s", path, "set", "1", "boot", "on") or raise "Failed to set bios boot partition"
56     end
57     puts "Image partition has been created."
58   end
59   
60   def with_loopdev &block
61     begin
62       devices = []
63       dirs.each_with_index do |di, idx|
64         system("kpartx", "-as", imgpath(idx)) or raise "Failed to map loop device"
65         di.device = "/dev/mapper/" + `kpartx -l #{Shellwords.escape imgpath(idx)}`.split("\n").first[/loop\d+p\d+/]
66         devices << di.device
67       end
68       yield devices
69     ensure
70       dirs.each_with_index do |di, idx|
71         system "kpartx", "-d", imgpath(idx), err: "/dev/null"
72         di.device = nil
73       end
74     end
75   end
76
77   def create_fs
78     with_loopdev do |devices|
79       dirs.each_with_index do |di, index|
80         dev = di.device
81         puts "Creating filesystem on #{dev}..."
82         cmd = %w(mkfs.ext4 -q)
83         di = dirs[index]
84         if di.fs_features
85           cmd << '-O' << di.fs_features
86         end
87         cmd << dev
88         system(*cmd) or raise "Failed to create file system on #{dev}"
89         system "e2label", dev, di.path == '/' ? 'ROOT' : di.path[1..-1].tr('/', '-')
90         di.fs_uuid = `blkid -o value -s UUID #{di.device}`.chomp("\n")
91       end
92     end
93   end
94
95   def sync_dirs
96     with_loopdev do |devices|
97       devices.each_with_index do |dev, idx|
98         di = dirs[idx]
99         mount_point = "/mnt/ci-#{$$}-#{name}-#{idx}"
100         system("mkdir", "-p", mount_point)
101         begin
102           system("mount", dev, mount_point) or raise "Failed to mount file system #{dev} on #{mount_point}"
103           puts "Copying #{src_host}:#{di.srcpath} to #{dev}..."
104           unless system("rsync", "-azHSAX", "--numeric-ids", "--info=progress2", "#{src_host}:#{di.srcpath}/", "#{mount_point}/", *((["--exclude"] * di.exclude.size).zip(di.exclude).flatten))
105             warn "rsync exit with error, file transfer may not be completed."
106           end
107         ensure
108           system("umount", mount_point)
109           File.directory?(mount_point) and
110             Dir.rmdir mount_point
111         end
112       end
113     end
114   end
115
116   def prepare_awsenv
117     puts "Prepareing AWS cloud boot environment..."
118     with_rootfs do |dir, devices|
119       system({'DEBIAN_FRONTEND' => 'noninteractive'}, "chroot", dir, *%w(sudo dpkg --remove td-agent smartmontools snmpd lm-sensors arrayprobe megacli megacli-check-change megaclisas megaclisas-status fancontrol))
120
121       pkgs = %w(apt-transport-https locales-all ntp ntpdate rsync python-apt git subversion less lv cloud-init tmux screen irqbalance)
122       if File.read("#{dir}/etc/debian_version").to_f >= 9.0
123         pkgs << 'cloud-guest-utils'
124       end
125
126       system("chroot", dir, "apt-get", "-qy", "update")
127       system({'DEBIAN_FRONTEND' => 'noninteractive'}, "chroot", dir, "apt-get", "-y", "--allow-unauthenticated", "install", *pkgs)
128
129       if File.exists? "#{dir}//var/spool/nullmailer/queue"
130         puts "Remove nullmailer queue"
131         system("chroot", dir, "find", "/var/spool/nullmailer/queue", "-type", "f", "-name", '[0-9]*.[0-9]*', "-delete")
132       end
133
134       unless system("chroot", dir, *%w(grep -rq cdn-aws.deb.debian.org /etc/apt/sources.list /etc/apt/sources.list.d))
135         puts "Rewrite apt sources list for AWS CDN"
136         rel = `chroot #{dir} lsb_release -c -s`.chomp
137         s = [
138           "deb http://cdn-aws.deb.debian.org/debian #{rel} main main contrib non-free",
139           "deb http://cdn-aws.deb.debian.org/debian #{rel}-updates main main contrib non-free",
140         ]
141         if File.exists? "#{dir}/etc/apt/sources.list"
142           s << File.read("#{dir}/etc/apt/sources.list")
143         end
144         File.write "#{dir}/etc/apt/sources.list", s.join("\n")
145       end
146
147       unless File.read("#{dir}/etc/ntp.conf").include? "169.254.169.123"
148         puts "Rewrite ntp.conf"
149         c = File.read("#{dir}/etc/ntp.conf")
150         c.gsub!(/^(pool|server) /) { "##{$1}" }
151         c << "\nserver 169.254.169.123 prefer iburst\n"
152         File.write "#{dir}/etc/ntp.conf", c
153       end
154
155       File.write "#{dir}/etc/cloud/cloud.cfg.d/50_keep_apt_source.cfg" "apt_preserve_sources_list: true\n"
156     end
157   end
158
159   def fix_boot
160     puts "Fixing boot environments..."
161     with_rootfs do |dir, devices|
162       puts "Override grub with host version..."
163       root_dev = "/dev/#{devices.first[/loop\d+/]}"
164       rootfs_uuid = dirs.find { |d| d.path == '/'}.fs_uuid
165       puts "New rootfs UUID=#{rootfs_uuid}"
166
167       system "rm", "-f", "#{dir}/etc/systemd/system/udev.service", "#{dir}/etc/systemd/system/systemd-udevd.service", "#{dir}/etc/udev/rules.d/70-persistent-net.rules"
168
169       puts "Rewrite fstab..."
170       File.open "#{dir}/etc/fstab", "w" do |f|
171         dirs.each_with_index do |di, idx|
172           f << %W(UUID=#{di.fs_uuid} #{di.path} ext4 defaults,noatime 0 #{di.path == '/' ? 1 : 2}).join("\t")
173           f << "\n"
174         end
175       end
176
177       system("chroot", dir, "apt-get", "-qy", "update")
178       if File.read("#{dir}/etc/debian_version").to_f >= 9.0
179         # Note: 2019-10-08 時点で Debian9 のカーネルバージョンに対応していないので、エラー回避のために既存のカーネルを全て削除し、強制的に jessie のカーネルをイントールする
180         system("rm", "-f", "#{dir}/var/lib/dpkg/info/linux-image-#{`uname -r`.chomp}.prerm")
181         system({'DEBIAN_FRONTEND' => 'noninteractive'}, "chroot", dir, "apt", "remove", "--purge", "-y", "linux-image-*")
182         system("wget", "-O", "#{dir}/tmp/linux.deb", "http://security-cdn.debian.org/debian-security/pool/updates/main/l/linux/linux-image-3.16.0-10-amd64_3.16.81-1_amd64.deb") or raise "Failed to get jessie kernel"
183         system("chroot", dir, "dpkg", "-i", "/tmp/linux.deb")
184         system({'DEBIAN_FRONTEND' => 'noninteractive'}, "chroot", dir, "apt", "install", "-f", "-y") or raise "Failed to install jessie kernel"
185         system({'DEBIAN_FRONTEND' => 'noninteractive'}, "chroot", dir, "apt", "-y", "install", "isc-dhcp-client")
186       else
187         system({'DEBIAN_FRONTEND' => 'noninteractive'}, "chroot", dir, "apt-get", "-y", "--allow-unauthenticated", "install", "linux-image-amd64", "isc-dhcp-client")
188       end
189
190       puts "Update grub..."
191       if File.exists? "#{dir}/var/lib/dpkg/info/grub-efi-amd64.list"
192         system({'DEBIAN_FRONTEND' => 'noninteractive'}, "chroot", dir, "apt", "remove", "--purge", "-y", "grub-efi-amd64") or raise "Failed to purge grub-efi"
193       end
194       if !File.exists?("#{dir}/var/lib/dpkg/info/grub-pc.list") || !(File.exists?("#{dir}/usr/sbin/grub-bios-setup") || File.exists?("#{dir}/usr/sbin/grub-setup"))
195         system("chroot", dir, "apt-get", "-qy", "update") or raise "Failed to install grub-pc"
196         system({'DEBIAN_FRONTEND' => 'noninteractive'}, "chroot", dir, "apt-get", "-y", "install", "grub-pc")
197       end
198       File.open "#{dir}/boot/grub/device.map", "w" do |f|
199         f.puts "(hd0)\t#{root_dev}"
200       end
201       system("chroot", dir, "grub-mkconfig", "-o", "/boot/grub/grub.cfg") or raise "grub-mkconfig fails."
202       system(*%W(grub-install --no-floppy --grub-mkdevicemap=#{dir}/boot/grub/device.map --root-directory=#{dir} #{root_dev})) or raise "grub-install failed."
203
204       unless Array(run_cmds).empty?
205         Array(run_cmds).each do |cmd|
206           system({'DEBIAN_FRONTEND' => 'noninteractive'}, "chroot", dir, *Array(cmd)) or raise "Failed to execute command (#{cmd}): #{$!}"
207         end
208       end
209
210       cfg = File.read "#{dir}/boot/grub/grub.cfg"
211       cfg.gsub! %r{mapper/loop\d+p}, "sda"
212       File.write "#{dir}/boot/grub/grub.cfg", cfg
213     end
214   end
215
216   def with_rootfs(&block)
217     Dir.mktmpdir("ci-#{$$}-#{name}") do |dir|
218       with_loopdev do |devices|
219         begin
220           system("mount", devices.first, dir) or raise "Failed to mount #{devices.first} to #{dir}"
221           system("mount", "--bind", "/dev", "#{dir}/dev") or raise "Failed to mount /dev to #{dir}/dev"
222           system("mount", "--bind", "/proc", "#{dir}/proc") or raise "Failed to mount /proc to #{dir}/proc"
223
224           dirs[1..-1].each_with_index do |di, idx|
225             system "mkdir", "-p", "#{dir}#{di.path}"
226             system("mount", di.device, "#{dir}#{di.path}") or raise "Failed to mount #{di.device} to #{dir}#{path}"
227           end
228
229           yield(dir, devices)
230
231         ensure
232           system("umount", "#{dir}/dev")
233           system("umount", "#{dir}/proc")
234           dirs.reverse[0..-2].each do |di, idx|
235             system("umount", "#{dir}#{di.path}")
236           end
237           system("umount", dir)
238         end
239       end
240     end
241   end
242
243   def write_json
244     jdef = []
245     dirs.each_with_index do |dir, idx|
246       jdef.push({
247         "Description" => dir.path == '/' ? 'root' : dir.path[1..-1].tr('/', '-'),
248         "Format" => "raw",
249         "UserBucket" => {
250           "S3Bucket" => ENV.fetch("S3_BUCKET", "osdn-base-images"),
251           "S3Key" => "#{ENV.fetch("S3_KEY_PREFIX", "src-disks/")}#{img_path_base}_#{idx}.img"
252         }
253       })
254     end
255     File.write "#{img_path_base}.json", JSON.pretty_generate(jdef)
256   end
257
258   def run
259     create_disk
260     create_fs
261     sync_dirs
262     prepare_awsenv
263     fix_boot
264     write_json
265     puts "Image creation has been complated (#{name})"
266   end
267 end
268
269 if $0 == __FILE__
270   require 'optparse'
271
272   opts = ARGV.getopts('l:', 'limit:', 'skip:')
273   limit_pat = opts['limit'] || opts['l']
274   limit_pat and
275     limit_pat = Regexp.new(limit_pat)
276
277   list = YAML.load_file(ARGV[0] || 'image-list.yml')
278   list.each do |imgdef|
279     name = nil
280     dirs = []
281     opts = {}
282     if imgdef.kind_of?(Hash)
283       name = imgdef['name']
284       (imgdef['dirs'] || {}).each do |path, opts|
285         opts.kind_of?(Hash) or opts = {size: opts}
286         dirs << SyncDirDef.new({path: path}.merge(opts.keys.map(&:to_sym).zip(opts.values).to_h))
287       end
288       imgdef.keys.each do |k|
289         next if %w[dirs name].member?(k)
290         opts[k.to_sym] = imgdef[k]
291       end
292     else
293       name = imgdef
294     end
295     if limit_pat
296       limit_pat.match?(name) or next
297     end
298     dirs.empty? and dirs << SyncDirDef.new
299     ImageCreator.new(name, dirs, **opts).run
300   end
301 end