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'
164 def enough_free_host_memory_for_ram_build?
165 return false unless RbConfig::CONFIG['host_os'] =~ /linux/i
168 usable_free_mem = `free`.split[12].to_i
169 usable_free_mem > VM_MEMORY_FOR_RAM_BUILDS * 1024
176 capture_vagrant_ssh('free').first.chomp.split[12].to_i
179 def enough_free_vm_memory_for_ram_build?
180 free_vm_memory > BUILD_SPACE_REQUIREMENT * 1024
183 def enough_free_memory_for_ram_build?
184 if vm_state == :running
185 enough_free_vm_memory_for_ram_build?
187 enough_free_host_memory_for_ram_build?
192 git_helper('git_on_a_tag?')
196 return unless RbConfig::CONFIG['host_os'] =~ /linux/i
199 File.read('/proc/cpuinfo').scan(/^processor\s+:/).count
205 ENV['TAILS_WEBSITE_CACHE'] = releasing? ? '0' : '1'
207 task :parse_build_options do
210 # Default to in-memory builds if there is enough RAM available
211 options << 'ram' if enough_free_memory_for_ram_build?
212 # Default to build using the in-VM proxy
214 # Default to fast compression on development branches
215 options << 'fastcomp' unless releasing?
216 # Default to the number of system CPUs when we can figure it out
218 options << "cpus=#{cpus}" if cpus
220 options += ENV['TAILS_BUILD_OPTIONS'].split if ENV['TAILS_BUILD_OPTIONS']
222 options.uniq.each do |opt| # rubocop:disable Metrics/BlockLength
224 # Memory build settings
226 ENV['TAILS_RAM_BUILD'] = '1'
228 ENV['TAILS_RAM_BUILD'] = nil
229 # Bootstrap cache settings
230 # HTTP proxy settings
232 unless EXTERNAL_HTTP_PROXY
233 abort 'No HTTP proxy set, but one is required by ' \
234 'TAILS_BUILD_OPTIONS. Aborting.'
236 ENV['TAILS_PROXY'] = EXTERNAL_HTTP_PROXY
237 ENV['TAILS_PROXY_TYPE'] = 'extproxy'
238 when 'vmproxy', 'vmproxy+extproxy'
239 ENV['TAILS_PROXY'] = INTERNAL_HTTP_PROXY
240 ENV['TAILS_PROXY_TYPE'] = 'vmproxy'
241 if opt == 'vmproxy+extproxy'
242 unless EXTERNAL_HTTP_PROXY
243 abort 'No HTTP proxy set, but one is required by ' \
244 'TAILS_BUILD_OPTIONS. Aborting.'
246 ENV['TAILS_ACNG_PROXY'] = EXTERNAL_HTTP_PROXY
249 ENV['TAILS_PROXY'] = nil
250 ENV['TAILS_PROXY_TYPE'] = 'noproxy'
252 ENV['TAILS_OFFLINE_MODE'] = '1'
253 when /cachewebsite(?:=([a-z]+))?/
254 value = Regexp.last_match(1)
256 warn "Building a release ⇒ ignoring #{opt} build option"
257 ENV['TAILS_WEBSITE_CACHE'] = '0'
259 value = 'yes' if value.nil?
262 ENV['TAILS_WEBSITE_CACHE'] = '1'
264 ENV['TAILS_WEBSITE_CACHE'] = '0'
266 raise "Unsupported value for cachewebsite option: #{value}"
269 # SquashFS compression settings
270 when 'fastcomp', 'gzipcomp'
272 warn "Building a release ⇒ ignoring #{opt} build option"
273 ENV['MKSQUASHFS_OPTIONS'] = nil
275 ENV['MKSQUASHFS_OPTIONS'] = '-comp zstd -no-exports'
278 ENV['MKSQUASHFS_OPTIONS'] = nil
279 # Virtual hardware settings
280 when /machinetype=([a-zA-Z0-9_.-]+)/
281 ENV['TAILS_BUILD_MACHINE_TYPE'] = Regexp.last_match(1)
283 ENV['TAILS_BUILD_CPUS'] = Regexp.last_match(1)
284 when /cpumodel=([a-zA-Z0-9_-]+)/
285 ENV['TAILS_BUILD_CPU_MODEL'] = Regexp.last_match(1)
288 ENV['TAILS_BUILD_IGNORE_CHANGES'] = '1'
289 when /dateoffset=([-+]\d+)/
290 ENV['TAILS_DATE_OFFSET'] = Regexp.last_match(1)
291 # Developer convenience features
294 $force_cleanup = false
296 $force_cleanup = true
297 $keep_running = false
300 ENV['TAILS_BUILD_FAILURE_RESCUE'] = '1'
302 when 'nomergebasebranch'
303 $skip_mergebasebranch = true
305 raise "Unknown Tails build option '#{opt}'"
309 if ENV['TAILS_OFFLINE_MODE'] == '1' && ENV['TAILS_PROXY'].nil?
310 abort 'You must use a caching proxy when building offline'
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_RAM_BUILD'] && !enough_free_memory_for_ram_build?
495 warn <<-END_OF_MESSAGE.gsub(/^ /, '')
497 The virtual machine is not currently set with enough memory to
498 perform an in-memory build. Either remove the `ram` option from
499 the TAILS_BUILD_OPTIONS environment variable, or shut the
500 virtual machine down using `rake vm:halt` before trying again.
503 abort 'Not enough memory for the virtual machine to run an in-memory ' \
507 if ENV['TAILS_BUILD_CPUS'] \
508 && current_vm_cpus != ENV['TAILS_BUILD_CPUS'].to_i
509 warn <<-END_OF_MESSAGE.gsub(/^ /, '')
511 The virtual machine is currently running with #{current_vm_cpus}
512 virtual CPU(s). In order to change that number, you need to
513 stop the VM first, using `rake vm:halt`. Otherwise, please
514 adjust the `cpus` options accordingly.
517 abort 'The virtual machine needs to be reloaded to change the number ' \
521 exported_env = EXPORTED_VARIABLES
522 .select { |k| ENV[k] }
523 .map { |k| "#{k}='#{ENV[k]}'" }.join(' ')
526 retrieved_artifacts = false
527 run_vagrant_ssh("#{exported_env} build-tails")
528 rescue VagrantCommandError
529 retrieve_artifacts(missing_ok: true)
530 retrieved_artifacts = true
532 retrieve_artifacts(missing_ok: false) unless retrieved_artifacts
533 clean_up_builder_vms unless $keep_running
536 clean_up_builder_vms if $force_cleanup
539 desc 'Retrieve build artifacts from the Vagrant box'
540 task :retrieve_artifacts do
544 def retrieve_artifacts(missing_ok: false)
545 artifacts = list_artifacts
547 msg = 'No build artifacts were found!'
548 raise msg unless missing_ok
553 user = vagrant_ssh_config('User')
554 hostname = vagrant_ssh_config('HostName')
555 key_file = vagrant_ssh_config('IdentityFile')
556 warn 'Retrieving artifacts from Vagrant build box.'
558 "sudo chown #{user} " + artifacts.map { |a| "'#{a}'" }.join(' ')
563 # We don't want to use any identity saved in ssh agent'
564 '-o', 'IdentityAgent=none',
565 # We need this since the user will not necessarily have a
566 # known_hosts entry. It is safe since an attacker must
567 # compromise libvirt's network config or the user running the
568 # command to modify the #{hostname} below.
569 '-o', 'StrictHostKeyChecking=no',
570 '-o', 'UserKnownHostsFile=/dev/null',
572 '-o', 'Compression=no',
574 fetch_command += artifacts.map { |a| "#{user}@#{hostname}:#{a}" }
575 fetch_command << ENV['ARTIFACTS']
576 run_command(*fetch_command)
580 !capture_vagrant('box', 'list').grep(/^#{box_name}\s+\(libvirt,/).empty?
584 "#{box_name}_default"
587 def clean_up_builder_vms
588 libvirt = Libvirt.open('qemu:///system')
590 clean_up_domain = proc do |domain|
593 domain.destroy if domain.active?
597 .lookup_storage_pool_by_name('default')
598 .lookup_volume_by_name("#{domain.name}.img")
600 rescue Libvirt::RetrieveError
601 # Expected if the pool or disk does not exist
605 # Let's ensure that the VM we are about to create is cleaned up ...
606 previous_domain = libvirt.list_all_domains.find { |d| d.name == domain_name }
607 if previous_domain&.active?
609 run_vagrant_ssh('mountpoint -q /var/cache/apt-cacher-ng')
610 rescue VagrantCommandError
611 # Nothing to unmount.
613 run_vagrant_ssh('sudo systemctl stop apt-cacher-ng.service')
614 run_vagrant_ssh('sudo umount /var/cache/apt-cacher-ng')
615 run_vagrant_ssh('sudo sync')
618 run_vagrant_ssh('mountpoint -q /var/cache/tails-website')
619 rescue VagrantCommandError
620 # Nothing to unmount.
622 run_vagrant_ssh('sudo umount /var/cache/tails-website')
623 run_vagrant_ssh('sudo sync')
626 clean_up_domain.call(previous_domain)
628 # ... and the same for any residual VM based on another box (=>
629 # another domain name) that Vagrant still keeps track of.
633 open('vagrant/.vagrant/machines/default/libvirt/id', 'r', &:read)
635 libvirt.lookup_domain_by_uuid(old_domain_uuid)
636 rescue Errno::ENOENT, Libvirt::RetrieveError
637 # Expected if we don't have vagrant/.vagrant, or if the VM was
638 # undefined for other reasons (e.g. manually).
641 clean_up_domain.call(old_domain)
643 # We could use `vagrant destroy` here but due to vagrant-libvirt's
644 # upstream issue #746 we then risk losing the apt-cacher-ng data.
645 # Since we essentially implement `vagrant destroy` without this bug
646 # above, but in a way so it works even if `vagrant/.vagrant` does
647 # not exist, let's just do what is safest, i.e. avoiding `vagrant
648 # destroy`. For details, see the upstream issue:
649 # https://github.com/vagrant-libvirt/vagrant-libvirt/issues/746
650 FileUtils.rm_rf('vagrant/.vagrant')
655 desc 'Remove all libvirt volumes named tails-builder-* (run at your own risk!)'
656 task :clean_up_libvirt_volumes do
657 libvirt = Libvirt.open('qemu:///system')
659 pool = libvirt.lookup_storage_pool_by_name('default')
660 rescue Libvirt::RetrieveError
661 # Expected if the pool does not exist
663 pool.list_volumes.each do |disk|
664 next unless /^tails-builder-/.match(disk)
667 pool.lookup_volume_by_name(disk).delete
668 rescue Libvirt::RetrieveError
669 # Expected if the disk does not exist
678 !ENV['JENKINS_URL'].nil?
681 desc 'Clean up all build related files'
682 task clean_all: ['vm:destroy', 'basebox:clean_all']
685 desc 'Start the build virtual machine'
687 'parse_build_options',
688 'validate_http_proxy',
697 run_vagrant('up', '--provision')
698 rescue VagrantCommandError => e
699 clean_up_builder_vms if $force_cleanup
704 desc 'SSH into the builder VM'
709 desc 'Stop the build virtual machine'
714 desc 'Re-run virtual machine setup'
716 'parse_build_options',
717 'validate_http_proxy',
720 run_vagrant('provision')
723 desc 'Destroy build virtual machine (clean up all files except the ' \
724 "vmproxy's apt-cacher-ng data and the website cache)"
730 namespace :basebox do
731 desc 'Create and import the base box unless already done'
735 warn <<-END_OF_MESSAGE.gsub(/^ /, '')
737 This is the first time we are using this Vagrant base box so we
738 will have to bootstrap by building it from scratch. This will
739 take around 20 minutes (depending on your hardware) plus the
740 time needed for downloading around 250 MiB of Debian packages.
744 "#{VAGRANT_PATH}/definitions/tails-builder/generate-tails-builder-box.sh"
747 # Let's use an absolute path since run_vagrant changes the working
748 # directory but File.delete doesn't
749 box_path = "#{box_dir}/#{box_name}.box"
750 run_vagrant('box', 'add', '--name', box_name, box_path)
751 File.delete(box_path)
754 def basebox_date(box)
755 Date.parse(/^tails-builder-[^-]+-[^-]+-(\d{8})/.match(box)[1])
759 capture_vagrant('box', 'list')
761 .grep(/^tails-builder-.*/)
762 .map { |x| x.chomp.sub(/\s.*$/, '') }
765 def clean_up_basebox(box)
766 run_vagrant('box', 'remove', '--force', box)
768 libvirt = Libvirt.open('qemu:///system')
770 .lookup_storage_pool_by_name('default')
771 .lookup_volume_by_name("#{box}_vagrant_box_image_0.img")
773 rescue Libvirt::RetrieveError
774 # Expected if the pool or disk does not exist
780 desc 'Remove all base boxes'
782 baseboxes.each { |box| clean_up_basebox(box) }
785 desc 'Remove all base boxes older than six months'
788 # We always want to keep the newest basebox
789 boxes.sort! { |a, b| basebox_date(a) <=> basebox_date(b) }
792 clean_up_basebox(box) if basebox_date(box) < Date.today - 365.0 / 2.0