4 # Tails: https://tails.net/
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 " \
79 'with exit status %<status>s',
80 status: $CHILD_STATUS.exitstatus)
83 def capture_command(*args, **kwargs)
84 stdout, stderr, proc_status = Open3.capture3(*args, **kwargs)
85 if proc_status.exitstatus != 0
86 raise CommandError.new("command #{args}, #{kwargs} failed with exit status " \
87 '%<status>s: %<stderr>s',
88 stderr:, status: proc_status.exitstatus)
93 def git_helper(*args, **kwargs)
94 question = args.first.end_with?('?')
95 args.first.sub!(/\?$/, '')
99 stdout, = capture_command('auto/scripts/utils.sh', *args, **kwargs)
100 rescue CommandError => e
103 question ? status.zero? : stdout.chomp
106 class VagrantCommandError < CommandError
109 # Runs the vagrant command, letting stdout/stderr through. Throws an
110 # exception unless the vagrant command succeeds.
111 def run_vagrant(*args, **kwargs)
112 run_command('vagrant', *args, chdir: './vagrant', **kwargs)
113 rescue CommandError => e
114 raise(VagrantCommandError, "'vagrant #{args}' command failed with exit " \
115 "status #{e.status}")
118 # Runs the vagrant command, not letting stdout/stderr through, and
119 # returns [stdout, stderr, Process::Status].
120 def capture_vagrant(*args, **kwargs)
121 capture_command('vagrant', *args, chdir: './vagrant', **kwargs)
122 rescue CommandError => e
123 raise(VagrantCommandError, "'vagrant #{args}' command failed with exit " \
124 "status #{e.status}: #{e.stderr}")
127 [:run_vagrant, :capture_vagrant].each do |m|
128 define_method "#{m}_ssh" do |*args|
129 method(m).call('ssh', '-c', *args, '--', '-q')
133 def vagrant_ssh_config(key)
135 if $vagrant_ssh_config.nil?
136 $vagrant_ssh_config = capture_vagrant('ssh-config')
138 .map { |line| line.strip.split(/\s+/, 2) }.to_h
139 # The path in the ssh-config output is quoted, which is not what
140 # is expected outside of a shell, so let's get rid of the quotes.
141 $vagrant_ssh_config['IdentityFile'].gsub!(/^"|"$/, '')
143 $vagrant_ssh_config[key]
147 capture_vagrant_ssh('grep -c "^processor\s*:" /proc/cpuinfo').first.chomp.to_i
151 out, = capture_vagrant('status')
152 status_line = out.split("\n")[2]
153 if status_line['not created']
155 elsif status_line['shutoff']
157 elsif status_line['running']
160 raise 'could not determine VM state'
165 git_helper('git_on_a_tag?')
169 File.read('/proc/cpuinfo').scan(/^processor\s+:/).count
172 ENV['TAILS_WEBSITE_CACHE'] = releasing? ? '0' : '1'
174 task :parse_build_options do
177 # Default to in-memory builds
179 # Default to build using the in-VM proxy
181 # Default to fast compression on development branches
182 options << 'fastcomp' unless releasing?
183 # Default to use all host CPUs
184 options << "cpus=#{system_cpus}"
186 options += ENV['TAILS_BUILD_OPTIONS'].split if ENV['TAILS_BUILD_OPTIONS']
188 options.uniq.each do |opt| # rubocop:disable Metrics/BlockLength
190 # Memory build settings
192 ENV['TAILS_RAM_BUILD'] = '1'
194 ENV['TAILS_RAM_BUILD'] = nil
195 # Bootstrap cache settings
196 # HTTP proxy settings
198 unless EXTERNAL_HTTP_PROXY
199 abort 'No HTTP proxy set, but one is required by ' \
200 'TAILS_BUILD_OPTIONS. Aborting.'
202 ENV['TAILS_PROXY'] = EXTERNAL_HTTP_PROXY
203 ENV['TAILS_PROXY_TYPE'] = 'extproxy'
204 when 'vmproxy', 'vmproxy+extproxy'
205 ENV['TAILS_PROXY'] = INTERNAL_HTTP_PROXY
206 ENV['TAILS_PROXY_TYPE'] = 'vmproxy'
207 if opt == 'vmproxy+extproxy'
208 unless EXTERNAL_HTTP_PROXY
209 abort 'No HTTP proxy set, but one is required by ' \
210 'TAILS_BUILD_OPTIONS. Aborting.'
212 ENV['TAILS_ACNG_PROXY'] = EXTERNAL_HTTP_PROXY
215 ENV['TAILS_PROXY'] = nil
216 ENV['TAILS_PROXY_TYPE'] = 'noproxy'
218 ENV['TAILS_OFFLINE_MODE'] = '1'
219 when /cachewebsite(?:=([a-z]+))?/
220 value = Regexp.last_match(1)
222 warn "Building a release ⇒ ignoring #{opt} build option"
223 ENV['TAILS_WEBSITE_CACHE'] = '0'
225 value = 'yes' if value.nil?
228 ENV['TAILS_WEBSITE_CACHE'] = '1'
230 ENV['TAILS_WEBSITE_CACHE'] = '0'
232 raise "Unsupported value for cachewebsite option: #{value}"
235 # SquashFS compression settings
236 when 'fastcomp', 'gzipcomp'
238 warn "Building a release ⇒ ignoring #{opt} build option"
239 ENV['MKSQUASHFS_OPTIONS'] = nil
241 ENV['MKSQUASHFS_OPTIONS'] = '-comp zstd -no-exports'
244 ENV['MKSQUASHFS_OPTIONS'] = nil
245 # Virtual hardware settings
246 when /machinetype=([a-zA-Z0-9_.-]+)/
247 ENV['TAILS_BUILD_MACHINE_TYPE'] = Regexp.last_match(1)
249 ENV['TAILS_BUILD_CPUS'] = Regexp.last_match(1)
250 when /cpumodel=([a-zA-Z0-9_-]+)/
251 ENV['TAILS_BUILD_CPU_MODEL'] = Regexp.last_match(1)
254 ENV['TAILS_BUILD_IGNORE_CHANGES'] = '1'
255 when /dateoffset=([-+]\d+)/
256 ENV['TAILS_DATE_OFFSET'] = Regexp.last_match(1)
257 # Developer convenience features
260 $force_cleanup = false
262 $force_cleanup = true
263 $keep_running = false
266 ENV['TAILS_BUILD_FAILURE_RESCUE'] = '1'
268 when 'nomergebasebranch'
269 $skip_mergebasebranch = true
271 raise "Unknown Tails build option '#{opt}'"
275 if ENV['TAILS_OFFLINE_MODE'] == '1' && ENV['TAILS_PROXY'].nil?
276 abort 'You must use a caching proxy when building offline'
280 task :ensure_enough_free_memory do
281 next unless vm_state == :not_created
283 cpus = ENV['TAILS_BUILD_CPUS'].to_i
284 free_memory = capture_command('free', '--mebi').first.split[12].to_i
285 required_memory = if ENV['TAILS_RAM_BUILD']
286 vm_memory_for_ram_builds(cpus)
291 if free_memory < required_memory
292 message = <<-END_OF_MESSAGE.gsub(/^ */, '')
294 There is not enough free memory to start the virtual machine: only
295 #{free_memory} MB is free but #{required_memory} MB is required.
298 message += if ENV['TAILS_RAM_BUILD']
299 <<-END_OF_MESSAGE.gsub(/^ */, '')
300 Try again with the `noram` option added to the TAILS_BUILD_OPTIONS
301 environment variable to force a slower on-disk build.
305 <<-END_OF_MESSAGE.gsub(/^ */, '')
306 Try freeing up some system memory before attempting to build again.
314 task :ensure_clean_repository do
315 git_status = `git status --porcelain`
316 unless git_status.empty?
317 if ENV['TAILS_BUILD_IGNORE_CHANGES']
318 warn <<-END_OF_MESSAGE.gsub(/^ /, '')
320 You have uncommitted changes in the Git repository. They will
321 be ignored for the upcoming build:
326 warn <<-END_OF_MESSAGE.gsub(/^ /, '')
328 You have uncommitted changes in the Git repository. Due to limitations
329 of the build system, you need to commit them before building Tails:
332 If you don't care about those changes and want to build Tails nonetheless,
333 please add `ignorechanges` to the TAILS_BUILD_OPTIONS environment
337 abort 'Uncommitted changes. Aborting.'
343 user = vagrant_ssh_config('User')
344 stdout = capture_vagrant_ssh("find '/home/#{user}/amnesia/' -maxdepth 1 " \
345 "-name 'tails-amd64-*' " \
346 '-o -name tails-build-env.list').first
348 rescue VagrantCommandError
353 list_artifacts.each do |artifact|
354 run_vagrant_ssh("sudo rm -f '#{artifact}'")
358 task ensure_clean_home_directory: ['vm:up'] do
362 task :validate_http_proxy do
363 if ENV['TAILS_PROXY']
364 proxy_host = URI.parse(ENV['TAILS_PROXY']).host
367 ENV['TAILS_PROXY'] = nil
368 abort "Invalid HTTP proxy: #{ENV['TAILS_PROXY']}"
371 if ENV['TAILS_PROXY_TYPE'] == 'vmproxy'
372 warn 'Using the internal VM proxy'
374 if ['localhost', '[::1]'].include?(proxy_host) \
375 || proxy_host.start_with?('127.0.0.')
376 abort 'Using an HTTP proxy listening on the host\'s loopback ' \
377 'is doomed to fail. Aborting.'
379 warn "Using HTTP proxy: #{ENV['TAILS_PROXY']}"
382 warn 'No HTTP proxy set.'
386 task :validate_git_state do
387 if git_helper('git_in_detached_head?') && !git_helper('git_on_a_tag?')
388 raise 'We are in detached head but the current commit is not tagged'
392 task setup_environment: ['validate_git_state'] do
393 ENV['GIT_COMMIT'] ||= git_helper('git_current_commit')
394 ENV['GIT_REF'] ||= git_helper('git_current_head_name')
396 jenkins_branch = (ENV['GIT_BRANCH'] || '').sub(%r{^origin/}, '')
397 if !releasing? && jenkins_branch != ENV['GIT_REF']
398 raise "We expected to build the Git ref '#{ENV['GIT_REF']}', " \
399 "but GIT_REF in the environment says '#{jenkins_branch}'. Aborting!"
403 ENV['BASE_BRANCH_GIT_COMMIT'] ||= git_helper('git_base_branch_head')
404 ['GIT_COMMIT', 'GIT_REF', 'BASE_BRANCH_GIT_COMMIT'].each do |var|
405 next unless ENV[var].empty?
407 raise "Variable '#{var}' is empty, which should not be possible: " \
408 "either validate_git_state is buggy or the 'origin' remote " \
409 'does not point to the official Tails Git repository.'
413 task merge_base_branch: ['parse_build_options', 'setup_environment'] do
414 next if $skip_mergebasebranch
416 branch = git_helper('git_current_branch')
417 base_branch = git_helper('base_branch')
418 source_date_faketime = `date --utc --date="$(dpkg-parsechangelog --show-field=Date)" \
419 '+%Y-%m-%d %H:%M:%S'`.chomp
420 next if releasing? || branch == base_branch
422 commit_before_merge = git_helper('git_current_commit')
423 warn "Merging base branch '#{base_branch}' (at commit " \
424 "#{ENV['BASE_BRANCH_GIT_COMMIT']}) ..."
426 run_command('faketime', '-f', source_date_faketime, \
427 'git', 'merge', '--no-edit', ENV['BASE_BRANCH_GIT_COMMIT'])
429 run_command('git', 'merge', '--abort')
430 raise <<-END_OF_MESSAGE.gsub(/^ /, '')
432 There were conflicts when merging the base branch; either
433 merge it yourself and resolve conflicts, or skip this merge
434 by rebuilding with the 'nomergebasebranch' option.
438 run_command('git', 'submodule', 'update', '--init')
440 # If we actually merged anything we'll re-run rake in the new Git
441 # state in order to avoid subtle build errors due to mixed state.
442 next if commit_before_merge == git_helper('git_current_commit')
444 ENV['GIT_COMMIT'] = git_helper('git_current_commit')
445 ENV['FEATURE_BRANCH_GIT_COMMIT'] = commit_before_merge
446 ENV['TAILS_BUILD_OPTIONS'] = "#{ENV['TAILS_BUILD_OPTIONS'] || ''} nomergebasebranch"
447 Kernel.exec('rake', *ARGV)
450 task :maybe_clean_up_builder_vms do
451 clean_up_builder_vms if $force_cleanup
454 task :ensure_correct_permissions do
455 FileUtils.chmod('go+x', '.')
456 FileUtils.chmod_R('go+rX', ['.git', 'submodules', 'vagrant'])
458 # Changing permissions outside of the working copy, in particular on
459 # parent directories such as $HOME, feels too blunt and can have
460 # problematic security consequences, so we don't forcibly do that.
461 # Instead, when the permissions are not OK, display a nicer error
462 # message than "Virtio-9p Failed to initialize fs-driver […]"
464 capture_command('sudo', '-u', 'libvirt-qemu', 'stat', '.git')
466 abort <<-END_OF_MESSAGE.gsub(/^ /, '')
468 Incorrect permissions: the libvirt-qemu user needs to be allowed
469 to traverse the filesystem up to #{ENV['PWD']}.
471 To fix this, you can for example run the following command
472 on every parent directory of #{ENV['PWD']} up to #{ENV['HOME']}
475 chmod g+rx DIR && setfacl -m user:libvirt-qemu:rx DIR
483 'parse_build_options',
484 'ensure_clean_repository',
485 'maybe_clean_up_builder_vms',
486 'validate_git_state',
489 'validate_http_proxy',
490 'ensure_correct_permissions',
492 'ensure_clean_home_directory',
494 if ENV['TAILS_BUILD_CPUS'] \
495 && current_vm_cpus != ENV['TAILS_BUILD_CPUS'].to_i
496 warn <<-END_OF_MESSAGE.gsub(/^ /, '')
498 The virtual machine is currently running with #{current_vm_cpus}
499 virtual CPU(s). In order to change that number, you need to
500 stop the VM first, using `rake vm:halt`. Otherwise, please
501 adjust the `cpus` options accordingly.
504 abort 'The virtual machine needs to be reloaded to change the number ' \
508 exported_env = EXPORTED_VARIABLES
509 .select { |k| ENV[k] }
510 .map { |k| "#{k}='#{ENV[k]}'" }.join(' ')
513 retrieved_artifacts = false
514 run_vagrant_ssh("#{exported_env} build-tails")
515 rescue VagrantCommandError
516 retrieve_artifacts(missing_ok: true)
517 retrieved_artifacts = true
519 retrieve_artifacts(missing_ok: false) unless retrieved_artifacts
520 clean_up_builder_vms unless $keep_running
523 clean_up_builder_vms if $force_cleanup
526 desc 'Retrieve build artifacts from the Vagrant box'
527 task :retrieve_artifacts do
531 def retrieve_artifacts(missing_ok: false)
532 artifacts = list_artifacts
534 msg = 'No build artifacts were found!'
535 raise msg unless missing_ok
540 user = vagrant_ssh_config('User')
541 hostname = vagrant_ssh_config('HostName')
542 key_file = vagrant_ssh_config('IdentityFile')
543 warn 'Retrieving artifacts from Vagrant build box.'
545 "sudo chown #{user} " + artifacts.map { |a| "'#{a}'" }.join(' ')
550 # We don't want to use any identity saved in ssh agent'
551 '-o', 'IdentityAgent=none',
552 # We need this since the user will not necessarily have a
553 # known_hosts entry. It is safe since an attacker must
554 # compromise libvirt's network config or the user running the
555 # command to modify the #{hostname} below.
556 '-o', 'StrictHostKeyChecking=no',
557 '-o', 'UserKnownHostsFile=/dev/null',
559 '-o', 'Compression=no',
561 fetch_command += artifacts.map { |a| "#{user}@#{hostname}:#{a}" }
562 fetch_command << ENV['ARTIFACTS']
563 run_command(*fetch_command)
567 !capture_vagrant('box', 'list').grep(/^#{box_name}\s+\(libvirt,/).empty?
571 "#{box_name}_default"
574 def clean_up_builder_vms
575 libvirt = Libvirt.open('qemu:///system')
577 clean_up_domain = proc do |domain|
580 domain.destroy if domain.active?
584 .lookup_storage_pool_by_name('default')
585 .lookup_volume_by_name("#{domain.name}.img")
587 rescue Libvirt::RetrieveError
588 # Expected if the pool or disk does not exist
592 # Let's ensure that the VM we are about to create is cleaned up ...
593 previous_domain = libvirt.list_all_domains.find { |d| d.name == domain_name }
594 if previous_domain&.active?
596 run_vagrant_ssh('mountpoint -q /var/cache/apt-cacher-ng')
597 rescue VagrantCommandError
598 # Nothing to unmount.
600 run_vagrant_ssh('sudo systemctl stop apt-cacher-ng.service')
601 run_vagrant_ssh('sudo umount /var/cache/apt-cacher-ng')
602 run_vagrant_ssh('sudo sync')
605 run_vagrant_ssh('mountpoint -q /var/cache/tails-website')
606 rescue VagrantCommandError
607 # Nothing to unmount.
609 run_vagrant_ssh('sudo umount /var/cache/tails-website')
610 run_vagrant_ssh('sudo sync')
613 clean_up_domain.call(previous_domain)
615 # ... and the same for any residual VM based on another box (=>
616 # another domain name) that Vagrant still keeps track of.
620 open('vagrant/.vagrant/machines/default/libvirt/id', 'r', &:read)
622 libvirt.lookup_domain_by_uuid(old_domain_uuid)
623 rescue Errno::ENOENT, Libvirt::RetrieveError
624 # Expected if we don't have vagrant/.vagrant, or if the VM was
625 # undefined for other reasons (e.g. manually).
628 clean_up_domain.call(old_domain)
630 # We could use `vagrant destroy` here but due to vagrant-libvirt's
631 # upstream issue #746 we then risk losing the apt-cacher-ng data.
632 # Since we essentially implement `vagrant destroy` without this bug
633 # above, but in a way so it works even if `vagrant/.vagrant` does
634 # not exist, let's just do what is safest, i.e. avoiding `vagrant
635 # destroy`. For details, see the upstream issue:
636 # https://github.com/vagrant-libvirt/vagrant-libvirt/issues/746
637 FileUtils.rm_rf('vagrant/.vagrant')
642 desc 'Remove all libvirt volumes named tails-builder-* (run at your own risk!)'
643 task :clean_up_libvirt_volumes do
644 libvirt = Libvirt.open('qemu:///system')
646 pool = libvirt.lookup_storage_pool_by_name('default')
647 rescue Libvirt::RetrieveError
648 # Expected if the pool does not exist
650 pool.list_volumes.each do |disk|
651 next unless /^tails-builder-/.match(disk)
654 pool.lookup_volume_by_name(disk).delete
655 rescue Libvirt::RetrieveError
656 # Expected if the disk does not exist
665 !ENV['JENKINS_URL'].nil?
668 desc 'Clean up all build related files'
669 task clean_all: ['vm:destroy', 'basebox:clean_all']
672 desc 'Start the build virtual machine'
674 'parse_build_options',
675 'ensure_enough_free_memory',
676 'validate_http_proxy',
685 run_vagrant('up', '--provision')
686 rescue VagrantCommandError => e
687 clean_up_builder_vms if $force_cleanup
692 desc 'SSH into the builder VM'
697 desc 'Stop the build virtual machine'
702 desc 'Re-run virtual machine setup'
704 'parse_build_options',
705 'validate_http_proxy',
708 run_vagrant('provision')
711 desc 'Destroy build virtual machine (clean up all files except the ' \
712 "vmproxy's apt-cacher-ng data and the website cache)"
718 namespace :basebox do
719 desc 'Create and import the base box unless already done'
723 warn <<-END_OF_MESSAGE.gsub(/^ /, '')
725 This is the first time we are using this Vagrant base box so we
726 will have to bootstrap by building it from scratch. This will
727 take around 20 minutes (depending on your hardware) plus the
728 time needed for downloading around 250 MiB of Debian packages.
732 "#{VAGRANT_PATH}/definitions/tails-builder/generate-tails-builder-box.sh"
735 # Let's use an absolute path since run_vagrant changes the working
736 # directory but File.delete doesn't
737 box_path = "#{box_dir}/#{box_name}.box"
738 run_vagrant('box', 'add', '--name', box_name, box_path)
739 File.delete(box_path)
742 def basebox_date(box)
743 Date.parse(/^tails-builder-[^-]+-[^-]+-(\d{8})/.match(box)[1])
747 capture_vagrant('box', 'list')
749 .grep(/^tails-builder-.*/)
750 .map { |x| x.chomp.sub(/\s.*$/, '') }
753 def clean_up_basebox(box)
754 run_vagrant('box', 'remove', '--force', box)
756 libvirt = Libvirt.open('qemu:///system')
758 .lookup_storage_pool_by_name('default')
759 .lookup_volume_by_name("#{box}_vagrant_box_image_0.img")
761 rescue Libvirt::RetrieveError
762 # Expected if the pool or disk does not exist
768 desc 'Remove all base boxes'
770 baseboxes.each { |box| clean_up_basebox(box) }
773 desc 'Remove all base boxes older than six months'
776 # We always want to keep the newest basebox
777 boxes.sort! { |a, b| basebox_date(a) <=> basebox_date(b) }
780 clean_up_basebox(box) if basebox_date(box) < Date.today - 365.0 / 2.0