| require 'open3' |
| |
| SERVICE='web' # name must agree with services entry in docker-compose.yaml |
| |
| # N.B. this file must be invoked from its containing directory. |
| # It assumes that it will be run from the top of the Whimsy code tree |
| |
| def mkdir_p?(path) |
| mkdir_p path unless Dir.exist? path |
| end |
| |
| # Run system and abort if it fails |
| def system!(*args) |
| system(*args) or raise 'system!() failed!' |
| end |
| |
| # update gems and restart applications as needed |
| task :update, [:command] do |_task, args| |
| # determine last update time of library sources |
| lib_update = Dir['lib/**/*'].map {|n| File.mtime n rescue Time.at(0)}.max |
| |
| # restart passenger applications that have changed since the last update |
| # If a Gem is later updated below, any passenger app is restarted again. |
| # Most of the time, no Gems are installed, so this deploys changes quicker |
| Dir['**/config.ru'].each do |rackapp| |
| Dir.chdir File.dirname(rackapp) do |
| old_baseline = File.mtime('tmp/restart.txt') rescue Time.at(0) |
| last_update = Dir['**/*'].map {|n| File.mtime n rescue Time.at(0)}.max |
| if [lib_update, last_update].max > old_baseline and Dir.exist? 'tmp' |
| FileUtils.touch 'tmp/.restart.txt' |
| FileUtils.chmod 0777, 'tmp/.restart.txt' |
| FileUtils.mv 'tmp/.restart.txt', 'tmp/restart.txt' |
| end |
| end |
| end |
| |
| # locate system ruby |
| sysruby = File.realpath(`which ruby`.chomp) |
| # N.B. The %s is used to insert related commands such as 'bundle' later on |
| sysruby = File.join(File.dirname(sysruby), "%s#{sysruby[/ruby([.\d]*)$/, 1]}") |
| |
| # locate passenger ruby |
| conf = Dir['/etc/apache2/*/passenger.conf'].first |
| ruby = File.read(conf)[/PassengerRuby "?(.*?)"?$/, 1] if conf |
| if ruby |
| # create the base format string |
| passruby = File.join(File.dirname(ruby), "%s#{ruby[/ruby([.\d]*)$/, 1]}") |
| else |
| passruby = sysruby |
| end |
| |
| require 'bundler' |
| unless Bundler.bundle_path.writable? |
| # collect up all gems and install them so the sudo password is only |
| # asked for once |
| gemlines = Dir['**/Gemfile']. |
| map {|file| File.read file}.join.scan(/^\s*gem\s.*/) |
| |
| if File.exist? 'asf.gemspec' |
| gemlines += |
| File.read('asf.gemspec').scan(/add_dependency\((.*)\)/). |
| map {|(line)| "gem #{line}"} |
| end |
| |
| gems = gemlines.map {|line| [line[/['"](.*?)['"]/, 1], line.strip]}.to_h |
| gems['whimsy-asf'].sub!(/,.*/, ", path: #{Dir.pwd.inspect}") |
| |
| ldapname = |
| begin |
| File.read(File.expand_path('../asfldap.gemname', __FILE__)).strip |
| rescue Exception => e |
| 'ruby-ldap' |
| end |
| |
| # Also need to define version for wunderbar as per the asf.gemspec file |
| require 'tmpdir' |
| Dir.mktmpdir do |dir| |
| Dir.chdir dir do |
| contents = [ |
| "source 'https://rubygems.org'", |
| "ldapname = '#{ldapname}'", |
| 'ldapversion = nil', # Needed for initial gem setup |
| gems.values |
| ].join("\n") |
| File.write 'Gemfile', contents |
| $stderr.puts '* Preloading gems...' |
| system!('bundle', 'install') |
| $stderr.puts '* ... done' |
| end |
| end |
| end |
| |
| # update gems |
| $stderr.puts '* Update Gems' # use stderr so output appears in syslog |
| Dir['**/Gemfile'].each do |gemfile| |
| Dir.chdir File.dirname(gemfile) do |
| ruby = File.read('Gemfile')[/^ruby ['"](.*?)['"]/, 1] |
| ruby = `which ruby#{ruby}`.chomp if ruby |
| if ruby and not ruby.empty? |
| bundler = File.join(File.dirname(ruby), "bundle#{ruby[/ruby([.\d]*)$/, 1]}") |
| else |
| bundler = (File.exist?('config.ru') ? passruby : sysruby) % 'bundle' |
| end |
| |
| locktime = File.mtime('Gemfile.lock') rescue Time.at(0) |
| |
| bundler = 'bundle' unless File.exist?(bundler) |
| $stderr.puts "* Processing #{gemfile}" |
| system!(bundler, args.command || 'update') |
| |
| # if new gems were installed and this directory contains a passenger |
| # application, restart it |
| if (File.mtime('Gemfile.lock') rescue Time.at(0)) != locktime |
| $stderr.puts '* Gemfile.lock was updated' |
| if File.exist?('tmp/restart.txt') |
| FileUtils.touch 'tmp/.restart.txt' |
| FileUtils.chmod 0o777, 'tmp/.restart.txt' |
| FileUtils.mv 'tmp/.restart.txt', 'tmp/restart.txt' |
| end |
| end |
| end |
| end |
| |
| # rebuild API documentation |
| Rake::Task['rdoc'].invoke |
| end |
| |
| # pristine version of update |
| task :pristine do |
| Rake::Task[:update].invoke('pristine') |
| end |
| |
| # This requires Gems such as Wunderbar to have been set up |
| task :config do |
| $LOAD_PATH.unshift 'lib' |
| require 'wunderbar' |
| require 'whimsy/asf/config' |
| require 'whimsy/asf/git' |
| require 'whimsy/asf/svn' |
| require 'whimsy/lockfile' |
| end |
| |
| namespace :svn do |
| task :update, [:arg1] => :config do |task, args| |
| arg1 = args.arg1 || '' # If defined, it is either the name of a checkout to update or 'skip' |
| options = [arg1, args.extras].flatten # capture all options |
| # Include all |
| svnrepos = ASF::SVN.repo_entries(true) || {} |
| |
| # must be outside loop |
| PREFIX = '#!:' # must agree with monitors/svn.rb |
| |
| # checkout/update svn repositories |
| svn = ASF::Config.get(:svn) |
| svn = Array(svn).find {|path| String === path and path.end_with? '/*'} |
| if svn.instance_of? String and svn.end_with? '/*' |
| mkdir_p? File.dirname(svn) |
| Dir.chdir File.dirname(svn) do |
| svnrepos.each do |name, description| |
| # skip the update unless it matches the parameter (if any) provided |
| # 'skip' is special and means update all list files |
| # The empty string means no options provided |
| next unless ['skip', ''].include?(arg1) || options.include?(name) |
| puts |
| puts File.join(Dir.pwd, name) |
| if description['list'] |
| puts "#{PREFIX} Updating listing file" |
| old,new = ASF::SVN.updatelisting(name,nil,nil,description['dates']) |
| if old == new |
| puts "List is at revision #{old}." |
| elsif old.nil? |
| puts new |
| else |
| puts "List updated from #{old} to revision #{new}." |
| end |
| end |
| svnpath = ASF::SVN.svnurl(name) |
| depth = description['depth'] || 'infinity' |
| noCheckout = %w(delete skip).include? depth |
| if Dir.exist? name |
| if noCheckout |
| puts "#{PREFIX} Removing #{name} as it is not intended for checkout" |
| FileUtils.rm_rf name # this will remove symlink only (on macOS at least) |
| else |
| curpath = ASF::SVN.getInfoItem(name,'url') |
| if curpath != svnpath |
| puts "Removing #{name} to correct URL: #{curpath} => #{svnpath}" |
| FileUtils.rm_rf name # this will remove symlink only (on macOS at least) |
| end |
| end |
| end |
| |
| next if arg1 == 'skip' |
| if noCheckout |
| puts 'Skipping' if depth == 'skip' # Must agree with monitors/svn.rb |
| next |
| end |
| |
| files = description['files'] |
| if Dir.exist? name |
| isSymlink = File.symlink?(name) # we don't want to change such checkouts |
| Dir.chdir(name) { |
| # ensure single-threaded SVN updates |
| LockFile.lockfile(Dir.pwd, nil, File::LOCK_EX) do # ignore the return parameter |
| system!('svn', 'cleanup') |
| unless isSymlink # Don't change depth for symlinks |
| curdepth = ASF::SVN.getInfoAsHash('.')['Depth'] || 'infinity' # not available as separate item |
| if curdepth != depth |
| puts "#{PREFIX} update depth from '#{curdepth}' to '#{depth}'" |
| system!('svn', 'update', '--set-depth', depth) |
| end |
| end |
| outerr = nil |
| # svn update can fail sometimes, so we retry |
| 2.times do |i| |
| if i > 0 |
| # log the failure - prefix tells monitor to ignore it |
| puts "#{PREFIX} failed!" |
| outerr.split("\n").each do |l| |
| puts "#{PREFIX} #{l}" |
| end |
| n = 10 |
| puts "#{PREFIX} will retry in #{n} seconds" |
| sleep n |
| end |
| begin |
| r, w = IO.pipe |
| # Note: list the files to update to cater for later additions |
| # Also update '.' so parent directory shows last changed revision for status/svn page |
| svncmd = %w(svn update .) |
| # '.' is redundant if files not present, but it simplifies logic |
| if files |
| svncmd += files |
| end |
| puts "#{PREFIX} #{svncmd.join(' ')}" |
| _pid = Process.spawn(*svncmd, out: w, err: [:child, :out]) |
| w.close |
| |
| _pid, status = Process.wait2 |
| outerr = r.read |
| r.close |
| |
| if status.success? |
| break |
| end |
| rescue StandardError => e |
| outerr = e.inspect |
| break |
| end |
| end |
| |
| puts outerr # show what happened last |
| end # lockfile |
| } # chdir |
| else # directory does not exist |
| # Don't bother locking here -- it should be very rarely needed |
| system!('svn', 'checkout', "--depth=#{depth}", svnpath, name) |
| if files |
| system!('svn', 'update', *files, {chdir: name}) |
| end |
| end |
| # check that explicitly required files exist |
| files&.each do |file| |
| path = File.join(name, file) |
| puts "Missing: #{path}" unless File.exist? path |
| end |
| end |
| end |
| end |
| end |
| |
| task :check => :config do |
| # check if the svn repositories have been set up OK |
| svnrepos = ASF::SVN.repo_entries || {} |
| errors = 0 |
| svn = ASF::Config.get(:svn) |
| if svn.instance_of? String and svn.end_with? '/*' |
| Dir.chdir File.dirname(svn) do |
| svnrepos.each do |name, description| |
| puts |
| puts File.join(Dir.pwd, name) |
| if Dir.exist? name |
| hash, err = ASF::SVN.getInfoAsHash(name) |
| if hash |
| urlact = hash['URL'] |
| urlexp = description['url'] |
| unless urlact.end_with? urlexp # urlexp is relative only |
| puts "URL: #{urlact} expected to end with #{urlexp}" |
| errors += 1 |
| end |
| depthact = hash['Depth'] || 'infinity' |
| depthexp = description['depth'] || 'infinity' |
| unless depthact == depthexp |
| puts "Depth: #{depthact} expected to be #{depthexp}" |
| errors += 1 |
| end |
| else |
| puts "Error getting details for #{name}: #{err}" |
| errors += 1 |
| end |
| else |
| puts "Directory not found - expecting checkout of #{ASF::SVN.svnpath!(name)}" |
| errors += 1 |
| end |
| end |
| end |
| puts |
| if errors > 0 |
| puts "** Found #{errors} error(s) **" |
| else |
| puts '** No errors found **' |
| end |
| end |
| end |
| |
| end |
| |
| namespace :git do |
| task :pull => :config do |
| gitrepos = ASF::Git.repo_entries() || {} |
| |
| # clone/pull git repositories |
| git = ASF::Config.get(:git) |
| if git.instance_of? String and git.end_with? '/*' |
| mkdir_p? File.dirname(git) |
| Dir.chdir File.dirname(git) do |
| require 'uri' |
| base = URI.parse('git://git.apache.org/') |
| gitrepos.each do |name, description| |
| unless description |
| puts "Skipping git:pull of #{name} because no details were found" |
| next |
| end |
| branch = description['branch'] |
| |
| puts |
| puts File.join(Dir.pwd, name) |
| |
| if Dir.exist? name |
| Dir.chdir(name) do |
| # update the location of the remote, if necessary |
| remote = `git config --get remote.origin.url`.chomp |
| if remote != (base + description['url']).to_s |
| `git config remote.origin.url #{base + description['url']}` |
| end |
| |
| # pull changes |
| system!('git', 'checkout', branch) if branch |
| system!('git', 'fetch', 'origin') |
| system!('git', 'reset', '--hard', "origin/#{branch || 'master'}") |
| end |
| else |
| depth = description['depth'] |
| |
| # fresh checkout |
| if depth |
| system!('git', 'clone', '--depth', depth.to_s, (base + description['url']).to_s, name) |
| else |
| system!('git', 'clone', (base + description['url']).to_s, name) |
| end |
| system!('git', 'checkout', branch, {chdir: name}) if branch |
| end |
| end |
| end |
| end |
| end |
| end |
| |
| # update documentation |
| task :rdoc => 'www/docs/api/index.html' |
| file 'www/docs/api/index.html' => Rake::FileList['lib/whimsy/**/*.rb'] do |
| # remove old files first |
| FileUtils.remove_dir(File.join(File.dirname(__FILE__),'www/docs/api'), true) # ignore error if missing |
| system!('rdoc', 'lib/whimsy', '--output', 'www/docs/api', '--force-output', |
| '--title', 'whimsy/asf lib', {chdir: File.dirname(__FILE__)}) |
| end |
| |
| # Travis support: run the tests associated with the bundle in question |
| task :default do |
| bg = ENV['BUNDLE_GEMFILE'] |
| if bg and bg != __FILE__ |
| Dir.chdir File.dirname(bg) do |
| sh 'rake test' |
| end |
| end |
| end |
| |
| # Temporary files used to propagate settings into container |
| LDAP_HTTPD_PATH = '../.ldap_httpd.tmp' |
| LDAP_WHIMSY_PATH = '../.ldap_whimsy.tmp' |
| |
| # Allow use of security database on macOS |
| # Keychain needs to be set up with an application password |
| # with the Account value of the user_dn |
| def getpass(user_dn) |
| pw = $stdin.getpass("password for #{user_dn}: ") |
| return pw unless pw == '*' |
| if RbConfig::CONFIG['host_os'].start_with? 'darwin' |
| pw, status = Open3.capture2('security', 'find-generic-password', '-a', user_dn, '-w') |
| raise "ERROR: problem running security: #{status}" unless status.success? |
| else |
| raise "ERROR: sorry, don't know how to get password from secure storage" |
| end |
| return pw.strip |
| end |
| |
| def ldap_init |
| $LOAD_PATH.unshift 'lib' |
| require 'io/console' # cannot prompt from container, so need to do this upfront |
| require 'whimsy/asf/config' |
| |
| whimsy_dn = ASF::Config.get(:whimsy_dn) or raise 'ERROR: Must provide whimsy_dn value in .whimsy' |
| whimsy_pw = getpass(whimsy_dn) |
| raise 'ERROR: Password is required' unless whimsy_pw.size > 1 |
| |
| httpd_dn = ASF::Config.get(:httpd_dn) |
| if httpd_dn |
| httpd_pw = getpass(httpd_dn) |
| raise 'ERROR: Password is required' unless httpd_pw.size > 1 |
| else # default to whimsy credentials |
| httpd_dn = whimsy_dn |
| httpd_pw = whimsy_pw |
| end |
| File.open(LDAP_HTTPD_PATH, 'w', 0o600) do |w| |
| w.puts httpd_dn |
| w.puts httpd_pw |
| end |
| File.open(LDAP_WHIMSY_PATH, 'w', 0o600) do |w| |
| w.puts whimsy_dn |
| w.puts whimsy_pw |
| end |
| end |
| |
| # Process template files replacing variable references |
| def filter(src, dst, ldaphosts, ldapbinddn, ldapbindpw) |
| require 'erb' |
| template = ERB.new(File.read(src)) |
| File.open(dst, 'w') do |w| |
| w.write(template.result(binding)) |
| end |
| end |
| |
| # Set up LDAP items in container context |
| def ldap_setup |
| # Link to file in running container |
| FileUtils.cp LDAP_WHIMSY_PATH, '/tmp/ldap.tmp' |
| FileUtils.rm_f LDAP_WHIMSY_PATH # remove work file |
| FileUtils.chown 'www-data', 'www-data', '/tmp/ldap.tmp' |
| ln_sf '/tmp/ldap.tmp', '/srv/ldap.txt' |
| |
| ldapbinddn = ldapbindpw = nil |
| File.open(LDAP_HTTPD_PATH, 'r') do |r| |
| ldapbinddn = r.readline.strip |
| ldapbindpw = r.readline.strip |
| end |
| FileUtils.rm_f LDAP_HTTPD_PATH # remove work file |
| |
| $LOAD_PATH.unshift 'lib' |
| require 'whimsy/asf/config' |
| hosts = ASF::Config.get(:ldap) |
| raise 'ERROR: Must define :ldap in ../.whimsy' unless hosts |
| |
| ldaphosts = hosts.join(' ').gsub('ldaps://', '') |
| |
| filter('docker-config/whimsy.conf', |
| '/etc/apache2/sites-enabled/000-default.conf', ldaphosts, ldapbinddn, ldapbindpw) |
| filter('docker-config/25-authz_ldap_group_membership.conf', |
| '/etc/apache2/conf-enabled/25-authz_ldap_group_membership.conf', ldaphosts, ldapbinddn, ldapbindpw) |
| # Add the URI and BASE for use by ldapsearch from shell |
| File.open("/etc/ldap/ldap.conf",'a+') do |f| |
| f.puts "URI #{hosts.join(' ')}" |
| f.puts "BASE dc=apache,dc=org" |
| end |
| end |
| |
| # Docker support |
| namespace :docker do |
| task :build do |
| sh "docker compose build #{SERVICE}" |
| end |
| |
| task :update => :build do |
| sh 'docker compose run --entrypoint ' + |
| %('bash -c "rake update"') + |
| " #{SERVICE}" |
| end |
| |
| task :up do |
| ldap_init # create LDAP config data files |
| # Start the container which then runs 'rake docker:entrypoint' |
| sh 'docker compose up' |
| end |
| |
| task :exec do |
| sh "docker compose exec #{SERVICE} /bin/bash" |
| end |
| |
| task :bash do |
| sh "docker compose run --rm --entrypoint /bin/bash #{SERVICE}" |
| end |
| |
| # cannot depend on :config |
| # It runs in container, and needs to occur first |
| task :scaffold do |
| |
| # This should already exist, but just in case |
| mkdir_p? '/srv/whimsy/www/members' |
| |
| unless File.exist? '/srv/whimsy/www/members/log' |
| ln_s '/var/log/apache2', '/srv/whimsy/www/members/log' |
| end |
| |
| begin |
| mode = File.stat('/var/log/apache2').mode |
| if mode & 7 != 5 |
| chmod 0o755, '/var/log/apache2' |
| end |
| # ensure log files are readable |
| sh 'chmod 0644 /var/log/apache2/*.log' |
| rescue StandardError => e |
| puts e.inspect |
| end |
| |
| # Create other needed directories |
| mkdir_p? '/srv/cache' |
| mkdir_p? '/srv/mail/secretary' |
| # The list-* files are pushed from the mailing list server to the live Whimsy |
| # ensure there are empty files here |
| mkdir_p? '/srv/subscriptions' |
| Dir.chdir '/srv/subscriptions' do |
| # start is done first by the server |
| %w{start allows counts denys digests flags mods sendsubscribertomods subs}.each do |suffix| |
| file = "list-#{suffix}" |
| FileUtils.touch file unless File.exist? file |
| end |
| end |
| # in case |
| mkdir_p? '/srv/whimsy/www/docs/api' |
| # there may be more |
| |
| # add support for CLI use |
| unless File.exist? '/root/.bash_aliases' |
| ln_s '/srv/.bash_aliases', '/root/.bash_aliases' |
| end |
| |
| # Allow logs to be written to host system |
| if Dir.exist? '/srv/apache2_logs' |
| FileUtils.rm_rf '/var/log/apache2' |
| ln_s '/srv/apache2_logs', '/var/log/apache2' |
| end |
| |
| ldap_setup # set up LDAP entries in container |
| end |
| |
| # This is the entrypoint in the Dockerfile so runs in the container |
| task :entrypoint => [:scaffold] do |
| sh 'apache2ctl -DFOREGROUND' |
| end |
| end |