4 # Tails: https://tails.boum.org/
5 # Copyright © 2012 Tails developers <tails@boum.org>
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
27 require_relative 'vagrant/lib/tails_build_settings'
29 # Path to the directory which holds our Vagrantfile
30 VAGRANT_PATH = File.expand_path('vagrant', __dir__)
32 # Branches that are considered 'stable' (used to select SquashFS compression)
33 STABLE_BRANCH_NAMES = ['stable', 'testing'].freeze
35 EXPORTED_VARIABLES = [
37 'APT_SNAPSHOTS_SERIALS',
39 'TAILS_BUILD_FAILURE_RESCUE',
45 'TAILS_WEBSITE_CACHE',
48 'BASE_BRANCH_GIT_COMMIT',
49 'FEATURE_BRANCH_GIT_COMMIT',
51 ENV['EXPORTED_VARIABLES'] = EXPORTED_VARIABLES.join(' ')
53 EXTERNAL_HTTP_PROXY = ENV['http_proxy']
56 INTERNAL_HTTP_PROXY = 'http://127.0.0.1:3142'.freeze
58 ENV['ARTIFACTS'] ||= '.'
60 ENV['APT_SNAPSHOTS_SERIALS'] ||= ''
62 class CommandError < StandardError
63 attr_reader :status, :stderr
65 def initialize(message, **opts)
68 @status = opts[:status]
69 @stderr = opts[:stderr]
70 super(format(message, status: @status, stderr: @stderr))
74 def run_command(*args, **kwargs)
75 Process.wait Kernel.spawn(*args, **kwargs)
76 return if $CHILD_STATUS.exitstatus.zero?
78 raise CommandError.new("command #{args}, #{kwargs} failed with exit status %<status>s",
79 status: $CHILD_STATUS.exitstatus)
82 def capture_command(*args, **kwargs)
83 stdout, stderr, proc_status = Open3.capture3(*args, **kwargs)
84 if proc_status.exitstatus != 0
85 raise CommandError.new("command #{args}, #{kwargs} failed with exit status " \
86 '%<status>s: %<stderr>s',
87 stderr: stderr, status: proc_status.exitstatus)
92 def git_helper(*args, **kwargs)
93 question = args.first.end_with?('?')
94 args.first.sub!(/\?$/, '')
98 stdout, = capture_command('auto/scripts/utils.sh', *args, **kwargs)
99 rescue CommandError => e
102 question ? status.zero? : stdout.chomp
105 class VagrantCommandError < CommandError
108 # Runs the vagrant command, letting stdout/stderr through. Throws an
109 # exception unless the vagrant command succeeds.
110 def run_vagrant(*args, **kwargs)
111 run_command('vagrant', *args, chdir: './vagrant', **kwargs)
112 rescue CommandError => e
113 raise(VagrantCommandError, "'vagrant #{args}' command failed with exit " \
114 "status #{e.status}")
117 # Runs the vagrant command, not letting stdout/stderr through, and
118 # returns [stdout, stderr, Process::Status].
119 def capture_vagrant(*args, **kwargs)
120 capture_command('vagrant', *args, chdir: './vagrant', **kwargs)
121 rescue CommandError => e
122 raise(VagrantCommandError, "'vagrant #{args}' command failed with exit " \
123 "status #{e.status}: #{e.stderr}")
126 [:run_vagrant, :capture_vagrant].each do |m|
127 define_method "#{m}_ssh" do |*args|
128 method(m).call('ssh', '-c', *args, '--', '-q')
132 def vagrant_ssh_config(key)
134 if $vagrant_ssh_config.nil?
135 $vagrant_ssh_config = capture_vagrant('ssh-config')
137 .map { |line| line.strip.split(/\s+/, 2) }.to_h
138 # The path in the ssh-config output is quoted, which is not what
139 # is expected outside of a shell, so let's get rid of the quotes.
140 $vagrant_ssh_config['IdentityFile'].gsub!(/^"|"$/, '')
142 $vagrant_ssh_config[key]
146 capture_vagrant_ssh('grep -c "^processor\s*:" /proc/cpuinfo').first.chomp.to_i
150 out, = capture_vagrant('status')
151 status_line = out.split("\n")[2]
152 if status_line['not created']
154 elsif status_line['shutoff']
156 elsif status_line['running']
159 raise 'could not determine VM state'
163 def enough_free_host_memory_for_ram_build?
164 return false unless RbConfig::CONFIG['host_os'] =~ /linux/i
167 usable_free_mem = `free`.split[12].to_i
168 usable_free_mem > VM_MEMORY_FOR_RAM_BUILDS * 1024
175 capture_vagrant_ssh('free').first.chomp.split[12].to_i
178 def enough_free_vm_memory_for_ram_build?
179 free_vm_memory > BUILD_SPACE_REQUIREMENT * 1024
182 def enough_free_memory_for_ram_build?
183 if vm_state == :running
184 enough_free_vm_memory_for_ram_build?
186 enough_free_host_memory_for_ram_build?
191 git_helper('git_on_a_tag?')
195 return unless RbConfig::CONFIG['host_os'] =~ /linux/i
198 File.read('/proc/cpuinfo').scan(/^processor\s+:/).count
204 ENV['TAILS_WEBSITE_CACHE'] = releasing? ? '0' : '1'
206 task :parse_build_options do
209 # Default to in-memory builds if there is enough RAM available
210 options << 'ram' if enough_free_memory_for_ram_build?
211 # Default to build using the in-VM proxy
213 # Default to fast compression on development branches
214 options << 'fastcomp' unless releasing?
215 # Default to the number of system CPUs when we can figure it out
217 options << "cpus=#{cpus}" if cpus
219 options += ENV['TAILS_BUILD_OPTIONS'].split if ENV['TAILS_BUILD_OPTIONS']
221 options.uniq.each do |opt| # rubocop:disable Metrics/BlockLength
223 # Memory build settings
225 ENV['TAILS_RAM_BUILD'] = '1'
227 ENV['TAILS_RAM_BUILD'] = nil
228 # Bootstrap cache settings
229 # HTTP proxy settings
231 unless EXTERNAL_HTTP_PROXY
232 abort 'No HTTP proxy set, but one is required by ' \
233 'TAILS_BUILD_OPTIONS. Aborting.'
235 ENV['TAILS_PROXY'] = EXTERNAL_HTTP_PROXY
236 ENV['TAILS_PROXY_TYPE'] = 'extproxy'
237 when 'vmproxy', 'vmproxy+extproxy'
238 ENV['TAILS_PROXY'] = INTERNAL_HTTP_PROXY
239 ENV['TAILS_PROXY_TYPE'] = 'vmproxy'
240 if opt == 'vmproxy+extproxy'
241 unless EXTERNAL_HTTP_PROXY
242 abort 'No HTTP proxy set, but one is required by ' \
243 'TAILS_BUILD_OPTIONS. Aborting.'
245 ENV['TAILS_ACNG_PROXY'] = EXTERNAL_HTTP_PROXY
248 ENV['TAILS_PROXY'] = nil
249 ENV['TAILS_PROXY_TYPE'] = 'noproxy'
251 ENV['TAILS_OFFLINE_MODE'] = '1'
252 when /cachewebsite(?:=([a-z]+))?/
253 value = Regexp.last_match(1)
255 warn "Building a release ⇒ ignoring #{opt} build option"
256 ENV['TAILS_WEBSITE_CACHE'] = '0'
258 value = 'yes' if value.nil?
261 ENV['TAILS_WEBSITE_CACHE'] = '1'
263 ENV['TAILS_WEBSITE_CACHE'] = '0'
265 raise "Unsupported value for cachewebsite option: #{value}"
268 # SquashFS compression settings
269 when 'fastcomp', 'gzipcomp'
271 warn "Building a release ⇒ ignoring #{opt} build option"
272 ENV['MKSQUASHFS_OPTIONS'] = nil
274 ENV['MKSQUASHFS_OPTIONS'] = '-comp zstd -no-exports'
277 ENV['MKSQUASHFS_OPTIONS'] = nil
278 # Virtual hardware settings
279 when /machinetype=([a-zA-Z0-9_.-]+)/
280 ENV['TAILS_BUILD_MACHINE_TYPE'] = Regexp.last_match(1)
282 ENV['TAILS_BUILD_CPUS'] = Regexp.last_match(1)
283 when /cpumodel=([a-zA-Z0-9_-]+)/
284 ENV['TAILS_BUILD_CPU_MODEL'] = Regexp.last_match(1)
287 ENV['TAILS_BUILD_IGNORE_CHANGES'] = '1'
288 when /dateoffset=([-+]\d+)/
289 ENV['TAILS_DATE_OFFSET'] = Regexp.last_match(1)
290 # Developer convenience features
293 $force_cleanup = false
295 $force_cleanup = true
296 $keep_running = false
299 ENV['TAILS_BUILD_FAILURE_RESCUE'] = '1'
301 when 'nomergebasebranch'
302 $skip_mergebasebranch = true
304 raise "Unknown Tails build option '#{opt}'"
308 if ENV['TAILS_OFFLINE_MODE'] == '1' && ENV['TAILS_PROXY'].nil?
309 abort 'You must use a caching proxy when building offline'
313 task :ensure_clean_repository do
314 git_status = `git status --porcelain`
315 unless git_status.empty?
316 if ENV['TAILS_BUILD_IGNORE_CHANGES']
317 warn <<-END_OF_MESSAGE.gsub(/^ /, '')
319 You have uncommitted changes in the Git repository. They will
320 be ignored for the upcoming build:
325 warn <<-END_OF_MESSAGE.gsub(/^ /, '')
327 You have uncommitted changes in the Git repository. Due to limitations
328 of the build system, you need to commit them before building Tails:
331 If you don't care about those changes and want to build Tails nonetheless,
332 please add `ignorechanges` to the TAILS_BUILD_OPTIONS environment
336 abort 'Uncommitted changes. Aborting.'
342 user = vagrant_ssh_config('User')
343 stdout = capture_vagrant_ssh("find '/home/#{user}/amnesia/' -maxdepth 1 " \
344 "-name 'tails-amd64-*' " \
345 '-o -name tails-build-env.list').first
347 rescue VagrantCommandError
352 list_artifacts.each do |artifact|
353 run_vagrant_ssh("sudo rm -f '#{artifact}'")
357 task ensure_clean_home_directory: ['vm:up'] do
361 task :validate_http_proxy do
362 if ENV['TAILS_PROXY']
363 proxy_host = URI.parse(ENV['TAILS_PROXY']).host
366 ENV['TAILS_PROXY'] = nil
367 abort "Invalid HTTP proxy: #{ENV['TAILS_PROXY']}"
370 if ENV['TAILS_PROXY_TYPE'] == 'vmproxy'
371 warn 'Using the internal VM proxy'
373 if ['localhost', '[::1]'].include?(proxy_host) \
374 || proxy_host.start_with?('127.0.0.')
375 abort 'Using an HTTP proxy listening on the host\'s loopback ' \
376 'is doomed to fail. Aborting.'
378 warn "Using HTTP proxy: #{ENV['TAILS_PROXY']}"
381 warn 'No HTTP proxy set.'
385 task :validate_git_state do
386 if git_helper('git_in_detached_head?') && !git_helper('git_on_a_tag?')
387 raise 'We are in detached head but the current commit is not tagged'
391 task setup_environment: ['validate_git_state'] do
392 ENV['GIT_COMMIT'] ||= git_helper('git_current_commit')
393 ENV['GIT_REF'] ||= git_helper('git_current_head_name')
395 jenkins_branch = (ENV['GIT_BRANCH'] || '').sub(%r{^origin/}, '')
396 if !releasing? && jenkins_branch != ENV['GIT_REF']
397 raise "We expected to build the Git ref '#{ENV['GIT_REF']}', " \
398 "but GIT_REF in the environment says '#{jenkins_branch}'. Aborting!"
402 ENV['BASE_BRANCH_GIT_COMMIT'] ||= git_helper('git_base_branch_head')
403 ['GIT_COMMIT', 'GIT_REF', 'BASE_BRANCH_GIT_COMMIT'].each do |var|
404 next unless ENV[var].empty?
406 raise "Variable '#{var}' is empty, which should not be possible: " \
407 "either validate_git_state is buggy or the 'origin' remote " \
408 'does not point to the official Tails Git repository.'
412 task merge_base_branch: ['parse_build_options', 'setup_environment'] do
413 next if $skip_mergebasebranch
415 branch = git_helper('git_current_branch')
416 base_branch = git_helper('base_branch')
417 source_date_faketime = `date --utc --date="$(dpkg-parsechangelog --show-field=Date)" '+%Y-%m-%d %H:%M:%S'`.chomp
418 next if releasing? || branch == base_branch
420 commit_before_merge = git_helper('git_current_commit')
421 warn "Merging base branch '#{base_branch}' (at commit " \
422 "#{ENV['BASE_BRANCH_GIT_COMMIT']}) ..."
424 run_command('faketime', '-f', source_date_faketime, \
425 'git', 'merge', '--no-edit', ENV['BASE_BRANCH_GIT_COMMIT'])
427 run_command('git', 'merge', '--abort')
428 raise <<-END_OF_MESSAGE.gsub(/^ /, '')
430 There were conflicts when merging the base branch; either
431 merge it yourself and resolve conflicts, or skip this merge
432 by rebuilding with the 'nomergebasebranch' option.
436 run_command('git', 'submodule', 'update', '--init')
438 # If we actually merged anything we'll re-run rake in the new Git
439 # state in order to avoid subtle build errors due to mixed state.
440 next if commit_before_merge == git_helper('git_current_commit')
442 ENV['GIT_COMMIT'] = git_helper('git_current_commit')
443 ENV['FEATURE_BRANCH_GIT_COMMIT'] = commit_before_merge
444 ENV['TAILS_BUILD_OPTIONS'] = (ENV['TAILS_BUILD_OPTIONS'] || '') + \
446 Kernel.exec('rake', *ARGV)
449 task :maybe_clean_up_builder_vms do
450 clean_up_builder_vms if $force_cleanup
453 task :ensure_correct_permissions do
454 FileUtils.chmod('go+x', '.')
455 FileUtils.chmod_R('go+rX', ['.git', 'submodules', 'vagrant'])
457 # Changing permissions outside of the working copy, in particular on
458 # parent directories such as $HOME, feels too blunt and can have
459 # problematic security consequences, so we don't forcibly do that.
460 # Instead, when the permissions are not OK, display a nicer error
461 # message than "Virtio-9p Failed to initialize fs-driver […]"
463 capture_command('sudo', '-u', 'libvirt-qemu', 'stat', '.git')
465 abort <<-END_OF_MESSAGE.gsub(/^ /, '')
467 Incorrect permissions: the libvirt-qemu user needs to be allowed
468 to traverse the filesystem up to #{ENV['PWD']}.
470 To fix this, you can for example run the following command
471 on every parent directory of #{ENV['PWD']} up to #{ENV['HOME']}
474 chmod g+rx DIR && setfacl -m user:libvirt-qemu:rx DIR
482 'parse_build_options',
483 'ensure_clean_repository',
484 'maybe_clean_up_builder_vms',
485 'validate_git_state',
488 'validate_http_proxy',
489 'ensure_correct_permissions',
491 'ensure_clean_home_directory',
493 if ENV['TAILS_RAM_BUILD'] && !enough_free_memory_for_ram_build?
494 warn <<-END_OF_MESSAGE.gsub(/^ /, '')
496 The virtual machine is not currently set with enough memory to
497 perform an in-memory build. Either remove the `ram` option from
498 the TAILS_BUILD_OPTIONS environment variable, or shut the
499 virtual machine down using `rake vm:halt` before trying again.
502 abort 'Not enough memory for the virtual machine to run an in-memory ' \
506 if ENV['TAILS_BUILD_CPUS'] \
507 && current_vm_cpus != ENV['TAILS_BUILD_CPUS'].to_i
508 warn <<-END_OF_MESSAGE.gsub(/^ /, '')
510 The virtual machine is currently running with #{current_vm_cpus}
511 virtual CPU(s). In order to change that number, you need to
512 stop the VM first, using `rake vm:halt`. Otherwise, please
513 adjust the `cpus` options accordingly.
516 abort 'The virtual machine needs to be reloaded to change the number ' \
520 exported_env = EXPORTED_VARIABLES
521 .select { |k| ENV[k] }
522 .map { |k| "#{k}='#{ENV[k]}'" }.join(' ')
525 retrieved_artifacts = false
526 run_vagrant_ssh("#{exported_env} build-tails")
527 rescue VagrantCommandError
528 retrieve_artifacts(missing_ok: true)
529 retrieved_artifacts = true
531 retrieve_artifacts(missing_ok: false) unless retrieved_artifacts
532 clean_up_builder_vms unless $keep_running
535 clean_up_builder_vms if $force_cleanup
538 desc 'Retrieve build artifacts from the Vagrant box'
539 task :retrieve_artifacts do
543 def retrieve_artifacts(missing_ok: false)
544 artifacts = list_artifacts
546 msg = 'No build artifacts were found!'
547 raise msg unless missing_ok
552 user = vagrant_ssh_config('User')
553 hostname = vagrant_ssh_config('HostName')
554 key_file = vagrant_ssh_config('IdentityFile')
555 warn 'Retrieving artifacts from Vagrant build box.'
557 "sudo chown #{user} " + artifacts.map { |a| "'#{a}'" }.join(' ')
562 # We don't want to use any identity saved in ssh agent'
563 '-o', 'IdentityAgent=none',
564 # We need this since the user will not necessarily have a
565 # known_hosts entry. It is safe since an attacker must
566 # compromise libvirt's network config or the user running the
567 # command to modify the #{hostname} below.
568 '-o', 'StrictHostKeyChecking=no',
569 '-o', 'UserKnownHostsFile=/dev/null',
571 '-o', 'Compression=no',
573 fetch_command += artifacts.map { |a| "#{user}@#{hostname}:#{a}" }
574 fetch_command << ENV['ARTIFACTS']
575 run_command(*fetch_command)
579 !capture_vagrant('box', 'list').grep(/^#{box_name}\s+\(libvirt,/).empty?
583 "#{box_name}_default"
586 # XXX: giving up on a few worst offenders for now
587 # rubocop:disable Metrics/AbcSize
588 # rubocop:disable Metrics/MethodLength
589 def clean_up_builder_vms
590 libvirt = Libvirt.open('qemu:///system')
592 clean_up_domain = proc do |domain|
595 domain.destroy if domain.active?
599 .lookup_storage_pool_by_name('default')
600 .lookup_volume_by_name("#{domain.name}.img")
602 rescue Libvirt::RetrieveError
603 # Expected if the pool or disk does not exist
607 # Let's ensure that the VM we are about to create is cleaned up ...
608 previous_domain = libvirt.list_all_domains.find { |d| d.name == domain_name }
609 if previous_domain&.active?
611 run_vagrant_ssh('mountpoint -q /var/cache/apt-cacher-ng')
612 rescue VagrantCommandError
613 # Nothing to unmount.
615 run_vagrant_ssh('sudo systemctl stop apt-cacher-ng.service')
616 run_vagrant_ssh('sudo umount /var/cache/apt-cacher-ng')
617 run_vagrant_ssh('sudo sync')
620 run_vagrant_ssh('mountpoint -q /var/cache/tails-website')
621 rescue VagrantCommandError
622 # Nothing to unmount.
624 run_vagrant_ssh('sudo umount /var/cache/tails-website')
625 run_vagrant_ssh('sudo sync')
628 clean_up_domain.call(previous_domain)
630 # ... and the same for any residual VM based on another box (=>
631 # another domain name) that Vagrant still keeps track of.
635 open('vagrant/.vagrant/machines/default/libvirt/id', 'r', &:read)
637 libvirt.lookup_domain_by_uuid(old_domain_uuid)
638 rescue Errno::ENOENT, Libvirt::RetrieveError
639 # Expected if we don't have vagrant/.vagrant, or if the VM was
640 # undefined for other reasons (e.g. manually).
643 clean_up_domain.call(old_domain)
645 # We could use `vagrant destroy` here but due to vagrant-libvirt's
646 # upstream issue #746 we then risk losing the apt-cacher-ng data.
647 # Since we essentially implement `vagrant destroy` without this bug
648 # above, but in a way so it works even if `vagrant/.vagrant` does
649 # not exist, let's just do what is safest, i.e. avoiding `vagrant
650 # destroy`. For details, see the upstream issue:
651 # https://github.com/vagrant-libvirt/vagrant-libvirt/issues/746
652 FileUtils.rm_rf('vagrant/.vagrant')
656 # rubocop:enable Metrics/AbcSize
657 # rubocop:enable Metrics/MethodLength
659 desc 'Remove all libvirt volumes named tails-builder-* (run at your own risk!)'
660 task :clean_up_libvirt_volumes do
661 libvirt = Libvirt.open('qemu:///system')
663 pool = libvirt.lookup_storage_pool_by_name('default')
664 rescue Libvirt::RetrieveError
665 # Expected if the pool does not exist
667 pool.list_volumes.each do |disk|
668 next unless /^tails-builder-/.match(disk)
671 pool.lookup_volume_by_name(disk).delete
672 rescue Libvirt::RetrieveError
673 # Expected if the disk does not exist
682 !ENV['JENKINS_URL'].nil?
685 desc 'Clean up all build related files'
686 task clean_all: ['vm:destroy', 'basebox:clean_all']
689 desc 'Start the build virtual machine'
691 'parse_build_options',
692 'validate_http_proxy',
701 run_vagrant('up', '--provision')
702 rescue VagrantCommandError => e
703 clean_up_builder_vms if $force_cleanup
708 desc 'SSH into the builder VM'
713 desc 'Stop the build virtual machine'
718 desc 'Re-run virtual machine setup'
720 'parse_build_options',
721 'validate_http_proxy',
724 run_vagrant('provision')
727 desc 'Destroy build virtual machine (clean up all files except the ' \
728 "vmproxy's apt-cacher-ng data and the website cache)"
734 namespace :basebox do
735 desc 'Create and import the base box unless already done'
739 warn <<-END_OF_MESSAGE.gsub(/^ /, '')
741 This is the first time we are using this Vagrant base box so we
742 will have to bootstrap by building it from scratch. This will
743 take around 20 minutes (depending on your hardware) plus the
744 time needed for downloading around 250 MiB of Debian packages.
747 run_command("#{VAGRANT_PATH}/definitions/tails-builder/generate-tails-builder-box.sh")
749 # Let's use an absolute path since run_vagrant changes the working
750 # directory but File.delete doesn't
751 box_path = "#{box_dir}/#{box_name}.box"
752 run_vagrant('box', 'add', '--name', box_name, box_path)
753 File.delete(box_path)
756 def basebox_date(box)
757 Date.parse(/^tails-builder-[^-]+-[^-]+-(\d{8})/.match(box)[1])
761 capture_vagrant('box', 'list')
763 .grep(/^tails-builder-.*/)
764 .map { |x| x.chomp.sub(/\s.*$/, '') }
767 def clean_up_basebox(box)
768 run_vagrant('box', 'remove', '--force', box)
770 libvirt = Libvirt.open('qemu:///system')
772 .lookup_storage_pool_by_name('default')
773 .lookup_volume_by_name("#{box}_vagrant_box_image_0.img")
775 rescue Libvirt::RetrieveError
776 # Expected if the pool or disk does not exist
782 desc 'Remove all base boxes'
784 baseboxes.each { |box| clean_up_basebox(box) }
787 desc 'Remove all base boxes older than six months'
790 # We always want to keep the newest basebox
791 boxes.sort! { |a, b| basebox_date(a) <=> basebox_date(b) }
794 clean_up_basebox(box) if basebox_date(box) < Date.today - 365.0 / 2.0