1#!/usr/bin/perl
2#
3# xpostgres
4#
5# Author:: Apple Inc.
6# Documentation:: Apple Inc.
7# Copyright (c) 2013 Apple Inc. All Rights Reserved.
8#
9# IMPORTANT NOTE: This file is licensed only for use on Apple-branded
10# computers and is subject to the terms and conditions of the Apple Software
11# License Agreement accompanying the package this file is a part of.
12# You may not port this file to another platform without Apple's written consent.
13# License:: All rights reserved.
14#
15# This tool is a wrapper for postgres.
16# Its function is to launch a postgres process and manage WAL archiving.
17# perltidy options: -pbp -l=100
18
19use strict;
20use warnings;
21use Errno qw(EAGAIN);
22use File::Basename qw(basename);
23use File::Basename qw(dirname);
24use File::Path qw(rmtree);
25use Time::Local;
26use Getopt::Std;
27use Fcntl;
28use Fcntl qw(:flock);
29use FileHandle;
30use POSIX;
31use Cwd qw();
32
33# Constants
34my $WAIT4PATH   = '/bin/wait4path';
35my $GNUTAR_PATH = '/usr/bin/gnutar';
36my $PLIST_BUDDY = '/usr/libexec/PlistBuddy';
37my $ARCHIVE_COMMAND_PATH
38    = '/Applications/Server.app/Contents/ServerRoot/usr/libexec/xpg_archive_command';
39my $PG_BASEBACKUP_PATH      = '/Applications/Server.app/Contents/ServerRoot/usr/bin/pg_basebackup';
40my $PSQL_PATH               = '/Applications/Server.app/Contents/ServerRoot/usr/bin/psql';
41my $PG_CTL_PATH             = '/Applications/Server.app/Contents/ServerRoot/usr/bin/pg_ctl';
42my $POSTGRES_PATH           = '/Applications/Server.app/Contents/ServerRoot/usr/bin/postgres_real';
43my $TMUTIL_PATH             = '/usr/bin/tmutil';
44my $DEFAULT_SOCKET_DIR      = '/var/pgsql_socket';
45my $RESTORE_ON_ABSENCE_FILE = '.NoRestoreNeeded';
46my $MY_NAME                 = 'xpostgres';
47my $BACKUP_DIRECTORY_NAME   = 'base_backup';
48my $BACKUP_ZIP_FILE_NAME    = 'base_complete.tar.gz';
49my $BACKUP_TEMP_FILE_NAME   = 'base.tar.gz';
50my $NUM_COMPLETE_BACKUPS_PER_DAY = 4;
51my $SECONDS_PER_DAY              = 86400;
52my $HEARTBEAT_SECS               = 10;      # seconds to wait between heartbeat intervals
53my $MAX_WAL_SENDERS = 2;    # for postgresql.conf, value for 'max_wal_senders' preference
54
55# Global vars
56my $g_debug_enabled             = 0;
57my $g_archived_logs_directory   = q{};    # empty string
58my $g_postgres_data_directory   = q{};
59my $g_postgres_socket_directory = q{};
60my $g_postgres_log_directory    = q{};
61my $g_kill_signal               = q{};
62my $g_apple_configuration       = q{};
63my $g_postgres_proccess_pid;
64my @g_argv;
65my $g_child_status;
66
67# Signal handlers
68$SIG{HUP}  = \&handle_signal;
69$SIG{TERM} = \&handle_signal;
70$SIG{INT}  = \&handle_signal;
71$SIG{CHLD} = \&reaper;
72
73#############################################################################
74# PostgreSQL process management
75#############################################################################
76
77#############################################################################
78sub start_postgres {
79    if ( defined $g_postgres_proccess_pid && $g_postgres_proccess_pid != 0 ) {
80        log_message( 'postgres appears to already be running with pid: '
81                . $g_postgres_proccess_pid
82                . '.  Not starting a new instance.' );
83        return 1;
84    }
85
86    if ( !-e $g_postgres_data_directory ) {
87        log_message('Waiting for data path to exit...');
88        system $WAIT4PATH, $g_postgres_data_directory;
89        if ( $g_child_status != 0 ) {
90            log_message("Wait4path returned error: $g_child_status");
91            return 1;
92        }
93    }
94
95    # Snip the shared memory block out of the lockfile if no process is running
96    # that matches the PID in the file.  Otherwise postgres will fail to start
97    # if it wasn't shut down properly and another process is now using that memory
98    # block.
99    my $postgres_pid_path = $g_postgres_data_directory . '/postmaster.pid';
100    if ( -e $postgres_pid_path ) {
101        my $FILE;
102        if ( !open $FILE, '+<', $postgres_pid_path ) {
103            log_message("Error opening lock file: $!");
104        }
105        else {
106            my @lines = <$FILE>;
107            if ( $lines[0] =~ / \A (\d+) \n* \z /xms ) {
108                my $old_pid = $1;
109                system 'kill', '-0', $old_pid;
110                if ( $g_child_status != 0 ) {
111
112                    # Process is not running
113                    log_message('Clearing shared memory block from lock file');
114                    if ( $lines[$#lines] =~ / \A \s* \d+ \s+ \d+ \s* \n* \z /xms ) {
115                        my $out = q{};
116                        for ( my $i = 0; $i < $#lines - 1; $i++ ) {
117                            $out .= $lines[$i];
118                        }
119                        if ( !seek $FILE, 0, 0 ) {
120                            log_message("Error, seek: $!");
121                            return 1;
122                        }
123                        print $FILE $out;
124                        truncate $FILE, tell($FILE);
125                    }
126                }
127            }
128            close $FILE;
129        }
130    }
131
132    # Clean up any stale socket files
133    my $SOCK_DIR;
134    if ( !opendir $SOCK_DIR, $g_postgres_socket_directory ) {
135        log_message("Cannot open socket directory: $!");
136        return 1;
137    }
138    my @files = readdir $SOCK_DIR;
139    closedir $SOCK_DIR;
140
141    foreach my $file (@files) {
142        if ( $file eq '.' || $file eq '..' ) {
143            next;
144        }
145        log_message("Found stale file in socket directory, removing it: $file");
146        if ( !unlink $g_postgres_socket_directory . "/$file" ) {
147            log_message("Failed to delete stale file: $!");
148        }
149    }
150
151FORK: {
152        my $pid;
153        if ( $pid = fork ) {
154            $g_postgres_proccess_pid = $pid;
155        }
156        elsif ( defined $pid ) {
157            exec $POSTGRES_PATH, @g_argv;
158        }
159        elsif ( $! == EAGAIN ) {
160            sleep 5;
161            redo FORK;
162        }
163        else {
164            log_message("Fork error: $!");
165            return 1;
166        }
167    }
168    my $success = 0;
169    for ( 1 .. 30 ) {
170        if ( !opendir $SOCK_DIR, $g_postgres_socket_directory ) {
171            log_message("Cannot open socket directory: $!");
172            return 1;
173        }
174        my @files = readdir $SOCK_DIR;
175        closedir $SOCK_DIR;
176
177        my $arr_size = @files;
178        if ( $arr_size > 2 ) {    # more than just '.' and '..'
179            $success = 1;
180            last;
181        }
182        if ($g_debug_enabled) {
183            log_message('Waiting for postgres socket file to be created...');
184        }
185        sleep 1;
186    }
187    if ( !$success ) {
188        log_message('Could not determine if postgres is running successfully, giving up');
189        return 1;
190    }
191
192    touch_dotfile();
193
194    return 0;
195}
196
197#############################################################################
198sub stop_postgres {
199    my $signal = shift;
200    if ( !defined $signal ) {
201        $signal = 'TERM';
202    }
203
204    # Disconnect any connected clients before sending a kill signal
205    my $sql = 'SELECT pid, (SELECT pg_terminate_backend(pid)) as killed from '
206        . "pg_stat_activity WHERE state LIKE 'idle';";
207    system $PSQL_PATH, '-q', '-h', $g_postgres_socket_directory, '-d', 'postgres', '-c', $sql;
208
209    if ( ( !defined $g_postgres_proccess_pid ) || $g_postgres_proccess_pid == 0 ) {
210        log_message('postgres appears to already be stopped.');
211        return;
212    }
213
214    if ( defined $g_postgres_proccess_pid && $g_postgres_proccess_pid != 0 ) {
215        kill $signal, $g_postgres_proccess_pid;
216    }
217
218    my $postgres_pid_path = $g_postgres_data_directory . '/postmaster.pid';
219    my $success           = 0;
220
221    # launchd is configured to wait 60 seconds before sending a SIGKILL to us,
222    # so allow up to 50 seconds for a "smart" shutdown, but send a SIGINT for
223    # a "fast" shutdown if that fails.
224    for ( 1 .. 50 ) {
225        if ( $g_postgres_proccess_pid == 0 ) {
226            last;
227        }
228
229        my $pid = waitpid $g_postgres_proccess_pid, WNOHANG;
230        if ( $pid == $g_postgres_proccess_pid ) {
231            $g_postgres_proccess_pid = 0;
232            last;
233        }
234
235        sleep 1;
236    }
237
238    if ( $g_postgres_proccess_pid > 0 ) {
239        kill 'INT', $g_postgres_proccess_pid;
240        $g_postgres_proccess_pid = 0;
241    }
242
243    return;
244}
245
246#############################################################################
247sub sighup_postgres {
248    if ( defined $g_postgres_proccess_pid && $g_postgres_proccess_pid != 0 ) {
249        kill 'HUP', $g_postgres_proccess_pid;
250    }
251}
252
253#############################################################################
254# Returns 1 if postgres is running, 0 if not running.
255sub postgres_is_running {
256    my $postgres_pid_path = $g_postgres_data_directory . '/postmaster.pid';
257    my $FILE;
258    if ( !-e $postgres_pid_path ) {
259        return 0;
260    }
261
262    if ( !open $FILE, '+<', $postgres_pid_path ) {
263        log_message("Error opening lock file: $!");
264        return -1;
265    }
266
267    my @lines = <$FILE>;
268    if ( $lines[0] =~ / \A (\d+) \n* \z /xms ) {
269        my $old_pid = $1;
270        close $FILE;
271        system 'kill', '-0', $old_pid;
272        if ( $g_child_status != 0 ) {
273            
274            # Process is not running
275            return 0;
276        }
277        else {
278            return 1;
279        }
280    }
281}
282
283#############################################################################
284# Authentication configuration file operations
285#############################################################################
286
287#############################################################################
288# Restrict postgres connections via pg_hba.conf
289sub enable_connection_restriction {
290
291    # Disable any non-replication line in pg_hba.conf
292    my $FILE;
293    if ( !open $FILE, '+<', $g_postgres_data_directory . '/pg_hba.conf' ) {
294        log_message("Error opening pg_hba.conf to restrict connections: $!");
295        return 1;
296    }
297    if ( !flock $FILE, LOCK_EX ) {
298        log_message("Error getting lock for file pg_hba.conf: $!");
299        return 1;
300    }
301    my $updated_file = 0;
302    my $out;
303    while (<$FILE>) {
304        if ( $_ =~ / \# /xms ) {
305            $out .= $_;
306        }
307        elsif ( $_ =~ / \A (\S+) \s+ (\S+) \s+ (\S+) \s+ (\S+) \s* (\S*) \s* \n \z /xms ) {
308            my ( $type, $database, $user ) = ( $1, $2, $3 );
309            my ( $address, $method );
310            if ( $5 ne q{} ) {
311                ( $address, $method ) = ( $4, $5 );
312            }
313            else {
314                $method = $4;
315            }
316            if ( $database eq 'replication' ) {
317                $out .= $_;
318                next;
319            }
320            else {
321                my $line = $_;
322                chomp $line;
323                $out .= '#' . $line . "    # UPDATED BY xpostgres\n";
324                $updated_file = 1;
325            }
326        }
327        else {
328            $out .= $_;
329        }
330    }
331
332    if ($updated_file) {
333        if ( !seek $FILE, 0, 0 ) {
334            log_message("Error writing pg_hba.conf: $!");
335            return 1;
336        }
337        print $FILE $out;
338        truncate $FILE, tell($FILE);
339    }
340    close $FILE;
341
342    return 0;
343}
344
345#############################################################################
346sub disable_connection_restriction {
347
348    # Enable any lines in pg_hba.conf that we previously disabled
349    my $FILE;
350    if ( !open $FILE, '+<', $g_postgres_data_directory . '/pg_hba.conf' ) {
351        log_message("Error opening pg_hba.conf to allow connections: $!");
352        return 1;
353    }
354    if ( !flock $FILE, LOCK_EX ) {
355        log_message("Error getting lock for file pg_hba.conf: $!");
356        return 1;
357    }
358    my $updated_file = 0;
359    my $out;
360    while (<$FILE>) {
361        if ( $_ =~ / \A \# \s* .+ \s* \# \s UPDATED \s BY \s xpostgres \n \z /xms ) {
362            my $line = $_;
363            $line =~ s/ \A \# //xms;
364            $line =~ s/\s*# UPDATED BY xpostgres//ms;
365            $out .= $line;
366            $updated_file = 1;
367        }
368        else {
369            $out .= $_;
370        }
371    }
372    if ($updated_file) {
373        if ( !seek $FILE, 0, 0 ) {
374            log_message("Error writing pg_hba.conf: $!");
375            return 1;
376        }
377        print $FILE $out;
378        truncate $FILE, tell($FILE);
379    }
380    close $FILE;
381
382    return 0;
383}
384
385#############################################################################
386# Takes one argument: 0 to disable archiving, 1 to enable archiving.  h
387sub toggle_wal_archiving_state {
388    my $enable_wal_archiving = shift;
389    if ( !( ( $enable_wal_archiving == 0 ) || ( $enable_wal_archiving == 1 ) ) ) {
390        return 1;
391    }
392
393    if ($enable_wal_archiving) {
394
395        # Enable local connections for replication (update pg_hba.conf)
396        my $FILE;
397        if ( !open $FILE, '+<', $g_postgres_data_directory . '/pg_hba.conf' ) {
398            log_message("Error opening pg_hba.conf to enable replication connections: $!");
399            return 1;
400        }
401        if ( !flock $FILE, LOCK_EX ) {
402            log_message("Error getting lock for file pg_hba.conf: $!");
403            return 1;
404        }
405        my $replication_enabled = 0;
406        while (<$FILE>) {
407            if ( $_ =~ /^ \# /xms ) {
408                next;
409            }
410            if ( $_ =~ /\A (\S+) \s+ (\S+) \s+ (\S+) \s+ (\S+) \s* (\S*) \z/xms ) {
411                my ( $type, $database, $user ) = ( $1, $2, $3 );
412                my ( $address, $method ) = q{};    # empty string
413                if ( $5 ne q{} ) {
414                    ( $address, $method ) = ( $4, $5 );
415                }
416                else {
417                    $method = $4;
418                }
419                if (   $type eq 'local'
420                    && $database eq 'replication'
421                    && $address  eq q{}
422                    && $method   eq 'trust' )
423                {
424                    $replication_enabled = 1;
425                    next;
426                }
427            }
428        }
429        if ( !$replication_enabled ) {
430            if ( !seek $FILE, 0, 2 ) {
431                log_message("Error updating file pg_hba.conf: $!");
432                return 1;
433            }
434            print $FILE "local   replication     _postgres                                trust\n";
435        }
436        close $FILE;
437    }
438
439    # Update postgresql.conf
440    # postgres must be restarted for the changes to go into effect.
441    my $out;
442    my $FILE;
443    if ( !open $FILE, '+<', $g_postgres_data_directory . '/postgresql.conf' ) {
444        log_message("Error opening file at path $g_postgres_data_directory/postgresql.conf: $!");
445        return 1;
446    }
447    if ( !flock $FILE, LOCK_EX ) {
448        log_message("Error getting lock for file postgresql.conf: $!");
449        return 1;
450    }
451    my $archive_mode;
452    my $archive_command;
453    my $max_wal_senders;
454    my $wal_level;
455    while (<$FILE>) {
456        my $line = $_;
457        if ( $line =~ / \A \s* (\#*) archive_mode \s* = \s* (.*) (.*\n) \z /xms ) {
458            if ( defined $archive_mode ) {
459                log_message(
460                    'Warning: found multiple occurrences of "archive_mode" in postgresql.conf');
461                next;
462            }
463            $archive_mode = $2;
464            my $trailer = $3;
465            my $enabled;
466            if ( $1 =~ / \# /xms ) {
467                $enabled = 0;
468            }
469            else {
470                $enabled = 1;
471            }
472
473            if ( ( $enabled == 0 ) && $enable_wal_archiving ) {
474                $out .= 'archive_mode = on' . $trailer;
475            }
476            elsif ( ( $enabled == 1 ) && ( !$enable_wal_archiving ) ) {
477                $out .= '#archive_mode = off' . $trailer;
478            }
479            else {
480                $out .= $line;
481            }
482        }
483        elsif ( $line =~ / \A \s* (\#*) archive_command \s* = \s* ['"] (.*) ['"] (.*\n) \z /xms ) {
484            if ( defined $archive_command ) {
485                log_message( 'Warning: found multiple occurrences of '
486                        . '"archive_command" in postgresql.conf' );
487                next;
488            }
489            $archive_command = $2;
490            my $trailer = $3;
491            my $enabled;
492            if ( $1 =~ / \# /xms ) {
493                $enabled = 0;
494            }
495            else {
496                $enabled = 1;
497            }
498
499            if ( ( $enabled == 0 ) && $enable_wal_archiving ) {
500                my $postgres_data_directory = escape_string_for_shell($g_postgres_data_directory);
501                my $archived_logs_directory = escape_string_for_shell($g_archived_logs_directory);
502                my $command = "\'$ARCHIVE_COMMAND_PATH -D \"$postgres_data_directory\" "
503                    . "-w \"$archived_logs_directory\" -f %f\'";
504
505                $out .= 'archive_command = ' . $command . $trailer;
506            }
507            elsif ( ( $enabled == 1 ) && ( !$enable_wal_archiving ) ) {
508                $out .= '#archive_command = \'\'' . $trailer;
509            }
510            else {
511                $out .= $line;
512            }
513        }
514        elsif ( $line =~ / \A \s* (\#*) max_wal_senders \s* = \s* (\d+) (.*\n) \z /xms ) {
515            if ( defined $max_wal_senders ) {
516                log_message( 'Warning: found multiple occurrences of '
517                        . '"max_wal_senders" in postgresql.conf' );
518                next;
519            }
520            $max_wal_senders = $2;
521            my $trailer = $3;
522            my $enabled;
523            if ( $1 =~ / \# /xms ) {
524                $enabled = 0;
525            }
526            else {
527                $enabled = 1;
528            }
529
530            if ( ( $enabled == 0 ) && $enable_wal_archiving ) {
531                $out .= 'max_wal_senders = ' . $MAX_WAL_SENDERS . $trailer;
532            }
533            elsif ( ( $enabled == 1 ) && ( !$enable_wal_archiving ) ) {
534                $out .= '#max_wal_senders = 0' . $trailer;
535            }
536            else {
537                $out .= $line;
538            }
539        }
540        elsif ( $line =~ / \A \s* (\#*) wal_level \s* = \s* (\S+) (.*\n) \z /xms ) {
541            if ( defined $wal_level ) {
542                log_message(
543                    'Warning: found multiple occurrences of "wal_level" in postgresql.conf');
544                next;
545            }
546            $wal_level = $2;
547            my $trailer = $3;
548            my $enabled;
549            if ( $1 =~ / \# /xms ) {
550                $enabled = 0;
551            }
552            else {
553                $enabled = 1;
554            }
555
556            if ( ( $enabled == 0 ) && $enable_wal_archiving ) {
557                $out .= 'wal_level = archive' . $trailer;
558            }
559            elsif ( ( $enabled == 1 ) && ( !$enable_wal_archiving ) ) {
560                $out .= '#wal_level = minimal' . $trailer;
561            }
562            else {
563                $out .= $line;
564            }
565        }
566        else {
567            $out .= $line;
568        }
569    }
570
571    if (!(     defined $archive_command
572            && defined $max_wal_senders
573            && defined $wal_level
574            && defined $archive_mode
575        )
576        )
577    {
578        log_message( 'Error: could not successfully parse postgresql.conf '
579                . 'in order to enable WAL archiving' );
580        return 1;
581    }
582
583    if ( !seek $FILE, 0, 0 ) {
584        log_message("Error writing postgresql.conf: $!");
585        return 1;
586    }
587
588    print $FILE $out;
589    truncate $FILE, tell($FILE);
590    close $FILE;
591
592    return 0;
593}
594
595#############################################################################
596sub touch_dotfile {
597    my $dotfile = $g_postgres_data_directory . q{/} . $RESTORE_ON_ABSENCE_FILE;
598    if ( -e $dotfile ) {
599        my $now = time;
600        utime $now, $now, $dotfile;
601    }
602    else {
603        my $FILE;
604        if ( !open $FILE, '>', $dotfile ) {
605            log_message("Error opening dotfile for creation: $!");
606        }
607        else {
608            close $FILE;
609            system $TMUTIL_PATH, 'addexclusion', $dotfile;
610            if ( $g_child_status != 0 ) {
611                log_message("Warning: tmutil appears to have failed: $g_child_status");
612            }
613        }
614    }
615}
616
617#############################################################################
618sub do_backup {
619    my $backup_zip_file
620        = $g_archived_logs_directory . q{/} . $BACKUP_DIRECTORY_NAME . q{/} . $BACKUP_ZIP_FILE_NAME;
621
622    # Determine if it is time to do a new complete backup
623    if ( -e $backup_zip_file ) {
624        my ( $sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst ) = localtime;
625        my $now = timelocal( $sec, $min, $hour, $mday, $mon, $year );
626        my ($dev,  $ino,   $mode,  $nlink, $uid,     $gid, $rdev,
627            $size, $atime, $mtime, $ctime, $blksize, $blocks
628        ) = stat $backup_zip_file;
629        my $maximum_file_age_secs = $SECONDS_PER_DAY / $NUM_COMPLETE_BACKUPS_PER_DAY;
630        if ( ( $now - $mtime ) < $maximum_file_age_secs ) {
631
632            # It's too soon for another complete backup
633            return 0;
634        }
635    }
636
637    if ( !-e $g_postgres_data_directory ) {
638        log_message('Error: missing postgres data directory for backup');
639        return 1;
640    }
641
642    # If a temp file already exists, get rid of it, or pg_basebackup will fail
643    my $temp_backup_file
644        = $g_archived_logs_directory . q{/}
645        . $BACKUP_DIRECTORY_NAME . q{/}
646        . $BACKUP_TEMP_FILE_NAME;
647    if ( -e $temp_backup_file ) {
648        if ( !unlink $temp_backup_file ) {
649            log_message("Error unlinking temporary backup zip file: $!");
650            return 1;
651        }
652    }
653
654    # Delete the outdated file, otherwise pg_basebackup will fail.
655    if ( -e $backup_zip_file ) {
656        if ( !unlink $backup_zip_file ) {
657            log_message("Error unlinking backup zip file: $!");
658            return 1;
659        }
660    }
661
662    # Make a list of WAL files to delete after backup succeeds.
663    # XXX Instead of basing the list on file creation times, it would be
664    # best to untar the base backup, check the backup_label file, and base
665    # deletion on the START WAL LOCATION.  However, this could use a lot of
666    # space.
667    my $LOG_DIR;
668    if ( !opendir $LOG_DIR, $g_archived_logs_directory ) {
669        log_message("Cannot open transaction log directory: $!");
670        return 1;
671    }
672    my @files = readdir $LOG_DIR;
673    closedir $LOG_DIR;
674
675    my %file_create_times;
676    foreach my $file (@files) {
677
678        # Ignore directories - this skips '.' and '..' as well as backup directories created
679        # by pg_basebackup
680        if ( -d "$g_archived_logs_directory/$file" ) {
681            next;
682        }
683
684        # Delete all but the past few log files.  When pg_basebackup is run,
685        # postgres may request to archive the most recent file, at least.
686        my ($dev,  $ino,   $mode,  $nlink, $uid,     $gid, $rdev,
687            $size, $atime, $mtime, $ctime, $blksize, $blocks
688        ) = stat "$g_archived_logs_directory/$file";
689        $file_create_times{$file} = $ctime;
690    }
691
692    system $PG_BASEBACKUP_PATH, '-Ft', '-z', '-h', $g_postgres_socket_directory, '-D',
693        "$g_archived_logs_directory/$BACKUP_DIRECTORY_NAME";
694    if ( !-e $temp_backup_file || $g_child_status != 0 ) {
695        log_message("Error executing pg_basebackup: $g_child_status");
696        return 1;
697    }
698
699    my $FILE;
700    if ( !open $FILE, '+<', $temp_backup_file ) {
701        log_message("Error: Can't open file to flush: $!\n");
702        return 1;
703    }
704
705    my $return_buffer = q{};
706    if ( !fcntl $FILE, 51, $return_buffer ) {    # F_FULLFSYNC
707        log_message("Error flushing pg_basebackup output file to disk: $!");
708        return 1;
709    }
710
711    close $temp_backup_file;
712
713    if ( !rename $temp_backup_file, $backup_zip_file ) {
714        log_message("Error renaming temp backup file: $!");
715    }
716
717# Clean up all of the log files that existed before backup, except for the last few in case they are
718# needed later.
719# Sort matched files by file creation time in ascending order
720    my @sorted_files
721        = sort { $file_create_times{$a} <=> $file_create_times{$b} } keys %file_create_times;
722    my $arr_size = @sorted_files;
723    for ( my $i = 0; $i < ( $arr_size - 4 ); $i++ ) {
724        if ( !unlink $g_archived_logs_directory . q{/} . $sorted_files[$i] ) {
725            log_message(
726                "Warning: failed to delete WAL file $g_archived_logs_directory/$sorted_files[$i]: $!"
727            );
728        }
729    }
730
731    return 0;
732}
733
734#############################################################################
735sub do_restore {
736    my $backup_zip_file
737        = $g_archived_logs_directory . q{/} . $BACKUP_DIRECTORY_NAME . q{/} . $BACKUP_ZIP_FILE_NAME;
738
739    if ( !-e $backup_zip_file ) {
740        if ( $g_debug_enabled ) {
741            log_message("Could not find a backup to restore at $backup_zip_file");
742        }
743        return 1;
744    }
745
746    if ( -e $g_postgres_data_directory ) {
747        log_message('Moving aside previous data directory.');
748        if ( -e $g_postgres_data_directory . '.previous' ) {
749            if ( !rmtree $g_postgres_data_directory . '.previous' ) {
750                log_message("Error deleting previous data directory: $!");
751                return 1;
752            }
753        }
754        if ( !rename $g_postgres_data_directory, $g_postgres_data_directory . '.previous' ) {
755            log_message("Error moving aside previous data directory: $!");
756            return 1;
757        }
758    }
759
760    if ( !mkdir $g_postgres_data_directory, 0700 ) {
761        log_message("Error: could not create data directory: $!");
762        return 1;
763    }
764
765    # Unpack our backup file
766    system $GNUTAR_PATH, '-xz', '-f', $backup_zip_file, '-C', $g_postgres_data_directory;
767    if ( $g_child_status != 0 ) {
768        log_message("gnutar returned error: $g_child_status");
769        return 1;
770    }
771
772    # Remove any existing recovery.done file
773    if ( -e $g_postgres_data_directory . '/recovery.done' ) {
774        if ( !unlink $g_postgres_data_directory . '/recovery.done' ) {
775            log_message("Error deleting recovery.done: $!");
776
777            # We depend on this file later so continuing causes a race condition.
778            return 1;
779        }
780    }
781
782    # Remove any existing dotfile
783    if ( -e $g_postgres_data_directory . q{/} . $RESTORE_ON_ABSENCE_FILE ) {
784        if ( !unlink $g_postgres_data_directory . q{/} . $RESTORE_ON_ABSENCE_FILE ) {
785            log_message("Error removing $RESTORE_ON_ABSENCE_FILE: $!");
786
787            # This will break future restores.
788            return 1;
789        }
790    }
791
792    # Create a recovery.conf file
793    my $archived_logs_directory = escape_string_for_shell($g_archived_logs_directory);
794    my $restore_command = "restore_command = \'/bin/cp \"$archived_logs_directory/%f\" %p\'";
795    my $FILE;
796    if ( !open $FILE, '>', $g_postgres_data_directory . '/recovery.conf' ) {
797        log_message("Error writing a recovery.conf file: $!");
798        return 1;
799    }
800    print $FILE "$restore_command\n";
801    close $FILE;
802
803    # Disallow client connections for now
804    enable_connection_restriction();
805
806    # Disable WAL archiving if it is enabled in the restored backup cluster
807    toggle_wal_archiving_state(0);
808
809    my $original_working_directory = Cwd::abs_path();
810    if ( !chdir $g_archived_logs_directory ) {
811        log_message("chdir error: $!");
812    }
813
814    log_message('Starting postgres for restore...');
815
816    if ( start_postgres() == 0 ) {
817        while (1) {
818
819            # XXX: Is there any chance that this could hang indefinitely?
820            if ( -e $g_postgres_data_directory . '/recovery.done' ) {
821                log_message('Postgres recovery completed: recovery.done found');
822                last;
823            }
824            my $output = `$PG_CTL_PATH status -D "$g_postgres_data_directory"`;
825            if ( $output !~ /server is running/ ) {
826                log_message('Postgres recovery completed: pg_ctl status shows server not running');
827                last;
828            }
829            sleep 1;
830        }
831    }
832
833    stop_postgres();
834
835    if ( !chdir $original_working_directory ) {
836        log_message("chdir error: $!");
837    }
838
839    disable_connection_restriction();
840
841    return 0;
842}
843
844#############################################################################
845# Utilities
846#############################################################################
847
848#############################################################################
849sub usage {
850    print "$MY_NAME : Apple wrapper for postgres with WAL archive management.\n";
851    print "\tThis tool is a wrapper for postgres that enables archive\n";
852    print "\tlevel WAL logging and handles backups and restores.\n";
853    print "\tIt is intended to be invoked via pg_ctl.\n";
854    print "\tThis is not intended for direct use by customers.\n";
855    print "\n";
856    print "Usage:\n";
857    print "\t$MY_NAME [args to be forwarded to the real postgres]\n";
858    print "\t$MY_NAME -a <path> [args to be forwarded to the real postgres]\n";
859    print "\tOptions:\n";
860    print "\t-a <path>: Specify a .plist containing an array of arguments\n";
861    print "\t           that will be forwarded to postgres, in lieu of or\n";
862    print "\t           in addition to other command line arguments.\n";
863    print "\n";
864}
865
866#############################################################################
867sub timestamp {
868    my ( $sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst ) = localtime;
869    $year += 1900;
870    $mon  += 1;
871    if ( $mday =~ / \A \d \z /xms ) { $mday = '0' . $mday; }
872    if ( $mon  =~ / \A \d \z /xms ) { $mon  = '0' . $mon; }
873    if ( $hour =~ / \A \d \z /xms ) { $hour = '0' . $hour; }
874    if ( $min  =~ / \A \d \z /xms ) { $min  = '0' . $min; }
875    if ( $sec  =~ / \A \d \z /xms ) { $sec  = '0' . $sec; }
876
877    my $ret = "$year-$mon-$mday $hour:$min:$sec";
878
879    return $ret;
880}
881
882#############################################################################
883sub log_message {
884
885    # We currently output only to STDOUT / STDERR and launchd is configured
886    # to redirect this to the postgres log.
887    print timestamp() . q{ } . $MY_NAME . ": @_\n";
888
889    return 0;
890}
891
892#############################################################################
893sub handle_signal {
894    $g_kill_signal = shift;
895}
896
897#############################################################################
898sub reaper {
899    my $pid;
900    while ( ( $pid = waitpid -1, WNOHANG ) > 0 ) {
901        if ( $pid == -1 ) {
902            next;
903        }
904        elsif ( defined $g_postgres_proccess_pid && $g_postgres_proccess_pid == $pid ) {
905            if ($g_debug_enabled) {
906                log_message('postgres child has died');
907            }
908            $g_postgres_proccess_pid = 0;
909        }
910        else {
911            $g_child_status = $? >> 8;
912        }
913    }
914    $SIG{CHLD} = \&reaper;
915}
916
917#############################################################################
918# Main
919#############################################################################
920
921if ( $< == 0 ) {
922    print "postgres will not run as root.  Try using a different user account.\n";
923    usage();
924    exit 1;
925}
926
927STDERR->autoflush(1);
928STDOUT->autoflush(1);
929
930# We want to allow for the data directory and socket directory to be specified in
931# a number of supported ways:
932# A. on the command-line as -D, -k
933# B. PGDATA may be defined in the environment
934# C. unix_socket_directory may be specified on the command-line
935# D. we support loading postgres args from a file, so they may be there
936
937@g_argv = @ARGV;
938our ( $opt_D, $opt_k );
939getopt('D:k:');
940
941# Try to find where the data directory has been specified
942if ( defined $opt_D ) {
943    $g_postgres_data_directory = $opt_D;
944}
945else {
946
947    # check environment for PGDATA and set the data directory if found
948    if ( defined $ENV{PGDATA} ) {
949        $g_postgres_data_directory = $ENV{PGDATA};
950    }
951}
952
953# Try to find where the socket directory has been specified
954$g_postgres_socket_directory = $DEFAULT_SOCKET_DIR;
955if ( defined $opt_k ) {
956    $g_postgres_socket_directory = $opt_k;
957}
958else {
959    my $arr_size = @g_argv;
960    for ( my $i = 0; $i < $arr_size; $i++ ) {
961        if ( $g_argv[$i] eq '-c' ) {
962            if ( $g_argv[ $i + 1 ] =~ /unix_socket_directory=(.+)/ ) {
963                $g_postgres_socket_directory = $1;
964            }
965            elsif ( $g_argv[ $i + 1 ] =~ /log_directory=(.+)/ ) {
966                $g_postgres_log_directory = $1;
967            }
968        }
969        elsif ( $g_argv[$i] eq '-a' ) {
970
971            # This is where the config options for "server services" cluster managed by
972            # servermgr_postgres_server are found.
973            # Note '-a' is not a currently support postgres option, so we can use it.
974            # TODO: Remove this and fix the code that uses it to pass options directly.
975            $g_apple_configuration = $g_argv[ $i + 1 ];
976
977            # We don't want to pass this on to postgres
978            splice @g_argv, $i, 2;
979            if ( ( $i - 2 ) == -2 ) {
980                $i -= 1;
981            }
982            else {
983                $i -= 2;
984            }
985            $arr_size -= 2;
986        }
987    }
988}
989
990# Load postgres arguments from file if it has been specified
991if ( defined $g_apple_configuration && $g_apple_configuration ne q{} ) {
992    if ( !-e $g_apple_configuration ) {
993        log_message('Error: received -a but could not find specified file');
994        exit 1;
995    }
996
997    # XXX: It would be better to use Foundation here.  Or not even have this option.
998    my @lines = `$PLIST_BUDDY -c 'Print :ProgramArguments' "$g_apple_configuration"`;
999
1000    # Skipping the first and last lines, which specify the class type.
1001    for ( my $i = 1; $i < $#lines; $i++ ) {
1002
1003        # Append the args to the list that we will past to postgres
1004        if ( $lines[$i] =~ / \A \s* (.+?) \s* \z /xms ) {
1005            push @g_argv, $1;
1006        }
1007
1008        # Look for the specifiers that we care about
1009        if ( $lines[$i] =~ /\A \s* unix_socket_directory=(.+?) \s* \z /xms ) {
1010            $g_postgres_socket_directory = $1;
1011        }
1012        elsif (( $lines[$i] =~ / \A \s* -k \s* \z /xms )
1013            && ( $lines[ $i + 1 ] =~ / \A \s* (.+?) \s* \z /xms ) )
1014        {
1015            $g_postgres_socket_directory = $1;
1016        }
1017        elsif (( $lines[$i] =~ / \A \s* -D \s* \z /xms )
1018            && ( $lines[ $i + 1 ] =~ / \A \s* (.+?) \s* \z /xms ) )
1019        {
1020            $g_postgres_data_directory = $1;
1021        }
1022        elsif ( $lines[$i] =~ /\A \s* log_directory=(.+?) \s* \z /xms ) {
1023            $g_postgres_log_directory = $1;
1024        }
1025    }
1026}
1027
1028# Create the log directory if it has been specified
1029if ( defined $g_postgres_log_directory && $g_postgres_log_directory ne q{} ) {
1030    if ( !-e $g_postgres_log_directory ) {
1031        if ( !mkdir $g_postgres_log_directory, 0755 ) {
1032            log_message("Could not create log directory: $!");
1033            return 1;
1034        }
1035    }
1036}
1037
1038if ( $g_postgres_data_directory eq q{} || $g_postgres_socket_directory eq q{} ) {
1039    log_message('Error: missing required argument.');
1040    exit 1;
1041}
1042
1043# Determine our WAL file directory based on the data directory path
1044my $base_name     = basename($g_postgres_data_directory);
1045my $data_dir_name = dirname($g_postgres_data_directory);
1046
1047$g_archived_logs_directory = $data_dir_name . q{/} . 'backup';
1048
1049if ( postgres_is_running() ) {
1050    log_message('Postgres is already running using the specified socket directory, exiting.');
1051    exit 1;
1052}
1053
1054if ( !-e $g_archived_logs_directory ) {
1055    if ( !mkdir $g_archived_logs_directory, 0700 ) {
1056        log_message("mkdir failed for WAL file directory $g_archived_logs_directory: $!");
1057        exit 1;
1058    }
1059}
1060
1061if ( !-e $g_postgres_data_directory . q{/} . $RESTORE_ON_ABSENCE_FILE ) {
1062    if ( !( do_restore() == 0 ) ) {
1063        if ( !-e $g_postgres_data_directory ) {
1064            log_message('Error: Restore failed and we are missing the data directory');
1065            exit 1;
1066        }
1067    }
1068}
1069
1070# Exclude our data directory from Time Machine backups
1071system $TMUTIL_PATH, 'addexclusion', $g_postgres_data_directory;
1072if ( $g_child_status != 0 ) {
1073    log_message("Warning: tmutil appears to have failed: $g_child_status");
1074}
1075
1076# Ensure that WAL archiving has been enabled.
1077toggle_wal_archiving_state(1);
1078
1079log_message('Starting up');
1080if ( !( start_postgres() == 0 ) ) {
1081    log_message('Could not start postgres.');
1082    exit 1;
1083}
1084log_message('PostgreSQL started.');
1085
1086my $previous_heartbeat = 0;
1087while (1) {
1088    if ( $g_kill_signal ne q{} ) {
1089        if ( ( $g_kill_signal eq 'INT' ) || ( $g_kill_signal eq 'TERM' ) ) {
1090            log_message("Received signal $g_kill_signal, shutting down");
1091            stop_postgres($g_kill_signal);
1092            exit 0;
1093        }
1094        $g_kill_signal = q{};    # empty string
1095    }
1096
1097    if ( $g_postgres_proccess_pid == 0 ) {
1098
1099        # postgres was seen to be shut down by SIGCHLD handler
1100        exit 1;
1101    }
1102
1103    my ( $sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst ) = gmtime;
1104    my $now = timegm( $sec, $min, $hour, $mday, $mon, $year );
1105    if ( ( $now - $previous_heartbeat ) >= $HEARTBEAT_SECS ) {
1106        do_backup();
1107
1108        ( $sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst ) = gmtime;
1109        $previous_heartbeat = timegm( $sec, $min, $hour, $mday, $mon, $year );
1110    }
1111    sleep 1;
1112}
1113
1114exit 0;
1115
1116#############################################################################
1117# This is out of place for XCode
1118sub escape_string_for_shell {
1119    my $string = shift;
1120    $string =~ s/([;<>\*\|`&\$!#\(\)\[\]\{\}:'"])/\\$1/g;
1121    return $string;
1122}
1123