Build doc: fix typos.
[tails.git] / Rakefile
blob85110f58c2f92bf27bbe16189ffe2e838ef9033c
1 # -*- coding: utf-8 -*-
2 # -*- mode: ruby -*-
3 # vi: set ft=ruby :
5 # Tails: The Amnesic Incognito Live System
6 # Copyright © 2012 Tails developers <tails@boum.org>
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 # GNU General Public License for more details.
18 # You should have received a copy of the GNU General Public License
19 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
21 require 'date'
22 require 'libvirt'
23 require 'open3'
24 require 'rbconfig'
25 require 'uri'
27 require_relative 'vagrant/lib/tails_build_settings'
29 # Path to the directory which holds our Vagrantfile
30 VAGRANT_PATH = File.expand_path('../vagrant', __FILE__)
32 # Branches that are considered 'stable' (used to select SquashFS compression)
33 STABLE_BRANCH_NAMES = ['stable', 'testing']
35 EXPORTED_VARIABLES = [
36   'MKSQUASHFS_OPTIONS',
37   'TAILS_BUILD_FAILURE_RESCUE',
38   'TAILS_DATE_OFFSET',
39   'TAILS_MERGE_BASE_BRANCH',
40   'TAILS_OFFLINE_MODE',
41   'TAILS_PROXY',
42   'TAILS_PROXY_TYPE',
43   'TAILS_RAM_BUILD',
44   'GIT_COMMIT',
45   'GIT_REF',
46   'BASE_BRANCH_GIT_COMMIT',
48 ENV['EXPORTED_VARIABLES'] = EXPORTED_VARIABLES.join(' ')
50 EXTERNAL_HTTP_PROXY = ENV['http_proxy']
52 # In-VM proxy URL
53 INTERNAL_HTTP_PROXY = "http://#{VIRTUAL_MACHINE_HOSTNAME}:3142"
55 ENV['ARTIFACTS'] ||= '.'
57 class CommandError < StandardError
58   attr_reader :status, :stderr
60   def initialize(message = nil, opts = {})
61     opts[:status] ||= nil
62     opts[:stderr] ||= nil
63     @status = opts[:status]
64     @stderr = opts[:stderr]
65     super(message % {status: @status, stderr: @stderr})
66   end
67 end
69 def run_command(*args)
70   Process.wait Kernel.spawn(*args)
71   if $?.exitstatus != 0
72     raise CommandError.new("command #{args} failed with exit status " +
73                            "%{status}", status: $?.exitstatus)
74   end
75 end
77 def capture_command(*args)
78   stdout, stderr, proc_status = Open3.capture3(*args)
79   if proc_status.exitstatus != 0
80     raise CommandError.new("command #{args} failed with exit status " +
81                            "%{status}: %{stderr}",
82                            stderr: stderr, status: proc_status.exitstatus)
83   end
84   return stdout, stderr
85 end
87 def git_helper(*args)
88   question = args.first.end_with?('?')
89   args.first.sub!(/\?$/, '')
90   status = 0
91   stdout = ''
92   begin
93     stdout, _ = capture_command('auto/scripts/utils.sh', *args)
94   rescue CommandError => e
95     status = e.status
96   end
97   if question
98     return status == 0
99   else
100     return stdout.chomp
101   end
104 class VagrantCommandError < CommandError
107 # Runs the vagrant command, letting stdout/stderr through. Throws an
108 # exception unless the vagrant command succeeds.
109 def run_vagrant(*args)
110   run_command('vagrant', *args, :chdir => './vagrant')
111 rescue CommandError => e
112   raise(VagrantCommandError, "'vagrant #{args}' command failed with exit " +
113                              "status #{e.status}")
116 # Runs the vagrant command, not letting stdout/stderr through, and
117 # returns [stdout, stderr, Preocess:Status].
118 def capture_vagrant(*args)
119   capture_command('vagrant', *args, :chdir => './vagrant')
120 rescue CommandError => e
121   raise(VagrantCommandError, "'vagrant #{args}' command failed with exit " +
122                              "status #{e.status}: #{e.stderr}")
125 [:run_vagrant, :capture_vagrant].each do |m|
126   define_method "#{m}_ssh" do |*args|
127     method(m).call('ssh', '-c', *args, '--', '-q')
128   end
131 def vagrant_ssh_config(key)
132   # Cache results
133   if $vagrant_ssh_config.nil?
134     $vagrant_ssh_config = capture_vagrant('ssh-config').first.split("\n") \
135                            .map { |line| line.strip.split(/\s+/, 2) } .to_h
136     # The path in the ssh-config output is quoted, which is not what
137     # is expected outside of a shell, so let's get rid of the quotes.
138     $vagrant_ssh_config['IdentityFile'].gsub!(/^"|"$/, '')
139   end
140   $vagrant_ssh_config[key]
143 def current_vm_cpus
144   capture_vagrant_ssh('grep -c "^processor\s*:" /proc/cpuinfo').first.chomp.to_i
147 def vm_state
148   out, _ = capture_vagrant('status')
149   status_line = out.split("\n")[2]
150   if    status_line['not created']
151     return :not_created
152   elsif status_line['shutoff']
153     return :poweroff
154   elsif status_line['running']
155     return :running
156   else
157     raise "could not determine VM state"
158   end
161 def enough_free_host_memory_for_ram_build?
162   return false unless RbConfig::CONFIG['host_os'] =~ /linux/i
164   begin
165     usable_free_mem = `free`.split[12].to_i
166     usable_free_mem > VM_MEMORY_FOR_RAM_BUILDS * 1024
167   rescue
168     false
169   end
172 def free_vm_memory
173   capture_vagrant_ssh('free').first.chomp.split[12].to_i
176 def enough_free_vm_memory_for_ram_build?
177   free_vm_memory > BUILD_SPACE_REQUIREMENT * 1024
180 def enough_free_memory_for_ram_build?
181   if vm_state == :running
182     enough_free_vm_memory_for_ram_build?
183   else
184     enough_free_host_memory_for_ram_build?
185   end
188 def is_release?
189   git_helper('git_on_a_tag?')
192 def system_cpus
193   return nil unless RbConfig::CONFIG['host_os'] =~ /linux/i
195   begin
196     File.read('/proc/cpuinfo').scan(/^processor\s+:/).count
197   rescue
198     nil
199   end
202 task :parse_build_options do
203   options = []
205   # Default to in-memory builds if there is enough RAM available
206   options << 'ram' if enough_free_memory_for_ram_build?
208   # Default to build using the in-VM proxy
209   options << 'vmproxy'
211   # Default to fast compression on development branches
212   options << 'gzipcomp' unless is_release?
214   # Default to the number of system CPUs when we can figure it out
215   cpus = system_cpus
216   options << "cpus=#{cpus}" if cpus
218   options += ENV['TAILS_BUILD_OPTIONS'].split if ENV['TAILS_BUILD_OPTIONS']
220   options.uniq.each do |opt|
221     case opt
222     # Memory build settings
223     when 'ram'
224       ENV['TAILS_RAM_BUILD'] = '1'
225     when 'noram'
226       ENV['TAILS_RAM_BUILD'] = nil
227     # Bootstrap cache settings
228     # HTTP proxy settings
229     when 'extproxy'
230       abort "No HTTP proxy set, but one is required by TAILS_BUILD_OPTIONS. Aborting." unless EXTERNAL_HTTP_PROXY
231       ENV['TAILS_PROXY'] = EXTERNAL_HTTP_PROXY
232       ENV['TAILS_PROXY_TYPE'] = 'extproxy'
233     when 'vmproxy'
234       ENV['TAILS_PROXY'] = INTERNAL_HTTP_PROXY
235       ENV['TAILS_PROXY_TYPE'] = 'vmproxy'
236     when 'noproxy'
237       ENV['TAILS_PROXY'] = nil
238       ENV['TAILS_PROXY_TYPE'] = 'noproxy'
239     when 'offline'
240       ENV['TAILS_OFFLINE_MODE'] = '1'
241     # SquashFS compression settings
242     when 'gzipcomp'
243       ENV['MKSQUASHFS_OPTIONS'] = '-comp gzip -Xcompression-level 1'
244       if is_release?
245         raise 'We must use the default compression when building releases!'
246       end
247     when 'defaultcomp'
248       ENV['MKSQUASHFS_OPTIONS'] = nil
249     # Virtual hardware settings
250     when /machinetype=([a-zA-Z0-9_.-]+)/
251       ENV['TAILS_BUILD_MACHINE_TYPE'] = $1
252     when /cpus=(\d+)/
253       ENV['TAILS_BUILD_CPUS'] = $1
254     when /cpumodel=([a-zA-Z0-9_-]+)/
255       ENV['TAILS_BUILD_CPU_MODEL'] = $1
256     # Git settings
257     when 'ignorechanges'
258       ENV['TAILS_BUILD_IGNORE_CHANGES'] = '1'
259     when /dateoffset=([-+]\d+)/
260       ENV['TAILS_DATE_OFFSET'] = $1
261     # Developer convenience features
262     when 'keeprunning'
263       $keep_running = true
264       $force_cleanup = false
265     when 'forcecleanup'
266       $force_cleanup = true
267       $keep_running = false
268     when 'rescue'
269       $keep_running = true
270       ENV['TAILS_BUILD_FAILURE_RESCUE'] = '1'
271     # Jenkins
272     when 'mergebasebranch'
273       ENV['TAILS_MERGE_BASE_BRANCH'] = '1'
274     else
275       raise "Unknown Tails build option '#{opt}'"
276     end
277   end
279   if ENV['TAILS_OFFLINE_MODE'] == '1'
280     if ENV['TAILS_PROXY'].nil?
281       abort "You must use a caching proxy when building offline"
282     end
283   end
286 task :ensure_clean_repository do
287   git_status = `git status --porcelain`
288   unless git_status.empty?
289     if ENV['TAILS_BUILD_IGNORE_CHANGES']
290       $stderr.puts <<-END_OF_MESSAGE.gsub(/^        /, '')
292         You have uncommitted changes in the Git repository. They will
293         be ignored for the upcoming build:
294         #{git_status}
296       END_OF_MESSAGE
297     else
298       $stderr.puts <<-END_OF_MESSAGE.gsub(/^        /, '')
300         You have uncommitted changes in the Git repository. Due to limitations
301         of the build system, you need to commit them before building Tails:
302         #{git_status}
304         If you don't care about those changes and want to build Tails nonetheless,
305         please add `ignorechanges` to the TAILS_BUILD_OPTIONS environment
306         variable.
308       END_OF_MESSAGE
309       abort 'Uncommitted changes. Aborting.'
310     end
311   end
314 def list_artifacts
315   user = vagrant_ssh_config('User')
316   stdout = capture_vagrant_ssh("find '/home/#{user}/amnesia/' -maxdepth 1 " +
317                                         "-name 'tails-*.iso*'").first
318   stdout.split("\n")
319 rescue VagrantCommandError
320   return Array.new
323 def remove_artifacts
324   list_artifacts.each do |artifact|
325     run_vagrant_ssh("sudo rm -f '#{artifact}'")
326   end
329 task :ensure_clean_home_directory => ['vm:up'] do
330   remove_artifacts
333 task :validate_http_proxy do
334   if ENV['TAILS_PROXY']
335     proxy_host = URI.parse(ENV['TAILS_PROXY']).host
337     if proxy_host.nil?
338       ENV['TAILS_PROXY'] = nil
339       $stderr.puts "Ignoring invalid HTTP proxy."
340       return
341     end
343     if ['localhost', '[::1]'].include?(proxy_host) || proxy_host.start_with?('127.0.0.')
344       abort 'Using an HTTP proxy listening on the loopback is doomed to fail. Aborting.'
345     end
347     $stderr.puts "Using HTTP proxy: #{ENV['TAILS_PROXY']}"
348   else
349     $stderr.puts "No HTTP proxy set."
350   end
353 task :validate_git_state do
354   if git_helper('git_in_detached_head?') && not(git_helper('git_on_a_tag?'))
355     raise 'We are in detached head but the current commit is not tagged'
356   end
359 task :setup_environment => ['validate_git_state'] do
360   ENV['GIT_COMMIT'] ||= git_helper('git_current_commit')
361   ENV['GIT_REF'] ||= git_helper('git_current_head_name')
362   if on_jenkins?
363     jenkins_branch = (ENV['GIT_BRANCH'] || '').sub(/^origin\//, '')
364     if not(is_release?) && jenkins_branch != ENV['GIT_REF']
365       raise "We expected to build the Git ref '#{ENV['GIT_REF']}', but GIT_REF in the environment says '#{jenkins_branch}'. Aborting!"
366     end
367   end
369   ENV['BASE_BRANCH_GIT_COMMIT'] = git_helper('git_base_branch_head')
370   ['GIT_COMMIT', 'GIT_REF', 'BASE_BRANCH_GIT_COMMIT'].each do |var|
371     if ENV[var].empty?
372       raise "Variable '#{var}' is empty, which should not be possible" +
373             "(validate_git_state must be buggy)"
374     end
375   end
378 task :maybe_clean_up_builder_vms do
379   clean_up_builder_vms if $force_cleanup
382 desc 'Build Tails'
383 task :build => ['parse_build_options', 'ensure_clean_repository', 'maybe_clean_up_builder_vms', 'validate_git_state', 'setup_environment', 'validate_http_proxy', 'vm:up', 'ensure_clean_home_directory'] do
385   begin
386     if ENV['TAILS_RAM_BUILD'] && not(enough_free_memory_for_ram_build?)
387       $stderr.puts <<-END_OF_MESSAGE.gsub(/^        /, '')
389         The virtual machine is not currently set with enough memory to
390         perform an in-memory build. Either remove the `ram` option from
391         the TAILS_BUILD_OPTIONS environment variable, or shut the
392         virtual machine down using `rake vm:halt` before trying again.
394       END_OF_MESSAGE
395       abort 'Not enough memory for the virtual machine to run an in-memory build. Aborting.'
396     end
398     if ENV['TAILS_BUILD_CPUS'] && current_vm_cpus != ENV['TAILS_BUILD_CPUS'].to_i
399       $stderr.puts <<-END_OF_MESSAGE.gsub(/^        /, '')
401         The virtual machine is currently running with #{current_vm_cpus}
402         virtual CPU(s). In order to change that number, you need to
403         stop the VM first, using `rake vm:halt`. Otherwise, please
404         adjust the `cpus` options accordingly.
406       END_OF_MESSAGE
407       abort 'The virtual machine needs to be reloaded to change the number of CPUs. Aborting.'
408     end
410     exported_env = EXPORTED_VARIABLES.select { |k| ENV[k] }.
411                    collect { |k| "#{k}='#{ENV[k]}'" }.join(' ')
412     run_vagrant_ssh("#{exported_env} build-tails")
414     artifacts = list_artifacts
415     raise 'No build artifacts were found!' if artifacts.empty?
416     user     = vagrant_ssh_config('User')
417     hostname = vagrant_ssh_config('HostName')
418     key_file = vagrant_ssh_config('IdentityFile')
419     $stderr.puts "Retrieving artifacts from Vagrant build box."
420     run_vagrant_ssh(
421       "sudo chown #{user} " + artifacts.map { |a| "'#{a}'" } .join(' ')
422     )
423     fetch_command = [
424       'scp',
425       '-i', key_file,
426       # We need this since the user will not necessarily have a
427       # known_hosts entry. It is safe since an attacker must
428       # compromise libvirt's network config or the user running the
429       # command to modify the #{hostname} below.
430       '-o', 'StrictHostKeyChecking=no',
431       '-o', 'UserKnownHostsFile=/dev/null',
432     ]
433     fetch_command += artifacts.map { |a| "#{user}@#{hostname}:#{a}" }
434     fetch_command << ENV['ARTIFACTS']
435     run_command(*fetch_command)
436     clean_up_builder_vms unless $keep_running
437   ensure
438     clean_up_builder_vms if $force_cleanup
439   end
442 def has_box?
443   not(capture_vagrant('box', 'list').grep(/^#{box_name}\s+\(libvirt,/).empty?)
446 def domain_name
447   "#{box_name}_default"
450 def clean_up_builder_vms
451   $virt = Libvirt::open("qemu:///system")
453   clean_up_domain = Proc.new do |domain|
454     next if domain.nil?
455     domain.destroy if domain.active?
456     domain.undefine
457     begin
458       $virt
459         .lookup_storage_pool_by_name('default')
460         .lookup_volume_by_name("#{domain.name}.img")
461         .delete
462     rescue Libvirt::RetrieveError
463       # Expected if the pool or disk does not exist
464     end
465   end
467   # Let's ensure that the VM we are about to create is cleaned up ...
468   previous_domain = $virt.list_all_domains.find { |d| d.name == domain_name }
469   if previous_domain && previous_domain.active?
470     begin
471       run_vagrant_ssh("mountpoint -q /var/cache/apt-cacher-ng")
472     rescue VagrantCommandError
473     # Nothing to unmount.
474     else
475       run_vagrant_ssh("sudo systemctl stop apt-cacher-ng.service")
476       run_vagrant_ssh("sudo umount /var/cache/apt-cacher-ng")
477       run_vagrant_ssh("sudo sync")
478     end
479   end
480   clean_up_domain.call(previous_domain)
482   # ... and the same for any residual VM based on another box (=>
483   # another domain name) that Vagrant still keeps track of.
484   old_domain =
485     begin
486       old_domain_uuid =
487         open('vagrant/.vagrant/machines/default/libvirt/id', 'r') { |f| f.read }
488         .strip
489       $virt.lookup_domain_by_uuid(old_domain_uuid)
490     rescue Errno::ENOENT, Libvirt::RetrieveError
491       # Expected if we don't have vagrant/.vagrant, or if the VM was
492       # undefined for other reasons (e.g. manually).
493       nil
494     end
495   clean_up_domain.call(old_domain)
497   # We could use `vagrant destroy` here but due to vagrant-libvirt's
498   # upstream issue #746 we then risk losing the apt-cacher-ng data.
499   # Since we essentially implement `vagrant destroy` without this bug
500   # above, but in a way so it works even if `vagrant/.vagrant` does
501   # not exist, let's just do what is safest, i.e. avoiding `vagrant
502   # destroy`. For details, see the upstream issue:
503   #   https://github.com/vagrant-libvirt/vagrant-libvirt/issues/746
504   FileUtils.rm_rf('vagrant/.vagrant')
505 ensure
506   $virt.close
509 desc "Remove all libvirt volumes named tails-builder-* (run at your own risk!)"
510 task :clean_up_libvirt_volumes do
511   $virt = Libvirt::open("qemu:///system")
512   begin
513     pool = $virt.lookup_storage_pool_by_name('default')
514   rescue Libvirt::RetrieveError
515     # Expected if the pool does not exist
516   else
517     for disk in pool.list_volumes do
518       if /^tails-builder-/.match(disk)
519         begin
520           pool.lookup_volume_by_name(disk).delete
521         rescue Libvirt::RetrieveError
522           # Expected if the disk does not exist
523         end
524       end
525     end
526   ensure
527     $virt.close
528   end
531 def on_jenkins?
532   !!ENV['JENKINS_URL']
535 desc 'Test Tails'
536 task :test do
537   args = ARGV.drop_while { |x| x == 'test' || x == '--' }
538   if on_jenkins?
539     args += ['--'] unless args.include? '--'
540     if not(is_release?)
541       args += ['--tag', '~@fragile']
542     end
543     base_branch = git_helper('base_branch')
544     if git_helper('git_only_doc_changes_since?', "origin/#{base_branch}") then
545       args += ['--tag', '@doc']
546     end
547   end
548   run_command('./run_test_suite', *args)
551 desc 'Clean up all build related files'
552 task :clean_all => ['vm:destroy', 'basebox:clean_all']
554 namespace :vm do
555   desc 'Start the build virtual machine'
556   task :up => ['parse_build_options', 'validate_http_proxy', 'setup_environment', 'basebox:create'] do
557     case vm_state
558     when :not_created
559       clean_up_builder_vms
560     end
561     begin
562       run_vagrant('up')
563     rescue VagrantCommandError => e
564       clean_up_builder_vms if $force_cleanup
565       raise e
566     end
567   end
569   desc 'SSH into the builder VM'
570   task :ssh do
571     run_vagrant('ssh')
572   end
574   desc 'Stop the build virtual machine'
575   task :halt do
576     run_vagrant('halt')
577   end
579   desc 'Re-run virtual machine setup'
580   task :provision => ['parse_build_options', 'validate_http_proxy', 'setup_environment'] do
581     run_vagrant('provision')
582   end
584   desc "Destroy build virtual machine (clean up all files except the vmproxy's apt-cacher-ng data)"
585   task :destroy do
586     clean_up_builder_vms
587   end
590 namespace :basebox do
592   desc 'Create and import the base box unless already done'
593   task :create do
594     next if has_box?
595     $stderr.puts <<-END_OF_MESSAGE.gsub(/^      /, '')
597       This is the first time we are using this Vagrant base box so we
598       will have to bootstrap by building it from scratch. This will
599       take around 20 minutes (depending on your hardware) plus the
600       time needed for downloading around 250 MiB of Debian packages.
602     END_OF_MESSAGE
603     box_dir = VAGRANT_PATH + '/definitions/tails-builder'
604     run_command("#{box_dir}/generate-tails-builder-box.sh")
605     # Let's use an absolute path since run_vagrant changes the working
606     # directory but File.delete doesn't
607     box_path = "#{box_dir}/#{box_name}.box"
608     run_vagrant('box', 'add', '--name', box_name, box_path)
609     File.delete(box_path)
610     end
612   def basebox_date(box)
613     Date.parse(/^tails-builder-[^-]+-[^-]+-(\d{8})/.match(box)[1])
614   end
616   def baseboxes
617     capture_vagrant('box', 'list').first.lines
618       .grep(/^tails-builder-.*/)
619       .map { |x| x.chomp.sub(/\s.*$/, '') }
620   end
622   def clean_up_basebox(box)
623     run_vagrant('box', 'remove', '--force', box)
624     begin
625       $virt = Libvirt::open("qemu:///system")
626       $virt
627         .lookup_storage_pool_by_name('default')
628         .lookup_volume_by_name("#{box}_vagrant_box_image_0.img")
629         .delete
630     rescue Libvirt::RetrieveError
631       # Expected if the pool or disk does not exist
632     ensure
633       $virt.close
634     end
635   end
637   desc 'Remove all base boxes'
638   task :clean_all do
639     baseboxes.each { |box| clean_up_basebox(box) }
640   end
642   desc 'Remove all base boxes older than six months'
643   task :clean_old do
644     boxes = baseboxes
645     # We always want to keep the newest basebox
646     boxes.sort! { |a, b| basebox_date(a) <=> basebox_date(b) }
647     boxes.pop
648     boxes.each do |box|
649       if basebox_date(box) < Date.today - 365.0/3.0
650         clean_up_basebox(box)
651       end
652     end
653   end