From a3f9fe3bdb00a0172184642c7ecfa8529eb881a6 Mon Sep 17 00:00:00 2001 From: Rod Wright Date: Wed, 25 Feb 2026 17:26:01 -0500 Subject: [PATCH] Incorporate changes for version 2.0 --- CHANGELOG | 25 + INSTALL | 21 + LICENSE | 341 ++++ README.md | 5 + VERSION.txt | 2 +- dist/etc/cron.daily/ribs_backups | 8 - dist/etc/ribs/conf-available/ribs.conf.sample | 32 +- .../ribs/conf-available/ribs.config.sample | 124 -- dist/usr/local/bin/ribs | 1761 ++++++++++++----- dist/usr/local/bin/ribs.py | 658 ------ dist/usr/local/bin/run_ribs_backups | 17 + install.sh | 16 +- 12 files changed, 1736 insertions(+), 1274 deletions(-) create mode 100644 CHANGELOG create mode 100644 INSTALL create mode 100644 LICENSE delete mode 100755 dist/etc/cron.daily/ribs_backups delete mode 100644 dist/etc/ribs/conf-available/ribs.config.sample delete mode 100755 dist/usr/local/bin/ribs.py create mode 100755 dist/usr/local/bin/run_ribs_backups diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..0a016a2 --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,25 @@ + RIBS - Rod's Incremental Backup System + Copyright (C) 2025 Rod Wright + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + + +Changelog + +1.x - 2024-01-09 + - Initial releases as a bash script. + +2.0 - 2025-03-19 + - Complete rewrite into python. diff --git a/INSTALL b/INSTALL new file mode 100644 index 0000000..db7444a --- /dev/null +++ b/INSTALL @@ -0,0 +1,21 @@ +Installation or upgrade of RIBS is straightforward. + +1. Unpack +Unpack the distribution in a convenient place. A directory will +created called RIBS-x.x/. + +2. Review License +Change to the RIBS-x.x/ directory and review the LICENSE file. + +3. Run the install script +In the same directory, type sudo ./install.sh at a command prompt to +install RIBS. + +4. Configure +Type ribs -h for help on the ribs command. Type ribs -c to manage ribs +configuration. + +5. Run RIBS +Type ribs at a command line to run the config files, or refer to the +/usr/local/bin/run_ribs_backups script for info on running ribs as a +scheduled task using cron. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9732b82 --- /dev/null +++ b/LICENSE @@ -0,0 +1,341 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc. + 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Library General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The \"Program\", below, +refers to any such program or work, and a \"work based on the Program\" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term \"modification\".) Each licensee is addressed as \"you\". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and \"any +later version\", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the \"copyright\" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a \"copyright disclaimer\" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Library General +Public License instead of this License. + diff --git a/README.md b/README.md index e69de29..4701093 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,5 @@ +README.md + +This is Rod's Incremental Backup System + +To install, run sudo ./install.sh diff --git a/VERSION.txt b/VERSION.txt index d5e98f7..415b19f 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.3.2 \ No newline at end of file +2.0 \ No newline at end of file diff --git a/dist/etc/cron.daily/ribs_backups b/dist/etc/cron.daily/ribs_backups deleted file mode 100755 index b7a96d8..0000000 --- a/dist/etc/cron.daily/ribs_backups +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -log_path="/var/log/ribs" -log_fn="ribs_$(date +%Y%m%d%H%M%S).log" - -/usr/local/bin/ribs > "$log_path"/"$log_fn" 2>&1 - - diff --git a/dist/etc/ribs/conf-available/ribs.conf.sample b/dist/etc/ribs/conf-available/ribs.conf.sample index 66ea6c3..ca55c74 100644 --- a/dist/etc/ribs/conf-available/ribs.conf.sample +++ b/dist/etc/ribs/conf-available/ribs.conf.sample @@ -20,17 +20,17 @@ # backed up. If not, the directory itself will be backed up. # NOTE: Special characters must be escaped with backslashes! # -source_dir=/var/www/ +#source_dir=/var/www/ # # Backup root directory - The directory on the backup storage where the # backups are stored. For offline storage, this is the mount point of # the backup drive filesystem. For remote storage, this is the path on -# the remote server. Must be writable to user running ribs. This must +# the remote server. Must be writable to user running RIBS. This must # be an absolute, not a relative path. Directories named as the date # of the backups will be created here containing the backed up files. # NOTE: Special characters must be escaped with backslashes! # -backup_root=/mnt/incremental_backup_dir +#backup_root=/mnt/incremental_backup_dir # # Backup subdirectory - A subdirectory of backup_root in which to store # the backup date directories. If this is not set, the date @@ -52,7 +52,7 @@ backup_root=/mnt/incremental_backup_dir # complete, directories with dates older than this limit will be # deleted. # -day_limit=90 +#day_limit=90 # # Storage type - Where the backups are to be stored. Only one of the # three options should be specified. Set the option you want, then @@ -63,11 +63,15 @@ day_limit=90 # offline - Back up to offline storage that must be mounted. Can be # anything that you can mount and unmount, including a filesystem # on a local block device or a remote NFS or CIFS/SMB share. +# NOTE: To use offline storage, you must have superuser privileges +# in order to mount and unmount the device. RIBS will abort if you +# set storage_type to offline and you are not running as root or +# with sudo. # remote - Back up to a remote system directly via rsync. If you use # this, you must define remote_user_host below and setup pubkey # authentication vi ssh for the remote user and host. # -storage_type=remote +#storage_type=remote # @@ -81,22 +85,26 @@ storage_type=remote [offline] # Options for offline storage type # -# Options in this section are required if storage_type above is set -# to offline. Otherwise, they are ignored. # # Mount parameters - Storage filesystem mount parameters. Should be what # you'd type on the command line between the word "mount" and the # mountpoint. See the manual page for the mount command (man 8 mount) -# for the available options. +# for the available options. If this is blank ("") or not defined, +# RIBS will attempt to mount backup_root, defined above in the +# [General] section, with no parameters, hoping that it is listed in +# /etc/fstab (man 5 fstab). You must run RIBS with superuser +# privileges for the offline storage type to work, since it requires +# mounting and unmounting filesystems. # -mount_parameters=-t ext4 -U 433a2e61-25aa-4da5-a7c7-4d730a5793e7 +#mount_parameters=-t ext4 -U 433a2e61-25aa-4da5-a7c7-4d730a5793e7 #mount_parameters="-t nfs nfsserver:/srv/ribs-backups" #mount_parameters="/dev/sdc1" # # Remount read-only - Set this to True if storage should be remounted in -# read-only mode when not being used for backups, False if not. +# read-only mode when not being used for backups, False if not. If +# blank ("") or not defined, the default is False. # -remount_ro=True +#remount_ro=True # [remote] @@ -112,5 +120,5 @@ remount_ro=True # usually done with the ssh-copy-id command, which also takes care of # adding the host's key fingerprint to the local known_hosts file. # -remote_user_host=user@backupserver +#remote_user_host=user@backupserver # diff --git a/dist/etc/ribs/conf-available/ribs.config.sample b/dist/etc/ribs/conf-available/ribs.config.sample deleted file mode 100644 index e6ef1b7..0000000 --- a/dist/etc/ribs/conf-available/ribs.config.sample +++ /dev/null @@ -1,124 +0,0 @@ -# Configuration file for Rod's Incremental Backup System -# Version 1.3.2 - -# ************************** General Options *************************** -# -# All of the options in the genera options section must be defined. -# -# ******************** -# Dry run - Set to false for normal mode or true for testing. -# -DRY_RUN=true -# -# ******************** -# Source directory - The directory where files to be backed up are -# located. This must be an absolute, not a relative path. If the -# trailing slash is included, the contents of the directory will be -# backed up. If not, the directory itself will be backed up. -# NOTE: Special characters must be escaped with backslashes! -# -SRC="/var/www/" -# -# ******************** -# Backup root directory - The directory on the backup storage where the -# backups are stored. For OFFLINE storage, this is the mount point of -# the backup drive filesystem. For REMOTE storage, this is the path on -# the remote server. Must be writable to user running ribs. This must -# be an absolute, not a relative path. Directories named as the date -# of the backups will be created here containing the backed up files. -# NOTE: Special characters must be escaped with backslashes! -# -BACKUP_ROOT="/mnt/incremental_backup_dir" -# -# ******************** -# Backup subdirectory - A subdirectory of BACKUP_ROOT in which to store -# the backup date directories. To just put the date directories -# directly in BACKUP_ROOT, set this to blank (""). -# NOTE: Special characters must be escaped with backslashes! -# -BACKUP_SUBDIR="" -#BACKUP_SUBDIR="/main" -# -# ******************** -# Exclude list - List of file patterns to exclude, separated by -# newlines. Anything in the source directory that matches a pattern in -# this list will not be backed up. If you don't want to exclude -# anything from the backup, set this to blank (""). -# NOTE: Special characters must be escaped with backslashes unless -# they represent a wildcard in a pattern! -# -EXCLUDE_LIST="" -#EXCLUDE_LIST=" -#DatabaseBackups-* -#somehugedir -#secretstuff -#" -# -# ******************** -# Day limit - The number of days of backups to keep. When the backup is -# complete, directories with dates older than this limit will be -# deleted. -# -DAY_LMT=90 -# -# ******************** -# Storage type - Where the backups are to be stored. Only one of the -# three options should be specified. Set the option you want, then -# refer to the appropriate section below for that specific -# configuration. Possible values are: -# LOCAL - Back up to a local directory. Can also be anything already -# mounted on the local filesystem. -# OFFLINE - Back up to offline storage that must be mounted. Can be -# anything that you can mount and unmount, including a filesystem -# on a local block device or a remote NFS or CIFS/SMB share. -# REMOTE - Back up to a remote system directly via rsync. If you use -# this, you must define REMOTE_USER_HOST below and setup pubkey -# authentication vi ssh for the remote user and host. -# -STORAGE_TYPE="REMOTE" -# -# ********************************************************************** - - -# ******************* Options for LOCAL storage type ******************* -# -# There are no specific settings for the local storage type. -# -# ********************************************************************** - - -# ****************** Options for OFFLINE storage type ****************** -# -# Leave this entire section commented out for LOCAL or REMOTE storage. -# -# Mount parameters - Storage filesystem mount parameters. Should be what -# you'd type on the command line between the word "mount" and the -# mountpoint. See the manual page for the mount command (man 8 mount) -# for the available options. -# -#MOUNT_PARAMETERS="-t nfs nfsserver:/srv/ribs-backups" -#MOUNT_PARAMETERS="-t ext4 -U 433a2e61-25aa-4da5-a7c7-4d730a5793e7" -#MOUNT_PARAMETERS="/dev/sdc1" -# -# Remount read-only - Set this to true if storage should be remounted in -# read-only mode when not being used for backups. -# -#REMOUNT_RO=true -#REMOUNT_RO=false -# -# ********************************************************************** - -# ****************** Options for REMOTE storage type ******************* -# -# Leave this entire section commented out for LOCAL or OFFLINE storage. -# -# Remote user and host - The user and host to connect to for backup -# storage. If you want RIBS to be able to run unattended, for example -# as a cron job, you will need to ensure that the user's ssh public -# key is listed in the remote host's authorized_keys file. This is -# usually done with the ssh-copy-id command, which also takes care of -# adding the host's key fingerprint to the local known_hosts file. -# -REMOTE_USER_HOST="user@backupserver" -# -# ********************************************************************** diff --git a/dist/usr/local/bin/ribs b/dist/usr/local/bin/ribs index d2b4535..083d144 100755 --- a/dist/usr/local/bin/ribs +++ b/dist/usr/local/bin/ribs @@ -1,485 +1,1308 @@ -#!/bin/bash -# Rod's Incremental Backup System +#!/usr/bin/env python3 -# Define constants - -RIBS_VERSION="1.3.2" -USER_CONF_DIR="$HOME/.ribs/conf-enabled" -SYSTEM_CONF_DIR="/etc/ribs/conf-enabled" - -# Define functions - -function find_configs() { - # Search for config files in the default places and build a list - # Takes one optional argument, the name of a config file. - # Defines the variable conf_files, a list of conf files +""" + Program: Rod's Incremental Backup System (ribs) + Author: Rod Wright + Date: 03/19/2025 - conf_files=() - # First check user's conf directory, then the system conf directory - if [[ $1 != "" ]] - then - conf_files+=("$1") - elif ls -1 "$USER_CONF_DIR"/* >/dev/null 2>&1 - then - for conf_file in "$USER_CONF_DIR"/* - do - conf_files+=("$conf_file") - done - elif ls -1 "$SYSTEM_CONF_DIR"/* >/dev/null 2>&1 - then - for conf_file in "$SYSTEM_CONF_DIR"/* - do - conf_files+=("$conf_file") - done - else - echo "Couldn't find any config files in" - echo "$USER_CONF_DIR or $SYSTEM_CONF_DIR," - echo "and one was not specified on the command line." - echo "Please refer to the file /etc/ribs/conf-available/ribs.conf.sample" - echo "and create one." - exit 1 - fi -} + Copyright (C) 2025 Rod Wright -function read_config() { - # Read the configuration file + SPDX-License-Identifier: GPL-2.0 +""" + +import subprocess +import sys +import os +import shutil +import glob +from datetime import datetime, timedelta +import textwrap +import argparse +import configparser +from pathlib import Path +import re + +# App version +APP_VERSION="2.0" + +# File paths +SYS_CONF=Path('/etc/ribs') +USER_CONF=Path.home().joinpath(Path('.ribs')) + +AVAILABLE=Path('conf-available') +ENABLED=Path('conf-enabled') +CONF_TEMPLATE=Path('ribs.conf.sample') + +SYS_AVAILABLE=SYS_CONF.joinpath(AVAILABLE) +SYS_ENABLED=SYS_CONF.joinpath(ENABLED) +USER_AVAILABLE=USER_CONF.joinpath(AVAILABLE) +USER_ENABLED=USER_CONF.joinpath(ENABLED) + + +# Determine if I have superuser privileges +if os.geteuid()==0: + superuser=True +else: + superuser=False + +# ****** BEGIN function definitions ****** + +def main(): + """ + Main function - # Make sure the provided config file is readable. - if [[ ! -r $CONF_FILE ]] - then - echo -n "Reading configuration file $CONF_FILE" - echo -e "\e[31mFAILED\e[0m" - echo "The config file $CONF_FILE does not exist or is not readable." - echo "Aborting." - exit 1 - fi - echo -n "Reading configuration file... " - if ! source "$CONF_FILE" - then - echo -e "\e[31mFAILED\e[0m" - echo "An error was encountered while reading the configuration file" - echo "$CONF_FILE." - echo "Aborting." - exit 1 - else - echo -e "\e[32mOK\e[0m" - fi - - # Add slash to the beginning of $BACKUP_SUBDIR if it's not there - if [[ "$(echo "$BACKUP_SUBDIR" | cut -c1)" != "/" ]] - then - BACKUP_SUBDIR="/$BACKUP_SUBDIR" - fi - # Remove slash from the end of $BACKUP_SUBDIR if it's there - if [[ "$(echo "$BACKUP_SUBDIR" | rev | cut -c1)" = "/" ]] - then - numchars=$(echo "$BACKUP_SUBDIR" | wc -m);let "numchars-=2" - BACKUP_SUBDIR=$(echo "$BACKUP_SUBDIR" | cut -c-$numchars) - fi -} - -function generate_excludes() { - # Generate exclude array - declare -a excludes - if [[ $EXCLUDE_LIST != "" ]] - then - for pattern in $EXCLUDE_LIST - do - echo "excluding pattern $pattern" - excludes+=' --exclude="$pattern" ' - done - fi -} - -function mount_storage() { - # Takes one argument, rw, ro, or u - mount_mode=$1 - if [[ $mount_mode = "ro" ]] - then - mode_text="read-only" - elif [[ $mount_mode = "rw" ]] - then - mode_text="read-write" - elif [[ $mount_mode = "u" ]] - then - mode_text="unmounted" - else - echo "Program error: invalid option \"$mode_text\" passed to mount_storage()" - exit 1 - fi - - # Mount the backup drive - # First, check to see if backup drive partition is mounted - if ! mount|grep -q "$BACKUP_ROOT" - then - remount_option="remount," - else - remount_option="" - fi - - if [[ $mount_mode = "u" ]] - then - # Unmount drive - echo -n "Unmounting backup storage... " - if ! umount "$BACKUP_ROOT" - then - echo -e "\e[31mFAILED\e[0m" - echo "An error was encountered while attempting to unmount $BACKUP_ROOT." - echo "Aborting." - exit 1 - else - echo -e "\e[32mOK\e[0m" - fi - else - # Mount the drive in the desired mode - echo -n "Mounting backup storage $mode_text... " - if [[ $MOUNT_PARAMETERS != "" ]] - then - if ! mount "$MOUNT_PARAMETERS" "$BACKUP_ROOT" -o"$remount_option""$mount_mode" - then - echo -e "\e[31mFAILED\e[0m" - echo "An error was encountered while attempting to mount the backup storage" - echo "on $BACKUP_ROOT in $mode_text mode. Check MOUNT_PARAMETERS in your" - echo "configuration file." - echo "Aborting." - exit 1 - else - echo -e "\e[32mOK\e[0m" - fi - else - echo -e "\e[31mFAILED\e[0m" - echo "Unable to mount backup storage." - echo "No MOUNT_PARAMETERS were provided in config file." - echo "Aborting." - exit 1 - fi - fi -} - -function list_backups() { - # Generate a list of backups based on storage type - if [[ $STORAGE_TYPE = "REMOTE" ]] - then - ssh $REMOTE_USER_HOST cd $BACKUP_ROOT$BACKUP_SUBDIR && ls -1d ????-??-??* - else - cd $BACKUP_ROOT$BACKUP_SUBDIR && ls -1d ????-??-??* - fi -} - -function delete_backup() { - # Delete a backup. Takes one argument, the date directory to delete - echo -n "Removing old backup $1... " - remove_fail=false - if [[ $STORAGE_TYPE = "REMOTE" ]] - then - if ! ssh $REMOTE_USER_HOST rm -r $BACKUP_ROOT$BACKUP_SUBDIR/$1 - then - remove_fail=true - fi - else - if ! rm -r $BACKUP_ROOT$BACKUP_SUBDIR/$1 - then - remove_fail=true - fi - fi - if $remove_fail - then - echo -e "\e[31mFAILED\e[0m" - else - echo -e "\e[32mOK\e[0m" - fi -} + Args: + No arguments -function run_backup() { - CONF_FILE="$1" + Returns: + Nothing - echo "Run started with configuration file:" - echo "$CONF_FILE" - date "+%a %b %d %H:%M:%S %Z %Y" - echo "" + """ - # Read config file - read_config - - # Generate exclude array - generate_excludes - - # Mount offline storage if required - if [[ $STORAGE_TYPE == "OFFLINE" ]] - then - mount_storage rw - fi - - # Today's date - current_datetime=$(date +%Y-%m-%dT%H:%m:%S) - echo "Today's date: $current_datetime" - - # Previous backup's date - if $list_cmd >/dev/null 2>&1 - then - previous_datetime=$($list_cmd | tail -n1) - else - # there are no other date directories - previous_datetime="" - fi - if [[ $previous_datetime == "" ]] - then - echo "No previous backup date." - fi - - # set target and link destination directories and day to remove - if [[ $STORAGE_TYPE == "REMOTE" ]] - then - TRG="$REMOTE_USER_HOST:$BACKUP_ROOT$BACKUP_SUBDIR/$current_datetime/" - LNK="$BACKUP_ROOT$BACKUP_SUBDIR/$previous_datetime/" - if ssh "$REMOTE_USER_HOST" test -e "$LNK" - then - link_dest_exists=true - else - link_dest_exists=false - fi - else - TRG="$BACKUP_ROOT$BACKUP_SUBDIR/$current_datetime/" - LNK="$BACKUP_ROOT$BACKUP_SUBDIR/$previous_datetime" - if [[ -e "$LNK" ]] - then - link_dest_exists=true - else - link_dest_exists=false - fi - fi - - # rsync options - for element in $excludes - do - echo "excludes array contains $element" - done - if ! $link_dest_exists - then - OPT="-s -ah --delete ${excludes[@]}" - else - OPT="-s -ah --delete --link-dest=$LNK ${excludes[@]}" - fi - if $DRY_RUN - then - OPT="$OPT -n" - fi - - if [[ $STORAGE_TYPE != "REMOTE" ]] - then - # -------- Run some tests ----- - testtimestamp=$(date +%Y%m%d%H%M%S%N) - test_filename="RIBS${testtimestamp}" - test_linkname="RIBS${testtimestamp}hardlink" + print(f"Rod's Incremental Backup System, version {APP_VERSION}") + print("") - # Make sure the backup root is writeable - echo -n "Testing backup location for writeability... " - if ! touch "$BACKUP_ROOT"/"$test_filename" >/dev/null 2>&1 - then - echo -e "\e[31mFAILED\e[0m" - echo "Aborting." - if [[ $STORAGE_TYPE = "OFFLINE" ]] - then - if $REMOUNT_RO - then - mount_storage ro - else - mount_storage u - fi - fi - exit 1 - else - echo -e "\e[32mOK\e[0m" - fi + if not superuser: + if not USER_CONF.exists(): + print("Creating user conf directory") + USER_CONF.mkdir() + USER_AVAILABLE.mkdir() + USER_ENABLED.mkdir() + user_conf_template=USER_AVAILABLE.joinpath(CONF_TEMPLATE) + sys_conf_template=SYS_AVAILABLE.joinpath(CONF_TEMPLATE) + if user_conf_template.is_file(): + user_conf_template.unlink() + if not user_conf_template.is_symlink(): + user_conf_template.symlink_to(sys_conf_template) - # Make sure the backup root supports hard links - echo -n "Testing backup location for hard link support... " - if ! ln "$BACKUP_ROOT"/"$test_filename" "$BACKUP_ROOT"/"$test_linkname" >/dev/null 2>&1 - then - echo -e "\e[31mFAILED\e[0m" - echo "Aborting." - rm -f "$BACKUP_ROOT"/"$test_filename" - if [[ $STORAGE_TYPE == "OFFLINE" ]] - then - if $REMOUNT_RO - then - mount_storage ro - else - mount_storage u - fi - fi - exit 1 - else - echo -e "\e[32mOK\e[0m" - fi - - rm -f "$BACKUP_ROOT"/"$test_filename" - rm -f "$BACKUP_ROOT"/"$test_linkname" - fi + print("") + cmdline=parse_cmdline() - # -------- Start backup ------- - # Create backup subdir if it's not there - if [[ $STORAGE_TYPE == "REMOTE" ]] - then - if ! $DRY_RUN && [[ $BACKUP_SUBDIR != "" ]] && ! ssh "$REMOTE_USER_HOST" test -d "$BACKUP_ROOT$BACKUP_SUBDIR" - then - ssh "$REMOTE_USER_HOST" mkdir -p "$BACKUP_ROOT""$BACKUP_SUBDIR" - fi - else - if ! $DRY_RUN && [[ $BACKUP_SUBDIR != "" ]] && ! test -d "$BACKUP_ROOT$BACKUP_SUBDIR" - then - mkdir -p "$BACKUP_ROOT""$BACKUP_SUBDIR" - fi - fi - - echo "Starting incremental backup." - if $DRY_RUN - then - echo "This will be a dry run. Nothing will actually be backed up." - sleep 10 - fi - # Execute the backup - echo -n "running rsync backup with command rsync $OPT $SRC $TRG... " - if ! bash -c "rsync $OPT $SRC $TRG" - then - echo -e "\e[31mFAILED\e[0m" - echo "An error was encountered while attempting to backup. No old" - echo "backups will be removed." - if [[ $STORAGE_TYPE == "OFFLINE" ]] - then - if $REMOUNT_RO - then - mount_storage ro - else - mount_storage u - fi - fi - exit 1 - else - echo -e "\e[32mOK\e[0m" - fi - - # Delete the specified backups, if it exists - limit_date=$(date -d "$current_datetime $DAY_LMT days ago") - limit_date_sec=$(date -d "$limit_date" +%s) - - if $DRY_RUN - then - echo "This is a dry run, so no old backups will be removed." - for date_dir in $(list_backups) - do - date_dir_sec=$(date -d "$date_dir" +%s) - if [[ $date_dir_sec < $limit_date_sec ]] - then - echo "We would be removing $date_dir" - fi - done - else - for date_dir in $(list_backups) - do - date_dir_sec=$(date -d "$date_dir" +%s) - if [[ $date_dir_sec < $limit_date_sec ]] - then - delete_backup $date_dir - fi - done - fi - - # Clean up - echo -n "Syncing disks... " - if ! sync - then - echo -e "\e[31mFAILED\e[0m" - else - echo -e "\e[32mOK\e[0m" - fi - - # Update capacity file if using offline storage - if [[ $STORAGE_TYPE == "OFFLINE" ]] - then - echo -n "Updating capacity file... " - if ! df -h |grep "$BACKUP_ROOT" > "$BACKUP_ROOT"/capacity.txt - then - echo -e "\e[31mFAILED\e[0m" - else - echo -e "\e[32mOK\e[0m" - fi - fi - - # Unmount or remount offline storage as required - if [[ $STORAGE_TYPE == "OFFLINE" ]] - then - if $REMOUNT_RO - then - echo -n "Remounting backup storage read-only... " - if ! mount_storage ro - then - echo -e "\e[31mFAILED\e[0m" - else - echo -e "\e[32mOK\e[0m" - fi - else - echo -n "Unmounting backup storage... " - if ! mount_storage u - then - echo -e "\e[31mFAILED\e[0m" - else - echo -e "\e[32mOK\e[0m" - fi - fi - fi - echo "Backup complete." - echo "" - - echo "Run ended with configuration file:" - echo "$CONF_FILE" - date "+%a %b %d %H:%M:%S %Z %Y" - echo "" -} + # Set flags + global simulate + simulate=cmdline['simulate'] + + # Dispatch operation + match cmdline['operation']: + case 'config': + config_menu() + case 'list': + config_list() + case 'enable'|'disable': + config_action(cmdline['operation'],cmdline['conf_file_arg']) + case 'run': + run_backup(cmdline['conf_file_arg']) + case _: + print("Invalid operation") -function show_help() { - echo "Usage: $0 [-h|--help] [config_file]" - echo "" - echo "Run an incremental backup using a config file specified." - echo "If no config file is specified, search for and read all config" - echo "files in the user's config directory ($USER_CONF_DIR)." - echo "If none are found there, search for and read all config files in" - echo "the system config directory ($SYSTEM_CONF_DIR)." - echo "" - echo "Please refer to the sample config file, " - echo "/etc/ribs/conf-available/ribs.conf.sample for an explanation" - echo "of parameters." - echo "" - echo " Options:" - echo "" - echo " -h|--help: Display this help message." - echo "" -} + + +def parse_cmdline(): + """ + Parses command line arguments + + Args: + No arguments + + Returns: + dict(cmdline): + { + simulate: True | False, + operation: 'run' | 'config' | 'enable' | 'disable' | None, + conf_file_arg: 'conf_file'|None + } + """ + cmdline={} + + parser=argparse.ArgumentParser(description='Rod\'s Incremental Backup System, version '+APP_VERSION) + group=parser.add_mutually_exclusive_group() + + parser.add_argument( + '-s', '--simulate', + help='simulate operations to be performed', + action="store_true" + ) + + group.add_argument( + '-c', '--config', + help='create, remove, modify, enable, or disable config files', + action="store_true" + ) + + group.add_argument( + '-l', '--list-configs', + help='list all config files', + action="store_true" + ) + + group.add_argument( + '-e', '--enable-config', + metavar='CONF_FILE', + help='enable a config file', + ) + + group.add_argument( + '-d', '--disable-config', + metavar='CONF_FILE', + help='disable a config file', + ) + + group.add_argument( + "conf_file", + help="Run RIBS using the specified config file. If none specified, run all enabled config files, looking in '~/.ribs/conf-enabled' for standard users, or in '/etc/ribs/conf-enabled' for superusers.", + nargs='?' + ) + + args=parser.parse_args() + + # Flags + if args.simulate: + cmdline['simulate']=True + else: + cmdline['simulate']=False + + # Operations + if args.config: + cmdline['operation']='config' + cmdline['conf_file_arg']=None + elif args.list_configs: + cmdline['operation']='list' + elif args.enable_config: + cmdline['operation']='enable' + cmdline['conf_file_arg']=args.enable_config + elif args.disable_config: + cmdline['operation']='disable' + cmdline['conf_file_arg']=args.disable_config + else: + cmdline['operation']='run' + cmdline['conf_file_arg']=args.conf_file + + + return cmdline + + +def run_rsync(source, destination, options=None, excludes=None): + """ + Runs rsync with the given source, destination, options, and excludes. + + Args: + source (str): The source path. + destination (str): The destination path. + options (list, optional): A list of rsync options. Defaults to None. + excludes (list, optional): A list of patterns to exclude. Defaults to None. + + + Returns: + int: The return code of the rsync command. + """ + command = ["rsync"] + if options: + command.extend(options) + if simulate: + command.extend(['--dry-run']) + if excludes: + for pattern in excludes: + command.extend(['--exclude='+pattern]) + + command.extend([source, destination]) + #print(command) + process = subprocess.run(command, capture_output=True, text=True) + + if '-v' in options or '--verbose' in options: + print(process.stdout) + + if process.returncode != 0: + print(f"Error running rsync: {process.stderr}") + return process.returncode + + +def mount_storage(mountpoint,operation,mountparams=None): + """ + Handle mounting and unmounting of offline storage. + + Args: + mountpoint (str or path object): The directory on the filesystem + where something will be mounted. + operation (str): The mount operation. one of: + 'ro': mount read-only + 'rw': mount read-write + 'u': unmount + mountparams (str, optional): The mount parameters. This would be a string + containing the arguments to the mount(8) command, up to but + not including the mount point. + + Returns: + int: The return code of the command + """ + # Check to see if mountpoint exists + try: + if not mountpoint.is_dir(): + raise Exception(f"Error: The mount point {mountpoint} does not exist or is not a directory. This is a fatal error and ribs cannot continue with this config file. Please check the config file: {config}, or create the mountpoint.") + except Exception as errmsg: + print("") + print(errmsg) + return 1 + + # Check to see if something is already mounted there + if mountpoint.is_mount(): + mounted=True + # Check to see if it is writeable + try: + writefile=mountpoint.joinpath(Path('writetest.tmp')) + writefile.touch(exist_ok=True) + writefile.unlink(missing_ok=True) + except Exception: + # May be mounted read-only. + writeable=False + else: + writeable=True + else: + mounted=False + + # Build the command + if operation=='u': + mountcmd=['umount'] + mountcmd.extend([str(mountpoint)]) + if not mounted: + # Nothing to do + mountcmd.clear() + elif operation=='ro': + mountcmd=['mount'] + if mounted: + # Already mounted + if not writeable: + # Already read-only + mountcmd.clear() + else: + # We are mounted read-write. Attempt to remount read-only. + if mountparams: + mountcmd.extend([mountparams]) + mountcmd.extend([str(mountpoint)]) + mountcmd.extend(['-oremount,ro']) + else: + # Not yet mounted + if mountparams: + mountcmd.extend([mountparams]) + mountcmd.extend([str(mountpoint)]) + mountcmd.extend(['-oro']) + elif operation=='rw': + mountcmd=['mount'] + if mounted: + # Already mounted + if writeable: + # Already writeable + mountcmd.clear() + else: + # We are mounted read-only. Attempt to remount read-write. + if mountparams: + mountcmd.extend([mountparams]) + mountcmd.extend([str(mountpoint)]) + mountcmd.extend(['-oremount,rw']) + else: + # Not yet mounted + if mountparams: + mountcmd.extend([mountparams]) + mountcmd.extend([str(mountpoint)]) + mountcmd.extend(['-orw']) + else: + print("Unknown operation passed to mount_storage") + return 1 + + if len(mountcmd)>0: + mountproc=subprocess.run(mountcmd, capture_output=True, text=True) + if mountproc.returncode != 0: + print(f"Error occurred while attempting to mount or unmount: {mountproc.stderr}") + return mountproc.returncode + else: + return 0 + + +def config_menu(): + """ + Show a menu of operations to perform on config files. + + Args: + none + + Returns: + nothing + """ + while True: + print("Select an action",end="") + if simulate: + print("(simulated):\n") + else: + print(":\n") + print("1. [Llist config files") + print("2. [C]reate a config file") + print("3. [R]emove an available config file") + print("4. [M]odify an available config file") + print("5. [E]nable an available config file") + print("6. [D]isable an enabled config file") + #print("H. Config file help") + print("Q. Quit") + + selection = input("\nEnter your selection: ") + + match selection: + case '1'|'l'|'L': + config_list() + input("\nPress Enter to continue...") + print("") + case '2'|'c'|'C': + config_create() + input("\nPress Enter to continue...") + print("") + case '3'|'r'|'R': + config_action('remove') + input("\nPress Enter to continue...") + print("") + case '4'|'m'|'M': + config_action('modify') + input("\nPress Enter to continue...") + print("") + case '5'|'e'|'E': + config_action('enable') + input("\nPress Enter to continue...") + print("") + case '6'|'d'|'D': + config_action('disable') + input("\nPress Enter to continue...") + print("") + # case 'h' | 'H': + # config_help() + # input("\nPress Enter to continue...") + # print("") + case 'q' | 'Q': + print("") + return 0 + case _: + print("Invalid selection. Please try again.") + + print("") + + +def config_list(): + """ + Find and print a list of config files. + + Args: + No arguments + + Returns: + Nothing + """ + if superuser: + conftype='system' + availconfdir=SYS_AVAILABLE + enaconfdir=SYS_ENABLED + else: + conftype='user' + availconfdir=USER_AVAILABLE + enaconfdir=USER_ENABLED + + configs=find_configs(conftype) + + print(f"{conftype} config files:\n") + if len(configs)==0: + wrap_message(f"No available {conftype} config files found. Refer to the sample config file {SYS_AVAILABLE}/ribs.conf.sample to create one. Note that config files must have the .conf extension to be recognized.") + else: + for config in configs: + if Path(enaconfdir/config).is_symlink(): + enabled_ind='enabled ------>' + else: + enabled_ind='available ---->' + print(f"{enabled_ind} {config}") + print("") + + +def config_action(action,configfile=None): + """ + Take some action on a config file. + + Args: + action (str): The action to take. + one of: 'enable'|'disable'|'modify'|'remove' + configfile (str, optional): The filename of a configuration file + + Returns: + int: 0 on success or 1 on failure + """ + if simulate: + print("\nAll actions will be simulated") + + if superuser: + conftype='system' + availconfdir=SYS_AVAILABLE + enaconfdir=SYS_ENABLED + else: + conftype='user' + availconfdir=USER_AVAILABLE + enaconfdir=USER_ENABLED + + action_file=None + + match action: + case 'disable': + configs=find_configs(conftype,confstatus='enabled') + case 'enable': + aconfigs=find_configs(conftype) + econfigs=find_configs(conftype,confstatus='enabled') + configs=[conf for conf in aconfigs if conf not in econfigs] + case 'modify'|'remove': + configs=find_configs(conftype) + + if len(configs)==0: + print("No config files were found.") + return 1 + + if configfile: + configfile=Path(configfile) + if configfile in configs: + # We're good. That's what we'll use. + action_file=configfile + else: + # The config file supplied doesn't exist + print(f"The config file {configfile} does not exist.") + return 1 + else: + action_file=select_config(configs,action=action) + + match action: + case 'enable': + if simulate: + print("Simulating enabling",Path(action_file).name) + else: + print("Enabling config:",Path(action_file).name) + link=Path(enaconfdir/action_file) + target=Path(availconfdir/action_file) + try: + link.symlink_to(target) + except FileExistsError: + print("That config file is already enabled.") + return 1 + + case 'disable': + if simulate: + print("Simulating disabling",Path(action_file).name) + else: + print("Disabling config:",Path(action_file).name) + link=Path(enaconfdir/action_file) + try: + link.unlink() + except FileNotFoundError: + print("That config file is already disabled.") + return 1 + + case 'modify': + if simulate: + print("Simulating modifying",Path(action_file).name) + else: + print("Modifying config:",action_file) + filepath_to_mod=Path(availconfdir/action_file) + editor = os.environ.get('EDITOR') + if editor: + subprocess.run([editor, filepath_to_mod]) + else: + try: + result=subprocess.run(['xdg-open', filepath_to_mod], stderr = subprocess.DEVNULL) + if result.returncode!=0: + raise ValueError + except (ValueError, FileNotFoundError): + try: + subprocess.run(['vi', filepath_to_mod]) + except FileNotFoundError: + try: + subprocess.run(['nano', filepath_to_mod]) + except FileNotFoundError: + print("No default editor found. Please set the $EDITOR environment variable or install a common text editor.") + return 1 + + case 'remove': + if simulate: + print("Simulating removing",Path(action_file).name) + else: + print("Removing config:",action_file) + ena_to_remove=Path(enaconfdir/action_file) + avail_to_remove=Path(availconfdir/action_file) + try: + if ena_to_remove.is_symlink(): + ena_to_remove.unlink() + avail_to_remove.unlink() + except FileNotFoundError: + print("That config file has already been removed.") + return 1 + + case _: + print("Invalid action passed to config_action()") + return 1 + + return 0 + + +def config_create(): + """ + Create a config file. + + Args: + none + + Returns: + Nothing + """ + if simulate: + print("\nAll actions will be simulated") + if superuser: + conftype='system' + availconfdir=SYS_AVAILABLE + else: + conftype='user' + availconfdir=USER_AVAILABLE + + # Prompt for a filename that doesn't already exist + print("\nCreating a new config file using ribs.conf.sample as the template.\n") + print("Please enter a filename. The file must not already exist") + print("and the filename extension must be '.conf'.") + print("Enter c to cancel.") + fnvalid=False + while not fnvalid: + newfn=input("\nfilename: ") + existing_confs=find_configs(conftype) + if newfn=='c' or newfn=='C': + return 0 + elif Path(newfn) in existing_confs: + print("That file already exists.") + print("Try again or enter c to cancel.") + elif not newfn.endswith('.conf'): + print("The filename must end with '.conf'.") + print("Try again or enter c to cancel.") + else: + fnvalid=True + + # Copy ribs.conf.sample to a new file with the provided filename and edit. + if simulate: + print("Simulating creating ",newfn) + else: + try: + shutil.copy(availconfdir.joinpath(Path('ribs.conf.sample')), availconfdir.joinpath(Path(newfn))) + except Exception as e: + print(f"An error occurred: {e}") + return 1 + print(f"New config file {newfn} created. Opening for editing...") + config_action('modify',newfn) + print("...done. In order to run this config file, you must enable it.") + + +def find_configs(conftype,confstatus='available'): + """ + Find config files of the specified type. + + Args: + conftype (str): 'user' | 'system'. + confstatus (str, optional): ''available' | 'enabled' + + Returns: + list(configs): A list of names of config files + """ + if conftype=='user': + if confstatus=='enabled': + dirpath=USER_ENABLED + elif confstatus=='available': + dirpath=USER_AVAILABLE + elif conftype=='system': + if confstatus=='enabled': + dirpath=SYS_ENABLED + elif confstatus=='available': + dirpath=SYS_AVAILABLE + + pattern="*.conf" + configs = [] + matching_files=sorted(dirpath.glob(pattern)) + if matching_files: + for filepath in matching_files: + filename=Path(filepath.name) + configs.append(filename) + + return(configs) + + +def select_config(configfiles,action=None): + """ + Show a menu of config files of the provided list, allow the user to select one, and return the path of the selected one. + + Args: + configfiles (list): a list of config files. + + Returns: + None or path object: A path object of a config file. + """ + if not configfiles: + return None + + if action: + print(f"Select a config file to {action}:\n") + else: + print(f"Select a config file:\n") + + for i, item in enumerate(configfiles): + print(f"{i + 1}. {item}") + print("Q. Quit") + while True: + try: + selection = input("\nEnter your selection: ") + if selection=='q' or selection=='Q': + return None + selection=int(selection) + if 1 <= selection <= len(configfiles): + return configfiles[selection - 1] + else: + print("Invalid selection. Please try again.") + except ValueError: + print("Invalid input. Please enter a number.") + + +def parse_config_file(conf_file): + """ + Read the specified config file and return the parameters. + + Args: + conf_file (str or path object): The full path of a configuration + file. + + Returns: + dict: A dictionary of option:value pairs + """ + config=configparser.ConfigParser(allow_no_value=True) + config.read(conf_file) + + # Check for the [General] section and its options + config_errors=[] + if config.has_section('General'): + if config.has_option('General','source_dir'): + source_dir=config['General']['source_dir'] + else: + config_errors.append('source_dir option missing from config file') + if config.has_option('General','backup_root'): + backup_root=config['General']['backup_root'] + else: + config_errors.append('backup_root option missing from config file') + if config.has_option('General','backup_subdir'): + backup_subdir=config['General']['backup_subdir'] + else: + backup_subdir=None + if config.has_option('General','exclude_list'): + exclude_list=config['General']['exclude_list'].split(',') + exclude_list=[pattern.strip() for pattern in exclude_list] + else: + exclude_list=None + if config.has_option('General','day_limit'): + day_limit=config['General']['day_limit'] + else: + config_errors.append('day_limit option missing from config file') + if config.has_option('General','storage_type'): + storage_type=config['General']['storage_type'] + else: + config_errors.append('storage_type option missing from config file') + else: + config_errors.append('General section missing from config file') + + # Abort on [General] section errors + if len(config_errors)>0: + print("The following configuration file errors were found") + for err_msg in config_errors: + print(err_msg) + print("These are fatal errors and ribs cannot continue.") + print("Please check the config file:",conf_file) + return 1 + + if storage_type=="local": + # Check for the [local] section and its options + config_errors=[] + if config.has_section('local'): + # Reserved for future use + pass + else: + pass + + # Abort on [local] section errors + if len(config_errors)>0: + print("The following configuration file errors were found") + for err_msg in config_errors: + print(err_msg) + print("These are fatal errors and ribs cannot continue.") + print("Please check the config file:",conf_file) + return 1 + + elif storage_type=="offline": + # Check for the [offline] section and its options + config_errors=[] + if config.has_section('offline'): + if config.has_option('offline','mount_parameters'): + mount_parameters=config['offline']['mount_parameters'] + else: + mount_parameters=None + if config.has_option('offline','remount_ro'): + remount_ro=config['offline']['remount_ro'] + if remount_ro=="": + remount_ro=False + else: + remount_ro=False + else: + mount_parameters=None + remount_ro=False + + # Abort on [offline] section errors + if len(config_errors)>0: + print("The following configuration file errors were found") + for err_msg in config_errors: + print(err_msg) + print("These are fatal errors and ribs cannot continue.") + print("Please check the config file:",conf_file) + return 1 + + elif storage_type=="remote": + # Check for the [remote] section and its options + config_errors=[] + if config.has_section('remote'): + if config.has_option('remote','remote_user_host'): + remote_user_host=config['remote']['remote_user_host'] + else: + config_errors.append('remote_user_host option missing from config file') + else: + config_errors.append('remote section missing from config file') + + # Abort on [remote] section errors + if len(config_errors)>0: + print("The following configuration file errors were found") + for err_msg in config_errors: + print(err_msg) + print("These are fatal errors and ribs cannot continue.") + print("Please check the config file:",conf_file) + return 1 + else: + config_errors.append('invalid storage_type. Must be local, offline, or remote.') + if len(config_errors)>0: + print("The following configuration file errors were found") + for err_msg in config_errors: + print(err_msg) + print("These are fatal errors and ribs cannot continue.") + print("Please check the config file:",conf_file) + return 1 + + # Check and correct option formatting and abort if necessary + config_errors=[] + if not source_dir.startswith('/'): + config_errors.append('source_dir must be an absolute path, but doesn\'t start with a \'/\'') + if not backup_root.startswith('/'): + config_errors.append('backup_root must be an absolute path, but doesn\'t start with a \'/\'') + if day_limit=='0': + config_errors.append('day_limit cannot be 0') + if storage_type=='remote' and not "@" in remote_user_host: + config_errors.append('remote_user_host must be in the format\'user@host\'') + + # Abort on unacceptable option format errors + if len(config_errors)>0: + print("The following configuration file errors were found") + for err_msg in config_errors: + print(err_msg) + print("These are fatal errors and ribs cannot continue.") + print("Please check the config file:",conf_file) + return 1 + + # Clean up variables + if backup_root.endswith('/'): + backup_root=backup_root.rstrip('/') + if backup_subdir: + if backup_subdir.startswith('/'): + backup_subdir=backup_subdir.lstrip('/') + if backup_subdir.endswith('/'): + backup_subdir=backup_subdir.rstrip('/') + + # Return a parameter:option dictionary + if storage_type=="local": + return { + 'storage_type':storage_type, + 'source_dir':source_dir, + 'backup_root':backup_root, + 'backup_subdir':backup_subdir, + 'exclude_list':exclude_list, + 'day_limit':day_limit + } + elif storage_type=="offline": + return { + 'storage_type':storage_type, + 'source_dir':source_dir, + 'backup_root':backup_root, + 'backup_subdir':backup_subdir, + 'exclude_list':exclude_list, + 'day_limit':day_limit, + 'mount_parameters':mount_parameters, + 'remount_ro':remount_ro + } + elif storage_type=="remote": + return { + 'storage_type':storage_type, + 'source_dir':source_dir, + 'backup_root':backup_root, + 'backup_subdir':backup_subdir, + 'exclude_list':exclude_list, + 'day_limit':day_limit, + 'remote_user_host':remote_user_host + } + else: + # Not sure how we got here, but if we did, something's wrong + return 1 + + +def update_capacity_file(file_location): + """ + Update the capacity file for offline media. Detects the capacity of the specified file location and writes it to a capacity.txt file in that location. + + Args: + file_location (path object): The full path of the mount point of offline media. + + Returns: + int: The return code of the command + """ + cap_file=file_location.joinpath(Path('capacity.txt')) + + # Check to see if the file_location is mounted + try: + if not file_location.is_mount(): + raise Exception(f"Could not find a mounted filesystem at {file_location}. Not creating capacity file.") + except Exception as errmsg: + print("") + print(errmsg) + return 1 + + # Check to see if the file location is writable + try: + cap_file.touch(exist_ok=True) + cap_file.unlink(missing_ok=True) + except Exception as errmsg: + print("") + print(errmsg) + print(f"Could not write to the filesystem at {file_location}. Not creating capacity file.") + return 1 + + # Get free space + command=['df'] + command.extend(['-h']) + command.extend([file_location]) + proc=subprocess.run(command, capture_output=True, text=True) + + if proc.returncode != 0: + print(f"Error running {command}: {proc.stderr}") + return proc.returncode + else: + capacitytext=proc.stdout + + # Write capacity info to file + try: + cap_file.write_text(capacitytext) + except Exception as errmsg: + print(errmsg) + return 1 + + +def run_backup(configfile=None): + """ + Run backup using the specified configfile, or all enabled config + files if none specified. + + Args: + configfile (str or path object, optional): The full path of a + config file. + + Returns: + Nothing + """ + runstart=datetime.now() + print(f"Run started {runstart.ctime()}") + + if simulate: + print("\nAll actions will be simulated") + + if superuser: + conftype='system' + availconfdir=SYS_AVAILABLE + enaconfdir=SYS_ENABLED + else: + conftype='user' + availconfdir=USER_AVAILABLE + enaconfdir=USER_ENABLED + + configs=[] + if configfile==None: + # Find and run all enabled configs + print("Finding enabled config files...") + configs=find_configs(conftype,'enabled') + if len(configs)>0: + print(f"Found {conftype} config files:") + for index, config in enumerate(configs): + print(config) + configs[index]=enaconfdir.joinpath(config) + else: + print("No enabled config files found") + else: + # Check to see if the given config file is in the current directory + if Path(configfile).is_file(): + configs=[Path(configfile)] + else: + # Given file is not in current directory. Look in conf-enabled + print(f"The config file {configfile} is not in the current directory.") + print(f"Searching enabled {conftype} config files...") + enaconfigs=find_configs(conftype,'enabled') + if len(enaconfigs)>0: + print(f"Found enabled {conftype} config files...") + if Path(configfile) in enaconfigs: + print(f"Found {configfile} in enabled {conftype} config files.") + configs=[enaconfdir.joinpath(configfile)] + else: + print("No enabled config files found.") + + if len(configs)==0: + print("Unable to find the config file you specified. Aborting.") + return 1 + + failed_confs=[] + for config in configs: + print("Running backup from the config file:\n",config) + + # Parse config file and create variables + conf_parameters=parse_config_file(config) + + source_dir=conf_parameters.get('source_dir') + backup_root=Path(conf_parameters.get('backup_root')) + backup_subdir=conf_parameters.get('backup_subdir') + if backup_subdir: + backup_subdir=Path(conf_parameters.get('backup_subdir')) + exclude_list=conf_parameters.get('exclude_list') + day_limit=conf_parameters.get('day_limit') + storage_type=conf_parameters.get('storage_type') + mount_parameters=conf_parameters.get('mount_parameters') + remount_ro=conf_parameters.get('remount_ro') + if remount_ro=='True': + remount_ro=True + elif remount_ro=='False': + remount_ro=False + remote_user_host=conf_parameters.get('remote_user_host') + + # Test for ability to mount filesystems + if storage_type=='offline' and not superuser: + wrap_message("You have set storage_type to offline in the config file, but you do not have superuser rights, preventing you from mounting filesystems. You can run ribs with sudo or change the storage_type to local and handle the mounting and unmounting outside of ribs. Aborting.") + failed_confs.append(config) + continue + + # Test for existance of backup_root + if storage_type=='local' or storage_type=='offline': + print(f"Testing for existance of {backup_root}... ",end="") + try: + if not backup_root.is_dir(): + raise Exception(f"{backup_root} does not exist or is not a directory. This is a fatal error and ribs cannot continue. Please check the config file: {config}") + except Exception as errmsg: + print("FAILED") + print("") + print(errmsg) + failed_confs.append(config) + continue + else: + print("OK") + elif storage_type=='remote': + print(f"Testing for existance of {backup_root}... ",end="",flush=True) + try: + command=['ssh', remote_user_host,'if [ -d ',str(backup_root),' ]; then echo \"true\"; else echo \"false\";fi'] + etest=subprocess.run(command, capture_output=True, text=True) + if etest.stdout=='false': + raise Exception(f"{backup_root} does not exist or is not a directory. This is a fatal error and ribs cannot continue. Please check the config file: {config}") + except Exception as errmsg: + print("FAILED") + print("") + print(errmsg) + failed_confs.append(config) + continue + else: + print("OK") + + + if storage_type=='offline': + # Mount offline storage read-write + try: + print(f"Mounting {backup_root}... ",end="",flush=True) + mount_result=mount_storage(backup_root,'rw',mountparams=mount_parameters) + if mount_result != 0: + print("FAILED") + raise Exception(f"Failed to mount {backup_root}") + else: + print("OK") + except Exception as errmsg: + print("FAILED") + print("") + print(errmsg) + failed_confs.append(config) + continue + else: + print("OK") + + # Test for writability + if simulate: + print("Skipping all write testing since we're only simulating.") + else: + testdir=backup_root.joinpath(Path('testdir')) + testfile=testdir.joinpath(Path('testfile')) + print(f"Testing for writability of{backup_root}... ",end="",flush=True) + try: + if storage_type=='local' or storage_type=='offline': + testdir.mkdir(exist_ok=True) + testfile.touch(exist_ok=True) + testfile.unlink(missing_ok=True) + shutil.rmtree(testdir) + elif storage_type=='remote': + command=['ssh', remote_user_host,'mkdir -p ',str(testdir),'&& touch ',str(testfile),'&& rm -rf ',str(testdir)] + wtest=subprocess.run(command, capture_output=True, text=True) + if wtest.returncode != 0: + raise Exception(f"Could not write to the remote directory {backup_root}") + except Exception as errmsg: + print("FAILED") + print("") + print(f"{errmsg} \n This is a fatal error and ribs cannot continue. Please check the config file:{config}") + if storage_type=='offline': + mount_storage(backup_root,'u') + failed_confs.append(config) + continue + else: + print("OK") + + # Test for hard link support + testlink=testdir.joinpath(Path('testlink')) + print("Testing for hard link support on",backup_root,"... ",end="",flush=True) + try: + if storage_type=='local' or storage_type=='offline': + testdir.mkdir(exist_ok=True) + testfile.touch(exist_ok=True) + testlink.hardlink_to(testfile) + testlink.unlink(missing_ok=True) + testfile.unlink(missing_ok=True) + shutil.rmtree(testdir) + elif storage_type=='remote': + command=['ssh', remote_user_host,'mkdir -p ',str(testdir),'&& touch ',str(testfile),'&& ln ',str(testfile),' ',str(testlink),'&& rm -rf ',str(testdir)] + ltest=subprocess.run(command, capture_output=True, text=True) + if ltest.returncode != 0: + raise Exception(f"The remote directory {backup_root} does not appear to support hard links.") + except Exception as errmsg: + print("FAILED") + print("") + print(f"{errmsg} \n This is a fatal error and ribs cannot continue. Please check the config file:{config}") + if storage_type=='offline': + mount_storage(backup_root,'u') + failed_confs.append(config) + continue + else: + print("OK") + + # Generate exclude list + if exclude_list != None: + for exclude_pattern in exclude_list: + print("excluding",exclude_pattern) + + # Determine today's date and generate current datetime directory name + datetimedir_format='%Y-%m-%d_%H:%M:%S' + datedir_format='%Y-%m-%d' + now=datetime.now() + current_dirname=now.strftime(datetimedir_format) + this_dirname=Path(current_dirname) + + # Find previous backup's directory, if present, to use as link destination + dirname_pattern='????-??-??*' + prev_backups = [] + if storage_type=='local' or storage_type=='offline': + # get local or offline listing + if backup_subdir: + backups_location=backup_root.joinpath(backup_subdir) + else: + backups_location=backup_root + matches=backups_location.glob(dirname_pattern) + matching_dirs=[match for match in matches if match.is_dir()] + if matching_dirs: + for dirpath in matching_dirs: + prev_backups.append(dirpath) + elif storage_type=='remote': + # get remote listing + if backup_subdir: + backups_location=backup_root.joinpath(backup_subdir) + else: + backups_location=backup_root + command=['ssh',remote_user_host,' cd ',str(backups_location),'&& ls -1d ',dirname_pattern] + remotelist=subprocess.run(command, capture_output=True, text=True) + matching_dirs=remotelist.stdout.splitlines() + if matching_dirs: + for dirpath in matching_dirs: + prev_backups.append(dirpath) + + if len(prev_backups)==0: + print("No previous backups were found") + has_previous=False + else: + has_previous=True + prev_backups.sort() + last_dirname=prev_backups[-1] + + last_backup=backups_location.joinpath(last_dirname) + print("The last backup was:",last_backup) + + this_backup=backups_location.joinpath(this_dirname) + print("This backup will be:",this_backup) + + + # Set rsync target and link destination directories + if storage_type=='local' or storage_type=='offline': + target_dir=this_backup + if has_previous: + link_dir=last_backup + else: + link_dir=None + elif storage_type=='remote': + target_dir=f"{remote_user_host}:{this_backup}" + if has_previous: + link_dir=last_backup + else: + link_dir=None + + + # Generate rsync options + rsync_options=[] + #rsync_options.append('--verbose') + rsync_options.append('--secluded-args') + rsync_options.append('--archive') + rsync_options.append('--human-readable') + rsync_options.append('--delete') + if has_previous: + rsync_options.append('--link-dest='+str(link_dir)+'/') + + # Create backup_subdir if necessary + if simulate: + print("Unable to create backup subdirectory since we're just simulating.") + else: + if backup_subdir: + print(f"Creating backup subdirectory {backup_subdir} if necessary.") + if storage_type=='local' or storage_type=='offline': + backups_location.mkdir(exist_ok=True) + elif storage_type=='remote': + command=['ssh',remote_user_host,' mkdir -p ',str(backups_location)] + subcre=subprocess.run(command, capture_output=True, text=True) + + # Run the rsync command + print("Running rsync... ",end="",flush=True) + + try: + if storage_type=='local' or storage_type=='offline': + run_rsync(str(source_dir), str(this_backup)+'/', options=rsync_options, excludes=exclude_list) + elif storage_type=='remote': + run_rsync(str(source_dir), remote_user_host+':'+str(this_backup)+'/', options=rsync_options, excludes=exclude_list) + except Exception as errmsg: + print("FAILED") + print("") + print(errmsg) + failed_confs.append(config) + continue + else: + print("OK") + + # Delete backup directories older than day_limit + try: + if simulate: + print(f"Simulating deleting backups that are more than {day_limit} days old.") + print(f"Deleting backups that are more than {day_limit} days old.") + if has_previous: + would_delete=False + for old_backup in prev_backups: + if re.search(r'\d{4}-\d{2}-\d{2}_\d{2}:\d{2}:\d{2}',str(old_backup)): + backup_date=datetime.strptime(os.path.basename(old_backup),datetimedir_format) + else: + backup_date=datetime.strptime(os.path.basename(old_backup),datedir_format) + if now-backup_date>timedelta(days=int(day_limit)): + would_delete=True + if simulate: + print(f"Simulating deleting {os.path.basename(old_backup)}") + else: + print(f"Deleting {os.path.basename(old_backup)}") + if storage_type=='local' or storage_type=='offline': + shutil.rmtree(backups_location.joinpath(old_backup)) + elif storage_type=='remote': + command=['ssh', remote_user_host,'rm -rf ',str(backups_location.joinpath(old_backup))] + delbkup=subprocess.run(command, capture_output=True, text=True) - -function main() { - # Begin execution - echo "RIBS $RIBS_VERSION" - echo "Rod's Incremental Backup System" - echo "" - - case "$arg" in - -h|--help) - show_help - exit 0 - ;; - *) - find_configs "$arg" - ;; - esac + if not would_delete: + print("No backups are more than",day_limit,"days old, so not deleting.") + else: + print("There are no previous backups to delete") + except Exception as errmsg: + print("") + print(errmsg) + + # Synchronize disks + if storage_type=='offline': + print("Synchronizing disks...",end="") + os.sync() + print("OK") - for each_conf in "${conf_files[@]}" - do - run_backup "$each_conf" - done -} + # If STORAGE_TYPE is OFFLINE, update the capacity file + if storage_type=='offline': + update_capacity_file(backup_root) + + # If STORAGE_TYPE is OFFLINE, unmount, and remount read-only if required + try: + if storage_type=='offline': + if remount_ro: + print(f"Remounting {backup_root} read-only... ",end="") + operation='ro' + else: + print(f"Unmounting {backup_root}... ",end="") + operation='u' + mount_result=mount_storage(backup_root,operation,mountparams=mount_parameters) + if mount_result != 0: + print("FAILED") + raise Exception(f"Failed to unmount/remount {backup_root}.") + else: + print("OK") + except Exception as errmsg: + print("") + print(errmsg) + failed_confs.append(config) + continue + + runend=datetime.now() + timediff=(runend-runstart).total_seconds() + print(f"Run ended: {runend.ctime()}") + print(f"Total time: {str(int(timediff //3600))} hours, {str(int((timediff % 3600) // 60))} minutes, {str(int(timediff % 60))} seconds") + + if len(failed_confs)>0: + print("Failures occurred when running the following config files:") + for failed_conf in failed_confs: + print(f" {failed_conf}") + print("") + wrap_message(f"The listed backups may have failed completely or partially. Please check the most recent backup to determine the nature of the failure, and check to make sure the settings in the config file are correct.") + return 1 + -main "$@" +def wrap_message(message_text, width=72): + """ + Display a long message with the necessary line breaks. + + Args: + message_text (str): The text to display. + width (int, optional): The max number of columns in each line. + + Returns: + Nothing + """ + linelist=textwrap.wrap(message_text,width=width,break_on_hyphens=False) + for line in linelist: + print(line) -exit 0 + +# ****** END function definitions ****** + +# Call main function +main() diff --git a/dist/usr/local/bin/ribs.py b/dist/usr/local/bin/ribs.py deleted file mode 100755 index 8eb5d0a..0000000 --- a/dist/usr/local/bin/ribs.py +++ /dev/null @@ -1,658 +0,0 @@ -#!/usr/bin/env python3 - -""" - Program: Rod's Incremental Backup System (ribs.py) - Author: Rod Wright - Date: 03/10/2025 -""" - -import subprocess -import sys -import os -import shutil -import glob -from datetime import datetime, timedelta -import textwrap -import argparse -import configparser -from pathlib import Path - -# App version -APP_VERSION="1.3.2" - -# File paths -SYS_EN_CONF_DIR="/etc/ribs/conf-enabled/" -SYS_AV_CONF_DIR="/etc/ribs/conf-available/" -USER_EN_CONF_DIR=str(Path.home())+"/.ribs/conf-enabled/" -USER_AV_CONF_DIR=str(Path.home())+"/.ribs/conf-available/" - - -# Determine if I have superuser privileges -if os.geteuid()==0: - superuser=True -else: - superuser=False - -# ****** BEGIN function definitions ****** - - -def main(): - """ - main function - """ - print("") - # Parse arguments and take required actions - parser=argparse.ArgumentParser(description='Rod\'s Incremental Backup System, version '+APP_VERSION) - group=parser.add_mutually_exclusive_group() - - parser.add_argument( - '-s', '--simulate', - help='simulate operations to be performed', - action="store_true" - ) - - group.add_argument( - '-l', '--list-configs', - help='list available and enabled config files', - action="store_true" - ) - - group.add_argument( - '-e', '--enable', - metavar='CONF_FILE', - help='enable a config file' - ) - - group.add_argument('-d', '--disable', - metavar='CONF_FILE', - help='disable a config file' - ) - - group.add_argument( - "conf_file", - help='Run RIBS using the specified config file. \ - If none specified, run all enabled config files, looking in \'~/.ribs/conf-enabled\' \ - for standard users, or in \'/etc/ribs/conf-enabled\' for superusers.', - nargs='?' - ) - - args=parser.parse_args() - - if args.simulate: - simulate=True - else: - simulate=False - - if args.enable: - if args.enable!=None: - enable_config(args.enable,simulate) - elif args.disable: - if args.disable!=None: - disable_config(args.disable,simulate) - elif args.list_configs: - if args.list_configs!=None: - list_configs() - else: - run_backup(args.conf_file,simulate) - - -def run_rsync(source, destination, options=None, excludes=None): - """ - Runs rsync with the given source, destination, options, and excludes. - - Args: - source (str): The source path. - destination (str): The destination path. - options (list, optional): A list of rsync options. Defaults to None. - excludes (list, optional): A list of patterns to exclude. Defaults to None. - - - Returns: - int: The return code of the rsync command. - """ - command = ["rsync"] - if options: - command.extend(options) - if excludes: - for pattern in excludes: - command.extend(['--exclude='+pattern]) - command.extend([source, destination]) - #print("complete rsync command is:",command) - process = subprocess.run(command, capture_output=True, text=True) - - if '-v' in options or '--verbose' in options: - print(process.stdout) - - if process.returncode != 0: - print(f"Error running rsync: {process.stderr}") - return process.returncode - - -def mount_offline(mountpoint,mountparams,mode): - """ - Handle mounting and unmounting of offline storage. - mountpoint is backup_root. - mountparams are the mount parameters from config file. - mode is ro, rw, or u. - """ - pass - - -def list_configs(): - """ - Show a list of config files. - """ - if superuser: - ena_conf_dir=SYS_EN_CONF_DIR - avail_conf_dir=SYS_AV_CONF_DIR - enabled_configs=find_configs('system','enabled') - available_configs=find_configs('system','available') - else: - ena_conf_dir=USER_EN_CONF_DIR - avail_conf_dir=USER_AV_CONF_DIR - enabled_configs=find_configs('user','enabled') - available_configs=find_configs('user','available') - print("Available config files:\n") - if len(available_configs)==0: - infostring="No available config files found. Refer to the sample config file "+SYS_AV_CONF_DIR+"ribs.conf.sample to create one. Note that config files must have the .conf extension to be recognized." - infostringlist=textwrap.wrap(infostring,width=80,break_on_hyphens=False) - for line in infostringlist: - print(line) - else: - for config in available_configs: - print(Path(config).name) - print("\n") - print("Enabled config files:\n") - if len(enabled_configs)==0: - infostring="No enabled config files found. Refer to the list of available config files above and enable one using ribs -e. Type ribs -h for help." - infostringlist=textwrap.wrap(infostring,width=80,break_on_hyphens=False) - for line in infostringlist: - print(line) - else: - for config in enabled_configs: - print(Path(config).name) - - - - -def enable_config(configfile,simulate=True): - """ - Enable the specified config file - """ - if simulate: - print("All actions will be simulated") - if superuser: - conftype='system' - availconfdir=SYS_AV_CONF_DIR - enaconfdir=SYS_EN_CONF_DIR - else: - conftype='user' - availconfdir=USER_AV_CONF_DIR - enaconfdir=USER_EN_CONF_DIR - - # Look for configs to enable - configs=find_configs(conftype,'available') - if len(configs)>0: - for config in configs: - if Path(config).name == configfile: - link=Path(enaconfdir+configfile) - target=Path(availconfdir+configfile) - if simulate: - print("Simulating enabling",Path(configfile).name) - else: - print("Enabling the user config:",configfile) - link.symlink_to(target) - - -def disable_config(configfile,simulate=True): - """ - Disable the specified config file - """ - if simulate: - print("All actions will be simulated") - if superuser: - conftype='system' - enaconfdir=SYS_EN_CONF_DIR - else: - conftype='user' - enaconfdir=USER_EN_CONF_DIR - - # Look for configs to disable - configs=find_configs(conftype,'enabled') - if len(configs)>0: - for config in configs: - if Path(config).name == configfile: - link=Path(enaconfdir+configfile) - if simulate: - print("Simulating disabling",Path(configfile).name) - else: - print("Disabling the user config:",configfile) - link.unlink(link) - - -def find_configs(conftype,confstate): - """ - Find and produce a list of all enabled config files - """ - if confstate=="enabled": - if conftype=="user": - dirpath=USER_EN_CONF_DIR - elif conftype=="system": - dirpath=SYS_EN_CONF_DIR - elif confstate=="available": - if conftype=="user": - dirpath=USER_AV_CONF_DIR - elif conftype=="system": - dirpath=SYS_AV_CONF_DIR - pattern="*.conf" - configs = [] - matching_files=glob.glob(os.path.join(dirpath,pattern)) - if matching_files: - for filepath in matching_files: - configs.append(filepath) - - return(configs) - - -def parse_config_file(conf_file): - """ - Read the specified config file and return a dictionary of the parameters - """ - config_errors=[] - config=configparser.ConfigParser(allow_no_value=True) - config.read(conf_file) - if config.has_section('General'): - # Pull the general options - if config.has_option('General','source_dir'): - source_dir=config['General']['source_dir'] - else: - config_errors.append('source_dir option missing from config file') - if config.has_option('General','backup_root'): - backup_root=config['General']['backup_root'] - else: - config_errors.append('backup_root option missing from config file') - if config.has_option('General','backup_subdir'): - backup_subdir=config['General']['backup_subdir'] - else: - backup_subdir=None - if config.has_option('General','exclude_list'): - exclude_list=config['General']['exclude_list'].split(',') - exclude_list=[pattern.strip() for pattern in exclude_list] - else: - exclude_list=None - if config.has_option('General','day_limit'): - day_limit=config['General']['day_limit'] - else: - config_errors.append('day_limit option missing from config file') - if config.has_option('General','storage_type'): - storage_type=config['General']['storage_type'] - else: - config_errors.append('storage_type option missing from config file') - else: - config_errors.append('General section missing from config file') - - if len(config_errors)>0: - print("The following configuration file errors were found") - for err_msg in config_errors: - print(err_msg) - print("These are fatal errors and ribs cannot continue.") - print("Please check the config file:",conf_file) - sys.exit(1) - config_errors=[] - - if storage_type=="local": - if config.has_section('local'): - # Reserved for future use - pass - else: - pass - elif storage_type=="offline": - if config.has_section('offline'): - if config.has_option('offline','mount_parameters'): - mount_parameters=config['offline']['mount_parameters'] - else: - config_errors.append('mount_parameters option missing from config file') - if config.has_option('offline','remount_ro'): - remount_ro=config['offline']['remount_ro'] - else: - remount_ro=False - else: - config_errors.append('offline section missing from config file') - elif storage_type=="remote": - if config.has_section('remote'): - if config.has_option('remote','remote_user_host'): - remote_user_host=config['remote']['remote_user_host'] - else: - config_errors.append('remote_user_host option missing from config file') - else: - config_errors.append('remote section missing from config file') - else: - config_errors.append('invalid storage_type. Must be local, offline, or remote.') - - if len(config_errors)>0: - print("The following configuration file errors were found") - for err_msg in config_errors: - print(err_msg) - print("These are fatal errors and ribs cannot continue.") - print("Please check the config file:",conf_file) - sys.exit(1) - - # QC option formatting - config_errors=[] - if not source_dir.startswith('/'): - config_errors.append('source_dir must be an absolute path, but doesn\'t start with a \'/\'') - if not backup_root.startswith('/'): - config_errors.append('backup_root must be an absolute path, but doesn\'t start with a \'/\'') - if day_limit=='0': - config_errors.append('day_limit cannot be 0') - if storage_type=='remote' and not "@" in remote_user_host: - config_errors.append('remote_user_host must be in the format\'user@host\'') - - if len(config_errors)>0: - print("The following configuration file errors were found") - for err_msg in config_errors: - print(err_msg) - print("These are fatal errors and ribs cannot continue.") - print("Please check the config file:",conf_file) - sys.exit(1) - - # Clean up variables - if backup_root.endswith('/'): - backup_root=backup_root.rstrip('/') - if backup_subdir: - if backup_subdir.startswith('/'): - backup_subdir=backup_subdir.lstrip('/') - if backup_subdir.endswith('/'): - backup_subdir=backup_subdir.rstrip('/') - - # Return a parameter:option dictionary - if storage_type=="local": - return { - 'storage_type':storage_type, - 'source_dir':source_dir, - 'backup_root':backup_root, - 'backup_subdir':backup_subdir, - 'exclude_list':exclude_list, - 'day_limit':day_limit - } - elif storage_type=="offline": - return { - 'storage_type':storage_type, - 'source_dir':source_dir, - 'backup_root':backup_root, - 'backup_subdir':backup_subdir, - 'exclude_list':exclude_list, - 'day_limit':day_limit, - 'mount_parameters':mount_parameters, - 'remount_ro':remount_ro - } - elif storage_type=="remote": - return { - 'storage_type':storage_type, - 'source_dir':source_dir, - 'backup_root':backup_root, - 'backup_subdir':backup_subdir, - 'exclude_list':exclude_list, - 'day_limit':day_limit, - 'remote_user_host':remote_user_host - } - else: - # Not sure how we got here, but if we did, something's wrong - return False - - -def run_backup(configfile=None,simulate=True): - """ - Run backup using the specified configfile, or all enabled config - files if none specified. - """ - if simulate: - print("All actions will be simulated") - if configfile==None: - # Find and run all enabled configs - print("Finding enabled config files...") - userconfigs=find_configs("user","enabled") - systemconfigs=find_configs("system","enabled") - if len(userconfigs)>0: - configs=userconfigs - print("Found user config files:") - for config in configs: - print(config) - elif len(systemconfigs)>0: - print("No user config files found.") - configs=systemconfigs - print("Found system config files:") - for config in configs: - print(config) - else: - print("No enabled config files found") - else: - # Run only the specified config - configs=[configfile] - - for config in configs: - print("Running backup from the config file:",config) - - # Parse config file and create variables - conf_parameters=parse_config_file(config) - - source_dir=Path(conf_parameters.get('source_dir')) - backup_root=Path(conf_parameters.get('backup_root')) - backup_subdir=conf_parameters.get('backup_subdir') - if backup_subdir: - backup_subdir=Path(conf_parameters.get('backup_subdir')) - exclude_list=conf_parameters.get('exclude_list') - day_limit=conf_parameters.get('day_limit') - storage_type=conf_parameters.get('storage_type') - mount_parameters=conf_parameters.get('mount_parameters') - remount_ro=conf_parameters.get('remount_ro') - remote_user_host=conf_parameters.get('remote_user_host') - - # Generate exclude list - if exclude_list != None: - for exclude_pattern in exclude_list: - print("excluding",exclude_pattern) - - # If storage_type is offline, mount offline storage read-write - if storage_type=='offline': - try: - if not backup_root.is_dir(): - raise Exception("Error: The mount point "+backup_root+" does not exist.") - except Exception as errmsg: - print("") - print(errmsg) - print("This is a fatal error and ribs cannot continue.") - print("Please check the config file:",config,", or create the mountpoint.") - sys.exit(1) - else: - mount_offline(backup_root,mount_parameters,'rw') - - # Make sure backup_root exists, is writable, and supports hard links - if storage_type=='local' or storage_type=='offline': - testdir=backup_root.joinpath(Path('testdir')) - testfile=testdir.joinpath(Path('testfile')) - testlink=testdir.joinpath(Path('testlink')) - - # Test for existance - if storage_type=='local': - print("Testing for existance of",backup_root,"... ",end="") - try: - if not backup_root.is_dir(): - raise Exception(backup_root+" is not a directory.") - except Exception as errmsg: - print("FAILED") - print("") - print(errmsg) - print("") - print("This is a fatal error and ribs cannot continue.") - print("Please check the config file:",config) - sys.exit(1) - else: - print("OK") - - # Test for writability - if simulate: - print("Skipping all write testing since we're only simulating.") - else: - print("Testing for writability of",backup_root,"... ",end="") - try: - testdir.mkdir(exist_ok=True) - testfile.touch(exist_ok=True) - testfile.unlink(missing_ok=True) - shutil.rmtree(testdir) - except Exception as errmsg: - print("FAILED") - print("") - print(errmsg) - print("") - print("This is a fatal error and ribs cannot continue.") - print("Please check the config file:",config) - if storage_type=='offline': - mount_offline(backup_root,mountparams,'u') - sys.exit(1) - else: - print("OK") - - # Test for hard link support - print("Testing for hard link support on",backup_root,"... ",end="") - try: - testdir.mkdir(exist_ok=True) - testfile.touch(exist_ok=True) - testlink.hardlink_to(testfile) - testlink.unlink(missing_ok=True) - testfile.unlink(missing_ok=True) - shutil.rmtree(testdir) - except Exception as errmsg: - print("FAILED") - print("") - print(errmsg) - print("") - print("This is a fatal error and ribs cannot continue.") - print("Please check the config file:",config) - if storage_type=='offline': - mount_offline(backup_root,mountparams,'u') - sys.exit(1) - else: - print("OK") - - # Determine today's date and generate current datetime directory name - datetimedir_format='%Y-%m-%d_%H:%M:%S' - now=datetime.now() - current_dirname=now.strftime(datetimedir_format) - this_dirname=Path(current_dirname) - - # Find previous backup's directory, if present, to use as link destination - dirname_pattern='????-??-??*' - prev_backups = [] - - if storage_type=='remote': - # get remote listing - pass - else: - # get local or offline listing - if backup_subdir: - print("The backup subdirectory is",backup_subdir) - backups_location=backup_root.joinpath(backup_subdir) - else: - backups_location=backup_root - matches=backups_location.glob(dirname_pattern) - #matching_dirs=glob.glob(os.path.join(backup_root+backup_subdir+"/",dirname_pattern)) - matching_dirs=[match for match in matches if match.is_dir()] - if matching_dirs: - for dirpath in matching_dirs: - prev_backups.append(dirpath) - - if len(prev_backups)==0: - print("No previous backups were found") - has_previous=False - else: - has_previous=True - prev_backups.sort() - last_dirname=prev_backups[-1] - - last_backup=Path(last_dirname) - print("The last backup was in:",last_backup) - - this_backup=backups_location.joinpath(this_dirname) - print("This backup will be in:",this_backup) - - - # Set rsync target and link destination directories - if storage_type=='remote': - target_dir=remote_user_host+":"+this_backup - link_dir=last_backup+'/' - else: - target_dir=this_backup - if has_previous: - link_dir=last_backup - else: - link_dir=None - - # Generate rsync options - rsync_options=[] - #rsync_options.append('--verbose') - rsync_options.append('--secluded-args') - rsync_options.append('--archive') - rsync_options.append('--human-readable') - rsync_options.append('--delete') - if has_previous: - rsync_options.append('--link-dest='+str(link_dir)) - if simulate: - rsync_options.append('-n') - - # Create backup_subdir if necessary - if simulate: - print("Unable to create backup subdirectory since we're just simulating.") - else: - if backup_subdir: - backups_location.mkdir(exist_ok=True) - - # Run the rsync command - try: - print("Running rsync... ",end="") - run_rsync(str(source_dir), str(this_backup)+'/', options=rsync_options, excludes=exclude_list) - except Exception as errmsg: - print("FAILED") - print("") - print(errmsg) - else: - print("OK") - - # Delete backup directories older than day_limit - try: - if simulate: - print("Simulating ",end="") - print("Deleting backups that are more than",day_limit,"days old.") - if has_previous: - would_delete=False - for old_backup in prev_backups: - backup_date=datetime.strptime(os.path.basename(old_backup),datetimedir_format) - if now-backup_date>timedelta(days=int(day_limit)): - would_delete=True - if simulate: - print("Simulating deleting",os.path.basename(old_backup)) - else: - print("Deleting",os.path.basename(old_backup)) - shutil.rmtree(old_backup) - if not would_delete: - print("No backups are more than",day_limit,"days old, so not deleting.") - else: - print("There are no previous backups to delete") - except Exception as errmsg: - print("") - print(errmsg) - - - - # Synchronize disks - - # If STORAGE_TYPE is OFFLINE, update the capacity file - - # If STORAGE_TYPE is OFFLINE, unmount, and remount read-only if required - - - -# ****** END function definitions ****** - -# Call main function -main() diff --git a/dist/usr/local/bin/run_ribs_backups b/dist/usr/local/bin/run_ribs_backups new file mode 100755 index 0000000..f44af17 --- /dev/null +++ b/dist/usr/local/bin/run_ribs_backups @@ -0,0 +1,17 @@ +#!/bin/bash +# run_ribs_backups +# +# This script is intended to facilitate running ribs backups using cron. +# To run system configs, either create a link to this script in one or +# more of the /etc/cron.* directories or add a line calling this script +# to the /etc/crontab file. That will run all enabled system configs. +# To run user configs, the user should add a line calling this script +# to their personal crontab. That will run all enabled user configs for +# that user. + +log_path="/var/log/ribs" +log_fn="ribs_$(date +%Y%m%d%H%M%S).log" + +/usr/local/bin/ribs > "$log_path"/"$log_fn" 2>&1 + + diff --git a/install.sh b/install.sh index 8cf747d..efd05b0 100755 --- a/install.sh +++ b/install.sh @@ -2,6 +2,18 @@ # RIBS installer -rsync -av --chown=root:root dist/ / -chmod 777 /var/log/ribs +# test for superuser rights +if [[ $EUID -ne 0 ]]; then + echo "This script must be run with superuser privileges. Try sudo ./install.sh" + exit 1 +fi +echo "Copying new files." +sleep 2 +rsync -av --chown=root:root dist/ / +chmod +x /usr/local/bin/ribs +chmod +x /usr/local/bin/run_ribs_backups +chmod 777 /var/log/ribs +echo "Installation complete. RIBS must have a config file to run. Refer to" +echo "/etc/ribs/conf-available/ribs.conf.sample." +echo "Type ribs -h or ribs --help for help."