postgresql_backup.rb   [plain text]


#!/usr/bin/ruby
#
# postgresql_backup.rb - PostgreSQL Service backup and restore plugin for ServerBackup.
# Consolidates the _backup, _restore, and _verify functions in a single tool
#
# Author:: Apple Inc.
# Documentation:: Apple Inc.
# Copyright (c) 2011-2012 Apple Inc. All Rights Reserved.
#
# IMPORTANT NOTE: This file is licensed only for use on Apple-branded
# computers and is subject to the terms and conditions of the Apple Software
# License Agreement accompanying the package this file is a part of.
# You may not port this file to another platform without Apple's written consent.
# License:: All rights reserved.
#

require 'digest'
require 'ftools'
require 'logger'
require 'optparse'
require 'ostruct'
require 'shellwords'

$: << File.dirname(File.expand_path(__FILE__))
require 'backuptool'
require 'sysexits'

include SysExits


# == Apple-internal documentation
#
# == PostgreSQLTool
#
# === Description
#
# PostgreSQLTool is a subclass of BackupTool, processing PostgreSQL-specific
# functionality for ServerBackup(8).
#
# === Further documentation
#
# For information on how this class is typically invoked, see ServerBackup(8).
#
class PostgreSQLTool < BackupTool
	#
	# Constants
	#
	BACKUP_DIR = "/Library/Server/PostgreSQL/Backup"
	BACKUP_FILE_UNCOMPRESSED = "dumpall.psql"
	BACKUP_FILE = "dumpall.psql.gz"
	DB_DIR = "/Library/Server/PostgreSQL/Data"
	SECRET_DIR = "/.ServerBackups/postgresql"
	LOG_DIR = "/Library/Logs/PostgreSQL"
	SOCKET_DIR = "/var/pgsql_socket"

	#
	# Class Methods
	#
	def initialize
		super("postgres", "1.2")
		self
	end

	#
	# Instance Methods
	#

	# Get the current database location
	def dataDir
		dataDir = self.setting("postgres:dataDir")
		if (dataDir.nil? || dataDir.empty? || dataDir["/Library/Server/PostgreSQL/Data"])
			$log.warn("Error determining data directory; using default.")
			return DB_DIR
		end
		$log.debug("Service data directory is #{dataDir}")
		return dataDir
	end

	# Get the current database backup location
	def backupDir
		dataDir = self.dataDir
		if (dataDir.nil? || dataDir.empty? || dataDir["/Library/Server/PostgreSQL/Data"])
			return BACKUP_DIR
		end
		return dataDir.sub(/Data\z/, "Backup")
	end

	# Get the current socket directory.
	def socketDir
		return self.setting("postgres:unix_socket_directory", SOCKET_DIR)
	end

	# Primary operations

	# Validate arguments and backup this service
	def backup
		status = EX_OK
		unless (@options && @options[:path] && @options[:dataset])
			status = EX_USAGE
			raise OptionParser::InvalidArgument, "Missing arguments for 'backup'."
		end
		# Only attempt backup if the service is running
		state = false
		self.launch("/Applications/Server.app/Contents/ServerRoot/usr/sbin/serveradmin status postgres") do |output|
			state = ((/RUNNING/ =~ output) != nil)
		end
		orig_state = state
		$log.debug("@options = #{@options.inspect}")
		archive_dir = @options[:path]
		unless (archive_dir[0] == ?/)
			status = EX_USAGE
			raise OptionParser::InvalidArgument, "Paths must be absolute."
		end
		what = @options[:dataset]
		unless self.class::DATASETS.include?(what)
			status = EX_USAGE
			raise OptionParser::InvalidArgument, "Unknown data set '#{@options[:dataset]}' specified."
		end
		# The passed :archive_dir and :what are ignored because the dump is put
		# on the live data volume
		archive_dir = self.backupDir
		dump_file = "#{archive_dir}/#{BACKUP_FILE}"
		dump_file_uncompressed = "#{archive_dir}/#{BACKUP_FILE_UNCOMPRESSED}"
		# Create the backup directory as necessary.
		unless File.directory?(archive_dir)
			if File.exists?(archive_dir)
				$log.info "Moving aside #{archive_dir}...\n"
				FileUtils.mv(archive_dir, archive_dir + ".applesaved")
			end
			$log.info "Creating backup directory: #{archive_dir}...\n"
			FileUtils.mkdir_p(archive_dir, :mode => 0700)
			# _postgres:_postgres has uid:gid of 216:216
			File.chown(216, 216, archive_dir)
		end
		# Backup only once a day
		mod_time = File.exists?(dump_file) ? File.mtime(dump_file) : Time.at(0)
		if (Time.now - mod_time) >= (24 * 60 * 60)
			# Attempt to start the service if needed
			if (! state)
				self.launch("/Applications/Server.app/Contents/ServerRoot/usr/sbin/serveradmin start postgres") do |output|
					state = ((/RUNNING/ =~ output) != nil)
				end
			end
			if (! state)
				$log.info "PostgreSQL is not running, skipping database backup"
				return status
			end

			$log.info "Creating dump file \'#{dump_file}\'..."
			system("/Applications/Server.app/Contents/ServerRoot/usr/bin/pg_dumpall -U _postgres > #{dump_file_uncompressed.shellescape}")
			if ($?.exitstatus != 0)
				$log.error "...Backup failed on pg_dumpall, Status=#{$?.exitstatus}"
				status = EX_SOFTWARE
			end
			system("/usr/bin/gzip -f #{dump_file_uncompressed.shellescape}")				
			if ($?.exitstatus == 0)
				File.chmod(0640, dump_file)
				File.chown(216, 216, dump_file)
				$log.info "...Backup succeeded."
			else
				$log.error "...Backup failed on gzip! Status=#{$?.exitstatus}"
				status = EX_SOFTWARE
			end

			# Restore original service state
			if (! orig_state)
				# What if a dependent service was launched while we were backing up?  We
				# don't want to shut down postgres in that case.
				wiki_state = false
				calendar_state = false
				addressbook_state = false
				devicemgr_state = false
				self.launch("/Applications/Server.app/Contents/ServerRoot/usr/sbin/serveradmin status wiki") do |output|
					wiki_state = ((/RUNNING/ =~ output) != nil)
				end
				self.launch("/Applications/Server.app/Contents/ServerRoot/usr/sbin/serveradmin status calendar") do |output|
					calendar_state = ((/RUNNING/ =~ output) != nil)
				end
				self.launch("/Applications/Server.app/Contents/ServerRoot/usr/sbin/serveradmin status addressbook") do |output|
					addressbook_state = ((/RUNNING/ =~ output) != nil)
				end
				self.launch("/Applications/Server.app/Contents/ServerRoot/usr/sbin/serveradmin status devicemgr") do |output|
					devicemgr_state = ((/RUNNING/ =~ output) != nil)
				end
				if (! (wiki_state || calendar_state || addressbook_state || devicemgr_state))
					self.launch("/Applications/Server.app/Contents/ServerRoot/usr/sbin/serveradmin stop postgres")
				end
			end
		else
			$log.info "Dump file is less than 24 hours old; skipping."
		end
		return status
	end

	# Validate arguments and verify that the backup archive matches the file system.
	def verify
		unless (@options && @options[:path] && @options[:target])
			raise OptionParser::InvalidArgument, "Missing arguments for 'verify'."
		end
		$log.debug("@options = #{@options.inspect}")
		source_dir = @options[:path]
		unless (source_dir[0] == ?/)
			raise OptionParser::InvalidArgument, "Paths must be absolute."
		end
		# Point to the root volume if :path points to the secret restore path.
		if (source_dir == SECRET_DIR)
			source_dir = ""
		end
		# Bail if the restore file is not present.
		archive_dir = self.backupDir
		dump_file = "#{source_dir}#{archive_dir}/#{BACKUP_FILE}"
		unless File.file?("#{dump_file}")
			raise RuntimeError, "Backup file not present in source volume."
		end
		target = @options[:target]
		unless (target == "/")
			raise RuntimeError, "Backups can only be verified against a running service."
		end
		digest_disk = Digest::SHA256.file("#{dump_file}")
		digest_live = Digest::SHA256.new
		open("|/Applications/Server.app/Contents/ServerRoot/usr/bin/pg_dumpall -U _postgres | /usr/bin/gzip") do |f|
			buf = ""
			while f.read(16384, buf)
				digest_live << buf
			end
		end
		return (digest_disk == digest_live)
	end

	# Validate arguments and restore this service
	def restore
		status = EX_OK
		unless (@options && @options[:path] && @options[:dataset] && @options[:target])
			status = EX_USAGE
			raise OptionParser::InvalidArgument, "Missing arguments for 'restore'."
		end
		$log.debug("@options = #{@options.inspect}")
		source_dir = @options[:path]
		unless (source_dir[0] == ?/)
			status = EX_USAGE
			raise OptionParser::InvalidArgument, "Paths must be absolute."
		end
		what = @options[:dataset]
		unless self.class::DATASETS.include?(what)
			status = EX_USAGE
			raise OptionParser::InvalidArgument, "Unknown data set '#{@options[:dataset]}' specified."
		end
		if (what.to_sym == :configuration)
			$log.info "Configuration is part of the data set; nothing to restore."
			return
		end
		target = @options[:target]
		unless (target == "/")
			status = EX_UNAVAILABLE
			raise RuntimeError, "Databases can only be restored to a running service."
		end

		# Point to the root volume if :path points to the secret restore path.
		if (source_dir == SECRET_DIR)
			source_dir = ""
		end

		# Create the log dir if it doesn't exist.
		if !File.exists?(LOG_DIR)
			FileUtils.mkdir(LOG_DIR)
			FileUtils.chmod(0755, LOG_DIR)
			FileUtils.chown("_postgres", "_postgres", LOG_DIR)
		end

		# Create the socket directory if it doesn't exist.
		socket_dir = self.socketDir
		if !File.exists?(socket_dir)
			$log.warn "Recreating #{socket_dir}."
			FileUtils.mkdir_p(socket_dir, :mode => 0750)
			FileUtils.chown(216, 216, socket_dir)
		end

		# Bail if the restore file is not present.
		archive_dir = self.backupDir
		dump_file = "#{source_dir}#{archive_dir}/#{BACKUP_FILE}"
		$log.info "Restoring \'#{dump_file}\' to \'#{target}\'..."
		unless File.file?(dump_file)
			status = EX_NOINPUT
			raise RuntimeError, "Backup file not present in source volume! Nothing to restore!"
		end

		# Recall if the service was previously enabled
		db_dir = self.dataDir
		state = false
		self.launch("/Applications/Server.app/Contents/ServerRoot/usr/sbin/serveradmin status postgres") do |output|
			state = ((/RUNNING/ =~ output) != nil)
		end
		self.launch("/Applications/Server.app/Contents/ServerRoot/usr/sbin/serveradmin stop postgres") if state
		if (File.directory?(db_dir))
			$log.info "...moving aside previous database..."
			FileUtils.mv(db_dir, "#{db_dir}.pre-restore-#{Time.now.strftime('%Y-%m-%d_%H:%M:%S_%Z')}")
		end
		$log.info "...creating an empty database at #{db_dir}..."
		FileUtils.mkdir_p(db_dir, :mode => 0700)
		# _postgres:_postgres has uid:gid of 216:216
		File.chown(216, 216, db_dir)
		self.launch("/usr/bin/sudo -u _postgres /Applications/Server.app/Contents/ServerRoot/usr/bin/initdb --encoding UTF8 -D #{db_dir.shellescape}")
		self.launch("/Applications/Server.app/Contents/ServerRoot/usr/sbin/serveradmin start postgres")
		$log.info "...replaying database contents (this may take a while)..."
		system("/usr/bin/gzcat #{dump_file.shellescape} | /Applications/Server.app/Contents/ServerRoot/usr/bin/psql -U _postgres postgres")
		self.launch("/Applications/Server.app/Contents/ServerRoot/usr/sbin/serveradmin stop postgres") unless state
		$log.info "...Restore succeeded."
		return status
	end
end

tool = PostgreSQLTool.new
$log.level = Logger::INFO
$logerr.level = Logger::INFO
begin
	tool.parse!(ARGV)
	status = tool.run
rescue OptionParser::InvalidArgument => exc
	$log.error "#{exc.to_s.capitalize}\n\n"
	tool.usage
	exit EX_USAGE
rescue RuntimeError => exc
	$log.error "#{exc.to_s.capitalize}\n"
	exit EX_UNAVAILABLE
rescue Exception => exc
	$log.error "#{exc.to_s.capitalize}\n"
	exit EX_UNAVAILABLE
rescue
	$log.error "unknown exception thrown\n"
	exit EX_UNAVAILABLE
end
exit status