#!/local/usr/ruby/shims/ruby
# The first line is for deployment machines only. For local machines, use:
#!/usr/bin/env ruby

# You can find more extensive documentation of this script at
# https://github.com/ontohub/ontohub/blob/staging/doc/backup_and_restore_of_ontohub_data.md

# Description
#   This backup script creates and restores backups of ontohub data. It includes:
#     * bare git repositories (data/repositories)
#     * named symlinks to git repositories (data/git_daemon and data/git_ssh)
#     * the postgres database
#
# Usage
#   First note: Run this as the root user, e.g. with sudo.
#   To create a backup, run this script with the argument `create`:
#     # script/backup create
#   Then a backup named with the current date and time is created in the
#   backup directory (see below).
#
#   To restore a backup, run this script with the argument `restore <backup name>`
#     # script/backup restore 2015-01-01_00-00
#   Then the selected backup is fully restored
#
# Backup directory
#   For production machines, the backup directory is:
#     /data/ontohub_data_backup
#
# Super user privileges
#   To create and restore, we need root privileges. Otherwise file modes are not
#   preserved. This script will call `sudo` when needed and inform you about the
#   reason for calling `sudo`. If you don't allow sudo, a backup will be created
#   or restored anyway, but the file modes and ownership are not preserved.
#   Then, you need to adjust them manually.
#
# Maintenance mode
#   While backing up and restoring the data, the maintenance mode is activated.
#   This way we guarantee data consistency of the backup.


require 'tmpdir.rb'
require 'fileutils'
require 'pathname'
require 'open3'

EXIT_CODES = {
  backup_missing: 1,
  maintenance_already_enabled: 2,
  wrong_user: 3,
  target_not_a_directory: 4,
  bad_arguments: 5,
}

module Backup
  class Backup
    # Amount of backups that have to be there at least
    BACKUPS_COUNT = 30
    # Backups are kept for at least 365 days
    BACKUPS_VALIDITY_TIME = 365 * 60 * 60 * 24

    MAINTENANCE_FILE = 'maintenance.txt'

    SQL_DUMP_FILE = 'ontohub_sql_dump.postgresql'
    REPOSITORY_FILE = 'ontohub_repositories.tar.gz'

    DATA_DIRS = %w(repositories git_daemon git_ssh)

    # Use 'sudo' on most systems
    SUDO_BINARY = '+'

    attr_reader :db_name, :data_root, :backup_root, :backup_instance_dir
    attr_reader :dry_run, :verbose, :sql_dump_as_db_user

    def initialize(db_name, data_root, backup_root,
      verbose: false, dry_run: true, sql_dump_as_db_user: nil,
      user: nil, group: nil)
      @db_name = db_name
      @backup_root = Pathname.new(backup_root)
      @data_root = Pathname.new(data_root)
      @data_root_basename = @data_root.basename.to_s
      @data_dirs = DATA_DIRS.map { |dir| File.join(@data_root_basename, dir) }
      @user = user
      @group = group

      @dry_run = dry_run
      @verbose = verbose
      @sql_dump_as_db_user = sql_dump_as_db_user
    end

    def create
      puts 'Creating backup...'
      enable_maintenance_mode
      initialize_backup
      create_sql_dump
      create_repository_archive
      # We needed to create the directory for the script to continue later on.
      Dir.rmdir(backup_instance_dir) if dry_run
      disable_maintenance_mode
      puts "Created backup in #{backup_instance_dir}"
      self.class.prune(backup_root)
    end

    def restore(backup_name)
      enable_maintenance_mode
      initialize_restore(backup_name)
      restore_sql_dump
      restore_repository_archive
      disable_maintenance_mode
      puts "Restored backup from #{backup_instance_dir}"
    end

    def self.prune(backup_root)
      if !Dir.exists?(backup_root)
        $stderr.puts "Nothing to prune: There is no backup directory."
        return
      end
      now = Time.now
      backup_dirs_allowed_to_delete(Dir.new(backup_root).entries).each do |dir|
        backup = backup_root.join(dir)
        if now - File.new(backup).ctime > BACKUPS_VALIDITY_TIME
          puts "removing old backup: #{dir}"
          FileUtils.rm_r(backup)
        end
      end
    end

    protected

    def new_backup_name
      Time.now.strftime("%Y-%m-%d_%H-%M-%S")
    end

    def initialize_backup
      @backup_instance_dir = backup_root.join(new_backup_name)
      puts "FileUtils.mkdir_p #{backup_instance_dir}" if verbose
      # Create directory even in dry run to let the script continue.
      FileUtils.mkdir_p(backup_instance_dir)
      puts "FileUtils.chown #{@user} #{@group} #{backup_instance_dir}" if verbose
      FileUtils.chown(@user, @group, backup_instance_dir)
    end

    def create_sql_dump
      puts 'Creating SQL dump...'
      Dir.chdir(backup_instance_dir) do
        exec('pg_dump', *pg_user_switch, '-Fc', db_name,
             '-f', backup_instance_dir.join(SQL_DUMP_FILE), user: @user)
      end
    end

    def create_repository_archive
      puts 'Creating repository archive...'
      Dir.chdir(data_root.join('..')) do
        archive_file = backup_instance_dir.join(REPOSITORY_FILE)
        exec('tar', verbose ? '-v' : '', '-czf', archive_file.to_s, *@data_dirs,
             user: @user)
      end
    end

    def initialize_restore(backup_name)
      @backup_instance_dir = backup_root.join(backup_name)
      unless Dir.exists?(backup_instance_dir)
        $stderr.puts (
          "Error: Backup '#{backup_name}' does not exist in #{backup_root}.")
        exit EXIT_CODES[:backup_missing]
      end
    end

    def restore_sql_dump
      'Restoring SQL dump...'
      Dir.chdir(backup_instance_dir) do
        exec('pg_restore', '-n', 'public',
             '-c', *pg_user_switch,
             '-d', db_name,
             SQL_DUMP_FILE,
             user: @user)
      end
    end

    def restore_repository_archive
      puts 'Restoring repository archive...'
      Dir.chdir(data_root.join('..')) do
        tmpdir = Dir.mktmpdir
        move_data_dirs_to_tmpdir(tmpdir)
        begin
          extract_archive
          remove_tmpdir(tmpdir)
        rescue => e
          puts <<-MSG

An error occured while restoring the repositories:
#{e.message}
You can find the pre-restore repositories at #{tmpdir}
Do something about it.
          MSG
          raise e
        end
      end
    end

    def move_data_dirs_to_tmpdir(tmpdir)
      puts "FileUtils.mv(#{@data_dirs}, #{tmpdir})" if verbose
      FileUtils.mv(@data_dirs, tmpdir) unless dry_run
    rescue Errno::EACCES
      puts <<-MSG

As the current user I have no access to move the repository data
directories #{@data_dirs.join(' ')} to a temporary directory #{tmpdir}.
This is used as a backup for the case of an error while restoring.
To continue, I try the command again using sudo.
      MSG
      exec('mv', *@data_dirs, tmpdir, user: 'root') unless dry_run
    end

    def extract_archive
      archive_file = backup_instance_dir.join(REPOSITORY_FILE)
      puts <<-MSG

Super user privileges are needed to reset the file permissions as
they were before the backup. If you refuse to enter the password
(Ctl-C) or enter a wrong password, only the permissions will not be
restored and all restored files will belong to the current user/group.
      MSG
      exec('tar', verbose ? 'vxf' : 'xf', archive_file.to_s, *@data_dirs,
           user: 'root')
    end

    def remove_tmpdir(tmpdir)
      puts "FileUtils.remove_entry(#{tmpdir})" if verbose
      FileUtils.remove_entry(tmpdir) # even do this in dry run
    rescue Errno::EACCES
      puts <<-MSG

As the current user I have no access to remove the temporary
directory #{tmpdir}.
To continue, I try the command again using sudo.
      MSG
      exec('rm', '-r', tmpdir, user: 'root')
    end

    def enable_maintenance_mode
      puts 'Enabling maintenance mode...'
      if File.exist?(maintenance_file)
        $stderr.puts 'Maintenance mode was already enabled.'
        $stderr.puts "Please check the file #{maintenance_file}"
        $stderr.puts 'Aborting.'
        exit EXIT_CODES[:maintenance_already_enabled]
      end
      puts "FileUtils.touch #{maintenance_file}" if verbose
      FileUtils.touch maintenance_file unless dry_run
    end

    def disable_maintenance_mode
      puts 'Disabling maintenance mode...'
      puts "FileUtils.rm #{maintenance_file}" if verbose
      FileUtils.rm maintenance_file unless dry_run
    end

    # Execute a command as the given user.
    def exec(*args, user: nil)
      print "[executing next command in #{Dir.getwd}" if verbose
      print " as user #{user}" if verbose && user
	  puts "]" if verbose

      out = args.join(' ')
      puts out if verbose
      if !dry_run
        if user == 'root'
          exec_system(*[sudo, *args])
        elsif user
          # This looks strange because of the combination of + and sudo.
          # It is needed on our deployment machines to get the environment right.
          # On other machines, remove the call of +.
          exec_system(*['+', 'sudo', '-u', user, 'bash', '-c',
                        "cd #{Dir.getwd} && #{escape_arguments(args)}"])
        else
          exec_system(*args)
        end
      end
    end

    def sudo
      SUDO_BINARY
    end

    def exec_system(*args)
      # puts args.join(' ') # For debugging
      system(*args)
    end

    def escape_arguments(args)
      rest = args[1..-1].map do |arg|
        if arg.to_s.include?(' ')
          %("#{arg.gsub('"', '\"')}")
        else
          arg
        end
      end
      ([args[0]] + rest).join(' ')
    end

    def maintenance_file
      data_root.join(MAINTENANCE_FILE)
    end

    def pg_user_switch
      sql_dump_as_db_user ? %W(-U #{sql_dump_as_db_user}) : []
    end

    def self.backup_dirs_allowed_to_delete(entries)
      entries.reject{ |entry| %w(. ..).include?(entry) }[0..-(BACKUPS_COUNT+1)]
    end
  end
end

def data_root(rails_root)
  ENV['DATA_ROOT'] ||'/data/git'
end

# Don't allow this to be run as the root user.
if ENV['USER'] != 'root'
  puts 'Running this script as a normal user is disabled.'
  puts 'Please run it as root.'
  exit EXIT_CODES[:wrong_user]
end

# We assume, this script runs in "RAILS_ROOT/script/".
RAILS_ROOT = Pathname.new(__FILE__).dirname.join('..')
BACKUP_ROOT_PRODUCTION = '/data/ontohub_data_backup'

USER = 'ontohub'
GROUP = 'webservd'

DATABASE = 'ontohub'

unless File.exist?(BACKUP_ROOT_PRODUCTION)
  FileUtils.mkdir_p(BACKUP_ROOT_PRODUCTION)
end
BACKUP_ROOT = File.realpath(BACKUP_ROOT_PRODUCTION)
unless File.directory?(BACKUP_ROOT)
  $stderr.puts "Target path is not a directory: #{BACKUP_ROOT}"
  exit EXIT_CODES[:target_not_a_directory]
end

backup = Backup::Backup.new(DATABASE, data_root(RAILS_ROOT), BACKUP_ROOT,
  sql_dump_as_db_user: 'ontohub',
  user: USER, group: GROUP,
  dry_run: false, verbose: true)

case ARGV.first
when 'create'
  backup.create
when 'restore'
  if ARGV.length == 1
    $stderr.puts(
      'To restore a backup, you need to specify one with the arguments')
    $stderr.puts('"restore backup_name"')
    exit EXIT_CODES[:bad_arguments]
  end
  backup_name = ARGV[1]
  backup.restore(backup_name)
when 'prune'
  Backup::Backup.prune(BACKUP_ROOT)
else
  $stderr.puts 'unknown or missing parameter'
  $stderr.puts 'use parameter "create" or "restore <backup_name>" or "prune"'
  exit EXIT_CODES[:bad_arguments]
end
