├── src ├── files │ ├── files_default.lst │ └── files_env1.lst ├── hosts │ ├── hosts_dev.lst │ └── hosts_prod.lst ├── post-get.d │ ├── readme.txt │ └── 01-remove-binary ├── pre-get.d │ └── readme.txt ├── post-commit.d │ └── readme.txt ├── pre-commit.d │ ├── readme.txt │ └── 01-add-git-ignore ├── keepconf.cfg ├── man │ └── keepconf.1 └── keepconf ├── CONTRIBUTING.md ├── grafana-dashboard ├── keepconf-csv2prom.sh └── keepconf-grafana.json ├── CHANGELOG ├── README.md ├── munin-plugin ├── keepconf_status └── keepconf_time ├── .github └── workflows │ └── codeql-analysis.yml ├── keepconf-install.sh ├── keepconf-hands-on.txt └── keepconf-manual.txt /src/files/files_default.lst: -------------------------------------------------------------------------------- 1 | # Get all /etc/ and subdirectories 2 | /etc/* 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Keepconf 2 | 3 | Any pull request will go to "dev" branch. Thanks. 4 | -------------------------------------------------------------------------------- /src/hosts/hosts_dev.lst: -------------------------------------------------------------------------------- 1 | # A simple hostname 2 | #hostname2 3 | 4 | # Hostnames from 1 to 5 using expand property 5 | #hostdev[1-2] 6 | -------------------------------------------------------------------------------- /src/post-get.d/readme.txt: -------------------------------------------------------------------------------- 1 | Directory for store the scripts that will be executing after fetching files. 2 | Scripts must be with executable flag. 3 | -------------------------------------------------------------------------------- /src/pre-get.d/readme.txt: -------------------------------------------------------------------------------- 1 | Directory for store the scripts that will be executing before fetching files. 2 | Scripts must be with executable flag. 3 | -------------------------------------------------------------------------------- /src/post-commit.d/readme.txt: -------------------------------------------------------------------------------- 1 | Directory for store the scripts that will be executing after commiting files. 2 | Scripts must be with executable flag. 3 | -------------------------------------------------------------------------------- /src/pre-commit.d/readme.txt: -------------------------------------------------------------------------------- 1 | Directory for store the scripts that will be executing before commiting files. 2 | Scripts must be with executable flag. 3 | -------------------------------------------------------------------------------- /src/hosts/hosts_prod.lst: -------------------------------------------------------------------------------- 1 | # Own hostname 2 | localhost 3 | 4 | # A simple hostname 5 | #hostname1 6 | 7 | # Hostnames from 1 to 5 using expand property 8 | #hostprod[1-5] 9 | -------------------------------------------------------------------------------- /src/files/files_env1.lst: -------------------------------------------------------------------------------- 1 | # Get a particular app and subdirectories 2 | #/opt/myapp/* 3 | 4 | # Except logs subdirectory (using '!') 5 | #!/opt/myapp/logs/ 6 | 7 | # But, include one file from a excluded dir (using '&') 8 | #&/opt/myapp/logs/one_file.txt 9 | -------------------------------------------------------------------------------- /grafana-dashboard/keepconf-csv2prom.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script converts .csv files from Keepconf monitor to Prometheus textfile collector format. 4 | # Add it to a cron schedule execution after keepconf run. 5 | 6 | # Origin Keepconf path 7 | dkeep='/var/tmp/keepconf/' 8 | 9 | # Destination Prometheus file 10 | dfile='/var/lib/prometheus/node-exporter/keepconf.prom' 11 | 12 | # Init file 13 | echo keepconf_enable 1 > $dfile 14 | 15 | # Loop along all csv files 16 | for fcsv in ${dkeep}*.csv; do 17 | 18 | # Replace underscore chars 19 | fbase=$(basename -s '.csv' $fcsv | tr '-' '_') 20 | 21 | { echo -n "keepconf_ok{file=\"$fbase\"} " && grep OK $fcsv | awk -F "\"*\"*" '{print $4}' 22 | echo -n "keepconf_bad{file=\"$fbase\"} " && grep BAD $fcsv | awk -F "\"*\"*" '{print $4}' 23 | echo -n "keepconf_fetch_time{file=\"$fbase\"} " && grep FETCH-T $fcsv | awk -F "\"*\"*" '{print $4}' 24 | echo -n "keepconf_commit_time{file=\"$fbase\"} " && grep COMMIT-T $fcsv | awk -F "\"*\"*" '{print $4}' 25 | echo -n "keepconf_total_time{file=\"$fbase\"} " && grep TOTAL-T $fcsv | awk -F "\"*\"*" '{print $4}' 26 | } >> $dfile 27 | done 28 | -------------------------------------------------------------------------------- /src/post-get.d/01-remove-binary: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Remove binary files before commit them into repository 4 | # 5 | # An alternative example script for avoid commit binary files using .gitignore 6 | # is located in pre-commit.d > 01-add-git-ignore 7 | # 8 | # receive a call: name-of-the-script arg1=list_of_hosts arg2=d_dest 9 | 10 | #echo $1 # Argument 1 11 | #echo $2 # Argument 2 12 | 13 | # Uncomment following "exit" line for avoid executing this file 14 | #exit 15 | 16 | echo -e " Script -> Removing binary files" 17 | 18 | HOSTS=$1 19 | D_DEST=$2 20 | 21 | F_TEMP1=`mktemp` 22 | F_TEMP2=`mktemp` 23 | 24 | 25 | # Remove special files: 26 | find ${D_DEST} -not -type d -not -type f -not -type l -delete -exec echo {} >> ${F_TEMP1} \; 27 | 28 | # Print list of special files removed 29 | LE=`wc -l ${F_TEMP1} | grep -v "^$" | awk '{print $1}'` 30 | if [[ $LE -gt 0 ]]; then 31 | echo -e " Removed special files: " 32 | while read line1; do 33 | echo " ${line1}" 34 | done < ${F_TEMP1} 35 | fi 36 | 37 | 38 | # Remove binary files: 39 | find ${D_DEST} -type f -executable -exec file -i '{}' \; | grep 'x-executable; charset=binary' | cut -d: -f1 | tee ${F_TEMP2} | xargs -i rm -f '{}' 40 | 41 | # Print list of binary files removed 42 | LB=`wc -l ${F_TEMP2} | grep -v "^$" | awk '{print $1}'` 43 | if [[ $LB -gt 0 ]]; then 44 | echo -e " Removed binary files: " 45 | while read line2; do 46 | echo " ${line2}" 47 | done < ${F_TEMP2} 48 | fi 49 | 50 | 51 | rm -f ${F_TEMP1} 52 | rm -f ${F_TEMP2} 53 | -------------------------------------------------------------------------------- /src/keepconf.cfg: -------------------------------------------------------------------------------- 1 | # Keepconf configuration file 2 | 3 | [main] 4 | # Directory for store remote files 5 | d_dest = /var/keepconf/hosts/ 6 | 7 | # Destination path for .csv files 8 | #d_monitor = /var/tmp/keepconf/ 9 | 10 | 11 | [hosts] 12 | # List with host to fetch (comma separated allowed) 13 | list_hosts = /etc/keepconf/hosts/hosts_prod.lst, /etc/keepconf/hosts/hosts_dev.lst 14 | 15 | # Or / And sample host list 16 | ftp.prod.mydomain 17 | dns.prod.mydomain 18 | 19 | 20 | [files] 21 | # List with files to fetch (comma separated allowed) 22 | list_files = /etc/keepconf/files/files_default.lst, /etc/keepconf/files/files_env1.lst 23 | 24 | # Or / And sample file list 25 | /home/user1/hello.txt 26 | /home/user2/letter[1-5].txt 27 | 28 | 29 | [sync] 30 | # Fetching files 31 | #fetch=True 32 | 33 | # Pre get directory 34 | pre_get = /etc/keepconf/pre-get.d/ 35 | 36 | # Post get directory 37 | post_get = /etc/keepconf/post-get.d/ 38 | 39 | # Default global max file limit to fetch 40 | #max_size = 25M 41 | 42 | # Default global user for connect to other hosts 43 | #rsync_user = backup 44 | 45 | # Ssh key for connect to other hosts 46 | #rsync_key = /home/backup/.ssh/servers-prod-key.rsa 47 | 48 | # Ssh port for connect to other hosts 49 | #rsync_port = 22 50 | 51 | # Rsync options 52 | #rsync_opt = "arHvzL" 53 | 54 | 55 | [vcs] 56 | # Committing changes 57 | #commit = True 58 | 59 | # Pre commit directory 60 | pre_commit = /etc/keepconf/pre-commit.d/ 61 | 62 | # Post commit directory 63 | post_commit = /etc/keepconf/post-commit.d/ 64 | 65 | # Define type of git repository 66 | #repo = False 67 | repo="local" 68 | #repo="ssh://user@host/rute/to/repo" 69 | -------------------------------------------------------------------------------- /src/man/keepconf.1: -------------------------------------------------------------------------------- 1 | .TH KEEPCONF 17 January 2021 "2.2.0" "Linux Manual" 2 | 3 | .SH NAME 4 | keepconf \- Backup and track files from remote hosts using rsync and git 5 | 6 | .SH SYNOPSIS 7 | keepconf [\-h | \-\-help] [{\-f | \-\-conf}=] [\-i | \-\-init] [\-n | \-\-nocommit] [\-c | \-\-commitonly] [{\-m | \-\-message}=] [\-s | \-\-silent] [\-x | \-\-xilent] [\-v | \-\-verbose] [\-V | \-\-version] 8 | 9 | .SH DESCRIPTION 10 | Keepconf is a agentless tool for backup and track files from remote hosts, using 11 | rsync and git for the purpose. 12 | 13 | .SH OPTIONS 14 | .SS FIELDS 15 | .B \-h | \-\-help 16 | \- Show this help message and exit. 17 | .PP 18 | .B \-f | \-\-conf 19 | \- Configuration path. 20 | .PP 21 | .B \-i | \-\-init 22 | \- Initialize git repostory only. 23 | .PP 24 | .B \-n | \-\-nocommit 25 | \- Avoid commit changes. 26 | .PP 27 | .B \-c | \-\-commitonly 28 | \- Only commit changes. 29 | .PP 30 | .B \-m | \-\-message 31 | \- Commit message. 32 | .PP 33 | .B \-s | \-\-silent 34 | \- Silent fetching output. 35 | .PP 36 | .B \-x | \-\-xilent 37 | \- Silent commit output. 38 | .PP 39 | .B \-v | \-\-verbose 40 | \- Verbose output. 41 | .PP 42 | .B \-V | \-\-version 43 | \- Show version. 44 | .SH FILES 45 | https://github.com/rfmoz/keepconf 46 | 47 | .SH SEE ALSO 48 | Detailed documentation in keepconf-manual.txt. 49 | 50 | .SH "AUTHOR" 51 | .IX Header "AUTHORS" 52 | Ricardo F. 53 | .PP 54 | .SH "COPYRIGHT" 55 | .IX Header "COPYRIGHT" 56 | Copyright (C) 2021 by Ricardo F. All Rights Reserved. 57 | This product is distributed in the hope that it will be useful, but 58 | WITHOUT any warranty; without even the implied warranty of 59 | MERCHANTABILITY or FITNESS for a particular purpose. 60 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | keepconf (2.2.0) stable; urgency=low 2 | 3 | * Auto initialize repositories. Not needed -i at begin. 4 | * Added csv to Prometheus converter script and Grafana dashboard. 5 | * Refactor and update code. 6 | * Update documentation. 7 | 8 | -- Ricardo F. Sat, 16 Jan 2021 12:15:00 +0200 9 | 10 | keepconf (2.1.10) stable; urgency=low 11 | 12 | * Arguments refactoring. Change deprecated optparse to argpaser. 13 | 14 | -- Ricardo F. Mon, 28 Jan 2018 11:23:18 +0200 15 | 16 | keepconf (2.1.0) stable; urgency=low 17 | 18 | * Adding support for list of hosts and files directly under [hosts] and [files] 19 | 20 | -- Ricardo F. Tue, 12 Jan 2016 14:46:18 +0200 21 | 22 | keepconf (2.0.0) stable; urgency=low 23 | 24 | * Allow multiple configuration files. 25 | * Adding support for one .csv report per configuration file. 26 | * Adding "list_hosts" and "list_files" properties. 27 | * Adding "rsync_port" and "rsync_key" properties. 28 | * Remove use of roles. 29 | * Remove "d_conf" variable. 30 | * Using logging module for verbose mode. 31 | * Refactoring code. 32 | 33 | -- Ricardo F. Wed, 26 Aug 2015 14:46:18 +0200 34 | 35 | keepconf (1.2.10) stable; urgency=low 36 | 37 | * Auto add 'roles/' dir to 'd_dest' variable in roles dir path. 38 | * Improve verbose report for invalid roles files. 39 | 40 | -- Ricardo F. Wed, 26 Aug 2015 14:46:18 +0200 41 | 42 | keepconf (1.2.0) stable; urgency=low 43 | 44 | * Update roles verbose and remove roles_dir option. 45 | 46 | -- Ricardo F. Fri, 20 Jul 2015 14:46:18 +0200 47 | 48 | keepconf (1.0.0) stable; urgency=low 49 | 50 | * First release. 51 | 52 | -- Ricardo F. Fri, 10 Apr 2015 14:46:18 +0200 53 | -------------------------------------------------------------------------------- /src/pre-commit.d/01-add-git-ignore: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Add binary files to .gitignore 4 | # 5 | # This is an alternative example script for avoid commit binary files 6 | # the other is located in post-get.d > 01-remove-binary 7 | # 8 | # receive a call: name-of-the-script arg1=list_of_hosts arg2=d_dest 9 | 10 | #echo $1 # Argument 1 11 | #echo $2 # Argument 2 12 | 13 | # Uncomment following "exit" line for avoid executing this file 14 | #exit 15 | 16 | echo -e " Script -> Adding files to .gitignore" 17 | 18 | HOSTS=$1 19 | D_DEST=$2 20 | 21 | F_TEMP1=`mktemp` 22 | F_TEMP2=`mktemp` 23 | F_TEMP3=`mktemp` 24 | F_GIT="${D_DEST}/.gitignore" 25 | 26 | 27 | # Remove special files: 28 | find ${D_DEST} -not -type d -not -type f -not -type l -delete -exec echo {} >> ${F_TEMP1} \; 29 | 30 | # Print list of special files removed 31 | LE=`wc -l ${F_TEMP1} | grep -v "^$" | awk '{print $1}'` 32 | if [[ $LE -gt 0 ]]; then 33 | echo -e " Removed special files: " 34 | while read line1; do 35 | echo " ${line1}" 36 | done < ${F_TEMP1} 37 | fi 38 | 39 | 40 | # Adding binary files to .gitignore: 41 | cd ${D_DEST} && find . -type f -executable -exec file -i '{}' \; | grep 'x-executable; charset=binary' | cut -d: -f1 > ${F_TEMP2} 42 | 43 | # Merge existing and new files 44 | touch ${F_GIT} 45 | cat ${F_GIT} ${F_TEMP2} | sort -n | uniq > ${F_TEMP3} 46 | 47 | # Replace original file 48 | mv ${F_TEMP3} ${F_GIT} 49 | 50 | # Print excluded files 51 | LB=`wc -l ${F_TEMP2} | grep -v "^$" | awk '{print $1}'` 52 | if [[ $LB -gt 0 ]]; then 53 | echo -e " Excluded binary files in .gitignore: " 54 | while read line2; do 55 | echo " ${line2}" 56 | done < ${F_TEMP2} 57 | fi 58 | 59 | 60 | rm -f ${F_TEMP1} 61 | rm -f ${F_TEMP2} 62 | rm -f ${F_TEMP3} 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | keepconf 2 | ======== 3 | 4 | Keepconf is a agentless tool for backup and track files from remote hosts, using rsync and git for the purpose. 5 | Indeed, it can: 6 | - Process lists of files/folders for retrieve it from hosts 7 | - Limit size of the files fetched 8 | - Store content in different defined directories 9 | - Trigger hooks for execute whatever after/before fetching/committing 10 | - Use a local or remote git repository 11 | - Report the final status for monitoring the results in csv format 12 | 13 | 14 | ### Basic Installation and usage 15 | 16 | Clone the repository or copy the installation script and execute it. Install all the python modules required with pip or with your preferred package manager: 17 | 18 | curl -k 'https://raw.githubusercontent.com/rfmoz/keepconf/master/keepconf-install.sh' > keepconf-install.sh 19 | bash keepconf-install.sh 20 | 21 | For this guide, a ssh connection to localhost is enought, but normally, a ssh connection to remote hosts its required: 22 | 23 | ssh-keygen -t dsa -P '' -f ~/.ssh/id_dsa 24 | cat ~/.ssh/id_dsa.pub >> ~/.ssh/authorized_keys 25 | 26 | Test the connection, you need to login without password prompt (Accept with 'yes' the first attempt): 27 | 28 | ssh localhost 29 | 30 | For this test, rsync user will be root, add the following line under the commented sync_user in "/etc/keepconf/keepconf.cfg": 31 | 32 | # sync_user = backup 33 | rsync_user = root 34 | 35 | Run it. 36 | Some sample files are located inside "/etc/keepconf/hosts" and "/etc/keepconf/files" for backup all "/etc/*" content in "localhost" plus some commented examples. 37 | 38 | keepconf 39 | 40 | Now, inside the destionation folder, there are all the files fetched and the git repo: 41 | 42 | cd /var/keepconf/hosts/localhost && ls 43 | git log 44 | 45 | 46 | ### More information 47 | 48 | Please, read keepconf-manual.txt for a complete reference manual. 49 | -------------------------------------------------------------------------------- /munin-plugin/keepconf_status: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # -*- sh -*- 3 | 4 | : << =cut 5 | 6 | =head1 NAME 7 | 8 | keepconf_report -Indicate the status of keepconf execution 9 | 10 | =head1 APPLICABLE SYSTEMS 11 | Linux box with keepconf installed. 12 | 13 | =head1 CONFIGURATION 14 | 15 | [keepconf_*] 16 | env.files /var/tmp/keepconf-mon.csv 17 | # Multifile its available 18 | #env.files /var/tmp/keepconf-client1.csv /var/tmp/keepconf-client2.csv 19 | 20 | =head1 AUTHOR 21 | 22 | Ricardo F. 23 | 24 | =head1 VERSION 25 | v.1.2.0 26 | 27 | =head1 LICENSE 28 | 29 | GPLv2 30 | 31 | =head1 MAGICK MARKERS 32 | 33 | #%# family=auto 34 | #%# capabilities=autoconf 35 | 36 | =cut 37 | 38 | . $MUNIN_LIBDIR/plugins/plugin.sh 39 | 40 | LIST=$files 41 | 42 | if [ "$1" = "autoconf" ]; then 43 | 44 | for j in ${LIST}; do 45 | if [ ! -f ${j} ]; then 46 | echo "no - (file '$j' is missing)" 47 | exit 1 48 | fi 49 | done 50 | 51 | echo yes 52 | exit 0 53 | fi 54 | 55 | if [ "$1" = "config" ]; then 56 | 57 | echo 'graph_title Keepconf report' 58 | echo 'graph_args --base 1000 -l 0 ' 59 | echo 'graph_vlabel Hosts' 60 | echo 'graph_scale no' 61 | echo 'graph_category other' 62 | echo 'graph_info Indicate the status of a keepconf execution.' 63 | 64 | for i in ${LIST}; do 65 | 66 | HNAME=`echo ${i} | md5sum | cut -d - -f1 | sed 's/ //g'` 67 | 68 | NAME=`cat ${i} | grep ^\"CFG-FILE\" | awk -F, '{print $2}' | tr -d '"'` 69 | 70 | echo "ok${HNAME}.label Ok ${NAME}" 71 | echo "ok${HNAME}.type GAUGE" 72 | echo "ok${HNAME}.min 0" 73 | 74 | echo "bad${HNAME}.label Bad ${NAME}" 75 | echo "bad${HNAME}.type GAUGE" 76 | echo "bad${HNAME}.min 0" 77 | 78 | done 79 | 80 | exit 0 81 | fi 82 | 83 | for i in ${LIST}; do 84 | OK=`cat ${i} | grep ^\"OK\" | awk -F, '{print $2}' | tr -d '"'` 85 | BAD=`cat ${i} | grep ^\"BAD\" | awk -F, '{print $2}' | tr -d '"'` 86 | 87 | HNAME=`echo ${i} | md5sum | cut -d - -f1 | sed 's/ //g'` 88 | 89 | echo "ok${HNAME}.value `echo ${OK}`" 90 | echo "bad${HNAME}.value `echo ${BAD}`" 91 | done 92 | -------------------------------------------------------------------------------- /munin-plugin/keepconf_time: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # -*- sh -*- 3 | 4 | : << =cut 5 | 6 | =head1 NAME 7 | 8 | keepconf_time -Indicate the times of keepconf execution 9 | 10 | =head1 APPLICABLE SYSTEMS 11 | Linux box with keepconf installed. 12 | 13 | =head1 CONFIGURATION 14 | 15 | [keepconf_*] 16 | env.files /var/tmp/keepconf-mon.csv 17 | # Multifile its available 18 | #env.files /var/tmp/keepconf-client1.csv /var/tmp/keepconf-client2.csv 19 | 20 | 21 | =head1 AUTHOR 22 | 23 | Ricardo F. 24 | 25 | =head1 LICENSE 26 | 27 | GPLv2 28 | 29 | =head1 VERSION 30 | v.1.2.0 31 | 32 | =head1 MAGICK MARKERS 33 | 34 | #%# family=auto 35 | #%# capabilities=autoconf 36 | 37 | =cut 38 | 39 | . $MUNIN_LIBDIR/plugins/plugin.sh 40 | 41 | LIST=$files 42 | 43 | if [ "$1" = "autoconf" ]; then 44 | 45 | for j in ${LIST}; do 46 | if [ ! -f ${j} ]; then 47 | echo no 48 | exit 1 49 | fi 50 | done 51 | 52 | echo yes 53 | exit 0 54 | fi 55 | 56 | if [ "$1" = "config" ]; then 57 | 58 | echo 'graph_title Keepconf times' 59 | echo 'graph_args --base 1000 -l 0 ' 60 | echo 'graph_vlabel Seconds' 61 | echo 'graph_scale no' 62 | echo 'graph_category keepconf' 63 | echo 'graph_info Indicate the times of a keepconf execution.' 64 | 65 | for i in ${LIST}; do 66 | 67 | HNAME=`echo ${i} | md5sum | cut -d - -f1 | sed 's/ //g'` 68 | 69 | NAME=`cat ${i} | grep ^\"CFG-FILE\" | awk -F, '{print $2}' | tr -d '"'` 70 | 71 | echo "fetch${HNAME}.label Fetch ${NAME}" 72 | echo "fetch${HNAME}.type GAUGE" 73 | echo "fetch${HNAME}.min 0" 74 | 75 | echo "commit${HNAME}.label Commit ${NAME}" 76 | echo "commit${HNAME}.type GAUGE" 77 | echo "commit${HNAME}.min 0" 78 | 79 | echo "total${HNAME}.label Total ${NAME}" 80 | echo "total${HNAME}.type GAUGE" 81 | echo "total${HNAME}.min 0" 82 | done 83 | 84 | exit 0 85 | fi 86 | 87 | 88 | for i in ${LIST}; do 89 | FET=`cat ${i} | grep ^\"FETCH-T\" | awk -F, '{print $2}' | tr -d '"'` 90 | COM=`cat ${i} | grep ^\"COMMIT-T\" | awk -F, '{print $2}' | tr -d '"'` 91 | TOT=`cat ${i} | grep ^\"TOTAL-T\" | awk -F, '{print $2}' | tr -d '"'` 92 | 93 | HNAME=`echo ${i} | md5sum | cut -d - -f1 | sed 's/ //g'` 94 | 95 | echo "fetch${HNAME}.value `echo ${FET}`" 96 | echo "commit${HNAME}.value `echo ${COM}`" 97 | echo "total${HNAME}.value `echo ${TOT}`" 98 | 99 | done 100 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [dev] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [dev] 14 | schedule: 15 | - cron: '0 3 * * 2' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['python'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | with: 35 | # We must fetch at least the immediate parents so that if this is 36 | # a pull request then we can checkout the head. 37 | fetch-depth: 2 38 | 39 | # If this run was triggered by a pull request event, then checkout 40 | # the head of the pull request instead of the merge commit. 41 | - run: git checkout HEAD^2 42 | if: ${{ github.event_name == 'pull_request' }} 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /keepconf-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 4 | # Keepconf installation script 5 | # v.1.2 6 | # 7 | 8 | # Destination dir for configuration 9 | D_CNF='/etc/keepconf' 10 | 11 | # Destination dir for executable file 12 | D_BIN='/usr/bin' 13 | 14 | 15 | # Test if git is installed 16 | git --version &> /dev/null 17 | if [ $? -ne 0 ]; then 18 | echo "ERROR: \"git\" command not available" 19 | echo "Please, install it"; exit 1 20 | fi 21 | 22 | # Test if rsync is installed 23 | rsync --version &> /dev/null 24 | if [ $? -ne 0 ]; then 25 | echo "ERROR: \"rsync\" command not available" 26 | echo "Please, install it"; exit 1 27 | fi 28 | 29 | # Test if file is installed 30 | file /dev/null &> /dev/null # For 01-remove-binary script 31 | if [ $? -ne 0 ]; then 32 | echo "ERROR: \"file\" command not available" 33 | echo "Please, install it"; exit 1 34 | fi 35 | 36 | # Test if python is installed 37 | pyver=`python3 --version 2>&1 /dev/null` 38 | if [ $? -ne 0 ]; then 39 | echo "ERROR: Python not available" 40 | echo "Please, install version 3"; exit 1 41 | else 42 | # Test if version 3 of python is installed 43 | pynum=`echo ${pyver} | tr -d '.''' | grep -Eo '[0-9]*' | head -1 | cut -c 1-2` 44 | if [ $pynum -lt 30 ] ; then 45 | echo "ERROR: Its needed Python version 3, not ${pyver}" 46 | exit 1 47 | else 48 | # Test if all modules needed are available 49 | pymod=`python3 -c "import sys, optparse, os, glob, time, string, re, tempfile, logging, configparser, subprocess, distutils"` 50 | if [ $? -ne 0 ]; then 51 | echo "ERROR: Please, ensure that these Python modules are available in the local system:" 52 | echo "sys, optparse, os, glob, time, string, re, tempfile, logging, configparser, subprocess, distutils" 53 | fi 54 | fi 55 | fi 56 | 57 | # Temporary dir for clone repo into it 58 | F_TMP1=`mktemp -d` 59 | 60 | echo "" 61 | echo "++ Keepconf installation script ++" 62 | echo "" 63 | 64 | echo "+ Clonning repository..." 65 | git clone https://github.com/rfmoz/keepconf.git ${F_TMP1} 66 | 67 | echo "+ Creating paths..." 68 | mkdir ${D_CNF} 69 | mkdir ${D_CNF}/hosts 70 | mkdir ${D_CNF}/files 71 | mkdir ${D_CNF}/pre-get.d 72 | mkdir ${D_CNF}/post-get.d 73 | mkdir ${D_CNF}/pre-commit.d 74 | mkdir ${D_CNF}/post-commit.d 75 | 76 | echo "+ Copying files..." 77 | cp ${F_TMP1}/src/keepconf ${D_BIN}/keepconf 78 | cp ${F_TMP1}/src/keepconf.cfg ${D_CNF}/ 79 | cp -r ${F_TMP1}/src/post-get.d/* ${D_CNF}/post-get.d/ 80 | cp -r ${F_TMP1}/src/pre-commit.d/* ${D_CNF}/pre-commit.d/ 81 | cp -r ${F_TMP1}/src/hosts/* ${D_CNF}/hosts/ 82 | cp -r ${F_TMP1}/src/files/* ${D_CNF}/files/ 83 | chmod 644 ${D_CNF}/post-*/*txt 84 | chmod 644 ${D_CNF}/pre-*/*txt 85 | chmod 744 ${D_BIN}/keepconf 86 | 87 | cd ${D_CNF} && ls 88 | 89 | echo "+ " 90 | echo "+ Instalation Complete, configure as your needs" 91 | echo "+ Don't forget an entry line in cron for schedule the process" 92 | echo "+ Enjoy!" 93 | -------------------------------------------------------------------------------- /grafana-dashboard/keepconf-grafana.json: -------------------------------------------------------------------------------- 1 | { 2 | "__inputs": [ 3 | { 4 | "name": "DS_PROMETHEUS", 5 | "label": "Prometheus", 6 | "description": "", 7 | "type": "datasource", 8 | "pluginId": "prometheus", 9 | "pluginName": "Prometheus" 10 | } 11 | ], 12 | "__requires": [ 13 | { 14 | "type": "grafana", 15 | "id": "grafana", 16 | "name": "Grafana", 17 | "version": "6.7.3" 18 | }, 19 | { 20 | "type": "panel", 21 | "id": "graph", 22 | "name": "Graph", 23 | "version": "" 24 | }, 25 | { 26 | "type": "datasource", 27 | "id": "prometheus", 28 | "name": "Prometheus", 29 | "version": "1.0.0" 30 | } 31 | ], 32 | "annotations": { 33 | "list": [ 34 | { 35 | "$$hashKey": "object:55", 36 | "builtIn": 1, 37 | "datasource": "-- Grafana --", 38 | "enable": true, 39 | "hide": true, 40 | "iconColor": "rgba(0, 211, 255, 1)", 41 | "name": "Annotations & Alerts", 42 | "type": "dashboard" 43 | } 44 | ] 45 | }, 46 | "editable": true, 47 | "gnetId": null, 48 | "graphTooltip": 0, 49 | "id": null, 50 | "iteration": 1611300719535, 51 | "links": [], 52 | "panels": [ 53 | { 54 | "aliasColors": {}, 55 | "bars": false, 56 | "dashLength": 10, 57 | "dashes": false, 58 | "datasource": "${DS_PROMETHEUS}", 59 | "fill": 1, 60 | "fillGradient": 0, 61 | "gridPos": { 62 | "h": 12, 63 | "w": 12, 64 | "x": 0, 65 | "y": 0 66 | }, 67 | "hiddenSeries": false, 68 | "id": 2, 69 | "legend": { 70 | "alignAsTable": true, 71 | "avg": false, 72 | "current": true, 73 | "hideEmpty": false, 74 | "max": true, 75 | "min": true, 76 | "rightSide": false, 77 | "show": true, 78 | "total": false, 79 | "values": true 80 | }, 81 | "lines": true, 82 | "linewidth": 1, 83 | "nullPointMode": "null as zero", 84 | "options": { 85 | "dataLinks": [] 86 | }, 87 | "percentage": false, 88 | "pointradius": 2, 89 | "points": false, 90 | "renderer": "flot", 91 | "seriesOverrides": [ 92 | { 93 | "$$hashKey": "object:750", 94 | "alias": "/BAD.*/", 95 | "transform": "negative-Y" 96 | } 97 | ], 98 | "spaceLength": 10, 99 | "stack": false, 100 | "steppedLine": false, 101 | "targets": [ 102 | { 103 | "expr": "keepconf_bad{instance=\"$node\"}", 104 | "interval": "", 105 | "legendFormat": "BAD {{file}}", 106 | "refId": "B" 107 | }, 108 | { 109 | "expr": "keepconf_ok{instance=\"$node\"}", 110 | "interval": "", 111 | "legendFormat": "OK {{file}}", 112 | "refId": "A" 113 | } 114 | ], 115 | "thresholds": [], 116 | "timeFrom": null, 117 | "timeRegions": [], 118 | "timeShift": null, 119 | "title": "Hosts Status", 120 | "tooltip": { 121 | "shared": true, 122 | "sort": 0, 123 | "value_type": "individual" 124 | }, 125 | "type": "graph", 126 | "xaxis": { 127 | "buckets": null, 128 | "mode": "time", 129 | "name": null, 130 | "show": true, 131 | "values": [] 132 | }, 133 | "yaxes": [ 134 | { 135 | "$$hashKey": "object:200", 136 | "format": "short", 137 | "label": "", 138 | "logBase": 1, 139 | "max": null, 140 | "min": null, 141 | "show": true 142 | }, 143 | { 144 | "$$hashKey": "object:201", 145 | "format": "short", 146 | "label": null, 147 | "logBase": 1, 148 | "max": null, 149 | "min": null, 150 | "show": false 151 | } 152 | ], 153 | "yaxis": { 154 | "align": false, 155 | "alignLevel": null 156 | } 157 | }, 158 | { 159 | "aliasColors": {}, 160 | "bars": false, 161 | "dashLength": 10, 162 | "dashes": false, 163 | "datasource": "${DS_PROMETHEUS}", 164 | "fill": 1, 165 | "fillGradient": 0, 166 | "gridPos": { 167 | "h": 12, 168 | "w": 12, 169 | "x": 12, 170 | "y": 0 171 | }, 172 | "hiddenSeries": false, 173 | "id": 4, 174 | "legend": { 175 | "alignAsTable": true, 176 | "avg": false, 177 | "current": true, 178 | "max": true, 179 | "min": true, 180 | "show": true, 181 | "total": false, 182 | "values": true 183 | }, 184 | "lines": true, 185 | "linewidth": 1, 186 | "nullPointMode": "null as zero", 187 | "options": { 188 | "dataLinks": [] 189 | }, 190 | "percentage": false, 191 | "pointradius": 2, 192 | "points": false, 193 | "renderer": "flot", 194 | "seriesOverrides": [ 195 | { 196 | "$$hashKey": "object:264", 197 | "alias": "/Com.*/", 198 | "transform": "negative-Y" 199 | } 200 | ], 201 | "spaceLength": 10, 202 | "stack": true, 203 | "steppedLine": false, 204 | "targets": [ 205 | { 206 | "expr": "keepconf_fetch_time{instance=\"$node\"}", 207 | "interval": "", 208 | "legendFormat": "Fetch {{file}}", 209 | "refId": "A" 210 | }, 211 | { 212 | "expr": "keepconf_commit_time{instance=\"$node\"}", 213 | "interval": "", 214 | "legendFormat": "Commit {{file}}", 215 | "refId": "B" 216 | } 217 | ], 218 | "thresholds": [], 219 | "timeFrom": null, 220 | "timeRegions": [], 221 | "timeShift": null, 222 | "title": "Times", 223 | "tooltip": { 224 | "shared": true, 225 | "sort": 0, 226 | "value_type": "individual" 227 | }, 228 | "type": "graph", 229 | "xaxis": { 230 | "buckets": null, 231 | "mode": "time", 232 | "name": null, 233 | "show": true, 234 | "values": [] 235 | }, 236 | "yaxes": [ 237 | { 238 | "$$hashKey": "object:200", 239 | "format": "s", 240 | "label": "", 241 | "logBase": 1, 242 | "max": null, 243 | "min": null, 244 | "show": true 245 | }, 246 | { 247 | "$$hashKey": "object:201", 248 | "format": "short", 249 | "label": null, 250 | "logBase": 1, 251 | "max": null, 252 | "min": null, 253 | "show": false 254 | } 255 | ], 256 | "yaxis": { 257 | "align": false, 258 | "alignLevel": null 259 | } 260 | }, 261 | { 262 | "aliasColors": {}, 263 | "bars": false, 264 | "dashLength": 10, 265 | "dashes": false, 266 | "datasource": "${DS_PROMETHEUS}", 267 | "fill": 1, 268 | "fillGradient": 0, 269 | "gridPos": { 270 | "h": 10, 271 | "w": 24, 272 | "x": 0, 273 | "y": 12 274 | }, 275 | "hiddenSeries": false, 276 | "id": 6, 277 | "legend": { 278 | "alignAsTable": true, 279 | "avg": false, 280 | "current": true, 281 | "max": true, 282 | "min": true, 283 | "show": true, 284 | "total": false, 285 | "values": true 286 | }, 287 | "lines": true, 288 | "linewidth": 1, 289 | "nullPointMode": "null as zero", 290 | "options": { 291 | "dataLinks": [] 292 | }, 293 | "percentage": false, 294 | "pointradius": 2, 295 | "points": false, 296 | "renderer": "flot", 297 | "seriesOverrides": [], 298 | "spaceLength": 10, 299 | "stack": true, 300 | "steppedLine": false, 301 | "targets": [ 302 | { 303 | "expr": "keepconf_total_time{instance=\"$node\"}", 304 | "interval": "", 305 | "legendFormat": "{{file}}", 306 | "refId": "A" 307 | } 308 | ], 309 | "thresholds": [], 310 | "timeFrom": null, 311 | "timeRegions": [], 312 | "timeShift": null, 313 | "title": "Total Times", 314 | "tooltip": { 315 | "shared": true, 316 | "sort": 0, 317 | "value_type": "individual" 318 | }, 319 | "type": "graph", 320 | "xaxis": { 321 | "buckets": null, 322 | "mode": "time", 323 | "name": null, 324 | "show": true, 325 | "values": [] 326 | }, 327 | "yaxes": [ 328 | { 329 | "$$hashKey": "object:200", 330 | "format": "s", 331 | "label": "", 332 | "logBase": 1, 333 | "max": null, 334 | "min": null, 335 | "show": true 336 | }, 337 | { 338 | "$$hashKey": "object:201", 339 | "format": "short", 340 | "label": null, 341 | "logBase": 1, 342 | "max": null, 343 | "min": null, 344 | "show": false 345 | } 346 | ], 347 | "yaxis": { 348 | "align": false, 349 | "alignLevel": null 350 | } 351 | } 352 | ], 353 | "refresh": "1m", 354 | "schemaVersion": 22, 355 | "style": "dark", 356 | "tags": [], 357 | "templating": { 358 | "list": [ 359 | { 360 | "allValue": null, 361 | "current": {}, 362 | "datasource": "${DS_PROMETHEUS}", 363 | "definition": "label_values(keepconf_enable, instance)", 364 | "hide": 0, 365 | "includeAll": false, 366 | "index": -1, 367 | "label": "Host:", 368 | "multi": false, 369 | "name": "node", 370 | "options": [], 371 | "query": "label_values(keepconf_enable, instance)", 372 | "refresh": 1, 373 | "regex": "", 374 | "skipUrlSync": false, 375 | "sort": 0, 376 | "tagValuesQuery": "", 377 | "tags": [], 378 | "tagsQuery": "", 379 | "type": "query", 380 | "useTags": false 381 | } 382 | ] 383 | }, 384 | "time": { 385 | "from": "now-7d", 386 | "to": "now" 387 | }, 388 | "timepicker": { 389 | "refresh_intervals": [ 390 | "5s", 391 | "10s", 392 | "30s", 393 | "1m", 394 | "5m", 395 | "15m", 396 | "30m", 397 | "1h", 398 | "2h", 399 | "1d" 400 | ] 401 | }, 402 | "timezone": "", 403 | "title": "Keepconf", 404 | "uid": "xWI_DAfGk", 405 | "variables": { 406 | "list": [] 407 | }, 408 | "version": 10 409 | } -------------------------------------------------------------------------------- /keepconf-hands-on.txt: -------------------------------------------------------------------------------- 1 | ---------------------- 2 | Keepconf 3 | Hands On 4 | ---------------------- 5 | Ricardo F. 6 | 16/January/2021 7 | 8 | 9 | 10 | 11 | 1.- Abstract 12 | 2.- Ssh configuration, for any host to backup 13 | 3.- Ssh configuration, for backup storage host 14 | 4.- Keepconf configuration, for backup storage host 15 | 5.- Keepconf case, adding new host 16 | 6.- Keepconf case, removing old host 17 | 7.- Keepconf case, using two configuration files 18 | 19 | 20 | 21 | 22 | ================ 23 | | 1.- Abstract | 24 | ================ 25 | 26 | This document is a hands on guide for starting from scratch, covering the base 27 | ssh and rsync configuration and various keepconf scenarios. 28 | 29 | The following hosts are used for the examples: 30 | 31 | backups.example.com - Backup storage 32 | web1.example.com - Webserver 33 | web2.example.com - Webserver 34 | 35 | 36 | 37 | =========================------------------------- 38 | | 2.- Ssh configuration | For any host to backup | 39 | =========================------------------------- 40 | 41 | + Prerequisites: 42 | 43 | - ssh server 44 | - rsync > 3.0 45 | - a user, for example "backman", for connect into. 46 | 47 | 48 | + "backman" a normal user: 49 | 50 | backman@web1:~$ id backman 51 | uid=1001(backman) gid=1001(backman) groups=1001(backman) 52 | 53 | 54 | + Generate the ssh key for the user "backman" without passphrase: 55 | 56 | backman@web1:~$ ssh-keygen -t rsa -P '' -f ~/.ssh/id_rsa 57 | Generating public/private rsa key pair. 58 | Your identification has been saved in /home/backman/.ssh/id_rsa. 59 | Your public key has been saved in /home/backman/.ssh/id_rsa.pub. 60 | The key fingerprint is: 61 | SHA256:EW/PLNgH/ZmD3PxOn84W8AxhpJkPJzyF5DVk+oQ2/ws backman@lnode3 62 | The key's randomart image is: 63 | +---[RSA 2048]----+ 64 | | . ..+B | 65 | | oooXo. | 66 | | . +#oo. | 67 | | =.*@B o | 68 | | S o B+% | 69 | | o .* | 70 | | E .+| 71 | | o++| 72 | | o=o| 73 | +----[SHA256]-----+ 74 | 75 | 76 | + Authorize the key for connect into it without password: 77 | 78 | backman@web1:~$ cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys 79 | 80 | 81 | + Ensure that ssh is running and rsync installed. 82 | 83 | 84 | 85 | 86 | =========================-------------------------- 87 | | 3.- Ssh configuration | For backup storage host | 88 | =========================-------------------------- 89 | 90 | Prerequisites: 91 | 92 | - ssh client 93 | - rsync > 3.0 94 | - git > 1.7 95 | - python 3 96 | - keepconf, latest release. 97 | - a user, in this case "root" will launch the backup. 98 | 99 | 100 | + Copy the private key from the user "backman" at "web1.example.com" into .ssh/ folder: 101 | 102 | root@backups:~# cd .ssh/ 103 | root@backups:~/.ssh# scp root@web1.example.com:/home/backman/.ssh/id_rsa id_rsa-backman 104 | root@web1.example.com's password: 105 | id_rsa 100% 668 0.7KB/s 00:00 106 | root@backups:~/.ssh# chmod 700 id_rsa-backman 107 | 108 | 109 | + Configure ssh for connect to "web1" with the user "backman" and its key. 110 | 111 | root@backups:~/.ssh# cat >> ~/.ssh/config << EOF 112 | Host web1.example.com 113 | IdentityFile /root/.ssh/id_rsa-backman 114 | EOF 115 | 116 | 117 | + Test it!, don't continue until you can connect to "web1.example.com" with the 118 | user "backman" without password: 119 | (Only the first time you will need to ask "yes" to the question) 120 | 121 | root@backups:~/.ssh# ssh backman@web1.example.com 122 | The authenticity of host 'web1.example.com (192.168.1.5)' can't be established. 123 | ECDSA key fingerprint is 97:59:3f:36:ae:0e:5e:67:5d:4c:cc:91:db:b9:76:c3. 124 | Are you sure you want to continue connecting (yes/no)? yes 125 | Warning: Permanently added 'web1.example.com,192.168.1.5' (ECDSA) to the list of known hosts. 126 | backman@web1:~$ 127 | 128 | 129 | 130 | 131 | ==============================-------------------------- 132 | | 4.- Keepconf configuration | For backup storage host | 133 | ==============================-------------------------- 134 | 135 | 136 | + Install keepconf: 137 | 138 | root@backups:~# bash <(curl -Ls https://raw.githubusercontent.com/rfmoz/keepconf/master/keepconf-install.sh) 139 | 140 | 141 | + Be sure that git is configured correctly with your name an email. Maybe you have already set them: 142 | 143 | root@backups:~# git config user.email || git config --global user.email "you@example.com" 144 | root@backups:~# git config user.name || git config --global user.name "Your Name" 145 | 146 | 147 | + Set the default backup user for keepconf, uncomment the rsync_user line and change the user to "backman": 148 | 149 | root@backups:~# vi /etc/keepconf/keepconf.cfg 150 | # Default global user for connect to other hosts 151 | rsync_user = backman 152 | 153 | 154 | + Remove the example conf files, you won't need it: 155 | 156 | root@backups:~# rm /etc/keepconf/hosts/* 157 | 158 | 159 | + Create a file called "/etc/keepconf/hosts/web-servers.lst" and add the following example lines: 160 | 161 | web1.example.com 162 | 163 | 164 | + Create a file called "/etc/keepconf/files/web-files.lst" and add the following example lines: 165 | 166 | /etc/passwd 167 | /var/lib/dpkg/status 168 | 169 | 170 | + Set both files on the variables inside "/etc/keepconf/keepconf.cfg" and remove 171 | '# Or / And sample .. list' content next to them too: 172 | 173 | list_hosts = /etc/keepconf/hosts/web-servers.lst 174 | 175 | list_files = /etc/keepconf/files/web-files.lst 176 | 177 | 178 | 179 | + Run it! (In this example, with "-s" for reduce output): 180 | 181 | root@backups:~# keepconf -s 182 | - - - 183 | - - 184 | - Processing: /etc/keepconf/keepconf.cfg 185 | - Initialize local repository on: /var/keepconf/hosts/ 186 | Initialized empty Git repository in /var/keepconf/hosts/.git/ 187 | - - 188 | - Fetching start 189 | - Reading hosts list: /etc/keepconf/hosts/web-servers.lst 190 | - Reading files list: /etc/keepconf/files/web-files.lst 191 | - Finding exec files in: /etc/keepconf/pre-get.d/ 192 | - Fetching host: web1.example.com 193 | - Finding exec files in: /etc/keepconf/post-get.d/ 194 | - Processing: /etc/keepconf/post-get.d/01-remove-binary 195 | Script -> Removing binary files 196 | - Fetching done 197 | - - 198 | - Committing start 199 | - Finding files in /etc/keepconf/pre-commit.d/ 200 | - Processing: /etc/keepconf/pre-commit.d/01-add-git-ignore 201 | Script -> Adding files to .gitignore 202 | - Committing into local repository 203 | [master (root-commit) f8cc8aa] Keepconf commit at 13:27:21 - Tuesday/August/2015 Hosts ok: [1] web1.example.com Hosts bad: [0] 204 | Committer: root 205 | 3 files changed, 8359 insertions(+) 206 | create mode 100644 .gitignore 207 | create mode 100644 web1.example.com/etc/passwd 208 | create mode 100644 web1.example.com/var/lib/dpkg/status 209 | - Finding files in /etc/keepconf/post-commit.d/ 210 | - Committing done 211 | - 212 | - Hosts rsync'ed: [1] web1.example.com 213 | - Hosts not rsync'ed: [0] 214 | - Fetched in: 0.44 seconds 215 | - Committed in: 0.04 seconds 216 | - Completed in: 0.48 seconds 217 | - Monitor file: /var/tmp/keepconf/keepconf.csv 218 | 219 | 220 | + Now, the files from "web1.example.com" are yet inside "backups.example.com" located where 221 | keepconf "d_dest" variable define, as you can see: 222 | 223 | root@backups:~# cd /var/keepconf/hosts/web1.example.com/ 224 | root@backups:/var/keepconf/hosts/web1.example.com# tree 225 | . 226 | ├── etc 227 | │   └── passwd 228 | └── var 229 | └── lib 230 | └── dpkg 231 | └── status 232 | 233 | 4 directories, 2 files 234 | 235 | 236 | + And tracked into a git repository: 237 | 238 | root@backups:/var/keepconf/hosts/web1.example.com# git log 239 | commit f8cc8aa01cbe9d95b1ed0da8d7b8dcf465ab7d80 240 | Author: root 241 | Date: Tue Aug 4 13:27:21 2015 +0200 242 | 243 | Keepconf commit at 13:27:21 - Tuesday/August/2015 244 | Hosts ok: [1] web1.example.com 245 | Hosts bad: [0] 246 | 247 | 248 | + Finally, add a cron entry for automate the process: 249 | 250 | root@backups:~# vi /etc/cron.d/keepconf 251 | # /etc/cron.d/keepconf: crontab entrie for keepconf update 252 | MAILTO=root 253 | 15 5 * * * root if [ -x /usr/bin/keepconf ]; then /usr/bin/keepconf > /dev/null; fi 254 | 255 | 256 | 257 | 258 | 259 | =====================------------------ 260 | | 5.- Keepconf case | Adding new host | 261 | =====================------------------ 262 | 263 | + Configure "web2" as described in section "2.- Ssh configuration, For any host to backup." 264 | 265 | + Add the new host to the configuration file, in this case "web2" to "/etc/keepconf/hosts/web-servers.lst": 266 | 267 | Previous state: 268 | web1.example.com 269 | 270 | Posterior state, option with full name: 271 | web1.example.com 272 | web2.example.com 273 | 274 | Posterior state, option using wildcard: 275 | web[1-2].example.com 276 | 277 | 278 | + Execute keepconf normally, and check that "web2" is fetched: 279 | 280 | root@backups:~# keepconf 281 | 282 | 283 | + If all went ok, inside "d_dest" directory there are a new folder for the new server with their files: 284 | 285 | root@backups:~# cd /var/keepconf/hosts/ 286 | root@backups:/var/keepconf/hosts/# ls 287 | web1.example.com web2.example.com 288 | 289 | 290 | + And the corresponding commit with the new host: 291 | 292 | root@backups:/var/keepconf/hosts/# git log 293 | commit dff0b7add12856bcfa8ae00d0bc573a841235f37 294 | Author: root 295 | Date: Tue Aug 5 12:22:01 2015 +0200 296 | 297 | Keepconf commit at 12:22:01 - Thursday/August/2015 298 | Hosts ok: [2] web1.example.com, web2.example.com 299 | Hosts bad: [0] 300 | 301 | 302 | 303 | 304 | =====================-------------------- 305 | | 6.- Keepconf case | Removing old host | 306 | =====================-------------------- 307 | 308 | 309 | + Remove the host from the configuration file, in this case "web2" from "/etc/keepconf/hosts/web-servers.lst": 310 | 311 | Previous state: 312 | web1.example.com 313 | web2.example.com 314 | 315 | Posterior state: 316 | web1.example.com 317 | 318 | 319 | + Remove the fetched files from the repo directory "/var/keepconf/hosts/": 320 | 321 | root@backups:/var/keepconf/hosts/# git rm -r web2.example.com 322 | 323 | 324 | + Execute keepconf with commit only and an informative message: 325 | 326 | root@backups:/var/keepconf/hosts/# keepconf -c -m "Deleting old host"' 327 | 328 | 329 | + Wich produce the following commit: 330 | 331 | root@backups:/var/keepconf/hosts/# git log 332 | commit df0ce9a83f3d7e509dfcaae3895318ff7db61b41 333 | Author: root 334 | Date: Thu Aug 6 10:19:50 2015 +0200 335 | 336 | Keepconf: Deleting old host 337 | 338 | 339 | 340 | 341 | =====================-------------------------------- 342 | | 7.- Keepconf case | Using two configuration files | 343 | =====================-------------------------------- 344 | 345 | 346 | Is it possible to have more than one configuration file, for example, each one for one enviroment. 347 | 348 | + Copy default configuration file to the same folder with an other name: 349 | 350 | root@backups:~# cp /etc/keepconf/keepconf.cfg /etc/keepconf/keepconf-dev.cfg 351 | 352 | + Change whatever you consider for the new enviroment. Avoid mix folders and repositories. 353 | 354 | + Run "keepconf", both files will be parsed and process using their configured values. 355 | -------------------------------------------------------------------------------- /keepconf-manual.txt: -------------------------------------------------------------------------------- 1 | ---------------------- 2 | Keepconf 3 | Configuration Manual 4 | ---------------------- 5 | version 2.2.0 6 | Ricardo F. 7 | 16/January/2021 8 | 9 | 10 | 11 | ============ 12 | | Abstract | 13 | ============ 14 | 15 | Keepconf is a agentless tool for backup and track files from remote hosts. It 16 | uses rsync and git for the purpouse. Indeed, it can: 17 | - process lists of files/folders for retrieve it from hosts 18 | - limit size of the files fetched 19 | - store content in different defined directories 20 | - trigger hooks for execute whatever after/before fetching/committing 21 | - use a local or remote git repository 22 | - report the final status for monitoring the results in csv format 23 | 24 | 25 | ================= 26 | | Prerequisites | 27 | ================= 28 | 29 | 30 | Almost these version of the following software: 31 | 32 | python 3 with this modules (included in python core by default): 33 | sys, optparse, os, glob, time, string, re, configparser, tempfile, subprocess, distutils 34 | rsync 3.0 35 | git 1.7 36 | 37 | A ssh-keys connection to the target hosts. Password prompt is not supported. 38 | ** NOTE: Please, be sure that the configured user can connect to the target 39 | host without any prompt. 40 | 41 | A configuration file, recomended in "/etc/keepconf/keepconf.cfg" 42 | 43 | Some free space for store the remote files. 44 | 45 | In the remote hosts, only rsync and a ssh client. 46 | 47 | 48 | 49 | ============================ 50 | | Command line parameters | 51 | ============================ 52 | 53 | There are only a few command line options : 54 | 55 | -h, --help show this help message and exit 56 | -f PATH, --conf=PATH configuration file/folder path 57 | -i, --init initialize git repostory only 58 | -n, --nocommit avoid commit changes 59 | -c, --commitonly only commit changes 60 | -m TEXT, --message=TEXT 61 | commit message 62 | -s, --silent silent fetching output 63 | -x, --xilent silent commit output 64 | -v, --verbose verbose output 65 | -V, --version show version 66 | 67 | 68 | -h, --help 69 | Print a quick reference of the command line parameters. 70 | 71 | Examples: 72 | keepconf -h 73 | keepconf --help 74 | 75 | 76 | -f , --conf= 77 | Define the configuration file or path to use. If it is a folder, each file 78 | inside it with .cfg extension will be parsed, wich is good if you want to separate 79 | hosts by different enviroments with different properties. 80 | 81 | Examples: 82 | keepconf -f /etc/keepconf/ # (Default)(Each .cfg file will be parsed) 83 | keepconf -f /etc/keepconf/keepconf.cfg 84 | keepconf -f /etc/keepconf/keepconf-client1.cfg 85 | keepconf --conf /home/user1/myservers/production.cfg 86 | 87 | 88 | -i, --init 89 | Initialize repository only, wich will be located at "d_dest" variable defined 90 | in the configuration file. Depends if "repo" variable is defined with local or 91 | remote repository, a fresh init or a clone will be done. If is not defined 92 | or is "False", any action will be done. With multiple configuration files, 93 | choose one of them with -f option. 94 | 95 | Examples: 96 | keepconf -i (For one con figuration file) 97 | keepconf -i -f /etc/keepconf/keepconf.cfg 98 | keepconf -i -f /etc/keepconf/keepconf-client1.cfg 99 | keepconf -init (For one con figuration file) 100 | 101 | 102 | -n, --nocommit 103 | Avoid committing changes, executing "pre_commit" and "post_commit" scripts. 104 | Only fetching files and executing "pre_get" and "post_get" will be done. 105 | 106 | Examples: 107 | keepconf -n 108 | keepconf --nocommit 109 | 110 | 111 | -c, --commitonly 112 | Avoid fetching files, executing "pre_get" and "post_get". Only commit 113 | operation and execution of "pre_commit" and "post_commit" will be done. 114 | 115 | Examples: 116 | keepconf -c 117 | keepconf --commitonly 118 | 119 | 120 | -m, --message 121 | Substitute the default commit message with the text defined inside this 122 | option. 123 | 124 | Examples: 125 | keepconf -m 'Fixing issue with repo' 126 | keepconf --message 'Deleting old files from old hosts' 127 | 128 | 129 | -s, --silent 130 | When rsync fetchs files, some information about their running state are 131 | printed trought the terminal. For silent the rsync stdout and stderr 132 | use this option. 133 | 134 | Examples: 135 | keepconf -s 136 | keepconf --silent 137 | 138 | -x, --xilent 139 | When git is execute, some information about their behaviour are printed 140 | trought the terminal. For silent git stdout and stderr use this option. 141 | 142 | Examples: 143 | keepconf -x 144 | keepconf --xilent 145 | 146 | 147 | -v, --verbose 148 | Print a lot of information about the internals of keepconf. Good for 149 | debugging how it gets the variables from files and the backgroud behaviour. 150 | 151 | Examples: 152 | keepconf -v 153 | keepconf --verbose 154 | 155 | 156 | -V, --version: 157 | Print version number and exit. 158 | 159 | Examples: 160 | keepconf -V 161 | keepconf --version 162 | 163 | 164 | 165 | ====================== 166 | | Configuration file | 167 | ====================== 168 | 169 | Inside the configuration file, anything following a sharp ('#') or semicolon 170 | (';') is ignored. Space caracter is allowed, but if is possible, avoid it. 171 | All names of sections and variables must be only in lowercase. 172 | Take care of the tabs and spaces at the beginning of each line, dont add any 173 | of them. 174 | 175 | [main] 176 | Section for general variables. The following are available inside here: 177 | 178 | d_dest= 179 | Directory for store the remote files and locate the repository. It is a 180 | requirement for the basic behaviour of keepconf. Rsync put there all 181 | files fetched inside a folder with the name of each host. A valid and 182 | empty dir is needed. If not exist, the initialize option creates it 183 | automatically. Require a full path definition. 184 | 185 | Example: 186 | d_dest = /var/keepconf/hosts/ 187 | 188 | d_monitor= 189 | Directory for store the .csv monitor files. The name of the files is 190 | the same that the configuration files except the extension. 191 | 192 | Examples: 193 | d_monitor = /var/tmp/keepconf/ 194 | d_monitor = False # (Default) 195 | 196 | 197 | [hosts] 198 | Section for define hosts to fetch. Two options are available here, list_hosts 199 | variable content and/or list of hosts alone: 200 | 201 | list_hosts= 202 | Path to file, or comma separated list of files, with the list of hosts to 203 | fetch. The content of all files is merged together. 204 | 205 | Example: 206 | list_hosts = /etc/keepconf/hosts/hosts_prod.lst 207 | list_hosts = /etc/mydir/hosts_prod.lst, /etc/mydir/hosts_dev.lst 208 | 209 | (list of hosts) 210 | Define hostsnames or IPs directly, one per line. The format is the same as 211 | described in "list_hosts and list_files format". If list_host is already 212 | present, all will be combined. 213 | 214 | Example: 215 | webserver 216 | ftpserver 217 | 192.168.1.50 218 | 219 | 220 | [files] 221 | Section for define lists of files to fetch. Two options are available here, 222 | list_files variable content and/or list of paths alone: 223 | 224 | list_files= 225 | Path to file, or comma separated list of files, with the list of files to 226 | fetch. The content of all files is merged together. 227 | 228 | Example: 229 | list_files = /etc/keepconf/files/files_default.lst 230 | list_files = /etc/mydir/files_default.lst, /etc/mydir/files_env1.lst 231 | 232 | (list of paths) 233 | Define paths for fetch directly, one per line. The format is the same as 234 | described in "list_hosts and list_files format". If list_files is already 235 | present, all will be combined. 236 | 237 | Example: 238 | /etc/resolv.conf 239 | /opt/app1/* 240 | !/opt/app1/logs 241 | 242 | 243 | [sync] 244 | Section for fetching variables. The following are available inside here: 245 | 246 | fetch= 247 | Enable or disable fetching behaviour. 248 | 249 | Examples: 250 | fetch = True # (Default) 251 | fetch = False 252 | 253 | pre_get= 254 | Directory for store the files that will be executed before fetching the 255 | files from the remote hosts. 256 | See "External scripts" section for more information. 257 | 258 | Examples: 259 | pre_get = /etc/keepconf/pre-get.d/ 260 | pre_get = False # (Default) 261 | 262 | post_get= 263 | Directory for store the files that will be executed after fetching the 264 | files from remote hosts. 265 | See "External scripts" section for more information. 266 | 267 | Examples: 268 | post_get = /etc/keepconf/post-get.d/ 269 | post_get = False # (Default) 270 | 271 | max_size= 272 | Size limit for files to fetch. It must be a number followed by the 273 | initial of the size name. 274 | 275 | Examples: 276 | max_size = 25M # (Default) 277 | max_size = 100K 278 | max_size = 1024B 279 | 280 | rsync_user= 281 | User for connect to remote hosts via ssh. 282 | 283 | Examples: 284 | rsync_user = backup # (Default) 285 | rsync_user = root 286 | 287 | rsync_key= 288 | Ssh key for use in the rsync connection to the hosts. Define a path to the 289 | key. If none is defined or this variable is not used, the default ssh 290 | behaviour for the user who execute keepconf will be used. 291 | 292 | Examples: 293 | rsync_key = None # (Default) 294 | rsync_key = /home/backup/.ssh/servers-prod-key.rsa 295 | 296 | rsync_port= 297 | Ssh port for use in the rsync connection tho the remote hosts. 298 | 299 | Examples: 300 | rsync_port = 22 # (Default) 301 | rsync_port = 2222 302 | 303 | rsync_opt= 304 | Common options for pass to rsync command. They must be a compact list 305 | like the example. By default: 306 | archive, recursive, preserve hard links, verbose, compress, copy links 307 | 308 | Examples: 309 | rsync_opt = arHvzL # (Default) 310 | rsync_opt = ar 311 | rsync_opt = v 312 | 313 | [vcs] 314 | Section for version control system variables. The following are available 315 | inside here: 316 | 317 | commit= 318 | Enable or disable commit behaviour. 319 | 320 | Examples: 321 | commit = True # (Default) 322 | commit = False 323 | 324 | pre_commit= 325 | Directory for store the files that will be executed before committing 326 | files located inside "d_dest" directory. See "External scripts" section 327 | for more information. 328 | 329 | Examples: 330 | pre_commit = /etc/keepconf/pre-commit.d/ 331 | pre_commit = False # (Default) 332 | 333 | post_commit= 334 | Directory for store the files that will be executed after committing 335 | files located inside "d_dest" directory. See "External scripts" section 336 | for more information. 337 | 338 | Examples: 339 | post_commit = /etc/keepconf/pre-commit.d/ 340 | post_commit = False # (Default) 341 | 342 | repo= 343 | Type of git repository: 344 | "False" disable commit behaviour. 345 | "local" local repository located inside "d_dest" directory. 346 | "ssh://user@host/rute/to/repo" remote repository cloned into "d_dest". 347 | 348 | Examples: 349 | repo = False 350 | repo = "local" 351 | repo = "ssh://user@host/rute/to/repo" 352 | 353 | 354 | ==================================== 355 | | list_hosts and list_files format | 356 | ==================================== 357 | 358 | Each file listed inside "list_hosts" and "list_files" variables (and hosts and 359 | files listed directly inside [hosts] and [files] sections) will have the 360 | following properties. 361 | 362 | Anything following a sharp ('#') a semicolon (';') or a comma (',') is ignored. 363 | Space character is allowed, but if is possible, avoid it use. Take care of the 364 | tabs and spaces at the beginning of each line, dont add any of them. 365 | 366 | For the files, any extension is valid, .lst .txt etc... 367 | 368 | "list_hosts" 369 | One host per line. 370 | 371 | Complete dns hostnames and IP are allowed. 372 | 373 | For willcard match its only available the use of ranges defined into 374 | brackets [1-25]. A third argument can be passed to the range brackets 375 | [1-25-2] for change the step. 376 | 377 | Examples: 378 | host1.prod 379 | 192.168.5.12 380 | db.local.company 381 | web-tier[1-10].prod.company 382 | 383 | "list_files" 384 | One path per line. 385 | 386 | Folder must ends with slash '/' character. 387 | 388 | It's available the use of asterisk '*', interrogation '?' and ranges defined 389 | into brackets [1-25] for willcard match. 390 | 391 | It's possible to include, exclude or force paths: 392 | Include - Normal line path. 393 | Exclude - A '!' character must be before the path. 394 | Force - Based on the behaviour of rsync and how keepconf interact with 395 | it, exclude rules have precedence over includes. For force the include 396 | of a path inside a exclude match, add a '&' character before the path. 397 | 398 | Examples: 399 | /etc/ # Include all /etc/ but not folders inside it 400 | /etc/* # Include folders inside it recursively 401 | /etc/apache2/sites-enabled/*.conf # Include all files ended in conf 402 | !/etc/cron.d/ # Exclude cron.d folder 403 | &/etc/cron.d/myfile # Force include inside a exclude rule match 404 | /home/user1/conf[1-15]*.conf # Get a range of files ended in conf 405 | 406 | 407 | ==================== 408 | | External Scripts | 409 | ==================== 410 | 411 | Variables "pre_get", "post_get", "pre_commit" and "post_commit" defines a 412 | folder wich all files into it with execution permissions are executed. But 413 | executed in diferent stages of the flow: 414 | 415 | pre-get -> fetch files -> post-get -> pre-commit -> commit -> post-commit 416 | 417 | Two arguments are passed to each file when its executed, first is a comma 418 | separated list with the hosts applicable and second is "d_dest" variable. 419 | 420 | The definition of the directory must be a complete route starting from the 421 | root path. 422 | 423 | Are a good point for include diferent particular tasks. For example: 424 | 425 | - Locate a script inside "pre_get" or "post_get" for backup a swich, firewall, 426 | appliance, whatever device... wich need a particular process. Put the files 427 | fetched inside a folder into "d_dest" directory and it will be tracked with 428 | git as all other content. 429 | 430 | - Execute keepconf with no commit option '-n' and put a script inside 431 | "post_get" for call an other version control system, like subversion. 432 | 433 | - Put a script inside "post_commit" directory for do some task after each 434 | execution. Sending an email as example. 435 | 436 | - Delete especial and binary files for avoid commit them. Actually this script 437 | it is included into "pre-commit.d" directory at the github repo. 438 | 439 | Full path definition is required for this variables. 440 | 441 | Note: Example bash scripts shipped with the proyect and located in post-get.d/ 442 | and pre-commit.d/ only work on Linux systems. 443 | 444 | 445 | =========== 446 | | Logging | 447 | =========== 448 | 449 | For get a log of the behaviour, redirect sdout and/or stderr to a file in 450 | execution time: 451 | 452 | Examples: 453 | keepconf > /var/log/keepconf.log # Redirect stdout 454 | keepconf 2> /var/log/keepconf.log # Redirect stderr 455 | keepconf &> /var/log/keepconf.log # Redirect stdout and stderr 456 | 457 | 458 | ====================== 459 | | Schedule execution | 460 | ====================== 461 | 462 | For automate the backup process with a log, this line can be added to the cron 463 | daemon: 464 | With traditional syslog: 465 | 15 06 * * * keepconf &> /var/log/keepconf.log 466 | 467 | With systemd journalctl: 468 | 15 06 * * * systemd-cat keepconf 469 | 470 | And for rotate the log generated, add a file called "keepconf" to 471 | /etc/logrotate.d with this content: 472 | 473 | /var/log/keepconf.log { 474 | rotate 12 475 | monthly 476 | compress 477 | missingok 478 | } 479 | 480 | 481 | =========================== 482 | | Using multiple ssh keys | 483 | =========================== 484 | 485 | Rely on ".ssh/config" to connect to different hosts with different ssh keys. 486 | 487 | As example: 488 | # cat .ssh/authorized_keys 489 | 490 | Host web1.rack1 491 | IdentityFile /root/.ssh/id_rsa_001 492 | 493 | Host *.rack2 494 | IdentityFile /root/.ssh/id_rsa_002 495 | 496 | 497 | ================ 498 | | Some Toughts | 499 | ================ 500 | 501 | For avoid problems with section definition, range brackets are not allowed 502 | at the beginning of a line. 503 | 504 | By default, paths ended in '/' fetch all content inside it only, if ends with 505 | '/*' fetch all content inside it recursively. 506 | 507 | The monitor csv files can be converted with a script to Prometheus (textfile 508 | collector), there is a Grafana dashboard for graph the values, look for 509 | both into the grafana-dashboard folder in this repo. 510 | 511 | Inside the rsync call, the arguments StrictHostKeyChecking=no and 512 | UserKnownHostsFile=/dev/null are passed for avoid annoying startup issues 513 | when hosts are new and any ssh connection is done before (avoid prompt for 514 | accept ssh fingerprint). 515 | 516 | If you found any bugs, please report it with all information as you can 517 | (a verbose output its helpfull). Thanks. 518 | -------------------------------------------------------------------------------- /src/keepconf: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | # keepconf - Backup and track files from hosts keeping them inside a repository 5 | # Copyright (C) 2021 - Ricardo F. 6 | 7 | # This program is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | import sys, argparse, os, glob, time, string, re, tempfile, logging, signal 20 | from configparser import ConfigParser 21 | from subprocess import call 22 | from distutils.dir_util import mkpath 23 | 24 | __version__ = '2.2.0' 25 | 26 | # Handling SIGPIPE to terminate 27 | signal.signal(signal.SIGPIPE, signal.SIG_DFL) 28 | 29 | def get_arguments(): 30 | """Get arguments from command line""" 31 | 32 | parser = argparse.ArgumentParser() 33 | parser.add_argument( 34 | '-f', '--conf', 35 | dest='config', 36 | default='/etc/keepconf/', 37 | action='store', 38 | help='configuration file/folder path', 39 | metavar="PATH" 40 | ) 41 | parser.add_argument( 42 | '-i', '--init', 43 | dest='gitinit', 44 | default=False, 45 | action='store_true', 46 | help='initialize git repostory only' 47 | ) 48 | parser.add_argument( 49 | '-n', '--nocommit', 50 | dest='commit', 51 | default=True, 52 | action='store_false', 53 | help='avoid commit changes' 54 | ) 55 | parser.add_argument( 56 | '-c', '--commitonly', 57 | dest='commitonly', 58 | default=True, 59 | action='store_false', 60 | help='only commit changes' 61 | ) 62 | parser.add_argument( 63 | '-m', '--message', 64 | dest='message', 65 | default=False, 66 | action='store', 67 | help='commit message', 68 | metavar="TEXT" 69 | ) 70 | parser.add_argument( 71 | '-s', '--silent', 72 | dest='silent', 73 | default=False, 74 | action='store_true', 75 | help='silent fetching output' 76 | ) 77 | parser.add_argument( 78 | '-x', '--xilent', 79 | dest='xilent', 80 | default=False, 81 | action='store_true', 82 | help='silent commit output' 83 | ) 84 | parser.add_argument( 85 | '-v', '--verbose', 86 | dest='verbose', 87 | default=False, 88 | action='store_true', 89 | help='verbose debug output' 90 | ) 91 | parser.add_argument( 92 | '-V', '--version', 93 | action='version', 94 | version='keepconf version ' + (__version__), 95 | help='show version' 96 | ) 97 | arg = parser.parse_args() 98 | 99 | if arg.verbose: 100 | logging.basicConfig(format='%(levelname)s:%(message)s', level=logging.DEBUG) 101 | print(vars(arg)) 102 | 103 | return arg 104 | 105 | 106 | def process_folder(folder_name, hosts, dest): 107 | """Execute all files located in a external folder""" 108 | 109 | # Test if dir exists 110 | if not os.path.isdir(folder_name): 111 | logging.info('%s not exists', str(folder_name)) 112 | return 113 | 114 | print('- Finding exec files in: ' + folder_name) 115 | 116 | # Get all files inside the folder 117 | folder_files = sorted(glob.glob(folder_name + '*')) 118 | for b in folder_files: 119 | 120 | # Only execute executable files 121 | if os.path.isfile(b) and os.access(b, os.X_OK): 122 | print('- Processing: ', b) 123 | sys.stdout.flush() 124 | # File are called with: 125 | # arg1: comma separated list of hosts 126 | # arg2: "d_dest" variable 127 | call([b, str(",".join(hosts)), str(dest)]) 128 | sys.stdout.flush() 129 | else: 130 | logging.info('WARNING nothing done in non executable file %s', str(b)) 131 | 132 | 133 | # The following two functions are written by Chin Fang 134 | # and are part of Ansible software located in github.com/ansible 135 | # Are distributed under the terms of GNU General Public License 136 | # (c) 2012, Zettar Inc. 137 | # File lib/ansible/inventory/expand_hosts.py - 4 May 2015 138 | # Commit ce3ef7f4c16e47d5a0b5600e1c56c177b7c93f0d 139 | 140 | def detect_range(line=None): 141 | """Detect is line have a range""" 142 | if 0 <= line.find("[") < line.find("-") < line.find("]"): 143 | return True 144 | else: 145 | return False 146 | 147 | 148 | def expand_range(line=None): 149 | """Expand name range""" 150 | all_hosts = [] 151 | if line: 152 | (head, nrange, tail) = line.replace('[', '|', 1).replace(']', '|', 1).split('|') 153 | bounds = nrange.split("-") 154 | if len(bounds) != 2 and len(bounds) != 3: 155 | logging.warning("Range must be begin:end") 156 | beg = bounds[0] 157 | end = bounds[1] 158 | if len(bounds) == 2: 159 | step = 1 160 | else: 161 | step = bounds[2] 162 | if not beg: 163 | beg = "0" 164 | if not end: 165 | logging.warning("Range must specify end value missing") 166 | if beg[0] == '0' and len(beg) > 1: 167 | rlen = len(beg) # range length formatting hint 168 | if rlen != len(end): 169 | logging.warning("Range format must specify equal-length begin and end formats") 170 | fill = lambda _: str(_).zfill(rlen) # range sequence 171 | else: 172 | fill = str 173 | 174 | try: 175 | i_beg = string.ascii_letters.index(beg) 176 | i_end = string.ascii_letters.index(end) 177 | if i_beg > i_end: 178 | print("WARNING: Range must have begin <= end") 179 | seq = string.ascii_letters[i_beg:i_end+1] 180 | except ValueError: # not an alpha range 181 | seq = range(int(beg), int(end)+1, int(step)) 182 | 183 | for rseq in seq: 184 | hname = ''.join((head, fill(rseq), tail)) 185 | if detect_range(hname): 186 | all_hosts.extend(expand_range(hname)) 187 | else: 188 | all_hosts.append(hname) 189 | 190 | return all_hosts 191 | 192 | 193 | def sanitize(text_value): 194 | """Remove especial characters""" 195 | 196 | # Remove excluded characters and clean line 197 | resultd = re.sub("[\'\";|<>]", '', (re.split('#|;', text_value)[0].strip())) 198 | if resultd: 199 | return resultd 200 | else: 201 | logging.info('Excluded by sanitize: ' + text_value.strip()) 202 | 203 | 204 | def call_out(call_, silent_=None, cwd_='/tmp'): 205 | """Execute external program and redirect stdout and stderr to null if is needed""" 206 | 207 | if silent_: 208 | return call(call_, cwd=cwd_, stdout=open(os.devnull, 'w'), stderr=open(os.devnull, 'w')) 209 | else: 210 | return call(call_, cwd=cwd_) 211 | 212 | 213 | def extract_walk(cfg_in_, walking=True): 214 | """Deconstruct path, extract elements""" 215 | 216 | # Store list to return 217 | paths_lst = [] 218 | 219 | # For each element in the list of files/paths 220 | for g in cfg_in_: 221 | 222 | # Store path walk 223 | walk_lst = [] 224 | 225 | # In some cases walking is not needed (for exclude files/paths) 226 | if walking: 227 | 228 | # extract from path everything except the last pathname component and last '/' 229 | head, _ = os.path.split(g) 230 | 231 | # For each element separated by '/' add it to the list 232 | walk_path = '' 233 | for t in head.split('/'): 234 | walk_path += t + '/' 235 | walk_lst.append(walk_path) 236 | 237 | # For a comprensive behaviour than rsync do alone, add an asterisk 238 | # to the paths that ends in a slash or slash and asterisk 239 | if g.endswith('/') or g.endswith('/*'): 240 | logging.info('Adding * to: %s', str(g)) 241 | g += '*' 242 | 243 | # add origin path after its deconstructed route 244 | walk_lst.append(g) 245 | 246 | # add processed path to the total amount 247 | paths_lst += walk_lst 248 | 249 | # Return sorted list 250 | return sorted(set(paths_lst)) 251 | 252 | 253 | def test_repo_dir(d_dest_, commit_): 254 | """Test the state of the destination repo dir""" 255 | 256 | if not os.path.isdir(d_dest_+'.git') and commit_: 257 | sys.exit('ERROR: '+str(d_dest_)+' not have a repository\nPlease, try to initialize it with \"keepconf -i\"') 258 | 259 | 260 | def git_init(c): 261 | '''Initialize git repo. Create path and init or clone''' 262 | 263 | # Local repository 264 | if c['repo'] == 'local': 265 | if not os.path.isdir(os.path.join(c['d_dest'], '.git')): 266 | print('- Initialize local repository on: ' + str(c['d_dest'])) 267 | call(('git', 'init', c['d_dest'])) 268 | else: 269 | logging.info('Local repository exists on %s', str(c['d_dest'])) 270 | sys.stdout.flush() 271 | 272 | # Remote repository 273 | elif c['repo']: 274 | if not os.path.isdir(c['d_dest']): 275 | print('- Making directory for repository on: ' + str(c['d_dest'])) 276 | os.makedirs(c['d_dest']) 277 | if not os.path.isdir(os.path.join(c['d_dest'], '.git')): 278 | print('- Cloning repository...') 279 | sys.stdout.flush() 280 | call(('git', 'clone', c['repo'], c['d_dest'])) 281 | else: 282 | logging.info('Remote repository exists on %s', str(c['d_dest'])) 283 | sys.stdout.flush() 284 | 285 | else: 286 | if c['repo'] is not False: 287 | sys.exit('ERROR initializating repository: ' + str(c['repo'])) 288 | 289 | 290 | def get_files_list(c, key1, key2): 291 | '''Get content of a list of files from config variable''' 292 | 293 | def parse_list(elements, values): 294 | '''Extract and clean lines from list''' 295 | for line in elements: 296 | try: 297 | nel = re.split(',', sanitize(line))[0] # Avoid comma separated lists 298 | if detect_range(nel): 299 | logging.info('Range detected: %s', str(nel)) 300 | values = values + expand_range(nel) 301 | else: 302 | values.append(nel) 303 | except: 304 | continue 305 | return values 306 | 307 | # Store list to return 308 | values = [] 309 | 310 | # Read lines and expand range 311 | values = parse_list(c[key2], values) 312 | 313 | # For each file in the list 314 | for file_var in c[key1].replace(" ", "").split(',', 1): 315 | print('- Reading '+ str(key2) +' list: '+ str(file_var)) 316 | 317 | # test if it is a file and open it 318 | if os.path.isfile(file_var): 319 | with open(file_var) as f: 320 | 321 | # read lines and expand range 322 | values = parse_list(f, values) 323 | 324 | # remove duplicate names and order 325 | values = sorted(set(values)) 326 | else: 327 | print(' File not exists: '+ str(file_var)) 328 | 329 | logging.info('%s list: %s', str(key1), str(values)) 330 | return values 331 | 332 | 333 | def stats_and_csv(c, file_conf, hosts, timec): 334 | '''Print stats and write csv''' 335 | 336 | print('- ') 337 | if c['fetch']: 338 | print('- Hosts rsync\'ed: ' +'['+ str(len(hosts['ok'])) +'] '+ ', '.join(hosts['ok'])) 339 | print('- Hosts not rsync\'ed: ' +'['+ str(len(hosts['bad'])) +'] '+ ', '.join(hosts['bad'])) 340 | print('- Fetched in: %.2f seconds' % timec['fetch']) 341 | if c['commit'] and c['repo']: 342 | print('- Committed in: %.2f seconds' % timec['commit']) 343 | print('- Completed in: %.2f seconds' % timec['total']) 344 | 345 | # Generate and write csv report file 346 | if c['d_monitor']: 347 | 348 | # Monitor file is destination path + name of configuration with replaced extension to .csv 349 | mon_file = str(c['d_monitor'] + os.path.splitext(os.path.basename(file_conf))[0] + '.csv') 350 | 351 | # Check if path exists, if not, do it 352 | if os.path.isdir(c['d_monitor']): 353 | logging.info('Destination csv folder exists: %s', str(c['d_monitor'])) 354 | with open(mon_file, "w"): # clean monitor file 355 | pass 356 | else: 357 | logging.info('Making destination csv folder: %s', str(c['d_monitor'])) 358 | mkpath(c['d_monitor']) 359 | 360 | # Create csv values 361 | if hosts['bad']: 362 | hosts_bad_list = ","+ ','.join(['\"'+ x +'\"' for x in hosts['bad']]) 363 | else: 364 | hosts_bad_list = '' 365 | if hosts['ok']: 366 | hosts_ok_list = "," +','.join(['\"'+ x +'\"' for x in hosts['ok']]) 367 | else: 368 | hosts_ok_list = '' 369 | 370 | # Write into file 371 | with open(mon_file, "a") as file_monitor: 372 | file_monitor.write('\"CFG-FILE\",\"' + os.path.basename(file_conf)+'\"\n') 373 | file_monitor.write('\"OK\",\"' + str(len(hosts['ok'])) + '\"' + hosts_ok_list + '\n') 374 | file_monitor.write('\"BAD\",\"' + str(len(hosts['bad'])) + '\"' + hosts_bad_list + '\n') 375 | file_monitor.write('\"FETCH-T\",\"%.2f' % timec['fetch'] + '\"\n') 376 | file_monitor.write('\"COMMIT-T\",\"%.2f' % timec['commit'] + '\"\n') 377 | file_monitor.write('\"TOTAL-T\",\"%.2f' % timec['total'] + '\"\n') 378 | 379 | print('- Monitor file: '+ str(mon_file)) 380 | 381 | 382 | def fetch_process(c, arg, hosts): 383 | ''' Fetch files from hosts''' 384 | 385 | def rsync_host(c, arg, f_walk, hosts): 386 | ''' Construct rsync call and fetch host''' 387 | # Define path name for store fetched files 388 | final_path = os.path.join(c['d_dest'], host) 389 | 390 | # Check if path exists, if not, do it 391 | if not os.path.isdir(final_path): 392 | logging.info('Making destination folder: %s', str(final_path)) 393 | mkpath(final_path) 394 | else: 395 | logging.info('Destination folder exists: %s', str(final_path)) 396 | 397 | print('- Fetching host: '+ host) 398 | 399 | # Construct ssh options: 400 | # If verbose is not used, quiet ssh mode 401 | if arg.verbose: 402 | ssh_loglevel = '' 403 | else: 404 | ssh_loglevel = ' -o LogLevel=quiet' 405 | 406 | # If rsync key is used, add it 407 | if c['rsync_key']: 408 | ssh_key = ' -i '+str(c['rsync_key']) 409 | else: 410 | ssh_key = '' 411 | 412 | # ssh options '-oStrictHostKeyChecking=no' and '-oUserKnownHostsFile=/dev/null' are for 413 | # for avoid annoying errors in rsync when hosts are new. 414 | # If you need security, please comment the following line and uncomment the next. 415 | ssh_opt = 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p '+ c['rsync_port'] + ssh_loglevel + ssh_key 416 | #ssh_opt = 'ssh -p '+ c['rsync_port'] + ssh_loglevel + ssh_key 417 | 418 | # Create rsync call. For modify it, the place is the following line: 419 | rsync_call = ('rsync', '-e', ssh_opt, '-'+c['rsync_opt'], '--progress', '--delete-excluded', 420 | '--timeout='+c['rsync_timeout'], '--ignore-errors', '--max-size='+c['max_size'], 421 | '--include-from=' + f_walk.name, '--prune-empty-dirs', c['rsync_user']+'@'+host+':/', final_path) 422 | logging.info(' '.join(map(str, rsync_call))) 423 | 424 | # Execute rsync and construct list with ok and bad hosts 425 | sys.stdout.flush() 426 | if (call_out(rsync_call, arg.silent, c['d_dest'])) == 0: 427 | hosts['ok'].append(host) 428 | else: 429 | hosts['bad'].append(host) 430 | sys.stdout.flush() 431 | 432 | return hosts['ok'], hosts['bad'] 433 | 434 | print('- -\n- Fetching start') 435 | test_repo_dir(c['d_dest'], c['commit']) 436 | 437 | # Get list of files with hostnames 438 | if c['list_hosts']: 439 | c['hosts'] = get_files_list(c, 'list_hosts', 'hosts') 440 | 441 | # Get list of files with filelist 442 | if c['list_files']: 443 | c['files'] = get_files_list(c, 'list_files', 'files') 444 | 445 | # Execute pre-get.d 446 | if c['pre_get']: 447 | process_folder(c['pre_get'], c['hosts'], c['d_dest']) 448 | 449 | # Construct lists for each includes / excludes / force includes files. Clean duplicates and order 450 | for fpath in c['files']: 451 | if fpath.startswith('!'): 452 | logging.info('Exclude: %s', str(fpath[1:].rstrip())) 453 | c['cfg_ex'].append(fpath[1:]) 454 | elif fpath.startswith('&'): 455 | logging.info('Force Incl: %s', str(fpath[1:].rstrip())) 456 | c['cfg_frz'].append(fpath[1:]) 457 | else: 458 | logging.info('Include: %s', str(fpath.rstrip())) 459 | c['cfg_in'].append(fpath) 460 | for val in ('cfg_in', 'cfg_ex', 'cfg_frz'): 461 | c[val] = sorted(set(c[val])) 462 | 463 | # Create temporary file for store the list of files and pass later to rsync 464 | _, f_tmp_walk = tempfile.mkstemp() 465 | f_walk = open(f_tmp_walk, 'w') 466 | 467 | # Write into temp file each list before extract the path to the file/folder. 468 | # This order of list (force>exclude>include) is a requirement for the file. 469 | if c['cfg_frz']: 470 | paths_lst_frz = extract_walk(c['cfg_frz']) 471 | logging.info('Force paths: %s -> %s', str(c['cfg_frz']), str(paths_lst_frz)) 472 | for e in paths_lst_frz: 473 | f_walk.write('+ ' + e + '\n') 474 | 475 | if c['cfg_ex']: 476 | paths_lst_ex = extract_walk(c['cfg_ex'], False) 477 | logging.info('Exclude paths: %s -> %s', str(c['cfg_ex']), str(paths_lst_ex)) 478 | for e in paths_lst_ex: 479 | f_walk.write('- ' + e + '\n') 480 | 481 | if c['cfg_in']: 482 | paths_lst_in = extract_walk(c['cfg_in']) 483 | logging.info('Include paths: %s -> %s', str(c['cfg_in']), str(paths_lst_in)) 484 | for e in paths_lst_in: 485 | f_walk.write('+ ' + e + '\n') 486 | 487 | # The temp file must have always the following last line 488 | f_walk.write('- *\n') 489 | 490 | # Close temporary file with the list of files used by rsync 491 | f_walk.close() 492 | 493 | # For each hostname in the list, rsync it. 494 | for host in c['hosts']: 495 | rsync_host(c, arg, f_walk, hosts) 496 | 497 | # Don't delete file if verbose mode is enabled (for easy debug) 498 | if arg.verbose: 499 | logging.info('WARNING temporary files not deleted in verbose mode') 500 | else: 501 | os.remove(f_tmp_walk) 502 | 503 | # Execute post-get.d 504 | if c['post_get']: 505 | process_folder(c['post_get'], c['hosts'], c['d_dest']) 506 | 507 | print('- Fetching done') 508 | return hosts 509 | 510 | 511 | def fetch_commit(c, arg, hosts): 512 | ''' Commit files fetched''' 513 | 514 | print('- - \n- Committing start') 515 | test_repo_dir(c['d_dest'], c['commit']) 516 | 517 | # Execute pre-commit.d 518 | if c['pre_commit']: 519 | process_folder(c['pre_commit'], c['hosts'], c['d_dest']) 520 | 521 | # Parse message option. Use default or custom. 522 | if arg.message: 523 | message = 'Keepconf: ' + arg.message 524 | else: 525 | message = 'Keepconf commit at '+time.strftime('%H:%M:%S - %A/%B/%Y') 526 | if hosts['ok'] or hosts['bad']: 527 | message += '\nHosts ok: '+'['+ str(len(hosts['ok']))+'] ' + ', '.join(hosts['ok']) 528 | message += '\nHosts bad: '+'['+ str(len(hosts['bad']))+'] ' + ', '.join(hosts['bad']) 529 | 530 | # Commit files into local or remote repository 531 | sys.stdout.flush() 532 | if c['repo'] == 'local': 533 | print('- Committing into local repository') 534 | call_out(('git', 'add', c['d_dest']+'*'), arg.xilent, c['d_dest']) 535 | call_out(('git', 'commit', '-am', message), arg.xilent, c['d_dest']) 536 | else: 537 | # print('- Updating remote repository') 538 | # call_out(('git', 'pull'), arg.xilent, cwd=c['d_dest']) 539 | print('- Committing into remote repository') 540 | call_out(('git', 'add', '--all'), arg.xilent, c['d_dest']) 541 | call_out(('git', 'commit', '-am', message), arg.xilent, c['d_dest']) 542 | call_out(('git', 'push'), arg.xilent, c['d_dest']) 543 | sys.stdout.flush() 544 | 545 | # Execute post-commit.d 546 | if c['post_commit']: 547 | process_folder(c['post_commit'], c['hosts'], c['d_dest']) 548 | 549 | print('- Committing done') 550 | 551 | 552 | def set_conf(arg, file_conf): 553 | """Set configuration values from file""" 554 | 555 | def set_var(c, parser, section, key, normpath=False): 556 | '''Sanitize variables''' 557 | try: 558 | if normpath: 559 | value = os.path.normpath(sanitize(parser.get(section, key)))+os.sep 560 | else: 561 | value = sanitize(parser.get(section, key)) 562 | if value.lower() in ('false', '0', 'not'): 563 | value = False 564 | except: 565 | # Assign default value as fallback 566 | value = c[key] 567 | 568 | logging.info('[ %s ] %s: %s', str(section), str(key), str(value)) 569 | return value 570 | 571 | # Default values 572 | c = { 573 | 'd_dest': False, 574 | 'd_monitor': False, 575 | 'list_hosts': False, 576 | 'list_files': False, 577 | 'fetch': True, 578 | 'pre_get': False, 579 | 'post_get': False, 580 | 'max_size': '25M', 581 | 'rsync_user': 'backup', 582 | 'rsync_key': None, 583 | 'rsync_port': '22', 584 | 'rsync_timeout': '5', 585 | 'rsync_opt': 'arHvzL', 586 | 'commit': True, 587 | 'pre_commit': False, 588 | 'post_commit': False, 589 | 'repo': 'local'} 590 | 591 | print('- - -\n- -') 592 | print('- Processing: '+ str(file_conf)) 593 | 594 | # Parse options in config file 595 | parser = ConfigParser(allow_no_value=True) 596 | parser.optionxform = str 597 | parser.read(file_conf) 598 | 599 | # Assign variables from config file to main dictionary 600 | c['d_dest'] = set_var(c, parser, 'main', 'd_dest') 601 | c['list_hosts'] = set_var(c, parser, 'hosts', 'list_hosts') 602 | c['hosts'] = [] # next fulfill with hostname list 603 | for line, _ in parser.items('hosts'): 604 | if not line == 'list_hosts': 605 | line = sanitize(line) 606 | c['hosts'].append(line) 607 | c['list_files'] = set_var(c, parser, 'files', 'list_files') 608 | c['files'] = [] # next fulfill with files list 609 | for line, _ in parser.items('files'): 610 | if not line == 'list_files': 611 | line = sanitize(line) 612 | c['files'].append(line) 613 | c['d_monitor'] = set_var(c, parser, 'main', 'd_monitor') 614 | if arg.commitonly: 615 | c['fetch'] = set_var(c, parser, 'sync', 'fetch') 616 | else: 617 | c['fetch'] = False 618 | c['pre_get'] = set_var(c, parser, 'sync', 'pre_get', normpath=True) 619 | c['post_get'] = set_var(c, parser, 'sync', 'post_get', normpath=True) 620 | c['max_size'] = set_var(c, parser, 'sync', 'max_size') 621 | c['rsync_user'] = set_var(c, parser, 'sync', 'rsync_user') 622 | c['rsync_key'] = set_var(c, parser, 'sync', 'rsync_key') 623 | c['rsync_port'] = set_var(c, parser, 'sync', 'rsync_port') 624 | c['rsync_opt'] = set_var(c, parser, 'sync', 'rsync_opt') 625 | if arg.commit: 626 | c['commit'] = set_var(c, parser, 'vcs', 'commit') 627 | else: 628 | c['commit'] = False 629 | c['pre_commit'] = set_var(c, parser, 'vcs', 'pre_commit', normpath=True) 630 | c['post_commit'] = set_var(c, parser, 'vcs', 'post_commit', normpath=True) 631 | c['repo'] = set_var(c, parser, 'vcs', 'repo') 632 | c['cfg_ex'] = [] # fulfill later with excluded files list 633 | c['cfg_frz'] = [] # fulfill later with force include files list 634 | c['cfg_in'] = [] # fulfill later with include files list 635 | 636 | for k, v in c.items(): 637 | if v is False and k in ('d_dest',): 638 | logging.error('Value "%s" required on configuration file %s', str(k), str(file_conf)) 639 | sys.exit(1) 640 | 641 | return c 642 | 643 | 644 | def main(): 645 | """Core logic, manage behaviour based on configuration""" 646 | 647 | # Get arguments from command line 648 | arg = get_arguments() 649 | 650 | logging.info('Parsing configuration path: %s', str(arg.config)) 651 | 652 | # Test if config path is a file or a folder, in that case, retrieve all .cfg files 653 | if os.path.isfile(str(arg.config)): 654 | logging.info('Config path is a file') 655 | conf_flst = (arg.config,) 656 | elif os.path.isdir(str(arg.config)): 657 | logging.info('Config path is a folder') 658 | conf_flst = sorted(glob.glob(arg.config + '*.cfg')) 659 | else: 660 | logging.error('Invalid configuration file/path') 661 | sys.exit(1) 662 | 663 | logging.info('Files for process: %s', str(len(conf_flst))) 664 | 665 | # Process each configuration file 666 | for file_conf in conf_flst: 667 | 668 | # Init time counters 669 | timec = {'start': time.time(), 'fetch': 0, 'commit': 0, 'total': 0} 670 | 671 | # Host status list 672 | hosts = {'ok': [], 'bad': []} 673 | 674 | # Set configuration from file 675 | c = set_conf(arg, file_conf) 676 | 677 | # Run initialize option 678 | git_init(c) 679 | if arg.gitinit: 680 | print('- Initialize repository only') 681 | sys.exit(0) 682 | 683 | # Run fetch (no commit) option 684 | if c['fetch']: 685 | start_fetch_time = time.time() 686 | hosts = fetch_process(c, arg, hosts) # Report ok and bad host list 687 | timec['fetch'] = time.time() - start_fetch_time 688 | else: 689 | print('- Skip fetch files') 690 | 691 | # Run commit option 692 | if c['commit'] and c['repo']: 693 | start_commit_time = time.time() 694 | fetch_commit(c, arg, hosts) 695 | timec['commit'] = time.time() - start_commit_time 696 | else: 697 | print('- Skip commit changes') 698 | 699 | # Run stats 700 | if c['fetch'] or (c['commit'] and c['repo']): 701 | timec['total'] = time.time() - timec['start'] 702 | stats_and_csv(c, file_conf, hosts, timec) 703 | 704 | if __name__ == "__main__": 705 | main() 706 | --------------------------------------------------------------------------------