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:". # 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;