Cron.pm's install_schedule() had its own cron line builder that didn't include --sysbackup. Now it checks SYSBACKUP=yes in the schedule config and appends --sysbackup to the cron command. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
279 lines
7.9 KiB
Perl
279 lines
7.9 KiB
Perl
package GnizaWHM::Cron;
|
|
# Per-schedule cron manipulation and gniza schedule CLI wrappers.
|
|
|
|
use strict;
|
|
use warnings;
|
|
use IPC::Open3;
|
|
use Symbol 'gensym';
|
|
|
|
use GnizaWHM::Config;
|
|
|
|
my $GNIZA_BIN = '/usr/local/bin/gniza';
|
|
my $GNIZA_TAG = '# gniza:';
|
|
my $SCHEDULES_DIR = '/etc/gniza/schedules.d';
|
|
|
|
# get_current_schedules()
|
|
# Reads crontab for gniza entries tagged with "# gniza:<name>".
|
|
# Returns hashref of { name => cron_line }.
|
|
sub get_current_schedules {
|
|
my %schedules;
|
|
|
|
my $crontab = '';
|
|
if (open my $pipe, '-|', 'crontab', '-l') {
|
|
local $/;
|
|
$crontab = <$pipe> // '';
|
|
close $pipe;
|
|
}
|
|
|
|
my @lines = split /\n/, $crontab;
|
|
for (my $i = 0; $i < @lines; $i++) {
|
|
if ($lines[$i] =~ /^\Q$GNIZA_TAG\E(.+)$/) {
|
|
my $name = $1;
|
|
if ($i + 1 < @lines) {
|
|
$schedules{$name} = $lines[$i + 1];
|
|
$i++; # skip the command line
|
|
}
|
|
}
|
|
}
|
|
|
|
return \%schedules;
|
|
}
|
|
|
|
# install_schedules()
|
|
# Runs: /usr/local/bin/gniza schedule install
|
|
# Returns ($success, $stdout, $stderr).
|
|
sub install_schedules {
|
|
return _run_gniza_command('schedule', 'install');
|
|
}
|
|
|
|
# remove_schedules()
|
|
# Runs: /usr/local/bin/gniza schedule remove
|
|
# Returns ($success, $stdout, $stderr).
|
|
sub remove_schedules {
|
|
return _run_gniza_command('schedule', 'remove');
|
|
}
|
|
|
|
# show_schedules()
|
|
# Runs: /usr/local/bin/gniza schedule show
|
|
# Returns ($success, $stdout, $stderr).
|
|
sub show_schedules {
|
|
return _run_gniza_command('schedule', 'show');
|
|
}
|
|
|
|
# install_schedule($name)
|
|
# Builds a cron entry for a single schedule and installs it.
|
|
# Returns ($success, $error_message).
|
|
sub install_schedule {
|
|
my ($name) = @_;
|
|
|
|
my $conf_path = "$SCHEDULES_DIR/$name.conf";
|
|
return (0, "Schedule config not found: $name") unless -f $conf_path;
|
|
|
|
my $conf = GnizaWHM::Config::parse($conf_path, 'schedule');
|
|
my $schedule = $conf->{SCHEDULE} // '';
|
|
return (0, "SCHEDULE not set in $name") unless $schedule;
|
|
|
|
# Build 5-field cron expression
|
|
my ($cron_expr, $err) = _schedule_to_cron($conf);
|
|
return (0, $err) unless defined $cron_expr;
|
|
|
|
# Build full cron command line
|
|
my $extra_flags = '';
|
|
my $remotes = $conf->{REMOTES} // '';
|
|
$remotes =~ s/^\s+|\s+$//g;
|
|
if ($remotes ne '') {
|
|
$extra_flags .= " --remote=$remotes";
|
|
}
|
|
if (($conf->{SYSBACKUP} // '') eq 'yes') {
|
|
$extra_flags .= " --sysbackup";
|
|
}
|
|
my $cmd_line = "$cron_expr $GNIZA_BIN backup${extra_flags} >> /var/log/gniza/cron-${name}.log 2>&1";
|
|
|
|
# Read current crontab, strip existing entry for this schedule, append new
|
|
my $crontab = _read_crontab();
|
|
$crontab = _strip_schedule_entries($crontab, $name);
|
|
|
|
# Append new entry
|
|
$crontab .= "\n" if $crontab ne '' && $crontab !~ /\n$/;
|
|
$crontab .= "$GNIZA_TAG$name\n$cmd_line\n";
|
|
|
|
return _write_crontab($crontab);
|
|
}
|
|
|
|
# remove_schedule($name)
|
|
# Removes the cron entry for a single schedule.
|
|
# Returns ($success, $error_message).
|
|
sub remove_schedule {
|
|
my ($name) = @_;
|
|
|
|
my $crontab = _read_crontab();
|
|
my $new_crontab = _strip_schedule_entries($crontab, $name);
|
|
|
|
return (1, undef) if $new_crontab eq $crontab; # nothing to remove
|
|
return _write_crontab($new_crontab);
|
|
}
|
|
|
|
# cron_to_human($cron_line)
|
|
# Converts a gniza cron line to human-readable timing and remotes.
|
|
# Returns ($timing, $remotes) in list context.
|
|
# e.g. "0 * * * * /usr/local/bin/gniza backup --remote=rasp ..." => ("Every hour", "rasp")
|
|
sub cron_to_human {
|
|
my ($cron_line) = @_;
|
|
|
|
# Extract the 5 cron fields
|
|
my @parts = split /\s+/, $cron_line, 6;
|
|
return ($cron_line, '') if @parts < 6;
|
|
my ($min, $hour, $dom, $mon, $dow) = @parts[0..4];
|
|
|
|
# Build timing description
|
|
my $timing;
|
|
my @dow_names = qw(Sunday Monday Tuesday Wednesday Thursday Friday Saturday);
|
|
|
|
if ($hour eq '*' && $dom eq '*' && $mon eq '*' && $dow eq '*') {
|
|
$timing = 'Every hour';
|
|
}
|
|
elsif ($dom eq '*' && $mon eq '*' && $dow eq '*') {
|
|
$timing = sprintf('Daily at %02d:%02d', $hour, $min);
|
|
}
|
|
elsif ($dom eq '*' && $mon eq '*' && $dow =~ /^[0-6]$/) {
|
|
my $day_name = $dow_names[$dow] // "day $dow";
|
|
$timing = sprintf('Every %s at %02d:%02d', $day_name, $hour, $min);
|
|
}
|
|
elsif ($mon eq '*' && $dow eq '*' && $dom =~ /^\d+$/) {
|
|
my $suffix = ($dom == 1 || $dom == 21 || $dom == 31) ? 'st'
|
|
: ($dom == 2 || $dom == 22) ? 'nd'
|
|
: ($dom == 3 || $dom == 23) ? 'rd'
|
|
: 'th';
|
|
$timing = sprintf('%d%s of each month at %02d:%02d', $dom, $suffix, $hour, $min);
|
|
}
|
|
else {
|
|
$timing = "$min $hour $dom $mon $dow";
|
|
}
|
|
|
|
# Extract target remotes from --remote= flag
|
|
my $remotes = 'All';
|
|
if ($cron_line =~ /--remote=(\S+)/) {
|
|
$remotes = $1;
|
|
}
|
|
|
|
return ($timing, $remotes);
|
|
}
|
|
|
|
# -- Private --
|
|
|
|
# Only these exact commands are allowed.
|
|
my %ALLOWED_COMMANDS = (
|
|
'schedule install' => 1,
|
|
'schedule show' => 1,
|
|
'schedule remove' => 1,
|
|
);
|
|
|
|
sub _run_gniza_command {
|
|
my (@args) = @_;
|
|
my $cmd_key = join(' ', @args);
|
|
|
|
unless ($ALLOWED_COMMANDS{$cmd_key}) {
|
|
return (0, '', "Command not allowed: gniza $cmd_key");
|
|
}
|
|
|
|
my $err = gensym;
|
|
my $pid = open3(my $in, my $out, $err, $GNIZA_BIN, @args);
|
|
close $in;
|
|
|
|
my $stdout = do { local $/; <$out> } // '';
|
|
my $stderr = do { local $/; <$err> } // '';
|
|
close $out;
|
|
close $err;
|
|
|
|
waitpid($pid, 0);
|
|
my $exit_code = $? >> 8;
|
|
|
|
return ($exit_code == 0, $stdout, $stderr);
|
|
}
|
|
|
|
# _schedule_to_cron(\%conf)
|
|
# Converts schedule config to a 5-field cron expression.
|
|
# Returns ($expr, undef) on success or (undef, $error) on failure.
|
|
sub _schedule_to_cron {
|
|
my ($conf) = @_;
|
|
|
|
my $schedule = $conf->{SCHEDULE} // '';
|
|
my $time = $conf->{SCHEDULE_TIME} // '02:00';
|
|
my $day = $conf->{SCHEDULE_DAY} // '';
|
|
my $cron_raw = $conf->{SCHEDULE_CRON} // '';
|
|
|
|
my ($hour, $minute) = (2, 0);
|
|
if ($time =~ /^(\d{1,2}):(\d{2})$/) {
|
|
($hour, $minute) = ($1 + 0, $2 + 0);
|
|
}
|
|
|
|
if ($schedule eq 'hourly') {
|
|
return ("$minute * * * *", undef);
|
|
} elsif ($schedule eq 'daily') {
|
|
return ("$minute $hour * * *", undef);
|
|
} elsif ($schedule eq 'weekly') {
|
|
return (undef, "SCHEDULE_DAY required for weekly") if $day eq '';
|
|
return ("$minute $hour * * $day", undef);
|
|
} elsif ($schedule eq 'monthly') {
|
|
return (undef, "SCHEDULE_DAY required for monthly") if $day eq '';
|
|
return ("$minute $hour $day * *", undef);
|
|
} elsif ($schedule eq 'custom') {
|
|
return (undef, "SCHEDULE_CRON required for custom") if $cron_raw eq '';
|
|
return ($cron_raw, undef);
|
|
}
|
|
|
|
return (undef, "Unknown schedule type: $schedule");
|
|
}
|
|
|
|
# _read_crontab()
|
|
# Returns current crontab as string (empty string if none).
|
|
sub _read_crontab {
|
|
my $crontab = '';
|
|
if (open my $pipe, '-|', 'crontab', '-l') {
|
|
local $/;
|
|
$crontab = <$pipe> // '';
|
|
close $pipe;
|
|
}
|
|
return $crontab;
|
|
}
|
|
|
|
# _write_crontab($content)
|
|
# Writes new crontab via 'crontab -'. Returns ($success, $error).
|
|
sub _write_crontab {
|
|
my ($content) = @_;
|
|
|
|
open my $pipe, '|-', 'crontab', '-'
|
|
or return (0, "Failed to open crontab for writing: $!");
|
|
print $pipe $content;
|
|
close $pipe;
|
|
|
|
if ($? != 0) {
|
|
return (0, "crontab command failed with exit code " . ($? >> 8));
|
|
}
|
|
return (1, undef);
|
|
}
|
|
|
|
# _strip_schedule_entries($crontab, $name)
|
|
# Removes the tag line and following command line for a specific schedule.
|
|
sub _strip_schedule_entries {
|
|
my ($crontab, $name) = @_;
|
|
|
|
my @lines = split /\n/, $crontab, -1;
|
|
my @out;
|
|
my $i = 0;
|
|
while ($i < @lines) {
|
|
if ($lines[$i] eq "$GNIZA_TAG$name") {
|
|
# Skip tag line and the following command line
|
|
$i++;
|
|
$i++ if $i < @lines; # skip command line
|
|
next;
|
|
}
|
|
push @out, $lines[$i];
|
|
$i++;
|
|
}
|
|
|
|
return join("\n", @out);
|
|
}
|
|
|
|
1;
|