From 22b197716acb24e0fb52411c2c2f498e0dbc5862 Mon Sep 17 00:00:00 2001 From: Ludovic <54670129+lbr38@users.noreply.github.com> Date: Fri, 16 Feb 2024 12:28:07 +0100 Subject: [PATCH] 3.0.0 (python version) --- .github/workflows/build-deb.yml | 93 ++- .github/workflows/build-rpm.yml | 70 +- .github/workflows/packaging/deb/control | 4 +- .github/workflows/packaging/deb/postinst | 46 +- .github/workflows/packaging/deb/preinst | 4 +- .github/workflows/packaging/rpm/spec | 33 +- .gitignore | 3 +- dependencies.txt | 32 + linupdate-agent.py | 20 + linupdate.py | 120 ++++ mods-available/reposerver.mod | 73 --- service/linupdate.systemd.template | 2 +- src/00_check-system | 126 ---- src/00_exclude | 27 - src/00_generateConf | 21 - src/00_get-conf | 35 - src/00_get-modules | 16 - src/00_help | 38 -- src/00_list-modules | 37 -- src/00_space-left | 20 - src/01_exec-pre-modules | 26 - src/01_load-modules | 22 - src/02_check-packages-before-update | 365 ----------- src/03_check-yum-lock | 14 - src/04_update | 86 --- src/09_exec-post-modules | 24 - src/09_service-restart | 30 - src/10_check-reboot-needed | 20 - src/10_send-mail | 29 - src/96_start-agent | 7 - src/97_stop-agent | 7 - src/98_restart-agent | 7 - src/99_clean-exit | 23 - src/99_disable-agent | 7 - src/99_enable-agent | 7 - src/__init__.py | 0 src/controllers/App/App.py | 165 +++++ src/controllers/App/Config.py | 461 ++++++++++++++ src/controllers/App/Service.py | 124 ++++ src/controllers/App/Utils.py | 20 + src/controllers/App/__init__.py | 0 src/controllers/Args.py | 393 ++++++++++++ src/controllers/Exit.py | 58 ++ src/controllers/HttpRequest.py | 102 +++ src/controllers/Log.py | 34 + src/controllers/Mail.py | 45 ++ src/controllers/Module/Module.py | 221 +++++++ src/controllers/Module/Reposerver/Agent.py | 225 +++++++ src/controllers/Module/Reposerver/Args.py | 241 +++++++ src/controllers/Module/Reposerver/Config.py | 571 +++++++++++++++++ src/controllers/Module/Reposerver/Register.py | 89 +++ .../Module/Reposerver/Reposerver.py | 90 +++ src/controllers/Module/Reposerver/Status.py | 285 +++++++++ src/controllers/Module/Reposerver/__init__.py | 0 src/controllers/Package/Apt.py | 523 +++++++++++++++ src/controllers/Package/Dnf.py | 596 ++++++++++++++++++ src/controllers/Package/Package.py | 294 +++++++++ src/controllers/Service/Service.py | 75 +++ src/controllers/Service/__init__.py | 0 src/controllers/System.py | 122 ++++ src/controllers/__init__.py | 0 version | 2 +- 62 files changed, 5075 insertions(+), 1155 deletions(-) create mode 100644 dependencies.txt create mode 100644 linupdate-agent.py create mode 100755 linupdate.py delete mode 100644 src/00_check-system delete mode 100644 src/00_exclude delete mode 100644 src/00_generateConf delete mode 100644 src/00_get-conf delete mode 100644 src/00_get-modules delete mode 100644 src/00_help delete mode 100644 src/00_list-modules delete mode 100644 src/00_space-left delete mode 100644 src/01_exec-pre-modules delete mode 100644 src/01_load-modules delete mode 100644 src/02_check-packages-before-update delete mode 100644 src/03_check-yum-lock delete mode 100644 src/04_update delete mode 100644 src/09_exec-post-modules delete mode 100644 src/09_service-restart delete mode 100644 src/10_check-reboot-needed delete mode 100644 src/10_send-mail delete mode 100644 src/96_start-agent delete mode 100644 src/97_stop-agent delete mode 100644 src/98_restart-agent delete mode 100644 src/99_clean-exit delete mode 100644 src/99_disable-agent delete mode 100644 src/99_enable-agent create mode 100644 src/__init__.py create mode 100644 src/controllers/App/App.py create mode 100644 src/controllers/App/Config.py create mode 100644 src/controllers/App/Service.py create mode 100644 src/controllers/App/Utils.py create mode 100644 src/controllers/App/__init__.py create mode 100644 src/controllers/Args.py create mode 100644 src/controllers/Exit.py create mode 100644 src/controllers/HttpRequest.py create mode 100644 src/controllers/Log.py create mode 100644 src/controllers/Mail.py create mode 100644 src/controllers/Module/Module.py create mode 100644 src/controllers/Module/Reposerver/Agent.py create mode 100644 src/controllers/Module/Reposerver/Args.py create mode 100644 src/controllers/Module/Reposerver/Config.py create mode 100644 src/controllers/Module/Reposerver/Register.py create mode 100644 src/controllers/Module/Reposerver/Reposerver.py create mode 100644 src/controllers/Module/Reposerver/Status.py create mode 100644 src/controllers/Module/Reposerver/__init__.py create mode 100644 src/controllers/Package/Apt.py create mode 100644 src/controllers/Package/Dnf.py create mode 100644 src/controllers/Package/Package.py create mode 100644 src/controllers/Service/Service.py create mode 100644 src/controllers/Service/__init__.py create mode 100644 src/controllers/System.py create mode 100644 src/controllers/__init__.py diff --git a/.github/workflows/build-deb.yml b/.github/workflows/build-deb.yml index 4e03e33..de4ca28 100644 --- a/.github/workflows/build-deb.yml +++ b/.github/workflows/build-deb.yml @@ -2,7 +2,7 @@ name: Build and test deb package for linupdate on: push: - branches: [ devel ] + branches: [ python ] pull_request: push: branches: [ main ] @@ -29,21 +29,19 @@ jobs: mkdir -p /tmp/linupdate-build/DEBIAN mkdir -p /tmp/linupdate-build/etc/linupdate/modules mkdir -p /tmp/linupdate-build/opt/linupdate - mkdir -p /tmp/linupdate-build/opt/linupdate/.src/ - mkdir -p /tmp/linupdate-build/opt/linupdate/mods-available/ - mkdir -p /tmp/linupdate-build/opt/linupdate/mods-enabled/ - mkdir -p /tmp/linupdate-build/opt/linupdate/agents-available/ - mkdir -p /tmp/linupdate-build/opt/linupdate/service/ - mkdir -p /tmp/linupdate-build/lib/systemd/system/ + mkdir -p /tmp/linupdate-build/opt/linupdate/src/ +# TODO: service not working for now +# mkdir -p /tmp/linupdate-build/opt/linupdate/service/ +# mkdir -p /tmp/linupdate-build/lib/systemd/system/ - name: Copy files to include in the build run: | - cp -r ${GITHUB_WORKSPACE}/src/* /tmp/linupdate-build/opt/linupdate/.src/ - cp -r ${GITHUB_WORKSPACE}/mods-available/* /tmp/linupdate-build/opt/linupdate/mods-available/ - cp -r ${GITHUB_WORKSPACE}/service/* /tmp/linupdate-build/opt/linupdate/service/ - cp ${GITHUB_WORKSPACE}/linupdate /tmp/linupdate-build/opt/linupdate/linupdate + cp -r ${GITHUB_WORKSPACE}/src/* /tmp/linupdate-build/opt/linupdate/src/ + cp ${GITHUB_WORKSPACE}/linupdate.py /tmp/linupdate-build/opt/linupdate/linupdate.py cp ${GITHUB_WORKSPACE}/version /tmp/linupdate-build/opt/linupdate/version - cp -r ${GITHUB_WORKSPACE}/service/linupdate.systemd.template /tmp/linupdate-build/lib/systemd/system/linupdate.service +# TODO: service not working for now +# cp -r ${GITHUB_WORKSPACE}/service/* /tmp/linupdate-build/opt/linupdate/service/ +# cp -r ${GITHUB_WORKSPACE}/service/linupdate.systemd.template /tmp/linupdate-build/lib/systemd/system/linupdate.service - name: Copy control file run: | @@ -69,14 +67,45 @@ jobs: path: /tmp/linupdate-test-build_${{ env.VERSION }}_all.deb retention-days: 1 + # Linupdate 3 is not working on Debian 10 # Try to install package on Debian 10 - install-debian-10: - name: Install on Debian 10 + # install-debian-10: + # name: Install on Debian 10 + # needs: + # build-deb + # runs-on: ubuntu-latest + # container: + # image: debian:10 + # options: --user root + # steps: + # - name: Checkout + # uses: actions/checkout@v3 + + # - name: Get linupdate version + # run: echo "VERSION=$(cat ${GITHUB_WORKSPACE}/version)" >> $GITHUB_ENV + + # # Download builded deb package artifact + # - name: Download artifact + # uses: actions/download-artifact@v3 + # with: + # name: linupdate-test-build_${{ env.VERSION }}_all.deb + + # - name: Install package + # run: | + # apt-get update -y + # apt-get install -y ./linupdate-test-build_${{ env.VERSION }}_all.deb + + # - name: Launch linupdate + # run: python3 /opt/linupdate/linupdate.py --check-updates + + # Try to install package on Debian 11 + install-debian-11: + name: Install on Debian 11 needs: build-deb runs-on: ubuntu-latest container: - image: debian:10 + image: debian:11 options: --user root steps: - name: Checkout @@ -96,14 +125,22 @@ jobs: apt-get update -y apt-get install -y ./linupdate-test-build_${{ env.VERSION }}_all.deb - # Try to install package on Debian 11 - install-debian-11: - name: Install on Debian 11 + - name: Test some params + run: | + python3 /opt/linupdate/linupdate.py --help + python3 /opt/linupdate/linupdate.py --version + python3 /opt/linupdate/linupdate.py --check-updates + python3 /opt/linupdate/linupdate.py --profile container + python3 /opt/linupdate/linupdate.py --env test + + # Try to install package on Debian 12 + install-debian-12: + name: Install on Debian 12 needs: build-deb runs-on: ubuntu-latest container: - image: debian:11 + image: debian:12 options: --user root steps: - name: Checkout @@ -123,6 +160,14 @@ jobs: apt-get update -y apt-get install -y ./linupdate-test-build_${{ env.VERSION }}_all.deb + - name: Test some params + run: | + python3 /opt/linupdate/linupdate.py --help + python3 /opt/linupdate/linupdate.py --version + python3 /opt/linupdate/linupdate.py --check-updates + python3 /opt/linupdate/linupdate.py --profile container + python3 /opt/linupdate/linupdate.py --env test + # Try to install package on Ubuntu (latest) install-ubuntu: name: Install on Ubuntu (latest) @@ -145,4 +190,12 @@ jobs: - name: Install package run: | sudo apt-get update -y - sudo apt-get install -y ./linupdate-test-build_${{ env.VERSION }}_all.deb \ No newline at end of file + sudo apt-get install -y ./linupdate-test-build_${{ env.VERSION }}_all.deb + + - name: Test some params + run: | + sudo python3 /opt/linupdate/linupdate.py --help + sudo python3 /opt/linupdate/linupdate.py --version + sudo python3 /opt/linupdate/linupdate.py --check-updates + sudo python3 /opt/linupdate/linupdate.py --profile container + sudo python3 /opt/linupdate/linupdate.py --env test diff --git a/.github/workflows/build-rpm.yml b/.github/workflows/build-rpm.yml index 8df4f33..a8a2242 100644 --- a/.github/workflows/build-rpm.yml +++ b/.github/workflows/build-rpm.yml @@ -2,7 +2,7 @@ name: Build and test rpm package for linupdate on: push: - branches: [ devel ] + branches: [ python ] pull_request: push: branches: [ main ] @@ -12,7 +12,7 @@ jobs: name: Build rpm package runs-on: ubuntu-latest container: - image: centos:7 + image: centos:8 options: --user root steps: - name: Checkout @@ -21,8 +21,36 @@ jobs: - name: Get linupdate version run: echo "VERSION=$(cat ${GITHUB_WORKSPACE}/version)" >> $GITHUB_ENV + - name: Install CentOS8 archive repositories + run: | + rm /etc/yum.repos.d/* -f + + echo "[os] + name=os repo + baseurl=https://vault.centos.org/8-stream/BaseOS/x86_64/os/ + enabled=1 + gpgkey=https://www.centos.org/keys/RPM-GPG-KEY-CentOS-Official-SHA256 + gpgcheck=1" > /etc/yum.repos.d/os.repo + + echo "[appstream] + name=updates repo + baseurl=https://vault.centos.org/8-stream/AppStream/x86_64/os/ + enabled=1 + gpgkey=https://www.centos.org/keys/RPM-GPG-KEY-CentOS-Official-SHA256 + gpgcheck=1" > /etc/yum.repos.d/appstream.repo + + echo "[extras] + name=extras repo + baseurl=https://vault.centos.org/8-stream/extras/x86_64/extras-common/ + enabled=1 + gpgkey=https://www.centos.org/keys/RPM-GPG-KEY-CentOS-Official-SHA256 + gpgcheck=1" > /etc/yum.repos.d/extras.repo + + dnf clean all + - name: Install dependencies packages - run: yum install rpmdevtools rpmlint -y + run: | + dnf install rpmdevtools rpmlint -y - name: Create build environment run: | @@ -33,20 +61,15 @@ jobs: mkdir -p $HOME/rpmbuild/SPECS mkdir -p /etc/linupdate/modules mkdir -p /opt/linupdate - mkdir -p /opt/linupdate/.src/ - mkdir -p /opt/linupdate/mods-available/ - mkdir -p /opt/linupdate/mods-enabled/ - mkdir -p /opt/linupdate/agents-available/ - mkdir -p /opt/linupdate/agents-enabled/ + mkdir -p /opt/linupdate/src/ mkdir -p /opt/linupdate/service/ mkdir -p /lib/systemd/system/ - name: Copy files to include in the build run: | - cp -r ${GITHUB_WORKSPACE}/src/* /opt/linupdate/.src/ - cp -r ${GITHUB_WORKSPACE}/mods-available/* /opt/linupdate/mods-available/ + cp -r ${GITHUB_WORKSPACE}/src/* /opt/linupdate/src/ cp -r ${GITHUB_WORKSPACE}/service/* /opt/linupdate/service/ - cp ${GITHUB_WORKSPACE}/linupdate /opt/linupdate/linupdate + cp ${GITHUB_WORKSPACE}/linupdate.py /opt/linupdate/linupdate.py cp ${GITHUB_WORKSPACE}/version /opt/linupdate/version cp -r ${GITHUB_WORKSPACE}/service/linupdate.systemd.template /lib/systemd/system/linupdate.service @@ -58,7 +81,7 @@ jobs: - name: Build package run: | cd $HOME/rpmbuild/SPECS - rpmbuild --target noarch -bb --quiet linupdate.spec + rpmbuild --target noarch -bb linupdate.spec mv $HOME/rpmbuild/RPMS/noarch/linupdate-${{ env.VERSION }}-stable.noarch.rpm /tmp/linupdate-test-build-${{ env.VERSION }}.noarch.rpm - name: Upload artifact @@ -68,9 +91,9 @@ jobs: path: /tmp/linupdate-test-build-${{ env.VERSION }}.noarch.rpm retention-days: 1 - # Try to install package on Fedora 37 + # Try to install package on latest Fedora install-fedora: - name: Install on Fedora 37 + name: Install on latest Fedora needs: build-rpm runs-on: ubuntu-latest @@ -93,6 +116,19 @@ jobs: - name: Install package run: | dnf update -y - yum clean all - yum --nogpgcheck localinstall -y ./linupdate-test-build-${{ env.VERSION }}.noarch.rpm - \ No newline at end of file + dnf clean all + dnf --nogpgcheck localinstall -y ./linupdate-test-build-${{ env.VERSION }}.noarch.rpm + + - name: debug + run: ls -l /opt/linupdate/ + + - name: Launch linupdate + run: python3 /opt/linupdate/linupdate.py --check-updates + + - name: Test some params + run: | + python3 /opt/linupdate/linupdate.py --help + python3 /opt/linupdate/linupdate.py --version + python3 /opt/linupdate/linupdate.py --check-updates + python3 /opt/linupdate/linupdate.py --profile container + python3 /opt/linupdate/linupdate.py --env test diff --git a/.github/workflows/packaging/deb/control b/.github/workflows/packaging/deb/control index 5606d29..59ce3a2 100644 --- a/.github/workflows/packaging/deb/control +++ b/.github/workflows/packaging/deb/control @@ -3,7 +3,7 @@ Version: __VERSION__ Section: main Priority: optional Architecture: all -Depends: curl, git, apt-transport-https, aptitude, mutt, locales, ngrep, inotify-tools, jq, virt-what, net-tools, dnsutils, locales-all +Depends: apt-transport-https, locales, ngrep, inotify-tools, virt-what, net-tools, dnsutils, locales-all, python3, python3-tabulate, python3-colorama, python3-dateutil, python3-yaml, python3-dateutil, python3-simplejson, python3-distro, python3-apt Maintainer: Ludovic -Description: Linupdate package updater - Repomanager client side agent +Description: Linupdate 3 (python version) - Packages updater and Repomanager client side agent Homepage: https://github.com/lbr38/linupdate diff --git a/.github/workflows/packaging/deb/postinst b/.github/workflows/packaging/deb/postinst index cb17ecb..c03a742 100644 --- a/.github/workflows/packaging/deb/postinst +++ b/.github/workflows/packaging/deb/postinst @@ -4,18 +4,13 @@ DATA_DIR="/opt/linupdate" SERVICE="$DATA_DIR/service/linupdate-agent" # Restore configuration file if exists -if [ -f "/tmp/linupdate.conf.debsave" ];then - rm -f /etc/linupdate/linupdate.conf - mv /tmp/linupdate.conf.debsave /etc/linupdate/linupdate.conf +if [ -f "/tmp/linupdate.yml.debsave" ];then + rm -f /etc/linupdate/linupdate.yml + mv /tmp/linupdate.yml.debsave /etc/linupdate/linupdate.yml fi # Create a symlink to main script -ln -sf /opt/linupdate/linupdate /usr/bin/linupdate - -# Delete old 'functions' directory if exists -if [ -d "$DATA_DIR/functions" ];then - rm -rf "$DATA_DIR/functions" -fi +ln -sf /opt/linupdate/linupdate.py /usr/bin/linupdate # Install en_US.UTF-8 locale if not present if ! locale -a | grep -q "en_US.UTF-8";then @@ -27,19 +22,20 @@ chmod 750 /etc/linupdate chmod 750 /opt/linupdate # Only if systemd is installed (not the case on github runners) -if [ -f "/usr/bin/systemctl" ];then - - # Enable service script by creating a symlink - ln -sf /lib/systemd/system/linupdate.service /etc/systemd/system/linupdate.service - chmod 550 "$SERVICE" - chown root:root "$SERVICE" - - /usr/bin/systemctl --quiet daemon-reload - - # Start service - if /usr/bin/systemctl is-active --quiet linupdate;then - /usr/bin/systemctl restart --quiet linupdate - else - /usr/bin/systemctl start --quiet linupdate - fi -fi \ No newline at end of file +# TODO: service is not working for now +# if [ -f "/usr/bin/systemctl" ];then + +# # Enable service script by creating a symlink +# ln -sf /lib/systemd/system/linupdate.service /etc/systemd/system/linupdate.service +# chmod 550 "$SERVICE" +# chown root:root "$SERVICE" + +# /usr/bin/systemctl --quiet daemon-reload + +# # Start service +# if /usr/bin/systemctl is-active --quiet linupdate;then +# /usr/bin/systemctl restart --quiet linupdate +# else +# /usr/bin/systemctl start --quiet linupdate +# fi +# fi \ No newline at end of file diff --git a/.github/workflows/packaging/deb/preinst b/.github/workflows/packaging/deb/preinst index ad6e378..d656cf4 100644 --- a/.github/workflows/packaging/deb/preinst +++ b/.github/workflows/packaging/deb/preinst @@ -1,8 +1,8 @@ #!/bin/bash # Save current configuration file if exists -if [ -f "/etc/linupdate/linupdate.conf" ];then - cp /etc/linupdate/linupdate.conf /tmp/linupdate.conf.debsave +if [ -f "/etc/linupdate/linupdate.yml" ];then + cp /etc/linupdate/linupdate.yml /tmp/linupdate.yml.debsave fi # Only if systemd is installed (not the case on github runners) diff --git a/.github/workflows/packaging/rpm/spec b/.github/workflows/packaging/rpm/spec index 1098ea7..e3594d9 100644 --- a/.github/workflows/packaging/rpm/spec +++ b/.github/workflows/packaging/rpm/spec @@ -1,30 +1,32 @@ Name: linupdate Version: __VERSION__ Release: stable -Summary: Linupdate package updater - Repomanager client side agent +Summary: Linupdate 3 (python version) - Packages updater and Repomanager client side agent BuildArch: noarch License: GPL-3.0 URL: https://github.com/lbr38/linupdate -Requires: curl -Requires: git -Requires: mutt Requires: ngrep Requires: inotify-tools -Requires: jq Requires: virt-what Requires: net-tools Requires: bind-utils -Requires: yum-utils +Requires: python3-tabulate +Requires: python3-colorama +Requires: python3-dateutil +Requires: python3-yaml +Requires: python3-dateutil +Requires: python3-simplejson +Requires: python3-distro %description -Linupdate package updater - Repomanager client side agent +Linupdate 3 (python version) - Packages updater and Repomanager client side agent %prep # Save current configuration file if exists -if [ -f "/etc/linupdate/linupdate.conf" ];then - cp /etc/linupdate/linupdate.conf /tmp/linupdate.conf.rpmsave +if [ -f "/etc/linupdate/linupdate.yml" ];then + cp /etc/linupdate/linupdate.yml /tmp/linupdate.yml.rpmsave fi # Only if systemd is installed (not the case on github runners) @@ -49,18 +51,13 @@ DATA_DIR="/opt/linupdate" SERVICE="$DATA_DIR/service/linupdate-agent" # Restore configuration file if exists -if [ -f "/tmp/linupdate.conf.rpmsave" ];then - rm -f /etc/linupdate/linupdate.conf - mv /tmp/linupdate.conf.rpmsave /etc/linupdate/linupdate.conf -fi - -# Delete old 'functions' directory if exists -if [ -d "$DATA_DIR/functions" ];then - rm -rf "$DATA_DIR/functions" +if [ -f "/tmp/linupdate.yml.rpmsave" ];then + rm -f /etc/linupdate/linupdate.yml + mv /tmp/linupdate.yml.rpmsave /etc/linupdate/linupdate.yml fi # Create a symlink to main script -ln -sf /opt/linupdate/linupdate /usr/bin/linupdate +ln -sf /opt/linupdate/linupdate.py /usr/bin/linupdate # Set permissions chmod 750 /etc/linupdate diff --git a/.gitignore b/.gitignore index 78434b7..4044886 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,7 @@ *.swp *.bak TODO -linuxautoupdate.conf -# ignoré car pas fini : +__pycache__ # Compiled source # diff --git a/dependencies.txt b/dependencies.txt new file mode 100644 index 0000000..7de75e8 --- /dev/null +++ b/dependencies.txt @@ -0,0 +1,32 @@ +# packages to install : + +# deb : +python3 +python3-tabulate +python3-colorama +python3-dateutil +python3-yaml +python3-dateutil +python3-simplejson +python3-distro +python3-apt + + +# rpm : +python3 +python3-tabulate +python3-colorama +python3-dateutil +python3-yaml +python3-dateutil +python3-simplejson +python3-distro + + + + + + + + + diff --git a/linupdate-agent.py b/linupdate-agent.py new file mode 100644 index 0000000..005941c --- /dev/null +++ b/linupdate-agent.py @@ -0,0 +1,20 @@ +#!/usr/bin/python3 +# coding: utf-8 + +# Import libraries +import time + +# Import classes +from src.controllers.App.Service import Service + +# Leave some time for the system to boot +# TODO +# time.sleep(60) + +# Instantiate Service class +my_service = Service() + +# Execute main function +my_service.main() + +exit(0) diff --git a/linupdate.py b/linupdate.py new file mode 100755 index 0000000..2b7e2e1 --- /dev/null +++ b/linupdate.py @@ -0,0 +1,120 @@ +#!/usr/bin/python3 +# coding: utf-8 + +# Import libraries +from colorama import Fore, Style +from pathlib import Path +from datetime import datetime +import socket + +# Import classes +from src.controllers.Log import Log +from src.controllers.App.App import App +from src.controllers.App.Config import Config +from src.controllers.Args import Args +from src.controllers.System import System +from src.controllers.Module.Module import Module +from src.controllers.Package.Package import Package +from src.controllers.Service.Service import Service +from src.controllers.Exit import Exit + +#------------------------------------------------------------------------------------------------------------------- +# +# Main function +# +#------------------------------------------------------------------------------------------------------------------- +def main(): + exit_code = 0 + + try: + # Get current date and time + todaydatetime = datetime.now() + date = todaydatetime.strftime('%Y-%m-%d') + time = todaydatetime.strftime('%Hh%Mm%Ss') + logsdir = '/var/log/linupdate' + logfile = date + '_' + time + '_linupdate_' + socket.gethostname() + '.log' + + # Create logs directory + Path(logsdir).mkdir(parents=True, exist_ok=True) + Path(logsdir).chmod(0o750) + + # Instanciate classes + my_exit = Exit() + my_app = App() + my_app_config = Config() + my_args = Args() + my_system = System() + my_module = Module() + my_package = Package() + my_service = Service() + + # Pre-parse arguments to check if --from-agent param is passed + my_args.preParse() + + # If --from-agent param is passed, then add -agent to the log filename and make it hidden + if my_args.from_agent: + logfile = '.' + date + '_' + time + '_linupdate_' + socket.gethostname() + '-agent.log' + + # Create log file with correct permissions + Path(logsdir + '/' + logfile).touch() + Path(logsdir + '/' + logfile).chmod(0o640) + + # Log everything to the log file + with Log(logsdir + '/' + logfile): + # Print Logo + my_app.printLogo() + + # Exit if the user is not root + if not my_system.isRoot(): + print(Fore.YELLOW + 'Must be executed with sudo' + Style.RESET_ALL) + my_exit.cleanExit(1) + + # Check if the system is supported + my_system.check() + + # Create lock file + my_app.setLock() + + # Create base directories + my_app.initialize() + + # Generate config file if not exist + my_app_config.generateConf() + + # Check if there are missing parameters + my_app_config.checkConf() + + # Parse arguments + my_args.parse() + + # Print system & app summary + my_app.printSummary(my_args.from_agent) + + # Load modules + my_module.load() + + # Execute pre-update modules functions + my_module.pre() + + # Execute packages update + my_package.update(my_args.assume_yes, my_args.ignore_exclude, my_args.check_updates, my_args.dist_upgrade, my_args.keep_oldconf) + + # Execute post-update modules functions + my_module.post(my_package.summary) + + # Restart services + my_service.restart(my_package.summary) + + # Check if reboot is required + if my_system.rebootRequired() is True: + print(' ' + Fore.YELLOW + 'Reboot is required' + Style.RESET_ALL) + + except Exception as e: + print('\n' + Fore.RED + ' ✕ ' + Style.RESET_ALL + str(e) + '\n') + exit_code = 1 + + # Exit with exit code and logfile for email report + my_exit.cleanExit(exit_code, True, logsdir + '/' + logfile) + +# Run main function +main() diff --git a/mods-available/reposerver.mod b/mods-available/reposerver.mod index 43a6872..ab4b3e0 100644 --- a/mods-available/reposerver.mod +++ b/mods-available/reposerver.mod @@ -411,11 +411,6 @@ function mod_configure getServerConf clean_exit ;; - # --get-profile-conf|--profile-get-conf) - # getModConf - # getProfileConf - # clean_exit - # ;; --get-profile-packages-conf) getModConf getProfilePackagesConf @@ -727,68 +722,6 @@ function preCheck fi } -# Get profile general configuration from reposerver -# function getProfileConf -# { -# # Si le serveur reposerver ne gère pas les profils ou que le client refuse d'être mis à jour par son serveur de repo, on quitte la fonction -# echo -ne " → Getting ${YELLOW}${PROFILE}${RESET} profile configuration: " - -# # Demande de la configuration des repos auprès du serveur de repos -# # Ce dernier renverra la configuration au format JSON -# CURL=$(curl -L --post301 -s -q -H "Authorization: Host $HOST_ID:$TOKEN" -X GET "${REPOSERVER_URL}/api/v2/profile/${PROFILE}" 2> /dev/null) -# curl_result_parse - -# # Si il y a eu une erreur lors de la requête on quitte la fonction -# if [ "$CURL_ERROR" != "0" ];then -# return 2 -# fi - -# # Puis on récupère la configuration transmise par le serveur au format JSON -# # On parcourt chaque configuration et on récupère le nom du fichier à créer, la description et le contenu à insérer -# # On remplace à la volée l'environnement dans le contenu récupéré -# for ROW in $(echo "${CURL}" | jq -r '.results[] | @base64'); do -# _jq() { -# echo ${ROW} | base64 --decode | jq -r ${1} -# } - -# GET_PROFILE_PKG_CONF_FROM_REPOSERVER=$(_jq '.Linupdate_get_pkg_conf') -# GET_PROFILE_REPOS_FROM_REPOSERVER=$(_jq '.Linupdate_get_repos_conf') -# done - -# if [ "$GET_PROFILE_PKG_CONF_FROM_REPOSERVER" == "null" ];then -# echo -e "[$YELLOW ERROR $RESET] Server sent ${YELLOW}null${RESET} data" -# return 2 -# fi - -# if [ "$GET_PROFILE_REPOS_FROM_REPOSERVER" == "null" ];then -# echo -e "[$YELLOW ERROR $RESET] Server sent ${YELLOW}null${RESET} data" -# return 2 -# fi - -# # converting to boolean -# if [ "$GET_PROFILE_PKG_CONF_FROM_REPOSERVER" == "no" ];then -# GET_PROFILE_PKG_CONF_FROM_REPOSERVER="false" -# fi -# if [ "$GET_PROFILE_PKG_CONF_FROM_REPOSERVER" == "yes" ];then -# GET_PROFILE_PKG_CONF_FROM_REPOSERVER="true" -# fi -# if [ "$GET_PROFILE_REPOS_FROM_REPOSERVER" == "no" ];then -# GET_PROFILE_REPOS_FROM_REPOSERVER="false" -# fi -# if [ "$GET_PROFILE_REPOS_FROM_REPOSERVER" == "yes" ];then -# GET_PROFILE_REPOS_FROM_REPOSERVER="true" -# fi - -# # On applique la nouvelle configuration récupérée -# sed -i "s/GET_PROFILE_PKG_CONF_FROM_REPOSERVER.*/GET_PROFILE_PKG_CONF_FROM_REPOSERVER=\"$GET_PROFILE_PKG_CONF_FROM_REPOSERVER\"/g" "$MOD_CONF" -# sed -i "s/GET_PROFILE_REPOS_FROM_REPOSERVER.*/GET_PROFILE_REPOS_FROM_REPOSERVER=\"$GET_PROFILE_REPOS_FROM_REPOSERVER\"/g" "$MOD_CONF" - -# echo -e "[${GREEN} OK ${RESET}]" - -# # Enfin on applique la nouvelle conf en récupérant de nouveau les paramètres du fichier de conf : -# getConf -# } - # Get profile packages configuratin (packages excludes) function getProfilePackagesConf { @@ -972,12 +905,6 @@ function pre if [ "$FAILLEVEL" -eq "1" ] && [ "$RESULT" -gt "0" ];then (( MOD_ERROR++ )); clean_exit;fi if [ "$FAILLEVEL" -eq "2" ] && [ "$RESULT" -ge "2" ];then (( MOD_ERROR++ )); clean_exit;fi - # On met à jour notre configuration à partir du serveur de repo (profils), si cela est autorisé des deux côtés - # getProfileConf - # RESULT="$?" - # if [ "$FAILLEVEL" -eq "1" ] && [ "$RESULT" -gt "0" ];then (( MOD_ERROR++ )); clean_exit;fi - # if [ "$FAILLEVEL" -eq "2" ] && [ "$RESULT" -ge "2" ];then (( MOD_ERROR++ )); clean_exit;fi - getProfilePackagesConf RESULT="$?" if [ "$FAILLEVEL" -eq "1" ] && [ "$RESULT" -gt "0" ];then (( MOD_ERROR++ )); clean_exit;fi diff --git a/service/linupdate.systemd.template b/service/linupdate.systemd.template index 72fccef..7d099fa 100644 --- a/service/linupdate.systemd.template +++ b/service/linupdate.systemd.template @@ -3,7 +3,7 @@ Description=linupdate-agent [Service] Type=simple -ExecStart=/opt/linupdate/service/linupdate-agent +ExecStart=/opt/linupdate/service/linupdate-agent.py [Install] WantedBy=multi-user.target \ No newline at end of file diff --git a/src/00_check-system b/src/00_check-system deleted file mode 100644 index 83917b5..0000000 --- a/src/00_check-system +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env bash - -# Détection du système - -function checkSystem -{ - # Le fichier /etc/os-release est présent sur les OS récents et permet de récupérer toutes les infos nécéssaires - if [ -f "/etc/os-release" ];then - if grep -q "^ID_LIKE=" /etc/os-release;then - OS_FAMILY=$(grep "^ID_LIKE=" /etc/os-release | cut -d'=' -f2 | sed 's/"//g') - fi - if grep -q "^ID=" /etc/os-release;then - OS_FAMILY=$(grep "^ID=" /etc/os-release | cut -d'=' -f2 | sed 's/"//g') - fi - - if [ -z "$OS_FAMILY" ];then - echo -e "[${RED} ERROR ${RESET}] Unknown OS family" - exit - fi - - # Si OS_FAMILY contient l'un des termes suivants alors c'est la famille redhat - if echo "$OS_FAMILY" | egrep -q -i 'rhel|centos|fedora|rocky|alma';then - OS_FAMILY="Redhat" - fi - - # Si OS_FAMILY contient l'un des termes suivants alors c'est la famille debian - if echo "$OS_FAMILY" | egrep -q -i 'debian|ubuntu|kubuntu|xubuntu|armbian|mint';then - OS_FAMILY="Debian" - fi - - # Enfin si OS_FAMILY n'est ni égale à Redhat ni à Debian alors on est sur un OS non pris en charge - if ! echo "$OS_FAMILY" | egrep -q -i 'Redhat|Debian';then - echo -e "[${RED} ERROR ${RESET}] Unsupported OS family ($OS_FAMILY)" - exit - fi - - # Toujours à partir de /etc/os-release, on récupère le nom de l'OS et sa version - OS_NAME=$(grep "^ID=" /etc/os-release | cut -d'=' -f2 | sed 's/"//g') - if [ -z "$OS_NAME" ];then - echo -e "[${RED} ERROR ${RESET}] Unknown OS name" - exit - fi - - OS_VERSION=$(grep "^VERSION_ID=" /etc/os-release | cut -d'=' -f2 | sed 's/"//g') - if [ -z "$OS_VERSION" ];then - echo -e "[${RED} ERROR ${RESET}] Unknown OS release" - exit - fi - fi - - # Cas où /etc/os-release n'existe pas - if [ ! -f "/etc/os-release" ];then - # Si apt est présent, on est sur un os de la famille Debian - if [ -f "/usr/bin/apt" ];then - OS_FAMILY="Debian" - fi - # Si yum ou dnf est présent, on est sur un os de la famille Redhat - if [ -f "/usr/bin/yum" ] || [ -f "/usr/bin/dnf" ];then - OS_FAMILY="Redhat" - fi - - # A ce stade si OS_FAMILY est vide alors on est sur un OS non pris en charge - if [ -z "$OS_FAMILY" ];then - echo -e "[${RED} ERROR ${RESET}] Unknown OS family, unsupported system" - exit - fi - - # /usr/bin/lsb_release est un programme uniquement présent sur les OS de la famille Debian - if [ -f "/usr/bin/lsb_release" ];then - OS_NAME=$(lsb_release -a 2> /dev/null | grep 'Distributor ID:' | awk '{print $NF}') - OS_VERSION=$(lsb_release -a 2> /dev/null | grep 'Release:' | awk '{print $NF}') - fi - - # Dernier recours pour les OS de la famille Redhat - # Si /etc/centos-release existe, c'est un centos - if [ -f "/etc/centos-release" ];then - OS_NAME="CentOS" - OS_ID="centos" - OS_VERSION=$(rpm --eval '%{centos_ver}') - # Si /etc/fedora-release existe, c'est un fedora - elif [ -f "/etc/fedora-release" ];then - OS_NAME="Fedora" - OS_ID="fedora" - OS_VERSION=$(cat /etc/os-release | grep "^VERSION_ID" | cut -d'=' -f2 | sed 's/"//g') - else - # Dernier recours on vérifie la présence d'un fichier os-release sinon on quitte - if [ ! -f "/etc/os-release" ];then - echo -e "[${RED} ERROR ${RESET}] Cannot determine OS release" - exit - fi - - OS_NAME=$(cat /etc/os-release | grep "^NAME=" | cut -d'=' -f2 | sed 's/"//g') - OS_ID=$(cat /etc/os-release | grep "^ID=" | cut -d'=' -f2 | sed 's/"//g') - OS_VERSION=$(cat /etc/os-release | grep "^VERSION_ID=" | cut -d'=' -f2 | sed 's/"//g') - fi - - # On quitte le script si on n'a rien trouvé à ce stade - if [ -z "$OS_NAME" ];then - echo -e "[${RED} ERROR ${RESET}] Unknown OS name" - exit - fi - if [ -z "$OS_VERSION" ];then - echo -e "[${RED} ERROR ${RESET}] Unknown OS release" - exit - fi - fi - - if [ "$OS_FAMILY" == "Debian" ];then - PKG_MANAGER="/usr/bin/apt" - PKG_TYPE="deb" - fi - if [ "$OS_FAMILY" == "Redhat" ];then - if [ -f "/usr/bin/yum" ];then - PKG_MANAGER="/usr/bin/yum" - fi - if [ -f "/usr/bin/dnf" ];then - PKG_MANAGER="/usr/bin/dnf" - fi - # Si les deux sont présents (fedora) alors on utilisera yum de préférence - if [ -f "/usr/bin/yum" ] && [ -f "/usr/bin/dnf" ];then - PKG_MANAGER="/usr/bin/yum" - fi - - PKG_TYPE="rpm" - fi -} \ No newline at end of file diff --git a/src/00_exclude b/src/00_exclude deleted file mode 100644 index 4301bcd..0000000 --- a/src/00_exclude +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash - -function exclude -{ - # Si les paquets à exclure n'ont pas été précisé en paramètre, on les demande - if [ -z "$READ_PACKAGES_TO_EXCLUDE" ];then - echo -ne "Specify packages to exclude (separated by a comma): "; read -p "" READ_PACKAGES_TO_EXCLUDE - fi - - # Suppression des éventuels espaces vides - READ_PACKAGES_TO_EXCLUDE=$(echo "$READ_PACKAGES_TO_EXCLUDE" | sed 's/ //g') - - # Si la valeur renseignée est "none" alors on supprime l'exclusion en place - if [ "$READ_PACKAGES_TO_EXCLUDE" == "none" ];then - READ_PACKAGES_TO_EXCLUDE="" - fi - - # Remplacement de la valeur dans le fichier de conf - # Cas où on a souhaité exclure des versions majeures uniquement - if [ "$READ_PACKAGES_TO_EXCLUDE_MAJOR" == "1" ];then - sed -i "s/^EXCLUDE_MAJOR=.*/EXCLUDE_MAJOR=\"$READ_PACKAGES_TO_EXCLUDE\"/g" "$CONF" - else - sed -i "s/^EXCLUDE=.*/EXCLUDE=\"$READ_PACKAGES_TO_EXCLUDE\"/g" "$CONF" - fi - - # Afficher un message de confirmation -} \ No newline at end of file diff --git a/src/00_generateConf b/src/00_generateConf deleted file mode 100644 index 3096c2e..0000000 --- a/src/00_generateConf +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash - -# Generate config file if not exist - -function generateConf -{ - if [ -f "$CONF" ]; then - return - fi - - echo '[CONFIGURATION]' > "$CONF" - echo 'PROFILE="Bare-metal"' >> "$CONF" - echo 'ENV="prod"' >> "$CONF" - echo 'MAIL_ENABLED="false"' >> "$CONF" - echo 'MAIL_RECIPIENT=""' >> "$CONF" - - echo -e '\n[SOFTWARE CONFIGURATION]' >> "$CONF" - echo 'EXCLUDE_MAJOR=""' >> "$CONF" - echo 'EXCLUDE=""' >> "$CONF" - echo 'SERVICE_RESTART=""' >> "$CONF" -} \ No newline at end of file diff --git a/src/00_get-conf b/src/00_get-conf deleted file mode 100644 index f2a355b..0000000 --- a/src/00_get-conf +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -# Retrieve configuration from linupdate.conf - -function getConf -{ - # If config file doesn't exist, exit - if [ ! -f "$CONF" ];then - echo -e "[${YELLOW} ERROR ${RESET}] No config file was found on this server. Use --install param to finalize linupdate installation.\n" - (( UPDATE_ERROR++ )) - - # Delete log file - rm "$LOG" -f - clean_exit - fi - - PROFILE=$(egrep "^PROFILE=" "$CONF" | cut -d'=' -f 2 | sed 's/"//g') # Profile name - ENV=$(egrep "^ENV=" "$CONF" | cut -d'=' -f 2 | sed 's/"//g') # Environment - MAIL_ENABLED=$(egrep "^MAIL_ENABLED=" "$CONF" | cut -d'=' -f 2 | sed 's/"//g') # Enable mail notification - MAIL_RECIPIENT=$(egrep "^MAIL_RECIPIENT=" "$CONF" | cut -d'=' -f 2 | sed 's/"//g') # Mail recipient - PACKAGES_EXCLUDE_MAJOR=$(egrep "^EXCLUDE_MAJOR=" "$CONF" | cut -d'=' -f 2 | sed 's/"//g') # Packages to exclude on a major update - PACKAGES_EXCLUDE=$(egrep "^EXCLUDE=" "$CONF" | cut -d'=' -f 2 | sed 's/"//g') # Packages to always exclude - SERVICE_RESTART=$(egrep "^SERVICE_RESTART=" "$CONF" | cut -d'=' -f 2 | sed 's/"//g') # Services to restart after update - - if [ -z "$PROFILE" ];then - echo -e "[${RED} ERROR ${RESET}] No profile is defined\n" - (( UPDATE_ERROR++ )) - clean_exit - fi - - if [ -z "$ENV" ];then - echo -e "[${RED} ERROR ${RESET}] No environment is defined\n" - (( UPDATE_ERROR++ )) - clean_exit - fi -} \ No newline at end of file diff --git a/src/00_get-modules b/src/00_get-modules deleted file mode 100644 index 556cb26..0000000 --- a/src/00_get-modules +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -# Récupérer les modules à jour depuis github - -function getModules -{ - TMP_DIR="/tmp/linupdate" - rm "$TMP_DIR" -rf - - # Clonage du repo - cd /tmp && - git clone --quiet https://github.com/lbr38/linupdate.git > /dev/null && - - # Copie de tous les modules dans le répertoire de modules - \cp -r ${TMP_DIR}/mods-available/* ${MODULES_DIR}/ && - rm "$TMP_DIR" -rf -} \ No newline at end of file diff --git a/src/00_help b/src/00_help deleted file mode 100644 index 05b903e..0000000 --- a/src/00_help +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash - -# Print help - -function help { - echo -e "Available parameters:\n" - echo -e " Main:" - echo -e " --vv|-vv → Enable verbose mode" - echo -e " --version|-v → Print current version" - echo -e " --profile|--type|--print-profile PROFILE → Configure host profile (leave empty to print actual)" - echo -e " --environment|--env ENV → Configure host environment (leave empty to print actual)" - echo -e "" - echo -e " Package update configuration" - echo -e " --exclude-major|-em PACKAGE → Configure packages to exclude on major release update, separated by a comma. Specify 'none' to clean." - echo -e " --exclude|-e PACKAGE → Configure packages to exclude, separated by a comma. Specify 'none' to clean." - echo -e "" - echo -e " Package update execution" - echo -e " --check-updates|-cu → Check packages to be updated and quit" - echo -e " --assume-yes|--force → Enable 'assume yes' (answer 'yes' to every confirm prompt)" - echo -e " --dist-upgrade|-du → Enable 'dist-upgrade' for apt (Debian only)" - echo -e " --keep-oldconf|-ko → Keep actual configuration file when attempting to be overwrited by apt during package update (Debian only)" - echo -e " --ignore-exclude|-ie → Ignore all packages minor or major release update exclusions" - echo -e "" - echo -e " Modules" - echo -e " --list-modules|--list-mod|-m → List available modules" - echo -e " --mod-enable|-mod-enable|-me MODULE → Enable specified module" - echo -e " --mod-disable|-mod-disable|-md MODULE → Disable specified module" - echo -e " --mod-configure|-mc|--mod-exec MODULE → Configure specified module (using module commands, see module help or documentation)" - echo -e " --mod-configure MODULE --help → Print module help" - echo -e "" - echo -e " Agent" - echo -e " --agent-start → Start linupdate agent" - echo -e " --agent-stop → Stop linupdate agent" - echo -e " --agent-restart → Restart linupdate agent" - echo -e " --agent-enable → Enable linupdate agent start on boot" - echo -e " --agent-disable → Disable linupdate agent start on boot" - echo -e "" -} \ No newline at end of file diff --git a/src/00_list-modules b/src/00_list-modules deleted file mode 100644 index 4a22005..0000000 --- a/src/00_list-modules +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -# Voir la liste des modules - -function listModules -{ - # Récupération de tous les modules à jour depuis github - getModules - - ENABLED_MODULES=() - - # Affiche les modules activés - echo -e "\n Enabled modules:" - if [ "$(ls -A $MODULES_ENABLED_DIR)" ];then - for MODULE in $(ls -A1 "${MODULES_ENABLED_DIR}/"*.mod);do - MODULE=$(basename $MODULE) - MODULE=${MODULE%.mod} - echo -e " $GREEN $MODULE $RESET" - - ENABLED_MODULES+=("$MODULE") - done - else - echo " No module enabled" - fi - - # Affiche les modules disponibles (qui ne sont pas dans ENABLED_MODULES) - echo -e "\n Available modules:" - if [ "$(ls -A $MODULES_DIR)" ];then - for MODULE in $(ls -A1 "${MODULES_DIR}/"*.mod);do - MODULE=$(basename $MODULE) - MODULE=${MODULE%.mod} - if [[ ! ${ENABLED_MODULES[*]} =~ "$MODULE" ]]; then - MODULE=$(basename $MODULE | sed 's/.mod//g') - echo -e " $YELLOW $MODULE $RESET" - fi - done - fi -} \ No newline at end of file diff --git a/src/00_space-left b/src/00_space-left deleted file mode 100644 index da8ffe3..0000000 --- a/src/00_space-left +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# Vérification que l'espace disque restant est suffisant pour effectuer la mise à jour (au moins 1Go d'espace) - -function spaceLeft -{ - echo -ne "\nSpace left: " - - SPACE_LEFT=$(df | egrep "/$" | awk '{print $4}') - if [ "$SPACE_LEFT" -lt 1000000 ];then - echo -ne "${RED}"; df -h | egrep "/$" | awk '{print $4}'; echo -ne "${RESET}" - (( UPDATE_ERROR++ )) - if [ "$MAIL_ENABLED" -eq "true" ];then - sendMail - fi - clean_exit - - else - df -h | egrep "/$" | awk '{print $4}' - fi -} \ No newline at end of file diff --git a/src/01_exec-pre-modules b/src/01_exec-pre-modules deleted file mode 100644 index fc573db..0000000 --- a/src/01_exec-pre-modules +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash - -# Exécution de modules complémentaires pre-mise à jour - -function execPreModules -{ - if [ -d "$MODULES_ENABLED_DIR" ];then - if [ "$(ls -A $MODULES_ENABLED_DIR)" ];then - echo -e " Executing pre-update mods:" - - for MODULE in $(ls -A1 ${MODULES_ENABLED_DIR});do - # On récupère le nom exact du module (sans le .mod) - MODULE_FORMATTED=$(echo "${MODULE%.mod}") - - # Si le module fait parti des modules chargés par loadModules alors on peut charger son code - if printf '%s\n' "${LOADED_MODULES[@]}" | grep -q "^${MODULE_FORMATTED}$";then - # On charge le code du module et on exécute sa fonction pre-mise à jour (pre) - source "${MODULES_ENABLED_DIR}/${MODULE}" - pre - fi - done - - echo "" - fi - fi -} \ No newline at end of file diff --git a/src/01_load-modules b/src/01_load-modules deleted file mode 100644 index 17ea24e..0000000 --- a/src/01_load-modules +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash - -# Chargement des modules complémentaires si existent dans mods-enabled -# Ce chargement a pour but d'afficher à l'utilisateur quels modules sont activés pendant la mise à jour et de faire une première vérification de la conf du module avant de poursuivre - -function loadModules -{ - if [ -d "$MODULES_ENABLED_DIR" ];then - if [ "$(ls -A $MODULES_ENABLED_DIR)" ];then - echo -e " Loading mods:" - #cd "$MODULES_ENABLED_DIR" - for MODULE in $(ls -A1 ${MODULES_ENABLED_DIR}/*.mod);do - # Source de module.mod - source "$MODULE" - # Appel de la fonction mod_load à l'intérieur du fichier précédemment sourcé - mod_load - done - - echo "" - fi - fi -} \ No newline at end of file diff --git a/src/02_check-packages-before-update b/src/02_check-packages-before-update deleted file mode 100644 index 17370ae..0000000 --- a/src/02_check-packages-before-update +++ /dev/null @@ -1,365 +0,0 @@ -#!/usr/bin/env bash - -# This function is executed just before packages update. -# It checks if packages to exclude have been specified in the configuration file and, if so, then exclude them if needed. - -function checkPackagesBeforeUpdate -{ - if [ "$OS_FAMILY" == "Redhat" ];then - # Clean yum cache - yum clean all -q - fi - if [ "$OS_FAMILY" == "Debian" ];then - # Clean and reload apt cache - apt-get clean && apt-get -qq --allow-releaseinfo-change update - fi - - # Temporary file to work with - CHECK_UPDATE_TMP="/tmp/linupdate_check-update.tmp" - - # List packages available for update - if [ "$OS_FAMILY" == "Redhat" ];then - if [ -f "/usr/bin/dnf" ];then - repoquery -q -a --qf="%{name} %{version}-%{release}.%{arch} %{repoid}" --upgrades >> "$CHECK_UPDATE_TMP" - else - repoquery -q -a --qf="%{name} %{version}-%{release}.%{arch} %{repoid}" --pkgnarrow=updates > "$CHECK_UPDATE_TMP" - fi - fi - if [ "$OS_FAMILY" == "Debian" ];then - aptitude -F"%p %V %v" --disable-columns search ~U > "$CHECK_UPDATE_TMP" - fi - - # Add the available packages list in an array, it could be needed for others operations - OLD_IFS=$IFS - PACKAGES=() - - while IFS=$'\n' read -r LINE; do - PACKAGES+=("$LINE") - done < <(awk '{print $1}' "$CHECK_UPDATE_TMP") - - IFS=$OLD_IFS - - # If --ignore-exclude param has been set then ignore all packages exclusions that have been specified by the user - if [ "$IGNORE_EXCLUDE" -eq "1" ];then - echo -e "${YELLOW}--ignore-exclude${RESET} param is enabled, no exclusion will be taken into account." - - if [ "$OS_FAMILY" == "Debian" ];then - # Get all packages that could have been holded (on another linupdate execution) - HOLDED_PACKAGES=$(apt-mark showhold) - - # Then unhold them all - if [ ! -z "$HOLDED_PACKAGES" ];then - OLD_IFS=$IFS - IFS=$'\n' - - for HOLDED_PACKAGE in $(echo "$HOLDED_PACKAGES");do - apt-mark unhold "$HOLDED_PACKAGE" - done - - IFS="$OLD_IFS" - fi - fi - fi - - # If --ignore-exclude param has NOT been set, then check if some packages need to be excluded from packages update - if [ "$IGNORE_EXCLUDE" -eq "0" ];then - # Gestion des exclusions de paquets - # - # Exemple pour illustrer le fonctionnement : - # - # Extrait de linupdate.conf : Extrait de /tmp/linupdate_check-update.tmp (liste des paquets disponibles pour mise à jour, généré avec repoquery) - # EXCLUDE_MAJOR="httpd.*,php.*" php-cli.x86_64 7.1.10.xxx updates - # EXCLUDE="kernel.*," php-common.x86_64 7.1.10.xxx updates - # ... ... - # - # Lorsqu'on utilise un wildcard (par exemple php.*), le - # script va vérifier le nb d'occurences dans le fichier - # /tmp/linupdate_check-update.tmp (dans cet exemple 2 occurences - # php-cli et php-common). Le script devra vérifier que - # pour ces deux paquets à mettre à jour si il s'agit d'une - # maj majeure ou non. Si php.* serait renseigné dans - # EXCLUDE= alors le script excluerai les deux occurences - #  trouvées quoi qu'il arrive. - - # Process packages to exclude on major release update (EXCLUDE_MAJOR): - if [ ! -z "$PACKAGES_EXCLUDE_MAJOR" ];then - - PACKAGES_EXCLUDE_MAJOR_ARRAY="" - IFS=',' - - # Inject exclusion list into PACKAGES_EXCLUDE_MAJOR_ARRAY - read -ra PACKAGES_EXCLUDE_MAJOR_ARRAY <<< "$PACKAGES_EXCLUDE_MAJOR" - - # Run through all packages in PACKAGES_EXCLUDE_MAJOR_ARRAY - # For each package name, check if an update is available by looking in $CHECK_UPDATE_TMP - for PACKAGE in "${PACKAGES_EXCLUDE_MAJOR_ARRAY[@]}"; do - - # If package occurence is found in $CHECK_UPDATE_TMP, then it means that an update is available for this package - # We will have to check if it is a major release update or a minor release update - if egrep -q "^${PACKAGE} " $CHECK_UPDATE_TMP;then - - # Save the actual IFS (comma) because it will be needed again by the previous FOR loop - OLD_IFS=$IFS - IFS=$'\n' - - # For every occurence of the package found in $CHECK_UPDATE_TMP (there could be more than one), - # check if its available upsate is a major release or a minor release update - for OCCURENCE in $(egrep "^${PACKAGE} " $CHECK_UPDATE_TMP | awk '{print $1}');do - - echo -e "${YELLOW} ⚠ ${OCCURENCE}:$RESET" - - # Get package exact name - PKG_NAME=$(egrep "^${OCCURENCE} " "$CHECK_UPDATE_TMP" | awk '{print $1}') - - # And its available release version - PKG_VER=$(egrep "^${OCCURENCE} " "$CHECK_UPDATE_TMP" | awk '{print $2}') - - # Get the package actual release version that is installed on the system - - # If Debian - if [ "$OS_FAMILY" == "Debian" ];then - PKG_ACTUAL_VERSION=$(egrep "^${OCCURENCE} " "$CHECK_UPDATE_TMP" | awk '{print $3}') - fi - # If RHEL - if [ "$OS_FAMILY" == "Redhat" ];then - PKG_ACTUAL_VERSION=$(rpm -qi $OCCURENCE | grep Version | head -n1 | awk '{print $3}') - PKG_RELEASE=$(rpm -qi $OCCURENCE | grep Release | head -n1 | awk '{print $3}') - # Concatenate both - PKG_ACTUAL_VERSION="${PKG_ACTUAL_VERSION}-${PKG_RELEASE}" - fi - - # If one of the variables is empty then print an error because we have not all the necessary informations to check if it's a major or minor update - # In doubt, the package will be excluded to avoid any problem - if [ -z "$PKG_NAME" ] || [ -z "$PKG_VER" ] || [ -z "$PKG_ACTUAL_VERSION" ];then - echo -e "[$RED ERROR $RESET] while checking ${YELLOW}${OCCURENCE}${RESET} package. It will be excluded from update." - - # Add the package name to the final exclude list - UPDATE_EXCLUDE+=" $OCCURENCE" - fi - - # Parsing and comparing retrieved release version - # ex : 9.2.24-1.el7_5 - # |_______ - # | - # If first number does not change, then it is not a major update but a minor update that should not be problematic. - # Else (if the first number is different), it is a major update - PARSE_PKG_VER="$(echo "$PKG_VER" | awk -F. '{print $1}')" - PARSE_PKG_ACTUAL_VERSION="$(echo "$PKG_ACTUAL_VERSION" | awk -F. '{print $1}')" - - # Cas it is a major update - if [ "$PARSE_PKG_VER" != "$PARSE_PKG_ACTUAL_VERSION" ];then - echo -e " [$YELLOW WARNING $RESET] A major release version is available for this package" - echo -e " → Current version : ${YELLOW}${PKG_ACTUAL_VERSION}${RESET}" - echo -e " → Available version : ${YELLOW}${PKG_VER}${RESET}" - - # Add the package name to the final exclude list - UPDATE_EXCLUDE+=" $OCCURENCE" - - # Case it is a minor update: just print a message - else - echo -e " An update is available for this package but no major release version (Current version: $PKG_ACTUAL_VERSION / Avail. version: $PKG_VER)." - fi - done - - # Set back the prviously saved IFS (comma) for the previous FOR loop to work - IFS=$OLD_IFS - - # Cas no update is available for this package - else - echo -e "${YELLOW} ⚠ ${PACKAGE} :$RESET" && - echo -e " No available update for this package." - fi - done - fi - - # Process packages to exclude no matter the release update, they always have to be excluded. - if [ ! -z "$PACKAGES_EXCLUDE" ];then - - PACKAGES_EXCLUDE_ARRAY="" - IFS=',' - - # Inject exclusion list into PACKAGES_EXCLUDE_ARRAY - read -ra PACKAGES_EXCLUDE_ARRAY <<< "$PACKAGES_EXCLUDE" - - # Run through all packages in PACKAGES_EXCLUDE - # For each package name, check if an update is available by looking in $CHECK_UPDATE_TMP - for PACKAGE in "${PACKAGES_EXCLUDE_ARRAY[@]}";do - - # If package occurence is found in $CHECK_UPDATE_TMP, then it means that an update is available for this package - # It will be excluded - if egrep -q "^${PACKAGE} " $CHECK_UPDATE_TMP;then - - # Save the actual IFS (comma) because it will be needed again by the previous FOR loop - OLD_IFS=$IFS - IFS=$'\n' - - # For every occurence of the package found in $CHECK_UPDATE_TMP (there could be more than one), - # add it to the final exclude list - for OCCURENCE in $(egrep "^${PACKAGE} " $CHECK_UPDATE_TMP | awk '{print $1}');do - UPDATE_EXCLUDE+=" $OCCURENCE" - done - - # Set back the prviously saved IFS (comma) for the previous FOR loop to work - IFS=$OLD_IFS - fi - done - fi - fi - - # Finalize the list by adding a white space in the end to be sure the last package in the list will be taken into account when processing exclusion from this list - if [ ! -z "$UPDATE_EXCLUDE" ];then - UPDATE_EXCLUDE="${UPDATE_EXCLUDE} " - # Delete leading white space - UPDATE_EXCLUDE=$(echo "$UPDATE_EXCLUDE" | sed -e 's/^[[:space:]]*//') - fi - - # Process services that will need a restart after packages update - if [ ! -z "$SERVICE_RESTART" ];then - OLD_IFS=$IFS - IFS=',' - - # Inject services list into SERVICES_TO_RESTART - read -ra SERVICES_TO_RESTART <<< "$SERVICE_RESTART" - - # Run through SERVICES_TO_RESTART - for SERVICE in "${SERVICES_TO_RESTART[@]}"; do - # If service restart is conditionned by a specific package update, then get the package name, e.g: - # httpd:ca-certificates => httpd service will be restarted if ca-certificates package is updated - if echo "$SERVICE" | grep -q ":"; then - SERVICE_CONDITIONNAL_PACKAGE_NAME=$(echo "$SERVICE" | awk -F: '{print $2}') - SERVICE=$(echo "$SERVICE" | awk -F: '{print $1}') - - # If conditionnal package is empty, ignore this service and continue - if [ -z "$SERVICE_CONDITIONNAL_PACKAGE_NAME" ];then - continue - fi - - # Check if the package is in the list of packages that will be updated, if not then ignore this service and continue - if ! printf '%s\n' "${PACKAGES[@]}" | grep -q "^${SERVICE_CONDITIONNAL_PACKAGE_NAME}$";then - continue - fi - fi - - # Check if specified service really exists, if yes, then add it to the final services to restart list - if systemctl list-units --all -t service --full | grep -q "${SERVICE}.service";then - # Also check if the service is active - if systemctl is-active --quiet "$SERVICE";then - SERVICE_TO_BE_RESTARTED+=" $SERVICE" - fi - fi - done - - SERVICE_TO_BE_RESTARTED="${SERVICE_TO_BE_RESTARTED} " - SERVICE_TO_BE_RESTARTED="${SERVICE_TO_BE_RESTARTED/ /}" - - IFS=$OLD_IFS - fi - - echo "" - - # Print all packages that will be excluded, if any - if [ ! -z "$UPDATE_EXCLUDE" ];then - echo -e "Following packages will be excluded from update: ${YELLOW}${UPDATE_EXCLUDE}${RESET}" - fi - - # Print all services that will be restarted, if any - if [ ! -z "$SERVICE_TO_BE_RESTARTED" ];then - echo -e "Following services will be restarted after update: ${YELLOW}${SERVICE_TO_BE_RESTARTED}${RESET}" - fi - - echo -e "\n$SEP\n" - - # If $CHECK_UPDATE_TMP is empty then there is no update available - if [ ! -s "$CHECK_UPDATE_TMP" ];then - echo -e "${YELLOW}No available package for update${RESET}\n" - - # Indicate that there is no need to execute apt/yum packages update - SOMETHING_TO_UPDATE="false" - - return - fi - - # Print packages that will be updated in columns - if [ "$OS_FAMILY" == "Debian" ];then - COLUMS_SIZE="%-40s %-20s %-45s %-45s\n" - printf "$COLUMS_SIZE" " Package" "" " Current version" " Available version" - fi - if [ "$OS_FAMILY" == "Redhat" ];then - COLUMS_SIZE="%-40s %-20s %-45s %-45s %-30s\n" - printf "$COLUMS_SIZE" " Package" "" " Current version" " Available version" " Repo" - fi - - while read PACKAGE;do - # Package name - PKG_NAME=$(echo "$PACKAGE" | awk '{print $1}') - - # Package version update that is available and will be installed - if [ "$OS_FAMILY" == "Redhat" ];then - PKG_VER=$(echo "$PACKAGE" | awk '{print $2}') - fi - if [ "$OS_FAMILY" == "Debian" ];then - PKG_VER=$(echo $PACKAGE | awk '{print $2}' | sed 's/(//g') - fi - - # Package actual version that is installed on the system - if [ "$OS_FAMILY" == "Redhat" ];then - PKG_ACTUAL_VERSION=$(rpm -qi $PKG_NAME | grep Version | head -n1 | awk '{print $3}') - fi - if [ "$OS_FAMILY" == "Debian" ];then - PKG_ACTUAL_VERSION=$(echo $PACKAGE | awk '{print $3}') - fi - - # On RHEL, also get the actual package release and the repo from where the package will be updated - if [ "$OS_FAMILY" == "Redhat" ];then - PKG_ACTUAL_RELEASE=$(rpm -qi $PKG_NAME | grep Release | head -n1 | awk '{print $3}') - REPO=$(echo "$PACKAGE" | awk '{print $3}') - fi - - # Now print the line with all the package informations - # Columns size is different depending on whether the system is Debian or RHEL - - # Cas the package to print will be excluded, a "excluded" tag will be printed - if echo "$UPDATE_EXCLUDE" | grep -q "$PKG_NAME ";then - EXCLUDE_STATE="(excluded)" - - if [ "$OS_FAMILY" == "Redhat" ];then - echo -ne "${RED} ✕ ${RESET}" - printf "$COLUMS_SIZE" "$PKG_NAME" "$EXCLUDE_STATE" "${PKG_ACTUAL_VERSION}-${PKG_ACTUAL_RELEASE}" "$PKG_VER" "$REPO" - echo -n "" - fi - if [ "$OS_FAMILY" == "Debian" ];then - echo -ne "${RED} ✕ ${RESET}" - printf "$COLUMS_SIZE" "$PKG_NAME" "$EXCLUDE_STATE" "$PKG_ACTUAL_VERSION" "$PKG_VER" - echo -n "" - fi - else - EXCLUDE_STATE="" - if [ "$OS_FAMILY" == "Redhat" ];then - echo -ne "${GREEN} ✔ ${RESET}" - printf "$COLUMS_SIZE" "$PKG_NAME" "$EXCLUDE_STATE" "${PKG_ACTUAL_VERSION}-${PKG_ACTUAL_RELEASE}" "$PKG_VER" "$REPO" - echo -n "" - fi - if [ "$OS_FAMILY" == "Debian" ];then - echo -ne "${GREEN} ✔ ${RESET}" - printf "$COLUMS_SIZE" "$PKG_NAME" "$EXCLUDE_STATE" "$PKG_ACTUAL_VERSION" "$PKG_VER" - echo -n "" - fi - fi - - done < "$CHECK_UPDATE_TMP" - - echo -e "\n${SEP}\n" - - # If --check-updates param, then quit - if [ "$ONLY_CHECK_UPDATE" == "true" ];then - exit - fi - - # If --assume-yes param has not been specified, then ask for confirmation before installing the printed packages update list - if [ "$ASSUME_YES" == "0" ];then - echo -ne "${YELLOW}→ Confirm installation (yes/no): $RESET"; read -p "" CONFIRM && - if [ "$CONFIRM" != "yes" ];then - clean_exit - fi - fi -} \ No newline at end of file diff --git a/src/03_check-yum-lock b/src/03_check-yum-lock deleted file mode 100644 index 42dd987..0000000 --- a/src/03_check-yum-lock +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash - -# Vérifie la présence d'un lock yum et attend pour sa libération si c'est le cas - -function checkYumLock -{ - if [ -f "/var/run/yum.pid" ];then - echo -e "Waiting for yum ${YELLOW}lock${RESET}..." - fi - - while [ -f "/var/run/yum.pid" ];do - sleep 2 - done -} \ No newline at end of file diff --git a/src/04_update b/src/04_update deleted file mode 100644 index 0972393..0000000 --- a/src/04_update +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env bash - -function update -{ - # Si la vérification des paquets n'a trouvé aucun paquet à mettre à jour alors il est inutile d'exécuter les mises à jour - # On sort de la fonction - if [ "$SOMETHING_TO_UPDATE" == "false" ];then - return - fi - - # Fonction lançant les mises à jour yum - - echo -e "\nUpdate is running..." - - echo -ne "Cleaning cache: " - if [ "$OS_FAMILY" == "Redhat" ];then - checkYumLock - yum clean all -q - fi - if [ "$OS_FAMILY" == "Debian" ];then - apt-get clean - fi - - echo -e "[$GREEN OK $RESET]" - - # Si c'est du Debian, on charge la liste des paquets - if [ "$OS_FAMILY" == "Debian" ];then - apt-get -o Acquire::Check-Valid-Until=false -qq --allow-releaseinfo-change update - fi - - # Si $UPDATE_EXCLUDE n'est pas vide, alors on exclu les paquets de la mise à jour - if [ ! -z "$UPDATE_EXCLUDE" ];then - if [ "$OS_FAMILY" == "Redhat" ];then - YUM_OPTIONS+=" --exclude=${UPDATE_EXCLUDE}" - # Trim whitespaces - YUM_OPTIONS="${YUM_OPTIONS#"${YUM_OPTIONS%%[![:space:]]*}"}" - YUM_OPTIONS="${YUM_OPTIONS%"${YUM_OPTIONS##*[![:space:]]}"}" - fi - - if [ "$OS_FAMILY" == "Debian" ];then - echo -e "\nExcluding critical packages: $YELLOW" - IFS=' ' - - for PACKAGE in ${UPDATE_EXCLUDE[@]};do # Attention ne pas placer de double quote sur cette variable - apt-mark hold "$PACKAGE" - if [ "$?" -eq "0" ];then - echo -e "Error while excluding $PACKAGE" - (( UPDATE_ERROR++ )) - return - fi - done - - echo -e "$RESET" - fi - fi - - # Updating packages - if [ "$OS_FAMILY" == "Redhat" ];then - if [ ! -z "$YUM_OPTIONS" ];then - yum "$YUM_OPTIONS" update -y - else - yum update -y - fi - if [ "$?" -ne "0" ];then - (( UPDATE_ERROR++ )) - fi - fi - - if [ "$OS_FAMILY" == "Debian" ];then - if [ ! -z "$APT_OPTIONS" ];then - apt-get "$APT_UPGRADE" -y "$APT_OPTIONS" - else - apt-get "$APT_UPGRADE" -y - fi - if [ "$?" -ne "0" ];then - (( UPDATE_ERROR++ )) - fi - fi - - if [ "$UPDATE_ERROR" -gt "0" ];then - echo -e "\n${RED}Update has failed${RESET}" - return - fi - - echo -e "\n${GREEN}Update completed${RESET}\n" -} \ No newline at end of file diff --git a/src/09_exec-post-modules b/src/09_exec-post-modules deleted file mode 100644 index b715211..0000000 --- a/src/09_exec-post-modules +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash - -# Exécution de modules complémentaires post-mise à jour - -function execPostModules -{ - if [ -d "$MODULES_ENABLED_DIR" ];then - if [ "$(ls -A $MODULES_ENABLED_DIR)" ];then - echo -e " Executing post-update mods:" - - for MODULE in $(ls -A1 ${MODULES_ENABLED_DIR});do - # On récupère le nom exact du module (sans le .mod) - MODULE_FORMATTED=$(echo "${MODULE%.mod}") - - # Si le module fait parti des modules chargés par loadModules alors on peut charger son code - if printf '%s\n' "${LOADED_MODULES[@]}" | grep -q "^${MODULE_FORMATTED}$";then - # On charge le code du module et on exécute sa fonction pre-mise à jour (pre) - source "${MODULES_ENABLED_DIR}/${MODULE}" - post - fi - done - fi - fi -} \ No newline at end of file diff --git a/src/09_service-restart b/src/09_service-restart deleted file mode 100644 index dfd1e51..0000000 --- a/src/09_service-restart +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -# Restart services after update - -function restartService -{ - # If $SERVICE_TO_BE_RESTARTED contains services that need to be restarted - if [ -z "$SERVICE_TO_BE_RESTARTED" ];then - return - fi - - OLD_IFS=$IFS - IFS=' ' - - for SERVICE in $(echo "$SERVICE_TO_BE_RESTARTED"); do - # Clean eventual spaces - SERVICE=$(echo "$SERVICE" | sed 's/ //g') - - echo -ne "→ Restarting ${YELLOW}${SERVICE}${RESET} service: " - - systemctl restart "$SERVICE" --quiet - - if [ "$?" != "0" ];then - echo -e "[$YELLOW ERROR $RESET] while restarting" - else - echo -e "[$GREEN OK $RESET]" - fi - done - - IFS=$OLD_IFS -} \ No newline at end of file diff --git a/src/10_check-reboot-needed b/src/10_check-reboot-needed deleted file mode 100644 index 5821b01..0000000 --- a/src/10_check-reboot-needed +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash - -# Check if a system reboot is required - -function checkRebootNeeded -{ - if [ "$OS_FAMILY" == "Redhat" ] && [ -f "/usr/bin/needs-restarting" ];then - # If following command does not return 0 then reboot is required - if ! /usr/bin/needs-restarting -r > /dev/null;then - REBOOT_REQUIRED="true" - fi - fi - - if [ "$OS_FAMILY" == "Debian" ];then - # If following file exists then reboot is required - if [ -f "/var/run/reboot-required" ];then - REBOOT_REQUIRED="true" - fi - fi -} \ No newline at end of file diff --git a/src/10_send-mail b/src/10_send-mail deleted file mode 100644 index 4e01a9b..0000000 --- a/src/10_send-mail +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash - -function sendMail -{ - if [ "$MAIL_ENABLED" == "true" ];then - # Remove ANSI color codes before sending mail: - sed 's,\x1B[[(][0-9;]*[a-zA-Z],,g' "$LOG" > "$LOG_REPORT_MAIL" - - echo -ne "→ Sending update mail report: " - - if [ "$MOD_ERROR" -gt "0" ];then - echo "Error while executing module" | mutt -s "[ ERROR ] Update has failed - $HOSTNAME - $DATE_DMY at $TIME - linupdate" -a "$LOG_REPORT_MAIL" -- $MAIL_RECIPIENT - fi - - if [ ! -z "$UPDATE_EXCLUDE" ];then - echo "Critical packages have been detected. They have been excluded from update. Check attached update report." | mutt -s "[ WARNING ] Update completed but critical packages update have been ignored - $HOSTNAME - $DATE_DMY at $TIME - linupdate" -a "$LOG_REPORT_MAIL" -- $MAIL_RECIPIENT - elif [ ! -z "$UPDATE_EXCLUDE" ] && [ "$UPDATE_ERROR" -gt "0" ];then - echo "Critical packages have been detected. They have been excluded from failed update. Check attached update report." | mutt -s "[ ERROR & WARNING ] Update failed and critical packages excluded - $HOSTNAME - $DATE_DMY at $TIME - linupdate" -a "$LOG_REPORT_MAIL" -- $MAIL_RECIPIENT - elif [ "$UPDATE_ERROR" -gt "0" ];then - # If there was error during update - echo "Update report is attached" | mutt -s "[ ERROR ] Update failed - $HOSTNAME - $DATE_DMY at $TIME - linupdate" -a "$LOG_REPORT_MAIL" -- $MAIL_RECIPIENT - else - # If there was no error during update - echo "Update report is attached" | mutt -s "[ OK ] Update completed - $HOSTNAME - $DATE_DMY at $TIME - linupdate" -a "$LOG_REPORT_MAIL" -- $MAIL_RECIPIENT - fi - - echo -e "[$GREEN OK $RESET]" - fi -} \ No newline at end of file diff --git a/src/96_start-agent b/src/96_start-agent deleted file mode 100644 index 1e1ba07..0000000 --- a/src/96_start-agent +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -function startAgent -{ - systemctl start linupdate && - echo -e "${YELLOW}linupdate${RESET} agent started" -} \ No newline at end of file diff --git a/src/97_stop-agent b/src/97_stop-agent deleted file mode 100644 index 8132c17..0000000 --- a/src/97_stop-agent +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -function stopAgent -{ - systemctl stop linupdate && - echo -e "${YELLOW}linupdate${RESET} agent stopped" -} \ No newline at end of file diff --git a/src/98_restart-agent b/src/98_restart-agent deleted file mode 100644 index 6c9f3f0..0000000 --- a/src/98_restart-agent +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -function restartAgent -{ - systemctl restart linupdate && - echo -e " ${YELLOW}linupdate${RESET} agent restarted" -} \ No newline at end of file diff --git a/src/99_clean-exit b/src/99_clean-exit deleted file mode 100644 index d968fc3..0000000 --- a/src/99_clean-exit +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash - -function clean_exit -{ - # Clean temp files - # rm "/opt/linupdate.maj.sh" -f - # rm "/opt/reposconf/" -rf - rm "$LOG_REPORT_MAIL" -f - rm "/tmp/linupdate"* -rf - rm "/tmp/.linupdate_${PROCID}"* -rf - - if [ "$MOD_ERROR" -gt "0" ];then - sendMail - fi - - rm "/tmp/linupdate.lock" -f - - if [ "$UPDATE_ERROR" -gt "0" ];then - (( ERROR_STATUS++ )) - fi - - exit $ERROR_STATUS -} \ No newline at end of file diff --git a/src/99_disable-agent b/src/99_disable-agent deleted file mode 100644 index b794da8..0000000 --- a/src/99_disable-agent +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -function disableAgent -{ - systemctl disable --quiet linupdate && - echo -e "${YELLOW}linupdate${RESET} agent disabled on boot" -} \ No newline at end of file diff --git a/src/99_enable-agent b/src/99_enable-agent deleted file mode 100644 index 62f8eeb..0000000 --- a/src/99_enable-agent +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -function enableAgent -{ - systemctl enable --quiet linupdate && - echo -e "${YELLOW}linupdate${RESET} agent enabled on boot" -} \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/controllers/App/App.py b/src/controllers/App/App.py new file mode 100644 index 0000000..1b06b54 --- /dev/null +++ b/src/controllers/App/App.py @@ -0,0 +1,165 @@ +# coding: utf-8 + +# Import libraries +from datetime import datetime +from pathlib import Path +from colorama import Fore, Style +import sys, socket, yaml, getpass, subprocess + +# Import classes +from src.controllers.App.Config import Config +from src.controllers.System import System + +class App: + #------------------------------------------------------------------------------------------------------------------- + # + # Return current version of the application + # + #------------------------------------------------------------------------------------------------------------------- + def getVersion(self): + file = open('/opt/linupdate/version', 'r') + version = file.read() + + return version + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return linupdate configuration from config file + # + #------------------------------------------------------------------------------------------------------------------- + # def getConf(self): + # # Open YAML config file: + # with open('/etc/linupdate/linupdate.yml') as stream: + # try: + # # Read YAML and return profile + # data = yaml.safe_load(stream) + # return data + + # except yaml.YAMLError as exception: + # print(exception) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Get linupdate daemon agent status + # + #------------------------------------------------------------------------------------------------------------------- + def getAgentStatus(self): + result = subprocess.run( + ["systemctl", "is-active", "linupdate"], + capture_output = True, + text = True + ) + + if result.returncode != 0: + return 'stopped' + + return 'running' + + + #------------------------------------------------------------------------------------------------------------------- + # + # Create lock file + # + #------------------------------------------------------------------------------------------------------------------- + def setLock(self): + try: + Path('/tmp/linupdate.lock').touch() + except Exception as e: + raise Exception('Could not create lock file /tmp/linupdate.lock: ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Remove lock file + # + #------------------------------------------------------------------------------------------------------------------- + def removeLock(self): + if not Path('/tmp/linupdate.lock').is_file(): + return + + try: + Path('/tmp/linupdate.lock').unlink() + except Exception as e: + raise Exception('Could not remove lock file /tmp/linupdate.lock: ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Create base directories + # + #------------------------------------------------------------------------------------------------------------------- + def initialize(self): + # Create base directories + try: + Path('/opt/linupdate').mkdir(parents=True, exist_ok=True) + Path('/etc/linupdate').mkdir(parents=True, exist_ok=True) + Path('/etc/linupdate/modules').mkdir(parents=True, exist_ok=True) + Path('/opt/linupdate/service').mkdir(parents=True, exist_ok=True) + Path('/var/log/linupdate').mkdir(parents=True, exist_ok=True) + except Exception as e: + raise Exception('Could not create base directories: ' + str(e)) + + # Set permissions + try: + Path('/opt/linupdate').chmod(0o750) + Path('/opt/linupdate/src').chmod(0o750) + Path('/etc/linupdate').chmod(0o750) + Path('/etc/linupdate/modules').chmod(0o750) + Path('/opt/linupdate/service').chmod(0o750) + Path('/var/log/linupdate').chmod(0o750) + except Exception as e: + raise Exception('Could not set permissions to base directories: ' + str(e)) + + # Check if the .src directory is empty + if not len(list(Path('/opt/linupdate/src').rglob('*'))): + raise Exception('Some linupdate core files are missing, please reinstall linupdate') + + + #------------------------------------------------------------------------------------------------------------------- + # + # Print app logo + # + #------------------------------------------------------------------------------------------------------------------- + def printLogo(self): + space = ' ' + # print(Fore.YELLOW) + print(space + ' __ ') + print(space + '.__ .__ ____ __( o`- .___ __ ') + print(space + '| | |__| ____ __ _\ \/ / \__ ________ __| _/____ _/ |_ ____ ') + print(space + '| | | |/ \| | \ /| | | \____ \ / __ |\__ \\ ___/ __ \ ') + print(space + '| |_| | | | | / \ ^^| | | |_> / /_/ | / __ \| | \ ___/ ') + print(space + '|____|__|___| |____/___/\ \ |____/| __/\____ |(____ |__| \___ >') + print(space + ' \/ \_/ |__| \/ \/ \/ \n') + + print(' Package updater for linux distributions\n') + + + #------------------------------------------------------------------------------------------------------------------- + # + # Print system and app summary + # + #------------------------------------------------------------------------------------------------------------------- + def printSummary(self, fromAgent: bool = False): + myAppConfig = Config() + mySystem = System() + + # Define execution method + if fromAgent: + exec_method = 'automatic (agent)' + else: + if not sys.stdin.isatty(): + exec_method = 'automatic (no tty)' + else: + exec_method = 'manual (tty)' + + print(' Hostname: ' + Fore.YELLOW + socket.gethostname() + Style.RESET_ALL) + print(' OS: ' + Fore.YELLOW + mySystem.getOsName() + ' ' + mySystem.getOsVersion() + Style.RESET_ALL) + print(' Kernel: ' + Fore.YELLOW + mySystem.getKernel() + Style.RESET_ALL) + print(' Virtualization: ' + Fore.YELLOW + mySystem.getVirtualization() + Style.RESET_ALL) + print(' Profile: ' + Fore.YELLOW + myAppConfig.getProfile() + Style.RESET_ALL) + print(' Environment: ' + Fore.YELLOW + myAppConfig.getEnvironment() + Style.RESET_ALL) + print(' Execution date: ' + Fore.YELLOW + datetime.now().strftime('%Y-%m-%d %H:%M:%S') + Style.RESET_ALL) + print(' Execution method: ' + Fore.YELLOW + exec_method + Style.RESET_ALL) + print(' Executed by user: ' + Fore.YELLOW + getpass.getuser() + Style.RESET_ALL + '\n') diff --git a/src/controllers/App/Config.py b/src/controllers/App/Config.py new file mode 100644 index 0000000..8782623 --- /dev/null +++ b/src/controllers/App/Config.py @@ -0,0 +1,461 @@ +# coding: utf-8 + +# Import libraries +from pathlib import Path +import yaml + +class Config: + #------------------------------------------------------------------------------------------------------------------- + # + # Return linupdate configuration from config file + # + #------------------------------------------------------------------------------------------------------------------- + def getConf(self): + # Open main YAML config file: + with open('/etc/linupdate/linupdate.yml') as stream: + try: + # Read YAML and return configuration + main = yaml.safe_load(stream) + + except yaml.YAMLError as e: + raise Exception(str(e)) + + # Open update YAML config file: + with open('/etc/linupdate/update.yml') as stream: + try: + # Read YAML and return configuration + update = yaml.safe_load(stream) + + except yaml.YAMLError as e: + raise Exception(str(e)) + + # Merge and return both configurations + configuration = {**main, **update} + + return configuration + + + #------------------------------------------------------------------------------------------------------------------- + # + # Check if the config file exists and if it contains the required parameters + # + #------------------------------------------------------------------------------------------------------------------- + def checkConf(self): + try: + # Check if main config file exists + if not Path('/etc/linupdate/linupdate.yml').is_file(): + raise Exception('configuration file /etc/linupdate/linupdate.yml is missing') + + # Check if update config file exists + if not Path('/etc/linupdate/update.yml').is_file(): + raise Exception('configuration file /etc/linupdate/update.yml is missing') + + # Retrieve configuration + configuration = self.getConf() + + # Check if main section is set in the config file + if 'main' not in configuration: + raise Exception('main section is missing in the configuration file') + + # Check if profile is set in the config file + if 'profile' not in configuration['main']: + raise Exception('profile param is missing in the configuration file') + + if configuration['main']['profile'] == None: + raise Exception('profile is empty in the configuration file') + + # Check if environment is set in the config file + if 'environment' not in configuration['main']: + raise Exception('environment param is missing in the configuration file') + + if configuration['main']['environment'] == None: + raise Exception('environment is empty in the configuration file') + + # Check if mail_alert section is set in the config file + if 'mail_alert' not in configuration['main']: + raise Exception('mail_alert section is missing in the configuration file') + + # Check if enabled param is set in the mail_alert section + if 'enabled' not in configuration['main']['mail_alert']: + raise Exception('enabled param is missing in the mail_alert section') + + if configuration['main']['mail_alert']['enabled'] == None: + raise Exception('enabled param is empty in the mail_alert section') + + # Check if recipient param is set in the mail_alert section + if 'recipient' not in configuration['main']['mail_alert']: + raise Exception('recipient param is missing in the mail_alert section') + + # Check if modules section is set in the config file + if 'modules' not in configuration: + raise Exception('modules section is missing in the configuration file') + + # Check if enabled param is set in the modules section + if 'enabled' not in configuration['modules']: + raise Exception('enabled param is missing in the modules section') + + # Check if the update section is set + if 'update' not in configuration: + raise Exception('update section is missing in /etc/linupdate/update.yml') + + # Check if the method param is set in the update section + if 'method' not in configuration['update']: + raise Exception('method param is missing in the update section') + + if configuration['update']['method'] == None: + raise Exception('method is empty in the update section') + + # Check if exit_on_package_update_error param is set in the update section + if 'exit_on_package_update_error' not in configuration['update']: + raise Exception('exit_on_package_update_error param is missing in the update section') + + if configuration['update']['exit_on_package_update_error'] == None: + raise Exception('exit_on_package_update_error is empty in the update section') + + # Check if packages section is set in the config file + if 'packages' not in configuration['update']: + raise Exception('packages section is missing in the configuration file') + + # Check if exclude section is set in the packages section + if 'exclude' not in configuration['update']['packages']: + raise Exception('exclude section is missing in the packages section') + + # Check if always param is set in the exclude section + if 'always' not in configuration['update']['packages']['exclude']: + raise Exception('always param is missing in the exclude section') + + # Check if on_major_update param is set in the exclude section + if 'on_major_update' not in configuration['update']['packages']['exclude']: + raise Exception('on_major_update param is missing in the exclude section') + + # Check if services section is set in the config file + if 'services' not in configuration['post_update']: + raise Exception('services section is missing in the configuration file') + + # Check if restart param is set in the services section + if 'restart' not in configuration['post_update']['services']: + raise Exception('restart param is missing in the services section') + + except Exception as e: + raise Exception('Fatal configuration file error: ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Generate config files if not exist + # + #------------------------------------------------------------------------------------------------------------------- + def generateConf(self): + # If main config file does not exist, generate it + if not Path('/etc/linupdate/linupdate.yml').is_file(): + + # Content + data = { + 'main': { + 'profile': 'PC', + 'environment': 'prod', + + 'mail_alert': { + 'enabled': False, + 'recipient': [], + } + }, + 'modules': { + 'enabled': [], + } + } + + # Write config file + try: + with open('/etc/linupdate/linupdate.yml', 'w') as file: + yaml.dump(data, file, default_flow_style=False, sort_keys=False) + except Exception as e: + raise Exception('Could not write to configuration file /etc/linupdate/linupdate.yml: ' + str(e)) + + # If update config file does not exist, generate it + if not Path('/etc/linupdate/update.yml').is_file(): + # Content + data = { + 'update': { + 'method': 'one_by_one', + 'exit_on_package_update_error': True, + 'packages': { + 'exclude': { + 'always': [], + 'on_major_update': [] + } + }, + }, + 'post_update': { + 'services': { + 'restart': [], + } + } + } + + # Write config file + try: + with open('/etc/linupdate/update.yml', 'w') as file: + yaml.dump(data, file, default_flow_style=False, sort_keys=False) + except Exception as e: + raise Exception('Could not write to configuration file /etc/linupdate/update.yml: ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Write linupdate configuration to config file + # + #------------------------------------------------------------------------------------------------------------------- + def writeConf(self, configuration): + # print(configuration) + main_config = { + 'main': { + **configuration['main'] + }, + 'modules': { + **configuration['modules'] + } + } + + update_config = { + 'update': { + **configuration['update'] + }, + 'post_update': { + **configuration['post_update'] + } + } + + # Write to main config file + try: + with open('/etc/linupdate/linupdate.yml', 'w') as file: + yaml.dump(main_config, file, default_flow_style=False, sort_keys=False) + except Exception as e: + raise Exception('Could not write configuration file /etc/linupdate/linupdate.yml: ' + str(e)) + + # Write to update config file + try: + with open('/etc/linupdate/update.yml', 'w') as file: + yaml.dump(update_config, file, default_flow_style=False, sort_keys=False) + except Exception as e: + raise Exception('Could not write configuration file /etc/linupdate/update.yml: ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return linupdate profile from config file + # + #------------------------------------------------------------------------------------------------------------------- + def getProfile(self): + # Get current configuration + configuration = self.getConf() + + return configuration['main']['profile'] + + + #------------------------------------------------------------------------------------------------------------------- + # + # Set linupdate profile in config file + # + #------------------------------------------------------------------------------------------------------------------- + def setProfile(self, profile): + # Get current configuration + configuration = self.getConf() + + # Set profile + configuration['main']['profile'] = profile + + # Write config file + self.writeConf(configuration) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return linupdate environment from config file + # + #------------------------------------------------------------------------------------------------------------------- + def getEnvironment(self): + # Get current configuration + configuration = self.getConf() + + return configuration['main']['environment'] + + + #------------------------------------------------------------------------------------------------------------------- + # + # Set linupdate environment in config file + # + #------------------------------------------------------------------------------------------------------------------- + def setEnvironment(self, environment): + # Get current configuration + configuration = self.getConf() + + # Set environment + configuration['main']['environment'] = environment + + # Write config file + self.writeConf(configuration) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Get mail enabled status + # + #------------------------------------------------------------------------------------------------------------------- + def getMailEnabled(self): + # Get current configuration + configuration = self.getConf() + + return configuration['main']['mail_alert']['enabled'] + + + #------------------------------------------------------------------------------------------------------------------- + # + # Get mail recipient(s) + # + #------------------------------------------------------------------------------------------------------------------- + def getMailRecipient(self): + # Get current configuration + configuration = self.getConf() + + return configuration['main']['mail_alert']['recipient'] + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return linupdate packages exclude list from config file + # + #------------------------------------------------------------------------------------------------------------------- + def getExclude(self): + # Get current configuration + configuration = self.getConf() + + return configuration['update']['packages']['exclude']['always'] + + + #------------------------------------------------------------------------------------------------------------------- + # + # Set linupdate packages exclude list in config file + # + #------------------------------------------------------------------------------------------------------------------- + def setExclude(self, exclude: str = None): + # Get current configuration + configuration = self.getConf() + + # If no package to exclude, set empty list + if not exclude: + configuration['update']['packages']['exclude']['always'] = [] + + else: + # For each package to exclude, append it to the list if not already in + for item in exclude.split(","): + if item not in configuration['update']['packages']['exclude']['always']: + # Append exclude + configuration['update']['packages']['exclude']['always'].append(item) + + # Write config file + self.writeConf(configuration) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return linupdate packages exclude list on major update from config file + # + #------------------------------------------------------------------------------------------------------------------- + def getExcludeMajor(self): + # Get current configuration + configuration = self.getConf() + + return configuration['update']['packages']['exclude']['on_major_update'] + + + #------------------------------------------------------------------------------------------------------------------- + # + # Set linupdate packages exclude list on major update in config file + # + #------------------------------------------------------------------------------------------------------------------- + def setExcludeMajor(self, exclude: str = None): + # Get current configuration + configuration = self.getConf() + + # If no package to exclude, set empty list + if not exclude: + configuration['update']['packages']['exclude']['on_major_update'] = [] + + else: + # For each package to exclude, append it to the list if not already in + for item in exclude.split(","): + if item not in configuration['update']['packages']['exclude']['on_major_update']: + # Append exclude + configuration['update']['packages']['exclude']['on_major_update'].append(item) + + # Write config file + self.writeConf(configuration) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Get services to restart + # + #------------------------------------------------------------------------------------------------------------------- + def getServiceToRestart(self): + # Get current configuration + configuration = self.getConf() + + return configuration['post_update']['services']['restart'] + + + #------------------------------------------------------------------------------------------------------------------- + # + # Set services to restart + # + #------------------------------------------------------------------------------------------------------------------- + def setServiceToRestart(self, services: str = None): + # Get current configuration + configuration = self.getConf() + + # If no service to restart, set empty list + if not services: + configuration['post_update']['services']['restart'] = [] + + else: + # For each service to restart, append it to the list if not already in + for item in services.split(","): + if item not in configuration['post_update']['services']['restart']: + # Append service + configuration['post_update']['services']['restart'].append(item) + + # Write config file + self.writeConf(configuration) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Append a module to the enabled list + # + #------------------------------------------------------------------------------------------------------------------- + def appendModule(self, module): + # Get current configuration + configuration = self.getConf() + + # Add module to enabled list + configuration['modules']['enabled'].append(module) + + # Write config file + self.writeConf(configuration) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Remove a module from the enabled list + # + #------------------------------------------------------------------------------------------------------------------- + def removeModule(self, module): + # Get current configuration + configuration = self.getConf() + + # Remove module from enabled list + configuration['modules']['enabled'].remove(module) + + # Write config file + self.writeConf(configuration) diff --git a/src/controllers/App/Service.py b/src/controllers/App/Service.py new file mode 100644 index 0000000..936037d --- /dev/null +++ b/src/controllers/App/Service.py @@ -0,0 +1,124 @@ +# coding: utf-8 + +# Import libraries +import subprocess +import signal +import sys +import importlib +import subprocess +from pathlib import Path + +# Import classes +from src.controllers.Module.Module import Module + +class Service: + def __init__(self): + self.child_processes = [] + self.moduleController = Module() + + #------------------------------------------------------------------------------------------------------------------- + # + # Check if a restart of this service is needed, and restart it if needed + # + #------------------------------------------------------------------------------------------------------------------- + def restart_self_if_needed(self): + if Path('/tmp/linupdate-service.restart').is_file(): + # Only restart the service if linupdate is not running otherwise it could cut off a running update... + if Path('/tmp/linupdate.lock').is_file(): + return + + print('A restart of this service is required. Restarting...') + Path('/tmp/linupdate-service.restart').unlink() + subprocess.run(["systemctl", "restart", "linupdate.service"]) + + result = subprocess.run( + ["systemctl", "--quiet", "restart", "linupdate.service"], + capture_output = True, + text = True + ) + + # If service could not be restarted, print error and exit + if result.returncode != 0: + print('Error: could not restart linupdate service: ' + result.stderr) + exit(1) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Service main function + # + #------------------------------------------------------------------------------------------------------------------- + def main(self): + try: + # Check if a restart of this service is needed + self.restart_self_if_needed() + + # Retrieve enabled modules + enabled_modules = self.moduleController.getEnabled() + + # For each enabled module, check if its agent is enabled + for module in enabled_modules: + # Convert module name to uppercase first letter + module_name = module.capitalize() + + # Import python module config class + module_import_path = importlib.import_module('src.controllers.Module.' + module_name + '.Config') + module_class = getattr(module_import_path, 'Config') + + # Instantiate module and get module configuration + my_module = module_class() + module_configuration = my_module.getConf() + + # Check if agent is enabled + if module_configuration['agent']['enabled']: + print('Executing agent for module ' + module_name) + + # Import python module agent class + module_import_path = importlib.import_module('src.controllers.Module.' + module_name + '.Agent') + my_module_agent_class = getattr(module_import_path, 'Agent')() + + # Instantiate module and call module agent main method in a child process + # process = subprocess.Popen(['python3', '-c', 'import src.controllers.Module.' + module_name + '.Agent; src.controllers.Module.' + module_name + '.Agent.main()']) + # self.child_processes.append(process) + + except Exception as e: + print('Linupdate service error:' + str(e)) + exit(1) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Stop all child processes + # + #------------------------------------------------------------------------------------------------------------------- + def stop_child_processes(self): + for process in self.child_processes: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + + +#------------------------------------------------------------------------------------------------------------------- +# +# Signal handler +# This function is called when the service receives a SIGTERM or SIGINT signal +# +#------------------------------------------------------------------------------------------------------------------- +def signal_handler(sig, frame): + print('Linupdate service received signal ' + str(sig) + '. Stopping all child processes') + service.stop_child_processes() + sys.exit(0) + + +#------------------------------------------------------------------------------------------------------------------- +# +# Main +# +#------------------------------------------------------------------------------------------------------------------- +if __name__ == "__main__": + service = Service() + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + service.main() diff --git a/src/controllers/App/Utils.py b/src/controllers/App/Utils.py new file mode 100644 index 0000000..349a837 --- /dev/null +++ b/src/controllers/App/Utils.py @@ -0,0 +1,20 @@ +# coding: utf-8 + +# Import libraries +import json + +class Utils: + #------------------------------------------------------------------------------------------------------------------- + # + # Return True if the string is a valid JSON + # + #------------------------------------------------------------------------------------------------------------------- + def isJson(self, jsonString): + try: + json.loads(jsonString) + except json.decoder.JSONDecodeError: + return False + except ValueError as e: + return False + + return True diff --git a/src/controllers/App/__init__.py b/src/controllers/App/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/controllers/Args.py b/src/controllers/Args.py new file mode 100644 index 0000000..6b6c65e --- /dev/null +++ b/src/controllers/Args.py @@ -0,0 +1,393 @@ +# coding: utf-8 + +# Import libraries +from colorama import Fore, Style +import sys +import argparse + +# Import classes +from src.controllers.App.App import App +from src.controllers.App.Config import Config +from src.controllers.Module.Module import Module +from src.controllers.Exit import Exit + +class Args: + + #------------------------------------------------------------------------------------------------------------------- + # + # Pre-parse arguments + # + #------------------------------------------------------------------------------------------------------------------- + def preParse(self): + # Default values + Args.from_agent = False + + # + # If --from-agent param has been set + # + if '--from-agent' in sys.argv: + Args.from_agent = True + + + #------------------------------------------------------------------------------------------------------------------- + # + # Parse arguments + # + #------------------------------------------------------------------------------------------------------------------- + def parse(self): + # Default values + Args.verbosity = False + Args.assume_yes = False + Args.check_updates = False + Args.ignore_exclude = False + Args.dist_upgrade = False + Args.keep_oldconf = True + + myApp = App() + myAppConfig = Config() + myModule = Module() + myExit = Exit() + + try: + # Parse arguments + parser = argparse.ArgumentParser() + + # Define valid arguments + + # Version + parser.add_argument("--version", "-v", action="store_true", default="null", help="Show version") + # Verbosity + parser.add_argument("--verbosity", action="store_true", default="null", help="Increase verbosity") + # Force / assume-yes + parser.add_argument("--assume-yes", "-y", action="store_true", default="null", help="Answer yes to all questions") + # Check updates + parser.add_argument("--check-updates", "-cu", action="store_true", default="null", help="Only check for updates and exit") + # Ignore exclude + parser.add_argument("--ignore-exclude", "-ie", action="store_true", default="null", help="Ignore all package exclusions") + + # Profile + parser.add_argument("--profile", "-p", action="store", nargs='?', default="null", help="Print current profile or set profile") + # Environment + parser.add_argument("--env", "-e", action="store", nargs='?', default="null", help="Print current environment or set environment") + + # Dist upgrade + parser.add_argument("--dist-upgrade", "-du", action="store_true", default="null", help="Perform a distribution upgrade (Debian based OS only)") + # Keep oldconf + parser.add_argument("--keep-oldconf", action="store_true", default="null", help="Keep old configuration files during package update (Debian based OS only)") + + # Get excluded packages + parser.add_argument("--get-exclude", action="store", nargs='?', default="null", help="Get the list of packages to exclude from update") + # Get excluded packages on major update + parser.add_argument("--get-exclude-major", action="store", nargs='?', default="null", help="Get the list of packages to exclude from update (if package has a major version update)") + # Get services to restart after package update + parser.add_argument("--get-service-restart", action="store", nargs='?', default="null", help="Get the list of services to restart after package update") + # Exclude + parser.add_argument("--exclude", action="store", nargs='?', default="null", help="Set packages to exclude from update") + # Exclude on major update + parser.add_argument("--exclude-major", action="store", nargs='?', default="null", help="Set packages to exclude from update (if package has a major version update)") + # Services to restart after package update + parser.add_argument("--service-restart", action="store", nargs='?', default="null", help="Set services to restart after package update") + + # List modules + parser.add_argument("--mod-list", action="store_true", default="null", help="List available modules") + # Module enable + parser.add_argument("--mod-enable", action="store", nargs='?', default="null", help="Enable module") + # Module disable + parser.add_argument("--mod-disable", action="store", nargs='?', default="null", help="Disable module") + # Module configure + parser.add_argument("--mod-configure", action="store", nargs='?', default="null", help="Configure module") + + # Parse arguments + args, unknown = parser.parse_known_args() + + # If unknown arguments are passed + if unknown: + raise Exception('unknown argument(s): ' + str(unknown)) + + except Exception as e: + raise Exception('Error while parsing arguments: ' + str(e)) + + try: + # + # If --version param has been set + # + if args.version != "null": + if args.version: + print(' Current version: ' + myApp.getVersion()) + myExit.cleanExit() + + # + # If --verbosity param has been set + # + if args.verbosity != "null": + Args.verbosity = True + + # + # If --assume-yes param has been set + # + if args.assume_yes != "null": + Args.assume_yes = True + + # + # If --profile param has been set + # + if args.profile != "null": + try: + # If a profile is set (not 'None'), change the app profile + if args.profile: + # Get current profile + currentProfile = myAppConfig.getProfile() + + # If a profile was already set + if currentProfile: + # Print profile change + print(' Switching from profile ' + Fore.YELLOW + currentProfile + Style.RESET_ALL + ' to ' + Fore.YELLOW + args.profile + Style.RESET_ALL) + else: + # Print profile change + print(' Switching to profile ' + Fore.YELLOW + args.profile + Style.RESET_ALL) + + # Set new profile + myAppConfig.setProfile(args.profile) + + # Else print the current profile + else: + print(' Current profile: ' + Fore.YELLOW + myAppConfig.getProfile() + Style.RESET_ALL) + + myExit.cleanExit() + + except Exception as e: + raise Exception('could not switch profile: ' + str(e)) + + # + # If --env param has been set + # + if args.env != "null": + try: + # If a environment is set (not 'None'), change the app environment + if args.env: + # Get current environment + currentEnvironment = myAppConfig.getEnvironment() + + # Print environment change + print(' Switching from environment ' + Fore.YELLOW + currentEnvironment + Style.RESET_ALL + ' to ' + Fore.YELLOW + args.env + Style.RESET_ALL) + + # Set new environment + myAppConfig.setEnvironment(args.env) + # Else print the current environment + else: + print('Current environment: ' + Fore.YELLOW + myAppConfig.getEnvironment() + Style.RESET_ALL) + + myExit.cleanExit() + + except Exception as e: + raise Exception('could not switch environment: ' + str(e)) + + + # + # If --ignore-exclude param has been set + # + if args.ignore_exclude != "null": + Args.ignore_exclude = True + + # + # If --check-updates param has been set + # + if args.check_updates != "null": + Args.check_updates = True + + # + # If --dist-upgrade param has been set + # + if args.dist_upgrade != "null": + Args.dist_upgrade = True + + # + # If --keep-oldconf param has been set + # + if args.keep_oldconf != "null": + Args.keep_oldconf = True + + # + # If --get-exclude param has been set + # + if args.get_exclude != "null": + packages = myAppConfig.getExclude() + + print(' Currently excluded packages: ' + Fore.YELLOW) + + for package in packages: + print(' ▪ ' + package) + + # If no package is excluded + if not packages: + print(' ▪ None') + + print(Style.RESET_ALL) + + myExit.cleanExit() + + # + # If --get-exclude-major param has been set + # + if args.get_exclude_major != "null": + packages = myAppConfig.getExcludeMajor() + + print(' Currently excluded packages on major update: ' + Fore.YELLOW) + + for package in packages: + print(' ▪ ' + package) + + # If no package is excluded + if not packages: + print(' ▪ None') + + print(Style.RESET_ALL) + + myExit.cleanExit() + + # + # If --get-service-restart param has been set + # + if args.get_service_restart != "null": + services = myAppConfig.getServiceToRestart() + + print(' Services to restart after package update: ' + Fore.YELLOW) + + for service in services: + print(' ▪ ' + service) + + # If no service is set to restart + if not services: + print(' ▪ None') + + print(Style.RESET_ALL) + + myExit.cleanExit() + + # + # If --exclude param has been set + # + if args.exclude != "null": + try: + # Exclude packages + myAppConfig.setExclude(args.exclude) + + # Print excluded packages + packages = myAppConfig.getExclude() + + print(' Excluding packages: ' + Fore.YELLOW) + + for package in packages: + print(' ▪ ' + package) + + # If no package is excluded + if not packages: + print(' ▪ None') + + print(Style.RESET_ALL) + + myExit.cleanExit() + except Exception as e: + raise Exception('Could not exclude packages: ' + str(e)) + + # + # If --exclude-major param has been set + # + if args.exclude_major != "null": + try: + # Exclude packages on major update + myAppConfig.setExcludeMajor(args.exclude_major) + + # Print excluded packages + packages = myAppConfig.getExcludeMajor() + + print(' Excluding packages on major update: ' + Fore.YELLOW) + + for package in packages: + print(' ▪ ' + package) + + # If no package is excluded + if not packages: + print(' ▪ None') + + print(Style.RESET_ALL) + + myExit.cleanExit() + except Exception as e: + raise Exception('Could not exclude packages on major update: ' + str(e)) + + # + # If --service-restart param has been set + # + if args.service_restart != "null": + try: + # Set services to restart after package update + myAppConfig.setServiceToRestart(args.service_restart) + + # Print services to restart + services = myAppConfig.getServiceToRestart() + + print(' Setting services to restart after package update: ' + Fore.YELLOW) + + for service in services: + print(' ▪ ' + service) + + # If no service is set to restart + if not services: + print(' ▪ None') + + print(Style.RESET_ALL) + + myExit.cleanExit() + except Exception as e: + raise Exception('Could not set services to restart after package update: ' + str(e)) + + # + # If --mod-list param has been set + # + if args.mod_list != "null": + myModule.list() + myExit.cleanExit() + + # + # If --mod-enable param has been set + # + if args.mod_enable != "null": + # If module to enable is set (not 'None'), enable the module + if args.mod_enable: + # Enable module + try: + myModule.enable(args.mod_enable) + myExit.cleanExit() + except Exception as e: + raise Exception('Could not enable module: ' + str(e)) + + # + # If --mod-disable param has been set + # + if args.mod_disable != "null": + # If module to disable is set (not 'None'), disable the module + if args.mod_disable: + # Disable module + try: + myModule.disable(args.mod_disable) + myExit.cleanExit() + except Exception as e: + raise Exception('Could not disable module: ' + str(e)) + + # + # If --mod-configure param has been set + # + if args.mod_configure != "null": + print(args.mod_configure) + # If module to configure is set (not 'None'), configure the module + if args.mod_configure: + try: + # Configure module + myModule.configure(args.mod_configure) + myExit.cleanExit() + except Exception as e: + raise Exception('Could not configure ' + args.mod_configure + ' module: ' + str(e)) + + except Exception as e: + raise Exception(str(e)) diff --git a/src/controllers/Exit.py b/src/controllers/Exit.py new file mode 100644 index 0000000..1eebb39 --- /dev/null +++ b/src/controllers/Exit.py @@ -0,0 +1,58 @@ +# coding: utf-8 + +# Import libraries +from colorama import Fore, Style + +# Import classes +from src.controllers.App.App import App +from src.controllers.App.Config import Config +from src.controllers.Mail import Mail + +class Exit: + #------------------------------------------------------------------------------------------------------------------- + # + # Clean and exit + # + #------------------------------------------------------------------------------------------------------------------- + def cleanExit(self, exit_code = 0, send_mail: bool = True, logfile: str = None): + my_app = App() + my_config = Config() + my_mail = Mail() + + # Try to get mail settings + # It could fail if the config file is not found or if the mail section is not defined (e.g. first execution) + # So if it fails to retrieve configuration, just don't send the mail + try: + mail_enabled = my_config.getMailEnabled() + mail_recipient = my_config.getMailRecipient() + except Exception as e: + send_mail = False + mail_enabled = False + mail_recipient = None + + # Send mail unless send_mail is False (in some case mail is not needed, like when exiting at update confirmation) + if send_mail is True: + # Check if mail is enabled and recipient is set + if (mail_enabled and mail_recipient): + # Define mail subject depending on exit code + if exit_code == 0: + subject = '[ OK ] Packages update successful' + + if exit_code == 1: + subject = '[ ERROR ] Packages update failed' + + print('\n Sending update email report:') + + try: + my_mail.send(subject, 'Linupdate has finished updating packages on your system. Please find the attached log file.', mail_recipient, logfile) + print(' ▪ Email sent') + except Exception as e: + print(' ▪ ' + Fore.YELLOW + 'Error: ' + str(e) + Style.RESET_ALL) + # If mail fails, exit with error code + exit_code = 1 + + # Remove lock + my_app.removeLock() + + # Final exit + exit(exit_code) diff --git a/src/controllers/HttpRequest.py b/src/controllers/HttpRequest.py new file mode 100644 index 0000000..fac4e09 --- /dev/null +++ b/src/controllers/HttpRequest.py @@ -0,0 +1,102 @@ +# coding: utf-8 + +# Import libraries +from colorama import Fore, Style +import requests +import json + +# Import classes +from src.controllers.App.Utils import Utils + +class HttpRequest: + def __init__(self): + self.quiet = False + + #------------------------------------------------------------------------------------------------------------------- + # + # GET request + # + #------------------------------------------------------------------------------------------------------------------- + def get(self, url: str, id: str, token: str, connectionTimeout: int = 5, readTimeout: int = 3): + # If an Id and a token are provided, add them to the URL + if id != "" and token != "": + response = requests.get(url, headers = {'Authorization': 'Host ' + id + ':' + token}, timeout = (connectionTimeout, readTimeout)) + else: + response = requests.get(url, timeout = (connectionTimeout, readTimeout)) + + # Parse response and return results if 200 + return self.requestParseResult(response) + + + #------------------------------------------------------------------------------------------------------------------- + # + # POST request with API key + # + #------------------------------------------------------------------------------------------------------------------- + def postToken(self, url: str, apiKey: str, data, connectionTimeout: int = 5, readTimeout: int = 3): + # Send POST request to URL with API key + response = requests.post(url, data = json.dumps(data), headers = {'Authorization': 'Bearer ' + apiKey, 'Content-Type': 'application/json'}, timeout = (connectionTimeout, readTimeout)) + + # Parse response and return results if 200 + return self.requestParseResult(response) + + + #------------------------------------------------------------------------------------------------------------------- + # + # PUT request with Id and token + # + #------------------------------------------------------------------------------------------------------------------- + def put(self, url: str, id: str, token: str, data, connectionTimeout: int = 5, readTimeout: int = 3): + # Send PUT request to URL with Id and token + response = requests.put(url, data = json.dumps(data), headers = {'Authorization': 'Host ' + id + ':' + token}, timeout = (connectionTimeout, readTimeout)) + + # Parse response and return results if 200 + return self.requestParseResult(response) + + + #------------------------------------------------------------------------------------------------------------------- + # + # DELETE request with Id and token + # + #------------------------------------------------------------------------------------------------------------------- + def delete(self, url: str, id: str, token: str, connectionTimeout: int = 5, readTimeout: int = 3): + # Send DELETE request to URL with Id and token + response = requests.delete(url, headers = {'Authorization': 'Host ' + id + ':' + token}, timeout = (connectionTimeout, readTimeout)) + + # Parse response and return results if 200 + return self.requestParseResult(response) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Parse request result + # + #------------------------------------------------------------------------------------------------------------------- + def requestParseResult(self, response): + myUtils = Utils() + + # Check is response is OK (200) + try: + response.raise_for_status() + + # Print response message if not quiet + if not self.quiet: + # If response is a JSON with a 'message' key, then print it + if myUtils.isJson(response.text) and 'message' in response.json(): + for message in response.json()['message']: + print(' ' + Fore.GREEN + '✔' + Style.RESET_ALL + ' ' + message) + + # If response is a JSON with a 'results' key, return it + if myUtils.isJson(response.text) and 'results' in response.json(): + return response.json()['results'] + + # Else return response + return response + except requests.exceptions.HTTPError as e: + # If response is a JSON with a 'message_error' key, return it + if myUtils.isJson(response.text) and 'message_error' in response.json(): + for message in response.json()['message_error']: + print(' ' + Fore.YELLOW + '✕' + Style.RESET_ALL + ' ' + message) + raise Exception() + else: + raise Exception('HTTP request error: ' + str(e)) diff --git a/src/controllers/Log.py b/src/controllers/Log.py new file mode 100644 index 0000000..b036bff --- /dev/null +++ b/src/controllers/Log.py @@ -0,0 +1,34 @@ +# coding: utf-8 + +import traceback +import sys + +# https://stackoverflow.com/a/57008553 + +# Context manager that copies stdout and any exceptions to a log file +class Log(object): + def __init__(self, filename): + self.file = open(filename, 'w') + self.stdout = sys.stdout + + def __enter__(self): + sys.stdout = self + + def __exit__(self, exc_type, exc_value, tb): + sys.stdout = self.stdout + if exc_type is not None: + self.file.write(traceback.format_exc()) + self.file.close() + + def write(self, data): + self.file.write(data) + self.stdout.write(data) + self.file.flush() + + def flush(self): + self.file.flush() + self.stdout.flush() + + def getContent(self): + return self.stdout + \ No newline at end of file diff --git a/src/controllers/Mail.py b/src/controllers/Mail.py new file mode 100644 index 0000000..8ab2571 --- /dev/null +++ b/src/controllers/Mail.py @@ -0,0 +1,45 @@ +# coding: utf-8 + +# Import libraries +import re +import smtplib +from email.message import EmailMessage +from email.headerregistry import Address + +class Mail(): + #------------------------------------------------------------------------------------------------------------------- + # + # Send email + # + #------------------------------------------------------------------------------------------------------------------- + def send(self, subject: str, body: str, recipient: list, logfile = None): + msg = EmailMessage() + + # If logfile is set, then clean it from ANSI escape codes + if logfile: + # Read logfile content + with open(logfile, 'r') as f: + content = f.read() + + # Get logfile real filename + attachment = logfile.split('/')[-1] + + # Replace ANSI escape codes + ansi_escape = re.compile(r'(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]') + content = ansi_escape.sub('', content) + + # Define email content and headers + msg.set_content(body) + msg['Subject'] = subject + # msg['From'] = Address('Linupdate', 'noreply', socket.gethostname()) + msg['From'] = Address('Linupdate', 'noreply', 'example.com') + msg['To'] = ','.join(recipient) + + # Add attachment + bs = content.encode('utf-8') + msg.add_attachment(bs, maintype='text', subtype='plain', filename=attachment) + + # Send the message via our own SMTP server + s = smtplib.SMTP('localhost') + s.send_message(msg) + s.quit() diff --git a/src/controllers/Module/Module.py b/src/controllers/Module/Module.py new file mode 100644 index 0000000..f4a2236 --- /dev/null +++ b/src/controllers/Module/Module.py @@ -0,0 +1,221 @@ +# coding: utf-8 + +# Import libraries +from colorama import Fore, Style +import os +import importlib + +# Import classes +from src.controllers.App.Config import Config +from src.controllers.Exit import Exit + +class Module: + def __init__(self): + self.configController = Config() + self.exitController = Exit() + self.loadedModules = [] + + #------------------------------------------------------------------------------------------------------------------- + # + # Return list of enabled modules + # + #------------------------------------------------------------------------------------------------------------------- + def getEnabled(self): + return self.configController.getConf()['modules']['enabled'] + + + #------------------------------------------------------------------------------------------------------------------- + # + # List available modules + # + #------------------------------------------------------------------------------------------------------------------- + def list(self): + # List all modules + print(' Available modules:') + for module in os.listdir('/opt/linupdate/src/controllers/Module'): + # Ignore cache files + if module == '__pycache__': + continue + + # Ignore non directories + if not os.path.isdir('/opt/linupdate/src/controllers/Module/' + module): + continue + + print(' - ' + Fore.YELLOW + module.lower() + Style.RESET_ALL) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Enable a module + # + #------------------------------------------------------------------------------------------------------------------- + def enable(self, module): + # Retrieve configuration + configuration = self.configController.getConf() + + # Loop through modules + for mod in module.split(','): + # Check if module exists + if not self.exists(mod): + raise Exception('Module ' + mod + ' does not exist') + + # Continue if module is already enabled + if mod in configuration['modules']['enabled']: + print(Fore.GREEN + ' Module ' + mod + ' is already enabled' + Style.RESET_ALL) + continue + + # Enable module + self.configController.appendModule(mod) + + # Print enabled modules + print(' Module ' + Fore.YELLOW + mod + Style.RESET_ALL + ' enabled') + + + #------------------------------------------------------------------------------------------------------------------- + # + # Disable a module + # + #------------------------------------------------------------------------------------------------------------------- + def disable(self, module): + # Retrieve configuration + configuration = self.configController.getConf() + + # Loop through modules + for mod in module.split(','): + # Check if module exists + if not self.exists(mod): + raise Exception('Module ' + mod + ' does not exist') + + # Continue if module is already disabled + if mod not in configuration['modules']['enabled']: + print(Fore.YELLOW + ' Module ' + mod + ' is already disabled' + Style.RESET_ALL) + continue + + # Disable module + self.configController.removeModule(mod) + + # Print disabled modules + print(' Module ' + Fore.YELLOW + mod + Style.RESET_ALL + ' disabled') + + + #------------------------------------------------------------------------------------------------------------------- + # + # Configure a module + # + #------------------------------------------------------------------------------------------------------------------- + def configure(self, module): + # Check if module exists + if not self.exists(module): + raise Exception('Module ' + module + ' does not exist') + + # Convert module name tu uppercase first letter + moduleName = module.capitalize() + + # Import python module class + moduleImportPath = importlib.import_module('src.controllers.Module.'+ moduleName + '.' + moduleName) + moduleClass = getattr(moduleImportPath, moduleName) + + # Instanciate module and call module load method + myModule = moduleClass() + myModule.main() + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return True if module exists + # + #------------------------------------------------------------------------------------------------------------------- + def exists(self, module): + # Check if module class file exists + if not os.path.exists('/opt/linupdate/src/controllers/Module/' + module.capitalize() + '/' + module.capitalize() + '.py'): + return False + + return True + + + #------------------------------------------------------------------------------------------------------------------- + # + # Load enabled modules + # + #------------------------------------------------------------------------------------------------------------------- + def load(self): + # Retrieve configuration + configuration = self.configController.getConf() + + # Quit if no modules are enabled + if not configuration['modules']['enabled']: + return + + print(' Loading modules') + + # Loop through modules + for module in configuration['modules']['enabled']: + try: + # Convert module name tu uppercase first letter + moduleName = module.capitalize() + + # Import python module class + moduleImportPath = importlib.import_module('src.controllers.Module.'+ moduleName + '.' + moduleName) + moduleClass = getattr(moduleImportPath, moduleName) + + # Instanciate module and call module load method + myModule = moduleClass() + myModule.load() + + print(Fore.GREEN + ' ✔ ' + Style.RESET_ALL + module + ' module loaded ') + + # Add module to the list of loaded modules + self.loadedModules.append(module) + + except Exception as e: + raise Exception('Could not load module ' + module + ': ' + str(e) + Style.RESET_ALL) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Execute modules pre-update actions (loaded modules only) + # + #------------------------------------------------------------------------------------------------------------------- + def pre(self): + for module in self.loadedModules: + try: + print('\n Executing ' + Fore.YELLOW + module + Style.RESET_ALL + ' pre-update actions') + # Convert module name to uppercase first letter + moduleName = module.capitalize() + + # Import python module class + moduleImportPath = importlib.import_module('src.controllers.Module.'+ moduleName + '.' + moduleName) + moduleClass = getattr(moduleImportPath, moduleName) + + # Instanciate module and call module pre method + myModule = moduleClass() + myModule.pre() + + except Exception as e: + print(Fore.YELLOW + str(e) + Style.RESET_ALL) + raise Exception('Error while executing pre-update actions for module ' + module) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Execute modules post-update actions + # + #------------------------------------------------------------------------------------------------------------------- + def post(self, updateSummary): + for module in self.loadedModules: + try: + print('\n Executing ' + Fore.YELLOW + module + Style.RESET_ALL + ' post-update actions') + # Convert module name to uppercase first letter + moduleName = module.capitalize() + + # Import python module class + moduleImportPath = importlib.import_module('src.controllers.Module.'+ moduleName + '.' + moduleName) + moduleClass = getattr(moduleImportPath, moduleName) + + # Instanciate module and call module post method + myModule = moduleClass() + myModule.post(updateSummary) + + except Exception as e: + print(Fore.YELLOW + str(e) + Style.RESET_ALL) + raise Exception('Error while executing post-update actions for module ' + module) diff --git a/src/controllers/Module/Reposerver/Agent.py b/src/controllers/Module/Reposerver/Agent.py new file mode 100644 index 0000000..fb42bbd --- /dev/null +++ b/src/controllers/Module/Reposerver/Agent.py @@ -0,0 +1,225 @@ +# coding: utf-8 + +# Import libraries +from colorama import Fore, Style +import subprocess +import time +from pathlib import Path + +# Import classes +from src.controllers.Module.Module import Module +from src.controllers.Module.Reposerver.Status import Status +from src.controllers.Module.Reposerver.Config import Config + +class Agent: + def __init__(self): + self.moduleController = Module() + self.configController = Config() + self.reposerverStatusController = Status() + + #------------------------------------------------------------------------------------------------------------------- + # + # Enable or disable agent + # + #------------------------------------------------------------------------------------------------------------------- + def setEnable(self, value: bool): + try: + # Get current configuration + configuration = self.configController.getConf() + + # Set allow_repos_update + configuration['agent']['enabled'] = value + + # Write config file + self.configController.writeConf(configuration) + + if value: + print(' Reposerver agent ' + Fore.GREEN + 'enabled' + Style.RESET_ALL) + else: + print(' Reposerver agent ' + Fore.YELLOW + 'disabled' + Style.RESET_ALL) + + except Exception as e: + raise Exception('could not set agent enable to ' + str(value) + ': ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Get current agent listening interface + # + #------------------------------------------------------------------------------------------------------------------- + def getListenInterface(self): + # Get current configuration + configuration = self.configController.getConf() + + # Return watch_interface + return configuration['agent']['listen']['interface'] + + + #------------------------------------------------------------------------------------------------------------------- + # + # Set agent listening interface + # + #------------------------------------------------------------------------------------------------------------------- + def setListenInterface(self, value: str): + try: + # Get current configuration + configuration = self.configController.getConf() + + # Set listen interface + configuration['agent']['listen']['interface'] = value + + # Write config file + self.configController.writeConf(configuration) + + print(' Agent listening interface set to ' + Fore.GREEN + value + Style.RESET_ALL) + + except Exception as e: + raise Exception('could not set agent listening interface to ' + value + ': ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Enable or disable agent listening + # + #------------------------------------------------------------------------------------------------------------------- + def setListenEnable(self, value: bool): + try: + # Get current configuration + configuration = self.configController.getConf() + + # Set allow_repos_update + configuration['agent']['listen']['enabled'] = value + + # Write config file + self.configController.writeConf(configuration) + + if value: + print(' Agent listening ' + Fore.GREEN + 'enabled' + Style.RESET_ALL) + else: + print(' Agent listening ' + Fore.YELLOW + 'disabled' + Style.RESET_ALL) + + except Exception as e: + raise Exception('could not set agent listening enable to ' + str(value) + ': ' + str(e)) + + + + + + + + + + + + + + def general_checks(self): + enabled_modules = self.moduleController.getEnabled() + + # Checking that reposerver module is enabled + if 'reposerver' not in enabled_modules: + raise Exception('reposerver module is not enabled') + + # Checking that a configuration file exists for reposerver module + if not Path('/etc/linupdate/modules/reposerver.yml').is_file(): + raise Exception('reposerver module configuration file does not exist') + + # Checking that a log file exists for yum/dnf or apt + if Path('/var/log/yum.log').is_file(): + self.log_file = '/var/log/yum.log' + elif Path('/var/log/dnf.log').is_file(): + self.log_file = '/var/log/dnf.log' + elif Path('/var/log/apt/history.log').is_file(): + self.log_file = '/var/log/apt/history.log' + else: + raise Exception('no log file found for yum/dnf or apt') + + + #------------------------------------------------------------------------------------------------------------------- + # + # Reposerver agent main function + # + #------------------------------------------------------------------------------------------------------------------- + def main(self): + self.ngrep_cmd = 'ngrep -q -t -W byline' + self.ngrep_interface = None + counter = 0 + + # Checking that all the necessary elements are present for the agent execution + self.general_checks() + + # Get current configuration + configuration = self.configController.getConf() + + # If ngrep scans are enabled + if configuration['agent']['listen']['enabled']: + # Retrieving network interface to scan if specified + interface = configuration['agent']['listen']['interface'] + + # If network interface is specified with "auto" or is empty, then try to automatically retrieve default interface + if interface == 'auto' or not interface: + # Get default network interface + result = subprocess.run( + ["/usr/sbin/route | /usr/bin/grep '^default' | /usr/bin/grep -o '[^ ]*$'"], + capture_output = True, + text = True, + shell = True + ) + + if result.returncode != 0: + raise Exception('could not determine default network interface on which to listen: ' + result.stderr) + + interface = result.stdout.strip() + + # Count the number of lines returned + lines = interface.split('\n') + + # If more than one line is returned, then there is a problem + if len(lines) > 1: + raise Exception('could not determine default network interface on which to listen: multiple default interfaces have been detected') + + # Taking into account the network interface + self.ngrep_interface = interface + + # Executing regular tasks + while True: + # Checking that all the necessary elements are present for the agent execution. + # This is checked every time in case that a configuration change has been made in the configuration file + self.general_checks() + + # Regulary sending data to the Repomanager server (every hour) + # 3600 / 5sec (sleep 5) = 720 + if counter == 0 or counter == 720: + # Sending full status + print('Periodically sending informations about this host to the repomanager server') + self.reposerverStatusController.sendGeneralStatus() + self.reposerverStatusController.sendPackagesStatus() + + # Reset counter + counter = 0 + + # If no inotify process is running, then execute it in background + # TODO + # inotify_package_event() + + # If no inotify process is running, then execute it in background + # TODO + # inotify_package_event() + + # If ngrep scans are enabled, then execute them in background + # if configuration['agent']['listen']['enabled']: + # Monitor general informations sending requests + # TODO + # ngrep_general_update_request + + # Monitor packages informations sending requests + # TODO + # ngrep_packages_status_request + + # Monitor package update requests + # TODO + # ngrep_packages_update_requested + + time.sleep(5) + + counter+=1 diff --git a/src/controllers/Module/Reposerver/Args.py b/src/controllers/Module/Reposerver/Args.py new file mode 100644 index 0000000..02adb22 --- /dev/null +++ b/src/controllers/Module/Reposerver/Args.py @@ -0,0 +1,241 @@ +# coding: utf-8 + +# Import libraries +from colorama import Fore, Style +import argparse + +# Import classes +from src.controllers.Exit import Exit +from src.controllers.Module.Reposerver.Config import Config +from src.controllers.Module.Reposerver.Register import Register +from src.controllers.Module.Reposerver.Status import Status +from src.controllers.Module.Reposerver.Agent import Agent +from src.controllers.HttpRequest import HttpRequest + +class Args: + def __init__(self): + self.exitController = Exit() + self.configController = Config() + self.registerController = Register() + self.statusController = Status() + self.agentController = Agent() + self.httpRequestController = HttpRequest() + + #------------------------------------------------------------------------------------------------------------------- + # + # Parse arguments + # + #------------------------------------------------------------------------------------------------------------------- + def parse(self): + try: + # Parse arguments + parser = argparse.ArgumentParser() + + # Define valid arguments + + # URL + parser.add_argument("--url", action="store", nargs='?', default="null", help="Specify target reposerver URL") + # API key + parser.add_argument("--api-key", action="store", nargs='?', default="null", help="Specify API key to authenticate to the reposerver") + # IP + parser.add_argument("--ip", action="store", nargs='?', default="null", help="Specify an alternative local IP address to use to authenticate to the reposerver") + + # Allow configuration update + parser.add_argument("--allow-conf-update", action="store", nargs='?', default="null", help="Allow updating local configuration with reposerver configuration") + # Allow repos update + parser.add_argument("--allow-repos-update", action="store", nargs='?', default="null", help="Allow updating local repositories configuration with reposerver repositories configuration") + + # Agent enable + parser.add_argument("--agent-enable", action="store", nargs='?', default="null", help="Enable reposerver module agent. This agent will regularly send informations about this host to reposerver (global informations, packages informations...)") + # Agent listen enable + parser.add_argument("--agent-listen-enable", action="store", nargs='?', default="null", help="Enable or disable agent listening for requests coming from the reposerver") + # Agent listen interface + parser.add_argument("--agent-listen-int", action="store", nargs='?', default="null", help="Specify the local network interface to use to listen for requests coming from the reposerver") + + # Register to reposerver + parser.add_argument("--register", action="store_true", default="null", help="Register this host to the reposerver (--api-key required)") + # Unregister from server + parser.add_argument("--unregister", action="store_true", default="null", help="Unregister this host from the reposerver") + + # Retrieve reposerver main configuration + parser.add_argument("--get-reposerver-conf", action="store_true", default="null", help="Get reposerver global configuration") + # Retrieve profile packages configuration from reposerver + parser.add_argument("--get-profile-packages-conf", action="store_true", default="null", help="Get profile packages configuration from reposerver") + # Retrieve profile repositories from reposerver + parser.add_argument("--get-profile-repos", action="store_true", default="null", help="Get profile repositories from reposerver") + + # Send status + parser.add_argument("--send-general-status", action="store_true", default="null", help="Send host global informations (OS, version, kernel..) to the reposerver") + # Send packages status + parser.add_argument("--send-packages-status", action="store_true", default="null", help="Send this host packages status to the reposerver (available, installed)") + # Send full history + parser.add_argument("--send-full-history", action="store_true", default="null", help="Send host packages events history (updates, downgrades, uninstallations...) to the reposerver") + # Send full status + parser.add_argument("--send-full-status", action="store_true", default="null", help="Send all of the previous status to the reposerver") + + # Parse arguments + args, unknown = parser.parse_known_args() + + # If unknown arguments are passed + # if unknown: + # raise Exception('unknown argument(s): ' + str(unknown)) + + except Exception as e: + raise Exception('error while parsing arguments: ' + str(e)) + + try: + # + # If --url param has been set + # + if args.url != "null": + # If a URL is set (not 'None'), change the app URL + if args.url: + # Set new URL + self.configController.setUrl(args.url) + + # Print URL change + print(' Reposerver URL set to ' + Fore.YELLOW + args.url + Style.RESET_ALL) + # Else print the current URL + else: + print(' Current Reposerver URL: ' + Fore.YELLOW + self.configController.getUrl() + Style.RESET_ALL) + + # + # If --api-key param has been set + # + if args.api_key != "null": + Args.api_key = args.api_key + + # + # If --ip param has been set + # + if args.ip != "null": + Args.ip = args.ip + + # + # If --agent-enable param has been set + # + if args.agent_enable != "null": + if args.agent_enable == 'true': + self.agentController.setEnable(True) + else: + self.agentController.setEnable(False) + + # + # If --agent-listen-enable param has been set + # + if args.agent_listen_enable != "null": + if args.agent_listen_enable == 'true': + self.agentController.setListenEnable(True) + else: + self.agentController.setListenEnable(False) + + # + # If --agent-listen-int param has been set + # + if args.agent_listen_int != "null": + # If an interface is set (not 'None'), change the agent interface + if args.agent_listen_int: + # Get current interface + currentInterface = self.agentController.getListenInterface() + + # Set new interface + self.agentController.setListenInterface(args.agent_listen_int) + + # Print interface change + print(' Switched from interface ' + currentInterface + ' to ' + args.agent_listen_int) + # Else print the current interface + else: + print(' Current interface: ' + self.agentController.getListenInterface()) + + # + # If --allow-conf-update param has been set + # + if args.allow_conf_update != "null": + if args.allow_conf_update == 'true': + self.configController.setAllowConfUpdate(True) + else: + self.configController.setAllowConfUpdate(False) + + # + # If --allow-repos-update param has been set + # + if args.allow_repos_update != "null": + if args.allow_repos_update == 'true': + self.configController.setAllowReposUpdate(True) + else: + self.configController.setAllowReposUpdate(False) + + # + # If --register param has been set + # + if args.register != "null" and args.register: + # Register to the URL with the API key and IP (could be "null" if not set) + self.registerController.register(args.api_key, args.ip) + self.exitController.cleanExit() + + # + # If --unregister param has been set + # + if args.unregister != "null" and args.unregister: + # Unregister from the reposerver + self.registerController.unregister() + self.exitController.cleanExit() + + # + # If --get-server-conf param has been set + # + if args.get_reposerver_conf != "null" and args.get_reposerver_conf: + # Get server configuration + self.configController.getReposerverConf() + self.exitController.cleanExit() + + # + # If --get-profile-packages-conf param has been set + # + if args.get_profile_packages_conf != "null" and args.get_profile_packages_conf: + # Get profile packages configuration + self.configController.getProfilePackagesConf() + self.exitController.cleanExit() + + # + # If --get-profile-repos param has been set + # + if args.get_profile_repos != "null" and args.get_profile_repos: + # Get profile repositories + self.configController.getProfileRepos() + self.exitController.cleanExit() + + # + # If --send-general-status param has been set + # + if args.send_general_status != "null" and args.send_general_status: + # Send general status + self.statusController.sendGeneralStatus() + self.exitController.cleanExit() + + # + # If --send-packages-status param has been set + # + if args.send_packages_status != "null" and args.send_packages_status: + self.statusController.sendPackagesStatus() + self.exitController.cleanExit() + + # + # If --send-full-history param has been set + # + if args.send_full_history != "null" and args.send_full_history: + # Send full history + self.statusController.sendFullHistory() + self.exitController.cleanExit() + + # + # If --send-full-status param has been set + # + if args.send_full_status != "null" and args.send_full_status: + # Send full status including general status, available packages status, installed packages status and full history + self.statusController.sendGeneralStatus() + self.statusController.sendPackagesStatus() + self.exitController.cleanExit() + + except Exception as e: + raise Exception(str(e)) diff --git a/src/controllers/Module/Reposerver/Config.py b/src/controllers/Module/Reposerver/Config.py new file mode 100644 index 0000000..aa01bed --- /dev/null +++ b/src/controllers/Module/Reposerver/Config.py @@ -0,0 +1,571 @@ +# coding: utf-8 + +# Import libraries +from pathlib import Path +from colorama import Fore, Style +import yaml +import ipaddress + +# Import classes +from src.controllers.System import System +from src.controllers.App.Config import Config as appConfig +from src.controllers.HttpRequest import HttpRequest +from src.controllers.App.Utils import Utils + +class Config: + def __init__(self): + self.conf = '/etc/linupdate/modules/reposerver.yml' + self.systemController = System() + self.appConfigController = appConfig() + self.httpRequestController = HttpRequest() + self.utilsController = Utils() + + #------------------------------------------------------------------------------------------------------------------- + # + # Generate reposerver config file if not exist + # + #------------------------------------------------------------------------------------------------------------------- + def generateConf(self): + + # Quit if the config file already exists + if Path(self.conf).is_file(): + return + + print(' Generating Reposerver module configuration file') + + # Minimal config file + data = { + 'reposerver': { + 'url': '', + 'ip': '', + 'package_type': [], + }, + 'client': { + 'id': '', + 'token': '', + 'get_profile_pkg_conf_from_reposerver': True, + 'get_profile_repos_from_reposerver': True, + 'profile': { + 'repos': { + 'clear_before_update': True + } + } + }, + 'agent': { + 'enabled': False, + 'listen': { + 'enabled': True, + 'interface': 'auto' + } + } + } + + try: + # Write config file + with open(self.conf, 'w') as file: + yaml.dump(data, file, default_flow_style=False, sort_keys=False) + except Exception as e: + raise Exception('error while generating reposerver configuration file ' + self.conf + ': ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return current reposerver URL + # + #------------------------------------------------------------------------------------------------------------------- + def getUrl(self): + # Get current configuration + configuration = self.getConf() + + # Check if url exists in configuration and is not empty + if 'url' not in configuration['reposerver']: + raise Exception('reposerver URL not found in configuration file') + + if configuration['reposerver']['url'] == '': + raise Exception('no reposerver URL set. Please set a URL with --url option') + + # Return URL + return configuration['reposerver']['url'] + + + #------------------------------------------------------------------------------------------------------------------- + # + # Set reposerver URL + # + #------------------------------------------------------------------------------------------------------------------- + def setUrl(self, url): + # Check that url is valid (start with http(s)://) + if not url.startswith('http://') and not url.startswith('https://'): + raise Exception('reposerver URL must start with http:// or https://') + + # Get current configuration + configuration = self.getConf() + + # Set url + configuration['reposerver']['url'] = url + + # Write config file + self.writeConf(configuration) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return reposerver configuration + # + #------------------------------------------------------------------------------------------------------------------- + def getConf(self): + # Open YAML config file + with open(self.conf, 'r') as stream: + try: + # Read YAML and return profile + return yaml.safe_load(stream) + + except yaml.YAMLError as e: + raise Exception('error while reading reposerver configuration file ' + self.conf + ': ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Write reposerver configuration to config file + # + #------------------------------------------------------------------------------------------------------------------- + def writeConf(self, configuration): + try: + # Write config file + with open(self.conf, 'w') as file: + yaml.dump(configuration, file, default_flow_style=False, sort_keys=False) + except Exception as e: + raise Exception('error while writing to reposerver configuration file ' + self.conf + ': ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Check if the config file exists and if it contains the required parameters + # + #------------------------------------------------------------------------------------------------------------------- + def checkConf(self): + if not Path(self.conf).is_file(): + raise Exception('reposerver module configuration file not found ' + self.conf) + + # Retrieve configuration + configuration = self.getConf() + + # Check if reposerver section exists + if 'reposerver' not in configuration: + raise Exception(Fore.YELLOW + 'reposerver' + Style.RESET_ALL + ' section not found in configuration file') + + # Check if url exists + if 'url' not in configuration['reposerver']: + raise Exception('reposerver ' + Fore.YELLOW + 'url' + Style.RESET_ALL + ' not found in configuration file') + + # Check if ip exists + if 'ip' not in configuration['reposerver']: + raise Exception('reposerver ' + Fore.YELLOW + 'ip' + Style.RESET_ALL + ' not found in configuration file') + + # Check if package_type exists + if 'package_type' not in configuration['reposerver']: + raise Exception('reposerver ' + Fore.YELLOW + 'package_type' + Style.RESET_ALL + ' not found in configuration file') + + # Check if client section exists + if 'client' not in configuration: + raise Exception(Fore.YELLOW + 'client' + Style.RESET_ALL + ' section not found in configuration file') + + # Check if id exists + if 'id' not in configuration['client']: + raise Exception('client ' + Fore.YELLOW + 'id' + Style.RESET_ALL + ' not found in configuration file') + + # Check if token exists + if 'token' not in configuration['client']: + raise Exception('client ' + Fore.YELLOW + 'token' + Style.RESET_ALL + ' not found in configuration file') + + # Check if get_profile_pkg_conf_from_reposerver exists and is set (True or False) + if 'get_profile_pkg_conf_from_reposerver' not in configuration['client']: + raise Exception('client ' + Fore.YELLOW + 'get_profile_pkg_conf_from_reposerver' + Style.RESET_ALL + ' not found in configuration file') + + if configuration['client']['get_profile_pkg_conf_from_reposerver'] not in [True, False]: + raise Exception('client ' + Fore.YELLOW + 'get_profile_pkg_conf_from_reposerver' + Style.RESET_ALL + ' must be set to True or False') + + # Check if get_profile_repos_from_reposerver exists and is set (True or False) + if 'get_profile_repos_from_reposerver' not in configuration['client']: + raise Exception(Fore.YELLOW + 'client get_profile_repos_from_reposerver' + Style.RESET_ALL + ' not found in configuration file') + + if configuration['client']['get_profile_repos_from_reposerver'] not in [True, False]: + raise Exception(Fore.YELLOW + 'client get_profile_repos_from_reposerver' + Style.RESET_ALL + ' must be set to True or False') + + # Check if profile section exists + if 'profile' not in configuration['client']: + raise Exception('client ' + Fore.YELLOW + 'profile' + Style.RESET_ALL + ' section not found in configuration file') + + # Check if repos section exists + if 'repos' not in configuration['client']['profile']: + raise Exception('client profile ' + Fore.YELLOW + 'repos' + Style.RESET_ALL +' section not found in configuration file') + + # Check if clear_before_update exists and is set (True or False) + if 'clear_before_update' not in configuration['client']['profile']['repos']: + raise Exception('client profile repos ' + Fore.YELLOW + 'clear_before_update' + Style.RESET_ALL + ' not found in configuration file') + + if configuration['client']['profile']['repos']['clear_before_update'] not in [True, False]: + raise Exception('client profile repos ' + Fore.YELLOW + 'clear_before_update' + Style.RESET_ALL + ' must be set to True or False') + + # Check if agent section exists + if 'agent' not in configuration: + raise Exception(Fore.YELLOW + 'agent' + Style.RESET_ALL + ' section not found in configuration file') + + # Check if enabled exists and is set (True or False) + if 'enabled' not in configuration['agent']: + raise Exception('agent ' + Fore.YELLOW + 'enabled' + Style.RESET_ALL + ' not found in configuration file') + + if configuration['agent']['enabled'] not in [True, False]: + raise Exception('agent ' + Fore.YELLOW + 'enabled' + Style.RESET_ALL + ' must be set to True or False') + + # Check if listen section exists + if 'listen' not in configuration['agent']: + raise Exception('agent ' + Fore.YELLOW + 'listen' + Style.RESET_ALL + ' section not found in configuration file') + + # Check if enabled exists and is set (True or False) + if 'enabled' not in configuration['agent']['listen']: + raise Exception('agent listen ' + Fore.YELLOW + 'enabled' + Style.RESET_ALL + ' not found in configuration file') + + if configuration['agent']['listen']['enabled'] not in [True, False]: + raise Exception('agent listen ' + Fore.YELLOW + 'enabled' + Style.RESET_ALL + ' must be set to True or False') + + # Check if interface exists + if 'interface' not in configuration['agent']['listen']: + raise Exception('agent listen ' + Fore.YELLOW + 'interface' + Style.RESET_ALL + ' not found in configuration file') + + + #------------------------------------------------------------------------------------------------------------------- + # + # Enable or disable configuration update from reposerver + # + #------------------------------------------------------------------------------------------------------------------- + def setAllowConfUpdate(self, value: bool): + # Get current configuration + configuration = self.getConf() + + # Set allow_conf_update + configuration['client']['get_profile_pkg_conf_from_reposerver'] = value + + # Write config file + self.writeConf(configuration) + + print(' Allow configuration update from reposerver set to ' + str(value)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Enable or disable repositories configuration update from reposerver + # + #------------------------------------------------------------------------------------------------------------------- + def setAllowReposUpdate(self, value: bool): + # Get current configuration + configuration = self.getConf() + + # Set allow_repos_update + configuration['client']['get_profile_repos_from_reposerver'] = value + + # Write config file + self.writeConf(configuration) + + print(' Allow repositories update from reposerver set to ' + str(value)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Get authentication id + # + #------------------------------------------------------------------------------------------------------------------- + def getId(self): + # Get current configuration + configuration = self.getConf() + + # Return Id + return configuration['client']['id'] + + + #------------------------------------------------------------------------------------------------------------------- + # + # Set authentication id + # + #------------------------------------------------------------------------------------------------------------------- + def setId(self, id: str): + # Get current configuration + configuration = self.getConf() + + # Set Id + configuration['client']['id'] = id + + # Write config file + self.writeConf(configuration) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Get authentication token + # + #------------------------------------------------------------------------------------------------------------------- + def getToken(self): + # Get current configuration + configuration = self.getConf() + + # Return Token + return configuration['client']['token'] + + + #------------------------------------------------------------------------------------------------------------------- + # + # Set authentication token + # + #------------------------------------------------------------------------------------------------------------------- + def setToken(self, token: str): + # Get current configuration + configuration = self.getConf() + + # Set Token + configuration['client']['token'] = token + + # Write config file + self.writeConf(configuration) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Get global configuration from reposerver + # + #------------------------------------------------------------------------------------------------------------------- + def getReposerverConf(self): + print(' ▪ Getting reposerver configuration:', end=' ') + + # Get reposerver URL + url = self.getUrl() + + # Get auth Id and token + id = self.getId() + token = self.getToken() + + # Do not print message if aknowledge request has been sent successfully + # self.httpRequestController.quiet = True + results = self.httpRequestController.get(url + '/api/v2/profile/server-settings', id, token) + + # Parse results + + # Check if IP has been send by the server + if 'Ip' not in results[0]: + raise Exception('reposerver did not send its IP address') + + # Check if package type has been send by the server + if 'Package_type' not in results[0]: + raise Exception('reposerver did not send its package type') + + # Set server IP + self.setServerIp(results[0]['Ip']) + + # Set server package type + self.setServerPackageType(results[0]['Package_type']) + + print('[' + Fore.GREEN + ' OK ' + Style.RESET_ALL + ']') + + + #------------------------------------------------------------------------------------------------------------------- + # + # Get profile packages configuration (exclusions) from reposerver + # + #------------------------------------------------------------------------------------------------------------------- + def getProfilePackagesConf(self): + # Get current configuration + configuration = self.getConf() + + # Get reposerver URL + url = self.getUrl() + + # Get current profile, auth Id and token + profile = self.appConfigController.getProfile() + id = self.getId() + token = self.getToken() + + print(' ▪ Getting ' + Fore.YELLOW + profile + Style.RESET_ALL + ' profile packages configuration:', end=' ') + + # Check if getting profile packages configuration from reposerver is enabled + if configuration['client']['get_profile_pkg_conf_from_reposerver'] == False: + print(Fore.YELLOW + 'Disabled' + Style.RESET_ALL) + return + + # Check if profile is not empty + if not profile: + raise Exception('no profile set. Please set a profile with --profile option') + + # Check if Id and token are not empty + if not id or not token: + raise Exception('no auth Id or token found in configuration') + + # Retrieve configuration from reposerver + results = self.httpRequestController.get(url + '/api/v2/profile/' + profile + '/excludes', id, token, 2) + + # Parse results + + # Packages to exclude no matter the version + if results[0]['Package_exclude'] != "null": + # First, clear the exclude list + self.appConfigController.setExclude() + + # Then, set the new exclude list + self.appConfigController.setExclude(results[0]['Package_exclude']) + + # Packages to exclude on major version + if results[0]['Package_exclude_major'] != "null": + # First, clear the exclude major list + self.appConfigController.setExcludeMajor() + + # Then, set the new exclude major list + self.appConfigController.setExcludeMajor(results[0]['Package_exclude_major']) + + # Service to restart after an update + if results[0]['Service_restart'] != "null": + # First clear the services to restart + self.appConfigController.setServiceToRestart() + + # Then set the new services to restart + self.appConfigController.setServiceToRestart(results[0]['Service_restart']) + + print('[' + Fore.GREEN + ' OK ' + Style.RESET_ALL + ']') + + + #------------------------------------------------------------------------------------------------------------------- + # + # Get profile repositories configuration from reposerver + # + #------------------------------------------------------------------------------------------------------------------- + def getProfileRepos(self): + # Get current configuration + configuration = self.getConf() + + # Get reposerver URL + url = self.getUrl() + + # Get current profile, auth Id and token + profile = self.appConfigController.getProfile() + env = self.appConfigController.getEnvironment() + id = self.getId() + token = self.getToken() + + print(' ▪ Getting ' + Fore.YELLOW + profile + Style.RESET_ALL + ' profile repositories:', end=' ') + + # Check if getting profile packages configuration from reposerver is enabled + if configuration['client']['get_profile_repos_from_reposerver'] == False: + print(Fore.YELLOW + 'Disabled' + Style.RESET_ALL) + return + + # Check if profile is not empty + if not profile: + raise Exception('no profile set. Please set a profile with --profile option') + + # Check if environment is not empty + if not env: + raise Exception('no environment set. Please set an environment with --env option') + + # Check if Id and token are not empty + if not id or not token: + raise Exception('no auth Id or token found in configuration') + + # Retrieve configuration from reposerver + results = self.httpRequestController.get(url + '/api/v2/profile/' + profile + '/repos', id, token, 2) + + # Parse results + + # Quit if no results + if not results: + print(Fore.YELLOW + 'No repositories configured ' + Style.RESET_ALL) + return + + # Clear current repositories if enabled + if configuration['client']['profile']['repos']['clear_before_update']: + # Debian + if self.systemController.getOsFamily() == 'Debian': + # Clear /etc/apt/sources.list + with open('/etc/apt/sources.list', 'w') as file: + file.write('') + + # Delete all files in /etc/apt/sources.list.d + for file in Path('/etc/apt/sources.list.d/').glob('*.list'): + file.unlink() + + # Redhat + if self.systemController.getOsFamily() == 'Redhat': + # Delete all files in /etc/yum.repos.d + for file in Path('/etc/yum.repos.d/').glob('*.repo'): + file.unlink() + + # Create each repo file + for result in results: + # Depending on the OS family, the repo files are stored in different directories + + # Debian + if self.systemController.getOsFamily() == 'Debian': + reposRoot = '/etc/apt/sources.list.d' + + # Redhat + if self.systemController.getOsFamily() == 'Redhat': + reposRoot = '/etc/yum.repos.d' + + # Insert description at the top of the file, then content + # Replace __ENV__ with current environment on the fly + with open(reposRoot + '/' + result['filename'], 'w') as file: + # Insert description + file.write('# ' + result['description'] + '\n' + result['content'].replace('__ENV__', env) + '\n') + + # Set permissions + Path(reposRoot + '/' + result['filename']).chmod(0o660) + + # Reload cache + # if self.systemController.getOsFamily() == 'Debian': + # TODO : utiliser le controller Apt pour vider le cache + + + # if self.systemController.getOsFamily() == 'Redhat': + # TODO + + print('[' + Fore.GREEN + ' OK ' + Style.RESET_ALL + ']') + + + #------------------------------------------------------------------------------------------------------------------- + # + # Set reposerver IP in configuration file + # + #------------------------------------------------------------------------------------------------------------------- + def setServerIp(self, ip): + # Check that IP is valid + try: + ipaddress.ip_address(ip) + except ValueError: + raise Exception('invalid Reposerver IP address ' + ip) + + # Get current configuration + configuration = self.getConf() + + # Set server ip + configuration['reposerver']['ip'] = ip + + # Write config file + self.writeConf(configuration) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Set reposerver package type in configuration file + # + #------------------------------------------------------------------------------------------------------------------- + def setServerPackageType(self, packageType): + # Get current configuration + configuration = self.getConf() + + # First clear the package type list + configuration['reposerver']['package_type'] = [] + + # For each package type, append it to the list if not already in + for item in packageType.split(","): + if item not in configuration['reposerver']['package_type']: + # Append package type + configuration['reposerver']['package_type'].append(item) + + # Write config file + self.writeConf(configuration) diff --git a/src/controllers/Module/Reposerver/Register.py b/src/controllers/Module/Reposerver/Register.py new file mode 100644 index 0000000..67f97b8 --- /dev/null +++ b/src/controllers/Module/Reposerver/Register.py @@ -0,0 +1,89 @@ +# coding: utf-8 + +# Import libraries +from colorama import Fore, Style +import ipaddress +import socket + +# Import classes +from src.controllers.Module.Reposerver.Config import Config +from src.controllers.HttpRequest import HttpRequest + +class Register: + def __init__(self): + self.configController = Config() + self.httpRequestController = HttpRequest() + + #------------------------------------------------------------------------------------------------------------------- + # + # Register to reposerver + # + #------------------------------------------------------------------------------------------------------------------- + def register(self, api_key: str, ip: str): + # Get Reposerver URL + url = self.configController.getUrl() + + # Check if URL is not null + if url == '': + raise Exception('you must configure the target reposerver URL [--url ]') + + print(' ▪ Registering to ' + Fore.YELLOW + url + Style.RESET_ALL + ':') + + # Check if API key is not null + if api_key == 'null': + raise Exception('you must specify an API key from a Repomanager user account [--register --api-key ]') + + # If no IP has been specified (null), then retrieve the public IP of the host + if ip == 'null': + try: + ip = self.httpRequestController.get('https://api.ipify.org', '', '', 2).text + except Exception as e: + raise Exception('failed to retrieve public IP from https://api.ipify.org (resource might be temporarily unavailable): ' + str(e)) + + # Check that the IP is valid + try: + ipaddress.ip_address(ip) + except ValueError: + raise Exception('invalid IP address ' + ip) + + # Register to server using API key and IP (POST) + data = { + 'ip': ip, + 'hostname': socket.gethostname() + } + + results = self.httpRequestController.postToken(url + '/api/v2/host/registering', api_key, data) + + # If registration is successful, the server will return an Id and a token, set Id and token in configuration + self.configController.setId(results['id']) + self.configController.setToken(results['token']) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Unregister from reposerver + # + #------------------------------------------------------------------------------------------------------------------- + def unregister(self): + # Get Reposerver URL + url = self.configController.getUrl() + + # Check if URL is not null + if url == '': + raise Exception('you must configure the target Reposerver URL [--url ]') + + print(' ▪ Unregistering from ' + Fore.YELLOW + url + Style.RESET_ALL + ':') + + # Get Id and token from configuration + id = self.configController.getId() + token = self.configController.getToken() + + # Check if Id and token are not null + if id == '': + raise Exception('no auth Id found in configuration') + + if token == '': + raise Exception('no auth token found in configuration') + + # Unregister from server using Id and token (DELETE) + self.httpRequestController.delete(url + '/api/v2/host/registering', id, token) diff --git a/src/controllers/Module/Reposerver/Reposerver.py b/src/controllers/Module/Reposerver/Reposerver.py new file mode 100644 index 0000000..5c72bd1 --- /dev/null +++ b/src/controllers/Module/Reposerver/Reposerver.py @@ -0,0 +1,90 @@ +# coding: utf-8 + +# Import classes +from src.controllers.Module.Reposerver.Config import Config as ReposerverConfig +from src.controllers.Module.Reposerver.Status import Status +from src.controllers.Module.Reposerver.Args import Args +from src.controllers.Exit import Exit + +class Reposerver: + def __init__(self): + self.reposerverConfigController = ReposerverConfig() + self.statusController = Status() + self.argsController = Args() + self.exitController = Exit() + + #------------------------------------------------------------------------------------------------------------------- + # + # Load Reposerver module + # + #------------------------------------------------------------------------------------------------------------------- + def load(self): + # Note: no need of try / except block here, as it is already handled in the Module load() function + + # Generate config file if not exist + self.reposerverConfigController.generateConf() + + # Check config file + self.reposerverConfigController.checkConf() + + + #------------------------------------------------------------------------------------------------------------------- + # + # Execute pre-update actions + # + #------------------------------------------------------------------------------------------------------------------- + def pre(self): + # Note: no need of try / except block here, as it is already handled in the Module pre() function + + # Retrieve global configuration from reposerver + self.reposerverConfigController.getReposerverConf() + + # Retrieve profile configuration from reposerver + self.reposerverConfigController.getProfilePackagesConf() + + # Retrieve profile repositories from reposerver + self.reposerverConfigController.getProfileRepos() + + + #------------------------------------------------------------------------------------------------------------------- + # + # Execute post-update actions + # + #------------------------------------------------------------------------------------------------------------------- + def post(self, updateSummary): + # Note: no need of try / except block here, as it is already handled in the Module pre() function + + # Quit if there was no packages updates + if updateSummary['update']['success']['count'] == 0: + print(' ▪ Nothing to do as no packages have been updated') + return + + # Generaly "*-release" packages on Redhat/CentOS reset .repo files. If a package of this type has been updated then we update the repos configuration from the repo server (profiles) + # If a package named *-release is present in the updated packages list + for package in updateSummary['update']['success']['packages']: + if package.endswith('-release'): + # Update repositories + self.reposerverConfigController.getProfileRepos() + break + + # Send last 4 packages history entries to the reposerver + self.statusController.sendFullHistory(4) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Reposerver main function + # + #------------------------------------------------------------------------------------------------------------------- + def main(self): + try: + # Generate config file if not exist + self.reposerverConfigController.generateConf() + + # Check config file + self.reposerverConfigController.checkConf() + + # Parse reposerver arguments + self.argsController.parse() + except Exception as e: + raise Exception(str(e)) diff --git a/src/controllers/Module/Reposerver/Status.py b/src/controllers/Module/Reposerver/Status.py new file mode 100644 index 0000000..ea38a48 --- /dev/null +++ b/src/controllers/Module/Reposerver/Status.py @@ -0,0 +1,285 @@ +# coding: utf-8 + +# Import libraries +from colorama import Fore, Style +import socket + +# Import classes +from src.controllers.System import System +from src.controllers.App.App import App +from src.controllers.App.Config import Config +from src.controllers.Module.Reposerver.Config import Config as ReposerverConfig +from src.controllers.Exit import Exit +from src.controllers.Package.Package import Package +from src.controllers.HttpRequest import HttpRequest + +class Status: + def __init__(self): + self.systemController = System() + self.appController = App() + self.configController = Config() + self.reposerverConfigController = ReposerverConfig() + self.httpRequestController = HttpRequest() + self.packageController = Package() + self.exitController = Exit() + + + #------------------------------------------------------------------------------------------------------------------- + # + # Update Reposerver request status + # + #------------------------------------------------------------------------------------------------------------------- + def updateRequestStatus(self, requestType: str, status: str): + try: + # Retrieve URL, ID and token + url = self.reposerverConfigController.getUrl() + id = self.reposerverConfigController.getId() + token = self.reposerverConfigController.getToken() + + # Do not print message if aknowledge request has been sent successfully + self.httpRequestController.quiet = True + + data = { + 'status': status, + } + + self.httpRequestController.put(url + '/api/v2/host/request/' + requestType, id, token, data) + except Exception as e: + raise Exception('could not acknowledge reposerver request of type ' + requestType + ': ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Send general status + # + #------------------------------------------------------------------------------------------------------------------- + def sendGeneralStatus(self): + try: + # Retrieve URL, ID and token + url = self.reposerverConfigController.getUrl() + id = self.reposerverConfigController.getId() + token = self.reposerverConfigController.getToken() + + data = { + 'hostname': socket.gethostname(), + 'os_family': self.systemController.getOsFamily(), + 'os': self.systemController.getOsName(), + 'os_version': self.systemController.getOsVersion(), + 'type': self.systemController.getVirtualization(), + 'kernel': self.systemController.getKernel(), + 'arch': self.systemController.getArch(), + 'profile': self.configController.getProfile(), + 'env': self.configController.getEnvironment(), + 'agent_status': self.appController.getAgentStatus(), + 'linupdate_version': self.appController.getVersion(), + 'reboot_required': str(self.systemController.rebootRequired()).lower() # Convert True/False to 'true'/'false' + } + + print('\n ▪ Sending status to ' + Fore.YELLOW + url + Style.RESET_ALL + ':') + + except Exception as e: + raise Exception('could not build general status data: ' + str(e)) + + # Update Reposerver's request status, set it to 'running' + self.updateRequestStatus('general-status-update', 'running') + + try: + self.httpRequestController.quiet = False + self.httpRequestController.put(url + '/api/v2/host/status', id, token, data) + # Update Reposerver's request status + self.updateRequestStatus('general-status-update', 'done') + except Exception as e: + # Update Reposerver's request status + self.updateRequestStatus('general-status-update', 'error') + + raise Exception('error while sending general status to reposerver: ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Send all packages status + # + #------------------------------------------------------------------------------------------------------------------- + def sendPackagesStatus(self): + # Update Reposerver's request status, set it to 'running' + self.updateRequestStatus('packages-status-update', 'running') + + try: + # Send all status + self.sendFullHistory() + self.sendAvailablePackagesStatus() + self.sendInstalledPackagesStatus() + + self.updateRequestStatus('packages-status-update', 'done') + except Exception as e: + self.updateRequestStatus('packages-status-update', 'error') + + raise Exception('error while sending packages status to reposerver: ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Send list of available packages + # + #------------------------------------------------------------------------------------------------------------------- + def sendAvailablePackagesStatus(self): + # Retrieve URL, ID and token + url = self.reposerverConfigController.getUrl() + id = self.reposerverConfigController.getId() + token = self.reposerverConfigController.getToken() + + available_packages = 'none' + + print('\n ▪ Building available packages list...') + + try: + # Retrieve available packages + packages = self.packageController.getAvailablePackages() + + if len(packages) > 0: + available_packages = '' + + for package in packages: + name = package['name'] + available_version = package['available_version'] + + # Ignore package if name is empty + if name == '': + continue + + # Redhat only + if self.systemController.getOsFamily() == 'Redhat': + # Remove epoch if it is equal to 0 + if available_version.startswith('0:'): + available_version = available_version[2:] + + # Add package name, its available version to the available_packages string + available_packages += name + '|' + available_version + ',' + + # Remove last comma + available_packages = available_packages[:-1] + + # Convert to JSON + data = { + 'available_packages': available_packages + } + + except Exception as e: + # Raise an exception to be caught in the main function + raise Exception('error while retrieving available packages: ' + str(e)) + + # Send available packages to Reposerver + print(' ▪ Sending data to ' + Fore.YELLOW + url + Style.RESET_ALL + ':') + + self.httpRequestController.quiet = False + self.httpRequestController.put(url + '/api/v2/host/packages/available', id, token, data) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Send list of installed packages + # + #------------------------------------------------------------------------------------------------------------------- + def sendInstalledPackagesStatus(self): + # Retrieve URL, ID and token + url = self.reposerverConfigController.getUrl() + id = self.reposerverConfigController.getId() + token = self.reposerverConfigController.getToken() + + installed_packages = '' + + print('\n ▪ Building installed packages list...') + + try: + # Retrieve installed packages + packages = self.packageController.getInstalledPackages() + + if len(packages) > 0: + for package in packages: + name = package['name'] + version = package['version'] + + # Ignore package if name or version is empty + if name == '' or version == '': + continue + + # Add package name, its available version to the installed_packages string + installed_packages += name + '|' + version + ',' + + # Remove last comma + installed_packages = installed_packages[:-1] + + # Convert to JSON + data = { + 'installed_packages': installed_packages + } + + except Exception as e: + # Raise an exception to set status to 'error' + raise Exception('error while retrieving installed packages: ' + str(e)) + + # Send installed packages to Reposerver + print(' ▪ Sending data to ' + Fore.YELLOW + url + Style.RESET_ALL + ':') + + self.httpRequestController.quiet = False + self.httpRequestController.put(url + '/api/v2/host/packages/installed', id, token, data) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Send packages history (installed, removed, upgraded, downgraded, etc.) + # + #------------------------------------------------------------------------------------------------------------------- + def sendFullHistory(self, entries_limit: int = 999999): + # Retrieve URL, ID and token + url = self.reposerverConfigController.getUrl() + id = self.reposerverConfigController.getId() + token = self.reposerverConfigController.getToken() + + # History parsing will start from the oldest to the newest + history_order = 'oldest' + + print('\n ▪ Building packages history...') + + # Update Reposerver's request status, set it to 'running' + self.updateRequestStatus('full-history-update', 'running') + + # If limit is set (not the default 999999), history parsing will start from the newest to the oldest + if entries_limit != 999999: + history_order = 'newest' + + try: + # Retrieve history Ids or files + history_entries = self.packageController.get_history(history_order) + except Exception as e: + self.updateRequestStatus('full-history-update', 'error') + raise Exception('error while retrieving history: ' + str(e)) + + # If there is no item (would be strange), exit + if len(history_entries) == 0: + print(' no history found') + self.updateRequestStatus('full-history-update', 'done') + return + + # Parse history files / Ids + try: + events = {} + events['events'] = self.packageController.parse_history(history_entries, entries_limit) + + # debug only: print pretty json + # import json + # r = json.dumps(events) + # json_object = json.loads(r) + # json_formatted_str = json.dumps(json_object, indent=2) + # print(json_formatted_str) + + self.updateRequestStatus('full-history-update', 'done') + except Exception as e: + # Raise an exception to set status to 'error' + self.updateRequestStatus('full-history-update', 'error') + raise Exception('could not parse packages history: ' + str(e)) + + print(' ▪ Sending data to ' + Fore.YELLOW + url + Style.RESET_ALL + ':') + + self.httpRequestController.quiet = False + self.httpRequestController.put(url + '/api/v2/host/packages/event', id, token, events) diff --git a/src/controllers/Module/Reposerver/__init__.py b/src/controllers/Module/Reposerver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/controllers/Package/Apt.py b/src/controllers/Package/Apt.py new file mode 100644 index 0000000..ddf3b39 --- /dev/null +++ b/src/controllers/Package/Apt.py @@ -0,0 +1,523 @@ +# coding: utf-8 + +# Import libraries +import apt +import subprocess +import glob +import os +import re +from colorama import Fore, Style + +class Apt: + def __init__(self): + # Create an instance of the apt cache + self.aptcache = apt.Cache() + + # self.aptcache.update() + self.aptcache.open(None) + + # Total count of success and failed package updates + self.summary = { + 'update': { + 'success': { + 'count': 0, + 'packages': [] + }, + 'failed': { + 'count': 0, + 'packages': [] + } + } + } + + # Define some default options + self.dist_upgrade = False + self.keep_oldconf = True + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return list of installed apt packages, sorted by name + # + #------------------------------------------------------------------------------------------------------------------- + def getInstalledPackages(self): + list = [] + + try: + # Loop through all installed packages + for pkg in self.aptcache: + # If the package is installed, add it to the list of installed packages + if pkg.is_installed: + list.append({ + 'name': pkg.name, + 'version': pkg.installed.version, + }) + + # Sort the list by package name + list.sort(key=lambda x: x['name']) + + except Exception as e: + raise Exception('could not get installed packages: ' + str(e)) + + return list + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return list of available apt packages, sorted by name + # + #------------------------------------------------------------------------------------------------------------------- + def getAvailablePackages(self): + try: + list = [] + + # Simulate an upgrade + self.aptcache.upgrade() + + # Loop through all packages marked for upgrade + for pkg in self.aptcache.get_changes(): + # If the package is upgradable, add it to the list of available packages + if pkg.is_upgradable: + myPackage = { + 'name': pkg.name, + 'current_version': pkg.installed.version, + 'available_version': pkg.candidate.version + } + + list.append(myPackage) + + # Sort the list by package name + list.sort(key=lambda x: x['name']) + + except Exception as e: + raise Exception('could not get available packages: ' + str(e)) + + return list + + + #------------------------------------------------------------------------------------------------------------------- + # + # Clear apt cache + # + #------------------------------------------------------------------------------------------------------------------- + def clear_cache(self): + result = subprocess.run( + ["apt", "clean", "all"], + capture_output = True, + text = True + ) + + if result.returncode != 0: + raise Exception('could not clear apt cache: ' + result.stderr) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Update apt cache + # + #------------------------------------------------------------------------------------------------------------------- + def updateCache(self): + try: + self.aptcache.upgrade() + + except Exception as e: + raise Exception('could not update apt cache: ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Get list of excluded packages (hold) + # + #------------------------------------------------------------------------------------------------------------------- + def get_exclude(self): + result = subprocess.run( + ["apt-mark", "showhold"], + capture_output = True, + text = True + ) + + if result.returncode != 0: + raise Exception('could not get excluded (holded) packages list:' + result.stderr) + + return result.stdout.splitlines() + + + #------------------------------------------------------------------------------------------------------------------- + # + # Exclude (hold) specified package + # + #------------------------------------------------------------------------------------------------------------------- + def exclude(self, package): + result = subprocess.run( + ["apt-mark", "hold", package], + capture_output = True, + text = True + ) + + if result.returncode != 0: + raise Exception('could not exclude ' + package + ' package from update: ' + result.stderr) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Remove all package exclusions (unhold) + # + #------------------------------------------------------------------------------------------------------------------- + def remove_all_exclusions(self): + # Retrieve the list of packages on hold + list = self.get_exclude() + + # Quit if there are no packages on hold + if list == '': + return + + # Unhold all packages + for package in list: + result = subprocess.run( + ["apt-mark", "unhold", package], + capture_output = True, + text = True + ) + + if result.returncode != 0: + raise Exception('could not unhold packages: ' + package) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Update packages + # + #------------------------------------------------------------------------------------------------------------------- + def update(self, packagesList, update_method: str = 'one_by_one', exit_on_package_update_error: bool = True): + # If update_method is 'one_by_one', update packages one by one (one command per package) + if update_method == 'one_by_one': + # Loop through the list of packages to update + for pkg in packagesList: + # If the package is excluded, ignore it + if pkg['excluded']: + continue + + print('\n ▪ Updating ' + Fore.GREEN + pkg['name'] + Style.RESET_ALL + ' (' + pkg['current_version'] + ' → ' + pkg['available_version'] + '):') + + # If --keep-oldconf is True, then keep the old configuration file + if self.keep_oldconf: + cmd = ['apt', 'install', pkg['name'], '-y', '-o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold"'] + else: + cmd = ['apt', 'install', pkg['name'], '-y'] + + popen = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=1, universal_newlines=True) + + # Print lines as they are read + for line in popen.stdout: + line = line.replace('\r', '') + print(' | ' + line, end='') + + # Wait for the command to finish + popen.wait() + + # If command failed, either raise an exception or print a warning + if popen.returncode != 0: + + self.summary['update']['failed']['count'] += 1 + + # If error is critical, raise an exception + if (exit_on_package_update_error == True): + raise Exception('error while updating ' + pkg['name']) + + # Else print a warning and continue to the next package + else: + print('error while updating ' + pkg['name']) + continue + + # Close the pipe + popen.stdout.close() + + # If command succeeded, increment the success counter + self.summary['update']['success']['count'] += 1 + + # TODO : à tester + # If update_method is 'global', update all packages at once (one command) + if update_method == 'global': + # If --keep-oldconf is True, then keep the old configuration file + if self.keep_oldconf: + cmd = ['apt', 'upgrade', '-y', '-o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold"'] + else: + cmd = ['apt', 'upgrade', '-y'] + + popen = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=1, universal_newlines=True) + + # Print lines as they are read + for line in popen.stdout: + line = line.replace('\r', '') + print(' | ' + line, end='') + + # Wait for the command to finish + popen.wait() + + # If command failed, either raise an exception or print a warning + if popen.returncode != 0: + # If error is critical, raise an exception + if (exit_on_package_update_error == True): + raise Exception('error while updating packages') + + # Close the pipe + popen.stdout.close() + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return apt history log files sorted by modification time + # + #------------------------------------------------------------------------------------------------------------------- + def get_history(self, order): + try: + files = sorted(glob.glob('/var/log/apt/history.log*'), key=os.path.getmtime) + + # If order is newest, then sort by date in ascending order + if order == 'newest': + files.reverse() + except Exception as e: + raise Exception('could not get apt history log files: ' + str(e)) + + return files + + + #------------------------------------------------------------------------------------------------------------------- + # + # Parse all apt history log files and return a list of events (JSON) + # + #------------------------------------------------------------------------------------------------------------------- + def parse_history(self, history_files: list, entries_limit: int): + # Initialize a limit counter which will be incremented until it reaches the entries_limit + limit_counter = 0 + + # Initialize a list of events + events = [] + + # Parse each apt history files + for history_file in history_files: + # Retrieve all Start-Date in the history file + result = subprocess.run( + ['zgrep "^Start-Date:*" ' + history_file], + capture_output = True, + text = True, + shell = True + ) + + # Quit if an error occurred + if result.returncode != 0: + raise Exception('could not retrieve Start-Date from ' + history_file + ': ' + result.stderr) + + # Split the result into a list + start_dates = result.stdout.strip().split('\n') + + # TODO : peut être pas utile finalement + # ignore_start_date = '' + + for start_date in start_dates: + # Reset all variables for each event + installed_packages = '' + upgraded_packages = '' + removed_packages = '' + purged_packages = '' + downgraded_packages = '' + reinstalled_packages = '' + installed_packages_json = '' + upgraded_packages_json = '' + removed_packages_json = '' + purged_packages_json = '' + downgraded_packages_json = '' + reinstalled_packages_json = '' + + # Quit if the limit of entries to send has been reached + if limit_counter > entries_limit: + break + + # Ignore this entry if it has the same Start-Date as the previous one + # TODO : peut être pas utile finalement + # if start_date == ignore_start_date: + # continue + + # TODO : peut être pas utile finalement + # Before starting to parse, we check if there were not multiple events at the same date in the history file + # result = subprocess.run( + # ['zgrep "' + start_date + '" ' + history_file + ' | wc -l'], + # capture_output = True, + # text = True, + # shell = True + # ) + + # if result.returncode != 0: + # raise Exception('could not count events for ' + start_date + ' in ' + history_file + ': ' + result.stderr) + + # count_event = int(result.stdout.strip()) + # TODO : on met en dur car peut être pas utile finalement + count_event = 1 + + # If there is only one event, we can parse it + if count_event == 1: + # Retrieve the event block : from the start date (START_DATE) to the next empty line + # If the file is compressed, we must use zcat to read it + if history_file.endswith('.gz'): + result = subprocess.run( + ['zcat ' + history_file + ' | sed -n "/' + start_date + '/,/^$/p"'], + capture_output = True, + text = True, + shell = True + ) + + # If the file is not compressed, we can use sed directly + else: + result = subprocess.run( + ['sed -n "/' + start_date + '/,/^$/p" ' + history_file], + capture_output = True, + text = True, + shell = True + ) + + if result.returncode != 0: + raise Exception('could not retrieve event for ' + start_date + ' in ' + history_file + ': ' + result.stderr) + + # Retrieve event block lines + event = result.stdout.strip() + + # From the retrieved event block, we can get the start date and time and the end date and time + date_start = re.search(r'^Start-Date: (.+)', event).group(1).split()[0].strip() + time_start = re.search(r'^Start-Date: (.+)', event).group(1).split()[1].strip() + date_end = re.search(r'End-Date: (.+)', event).group(1).split()[0].strip() + time_end = re.search(r'End-Date: (.+)', event).group(1).split()[1].strip() + + # Retrieve the event command + command = re.search(r'Commandline: (.+)', event).group(1).strip() + + # Retrieve packages installed, removed, upgraded, downgraded, etc. + if re.search(r'Install: (.+)', event): + installed_packages = re.search(r'Install: (.+)', event).group(1).strip() + if re.search(r'Upgrade: (.+)', event): + upgraded_packages = re.search(r'Upgrade: (.+)', event).group(1).strip() + if re.search(r'Remove: (.+)', event): + removed_packages = re.search(r'Remove: (.+)', event).group(1).strip() + if re.search(r'Purge: (.+)', event): + purged_packages = re.search(r'Purge: (.+)', event).group(1).strip() + if re.search(r'Downgrade: (.+)', event): + downgraded_packages = re.search(r'Downgrade: (.+)', event).group(1).strip() + if re.search(r'Reinstall: (.+)', event): + reinstalled_packages = re.search(r'Reinstall: (.+)', event).group(1).strip() + + # TODO : peut être pas utile finalement + # if count_event > 1: + # temporary_file = '/tmp/apt_history_temporary_file' + + # Retrieve all events with the same date into a signle temporary file + + + + # Finally, since we have processed multiple same events from the log file, we ignore all the next events that would be at the same date (so that they are not processed twice) + # ignore_start_date = start_date + + + # Parse packages lists and convert them to JSON + if installed_packages != '': + installed_packages_json = self.parse_packages_line_to_json(installed_packages, 'install') + + if upgraded_packages != '': + upgraded_packages_json = self.parse_packages_line_to_json(upgraded_packages, 'upgrade') + + if removed_packages != '': + removed_packages_json = self.parse_packages_line_to_json(removed_packages, 'remove') + + if purged_packages != '': + purged_packages_json = self.parse_packages_line_to_json(purged_packages, 'purge') + + if downgraded_packages != '': + downgraded_packages_json = self.parse_packages_line_to_json(downgraded_packages, 'downgrade') + + if reinstalled_packages != '': + reinstalled_packages_json = self.parse_packages_line_to_json(reinstalled_packages, 'reinstall') + + # Create the event JSON object + event = { + 'date_start': date_start, + 'time_start': time_start, + 'date_end': date_end, + 'time_end': time_end, + 'command': command + } + + if installed_packages_json != '': + event['installed'] = installed_packages_json + + if upgraded_packages_json != '': + event['upgraded'] = upgraded_packages_json + + if removed_packages_json != '': + event['removed'] = removed_packages_json + + if purged_packages_json != '': + event['purged'] = purged_packages_json + + if downgraded_packages_json != '': + event['downgraded'] = downgraded_packages_json + + if reinstalled_packages_json != '': + event['reinstalled'] = reinstalled_packages_json + + # Add the event to the list of events + events.append(event) + + limit_counter += 1 + + return events + + + #------------------------------------------------------------------------------------------------------------------- + # + # Parse a string of one or multiple package(s) into a list of JSON objects + # + #------------------------------------------------------------------------------------------------------------------- + def parse_packages_line_to_json(self, packages: str, action: str): + packages_json = [] + + # If there is more than one package on the same line + # e.g. + # libc6-i386:amd64 (2.35-0ubuntu3.7, 2.35-0ubuntu3.8), libc6:amd64 (2.35-0ubuntu3.7, 2.35-0ubuntu3.8), libc6:i386 (2.35-0ubuntu3.7, 2.35-0ubuntu3.8), libc-dev-bin:amd64 (2.35-0ubuntu3.7, 2.35-0ubuntu3.8), libc6-dbg:amd64 (2.35-0ubuntu3.7, 2.35-0ubuntu3.8), libc6-dev:amd64 (2.35-0ubuntu3.7, 2.35-0ubuntu3.8) + if re.search(r"\),", packages): + # Split all the packages from the same line into a list + packages = re.sub(r"\),", "\n", packages).split('\n') + + # Else if there is only one package on the same line, just split the line into a list + else: + packages = packages.split('\n') + + # For all packages in the list, retrieve the name and the version + for package in packages: + # First, remove extra spaces + package = package.strip() + + # Then, split the package into name and version + name = package.split(' ')[0] + + # Depending on the action, the version to retrieve is on a different position + if action == 'install' or action == 'remove' or action == 'purge' or action == 'reinstall': + version = package.split(' ')[1] + if action == 'upgrade' or action == 'downgrade': + version = package.split(' ')[2] + + # Remove parenthesis, commas, colons and spaces from name and version + for char in ['(', ')', ',', ' ']: + name = name.replace(char, '') + version = version.replace(char, '') + + # Also remove architecture from name + for arch in [':amd64', ':i386', ':all', ':arm64', ':armhf', ':armel', ':ppc64el', ':s390x', ':mips', ':mips64el', ':mipsel', ':powerpc', ':powerpcspe', ':riscv64', ':s390', ':sparc', ':sparc64']: + if arch in name: + name = name.replace(arch, '') + + # Add the package to the list of packages + packages_json.append({ + 'name': name, + 'version': version + }) + + # Return the list of packages as JSON + return packages_json diff --git a/src/controllers/Package/Dnf.py b/src/controllers/Package/Dnf.py new file mode 100644 index 0000000..ff58035 --- /dev/null +++ b/src/controllers/Package/Dnf.py @@ -0,0 +1,596 @@ +# coding: utf-8 + +# Import libraries +import os +import subprocess +import time +import re +from colorama import Fore, Style +from dateutil import parser as dateutil_parser +import configparser + +class Dnf: + def __init__(self): + # Total count of success and failed package updates + self.summary = { + 'update': { + 'success': { + 'count': 0, + 'packages': [] + }, + 'failed': { + 'count': 0, + 'packages': [] + } + } + } + + #------------------------------------------------------------------------------------------------------------------- + # + # Return list of installed apt packages, sorted by name + # + #------------------------------------------------------------------------------------------------------------------- + def getInstalledPackages(self): + list = [] + + # Get list of installed packages + # e.g. dnf repoquery -q -a --qf="%{name} %{version}-%{release}.%{arch} %{repoid}" --upgrades + result = subprocess.run( + ["dnf", "repoquery", "--installed", "-a", "--qf=%{name} %{epoch}:%{version}-%{release}.%{arch}"], + capture_output = True, + text = True + ) + + # Quit if an error occurred + if result.returncode != 0: + raise Exception('could not get installed packages: ' + result.stderr) + + try: + # Split all lines and parse them + for line in result.stdout.split('\n'): + if line == '': + continue + + # Split package name and version + # e.g: zlib-devel 0:1.2.11-41.el9.x86_64 + package = line.split() + + name = package[0] + version = package[1] + + # Remove epoch if it is equal to 0 + if version.startswith('0:'): + version = version[2:] + + list.append({ + 'name': name, + 'version': version + }) + + # Sort the list by package name + list.sort(key=lambda x: x['name']) + + except Exception as e: + raise Exception('could not get installed packages: ' + str(e)) + + return list + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return list of available dnf packages, sorted by name + # + #------------------------------------------------------------------------------------------------------------------- + def getAvailablePackages(self): + list = [] + + # Get list of packages to update sorted by name + # e.g. dnf repoquery --upgrades --latest-limit 1 -q -a --qf="%{name} %{version}-%{release}.%{arch} %{repoid}" + result = subprocess.run( + ["dnf", "repoquery", "--upgrades", "--latest-limit", "1", "-a", "--qf=%{name} %{version}-%{release}.%{arch} %{repoid}"], + capture_output = True, + text = True + ) + + # Quit if an error occurred + if result.returncode != 0: + raise Exception('could not retrieve available packages list: ' + result.stderr) + + # Split all lines and parse them + for line in result.stdout.split('\n'): + if line == '': + continue + + package = line.split(' ') + + # Retrieve current version with dnf + # e.g. rpm -q --qf="%{version}-%{release}.%{arch}" + result = subprocess.run( + ["rpm", "-q", "--qf=%{version}-%{release}.%{arch}", package[0]], + capture_output = True, + text = True + ) + + # Quit if an error occurred + if result.returncode != 0: + raise Exception('could not retrieve current version of package ' + package[0] + ': ' + result.stderr) + + current_version = result.stdout.strip() + + list.append({ + 'name': package[0], + 'current_version': current_version, + 'available_version': package[1], + 'repository': package[2] + }) + + return list + + + #------------------------------------------------------------------------------------------------------------------- + # + # Clear dnf cache + # + #------------------------------------------------------------------------------------------------------------------- + def clear_cache(self): + # Check if dnf lock is present + self.check_lock + + result = subprocess.run( + ["dnf", "clean", "all"], + capture_output = True, + text = True, + ) + + # Quit if an error occurred + if result.returncode != 0: + raise Exception('Error while clearing dnf cache: ' + result.stderr) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Update dnf cache + # + #------------------------------------------------------------------------------------------------------------------- + def updateCache(self): + # Useless because dnf update command already updates the cache + return + + + #------------------------------------------------------------------------------------------------------------------- + # + # Get list of excluded packages + # + #------------------------------------------------------------------------------------------------------------------- + def get_exclude(self): + # Get dnf.conf file content + try: + # Parse the content of dnf.conf, it's like a ini file + dnf_config = configparser.ConfigParser() + dnf_config.read('/etc/dnf/dnf.conf') + except Exception as e: + raise Exception('could not retrieve /etc/dnf/dnf.conf content: ' + str(e)) + + # If exclude is not present in the file, return an empty list + if not dnf_config.has_option('main', 'exclude'): + return [] + + # Else return the list of excluded packages + return dnf_config.get('main', 'exclude').split(' ') + + + #------------------------------------------------------------------------------------------------------------------- + # + # Exclude specified package + # + #------------------------------------------------------------------------------------------------------------------- + def exclude(self, package): + # Get dnf.conf file content + try: + # Parse the content of dnf.conf, it's like a ini file + dnf_config = configparser.ConfigParser() + dnf_config.read('/etc/dnf/dnf.conf') + except Exception as e: + raise Exception('could not retrieve /etc/dnf/dnf.conf content: ' + str(e)) + + # If exclude is not present in the file, add it + if not dnf_config.has_option('main', 'exclude'): + dnf_config.set('main', 'exclude', '') + + # Add the package to the list of excluded packages + dnf_config.set('main', 'exclude', dnf_config.get('main', 'exclude') + ' ' + package) + + # Write the new content to the file + try: + with open('/etc/dnf/dnf.conf', 'w') as configfile: + dnf_config.write(configfile) + except Exception as e: + raise Exception('could not write to /etc/dnf/dnf.conf: ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Remove all package exclusions + # + #------------------------------------------------------------------------------------------------------------------- + def remove_all_exclusions(self): + # Get dnf.conf file content + try: + # Parse the content of dnf.conf, it's like a ini file + dnf_config = configparser.ConfigParser() + dnf_config.read('/etc/dnf/dnf.conf') + except Exception as e: + raise Exception('could not retrieve /etc/dnf/dnf.conf content: ' + str(e)) + + # Remove the exclude option from the file + if dnf_config.has_option('main', 'exclude'): + dnf_config.remove_option('main', 'exclude') + + # Write the new content to the file + try: + with open('/etc/dnf/dnf.conf', 'w') as configfile: + dnf_config.write(configfile) + except Exception as e: + raise Exception('could not write to /etc/dnf/dnf.conf: ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Update packages + # + #------------------------------------------------------------------------------------------------------------------- + def update(self, packagesList, update_method: str = 'one_by_one', exit_on_package_update_error: bool = True): + # If update_method is 'one_by_one', update packages one by one (one command per package) + if update_method == 'one_by_one': + # Loop through the list of packages to update + for pkg in packagesList: + # If the package is excluded, ignore it + if pkg['excluded']: + continue + + print('\n ▪ Updating ' + Fore.GREEN + pkg['name'] + Style.RESET_ALL + ' (' + pkg['current_version'] + ' → ' + pkg['available_version'] + '):') + + # Define the command to update the package + cmd = ['dnf', 'update', pkg['name'], '-y'] + + popen = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=1, universal_newlines=True) + + # Print lines as they are read + for line in popen.stdout: + line = line.replace('\r', '') + print(' | ' + line, end='') + + # Wait for the command to finish + popen.wait() + + # If command failed, either raise an exception or print a warning + if popen.returncode != 0: + + self.summary['update']['failed']['count'] += 1 + + # Print error message for the package + print(' ▪ Error while updating ' + Fore.RED + pkg['name'] + Style.RESET_ALL) + + # If error is critical, raise an exception to quit + if (exit_on_package_update_error == True): + raise Exception('error while updating ' + pkg['name']) + + # Else continue to the next package + else: + continue + + # Close the pipe + popen.stdout.close() + + # If command succeeded, increment the success counter + self.summary['update']['success']['count'] += 1 + + # Print success message for the package + print(' ▪ ' + Fore.GREEN + pkg['name'] + Style.RESET_ALL + ' updated successfully') + + # TODO : à tester + # If update_method is 'global', update all packages at once (one command) + if update_method == 'global': + # Define the command to update all packages + cmd = ['dnf', 'update', '-y'] + + popen = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=1, universal_newlines=True) + + # Print lines as they are read + for line in popen.stdout: + line = line.replace('\r', '') + print(' | ' + line, end='') + + # Wait for the command to finish + popen.wait() + + # If command failed, either raise an exception or print a warning + if popen.returncode != 0: + # If error is critical, raise an exception to quit + if (exit_on_package_update_error == True): + raise Exception('error while updating packages') + + # Close the pipe + popen.stdout.close() + + + #------------------------------------------------------------------------------------------------------------------- + # + # Wait for DNF lock to be released + # + #------------------------------------------------------------------------------------------------------------------- + def check_lock(self): + if os.path.isfile('/var/run/dnf.pid'): + print(' Waiting for dnf lock...', end=' ') + + while os.path.isfile('/var/run/dnf.pid'): + time.sleep(2) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return dnf history Ids sorted by modification time + # + #------------------------------------------------------------------------------------------------------------------- + def get_history(self, order): + # Get history IDs + result = subprocess.run( + ["dnf history list | tail -n +3 | awk '{print $1}'"], + capture_output = True, + text = True, + shell = True + ) + + # Quit if an error occurred + if result.returncode != 0: + raise Exception('could nt retrieve dnf history: ' + result.stderr) + + # Retrieve history IDs + ids = result.stdout.splitlines() + + # If order is oldest, then sort by date in ascending order + if order == 'oldest': + ids.reverse() + + return ids + + + #------------------------------------------------------------------------------------------------------------------- + # + # Parse all dnf history IDs and return a list of events (JSON) + # + #------------------------------------------------------------------------------------------------------------------- + def parse_history(self, ids: list, entries_limit: int): + # Initialize a limit counter which will be incremented until it reaches the entries_limit + limit_counter = 0 + + # Initialize a list of events + events = [] + + # Parse each ids + for id in ids: + installed_packages_json = [] + installed_dependencies_json = [] + upgraded_packages_json = [] + removed_packages_json = [] + downgraded_packages_json = [] + reinstalled_packages_json = [] + + # Quit if the limit of entries to send has been reached + if limit_counter > entries_limit: + break + + # Retrieve informations from dnf history + # Force the locale to en_US.UTF-8 to avoid parsing issues + result = subprocess.run( + ["dnf", "history", "info", id], + capture_output = True, + text = True, + env = {'LC_ALL': 'en_US.UTF-8'} + ) + + # Quit if an error occurred + if result.returncode != 0: + raise Exception('could not retrieve dnf history for id ' + id + ': ' + result.stderr) + + # Retrieve event + event = result.stdout + + # Remove '**' is present in the event string + event = event.replace('**', '') + + # Skip if cannot retrieve event date and time + if not re.search(r'^Begin time(.+)', event, re.MULTILINE): + raise Exception('error parsing dnf event id #' + id + ': could not retrieve event date and time') + + # Skip if cannot retrieve command line + if not re.search(r'^Command Line(.+)', event, re.MULTILINE): + raise Exception('error parsing dnf event id #' + id + ': could not retrieve command line') + + # Skip if cannot retrieve packages altered + if not re.search(r'Packages Altered.*', event, re.DOTALL): + raise Exception('error parsing dnf event id #' + id + ': could not find any packages altered in the event') + + # Retrieve event date and time + date_time = re.search(r'^Begin time(.+)', event, re.MULTILINE).group(0).strip() + # Remove extra spaces and 'Begin time : ' string + date_time = str(date_time.replace(' ', '').replace('Begin time : ', '')) + + # Retrieve command line + command = re.search(r'^Command Line(.+)', event, re.MULTILINE).group(0).strip() + command = str(command.replace(' ', '').replace('Command Line :', '')).strip() + + # Retrieve packages altered + packages_altered = re.search(r'Packages Altered.*', event, re.DOTALL).group(0).strip() + packages_altered = re.sub(r' +', ' ', packages_altered) + + # Parsing and formatting + + # Convert date to %Y-%m-%d format + date_time_parsed = dateutil_parser.parse(date_time) + date = date_time_parsed.strftime('%Y-%m-%d') + time = date_time_parsed.strftime('%H:%M:%S') + + # Skip if there is no lines containing 'Install', 'Dep-Install', 'Upgraded', 'Upgrade', 'Obsoleting', 'Erase', 'Removed', 'Downgrade', 'Reinstall' + # Note: on CentOS7, it was 'Update' and 'Updated' instead of 'Upgrade' and 'Upgraded' + if not re.search(r'^ +(Install|Dep-Install|Upgraded|Upgrade|Obsoleting|Erase|Removed|Downgrade|Reinstall) .*', packages_altered, re.MULTILINE): + raise Exception('error parsing dnf event id #' + id + ': could not find any operation lines in the event') + + # For each lines of packages_altered + for line in packages_altered.split('\n'): + package_and_version = '' + package_name = '' + package_version = '' + repository = '' + operation= '' + + # If line starts with Install + if re.search(r'^ +Install .*', line): + package_and_version = re.search(r'^ +Install (.+)', line).group(0).strip().replace('Install ', '') + operation = 'install' + + # If line starts with Dep-Install + elif re.search(r'^ +Dep-Install .*', line): + package_and_version = re.search(r'^ +Dep-Install (.+)', line).group(0).strip().replace('Dep-Install ', '') + operation = 'dep-install' + + # If line starts with Upgrade + elif re.search(r'^ +Upgrade .*', line): + package_and_version = re.search(r'^ +Upgrade (.+)', line).group(0).strip().replace('Upgrade ', '') + operation = 'upgrade' + + # If line starts with Update + # elif re.search(r'^ +Update .*', line): + # package_and_version = re.search(r'^ +Update (.+)', line).group(0).strip().replace('Update ', '') + + # If line starts with Obsoleting + elif re.search(r'^ +Obsoleting .*', line): + package_and_version = re.search(r'^ +Obsoleting (.+)', line).group(0).strip().replace('Obsoleting ', '') + operation = 'obsoleting' + + # If line starts with Erase + # elif re.search(r'^ +Erase .*', line): + # package_and_version = re.search(r'^ +Erase (.+)', line).group(0).strip().replace('Erase ', '') + # operation = 'erase' + + # If line starts with Removed + elif re.search(r'^ +Removed .*', line): + package_and_version = re.search(r'^ +Removed (.+)', line).group(0).strip().replace('Removed ', '') + operation = 'remove' + + # If line starts with Downgrade + elif re.search(r'^ +Downgrade .*', line): + package_and_version = re.search(r'^ +Downgrade (.+)', line).group(0).strip().replace('Downgrade ', '') + operation = 'downgrade' + + # If line starts with Reinstall + elif re.search(r'^ +Reinstall .*', line): + package_and_version = re.search(r'^ +Reinstall (.+)', line).group(0).strip().replace('Reinstall ', '') + operation = 'reinstall' + + else: + continue + + # Remove extra spaces + package_and_version = package_and_version.strip() + + # Skip if package_and_version is empty + if package_and_version == '': + raise Exception('error parsing dnf event id #' + id + ': cannot retrieve package and version for line:\n' + line) + + # Skip if string starts with '@' + if package_and_version.startswith('@'): + continue + + # Retrieve package name, version and repository from package_and_version + package_name = re.sub(r'-[0-9].*', '', package_and_version).strip() + package_version_and_repository = re.sub(r'^-', '', package_and_version.replace(package_name, '')).strip() + + # Retrieve repository and package version + package_version = package_version_and_repository.split()[0].strip() + repository = package_version_and_repository.split()[1].strip() + + # Raise exception if package_name or package_version is empty + if package_name == '': + raise Exception('error parsing dnf event id #' + id + ': cannot retrieve package name for line:\n' + line) + + if package_version == '': + raise Exception('error parsing dnf event id #' + id + ': cannot retrieve package version for line:\n' + line) + + # Add package to the corresponding list + if operation == 'install': + installed_packages_json.append({ + 'name': package_name, + 'version': package_version, + 'repo': repository + }) + + if operation == 'dep-install': + installed_dependencies_json.append({ + 'name': package_name, + 'version': package_version, + 'repo': repository + }) + + if operation == 'upgrade': + upgraded_packages_json.append({ + 'name': package_name, + 'version': package_version, + 'repo': repository + }) + + if operation == 'remove': + removed_packages_json.append({ + 'name': package_name, + 'version': package_version, + }) + + if operation == 'downgrade': + downgraded_packages_json.append({ + 'name': package_name, + 'version': package_version, + }) + + if operation == 'reinstall': + reinstalled_packages_json.append({ + 'name': package_name, + 'version': package_version, + 'repo': repository + }) + + # TODO + # if operation == 'obsoleting': + + # Create the event JSON object + event = { + 'date_start': date, + 'time_start': time, + 'date_end': '', + 'time_end': '', + 'command': command, + } + + if installed_packages_json != '': + event['installed'] = installed_packages_json + + if installed_dependencies_json != '': + event['dep_installed'] = installed_dependencies_json + + if upgraded_packages_json != '': + event['upgraded'] = upgraded_packages_json + + if removed_packages_json != '': + event['removed'] = removed_packages_json + + if downgraded_packages_json != '': + event['downgraded'] = downgraded_packages_json + + if reinstalled_packages_json != '': + event['reinstalled'] = reinstalled_packages_json + + # Add the event to the list of events + events.append(event) + + limit_counter += 1 + + return events diff --git a/src/controllers/Package/Package.py b/src/controllers/Package/Package.py new file mode 100644 index 0000000..f997fe1 --- /dev/null +++ b/src/controllers/Package/Package.py @@ -0,0 +1,294 @@ +# coding: utf-8 + +# https://github.com/excid3/python-apt/blob/master/doc/examples/inst.py + +# Import libraries +from tabulate import tabulate +from colorama import Fore, Style +import re + +# Import classes +from src.controllers.System import System +from src.controllers.App.Config import Config +from src.controllers.Exit import Exit + +class Package: + def __init__(self): + self.systemController = System() + self.appConfigController = Config() + self.exitController = Exit() + + # Import libraries depending on the OS family + + # If Debian, import apt + if (self.systemController.getOsFamily() == 'Debian'): + from src.controllers.Package.Apt import Apt + self.myPackageManagerController = Apt() + + # If Redhat, import yum + if (self.systemController.getOsFamily() == 'Redhat'): + from src.controllers.Package.Dnf import Dnf + self.myPackageManagerController = Dnf() + + #------------------------------------------------------------------------------------------------------------------- + # + # Check for package exclusions + # + #------------------------------------------------------------------------------------------------------------------- + def exclude(self, ignore_exclude): + try: + # Create a new empty list of packages to update + packagesToUpdateList = [] + + # Retrieve the list of packages to exclude from the config file + configuration = self.appConfigController.getConf() + excludeAlways = configuration['update']['packages']['exclude']['always'] + excludeOnMajorUpdate = configuration['update']['packages']['exclude']['on_major_update'] + + # Loop through the list of packages to update + for package in self.packagesToUpdateList: + excluded = False + + # Check for exclusions and exclude packages only if the ignore_exclude parameter is False + if not ignore_exclude: + # If the package is in the list of packages to exclude (on major update), check if the available version is a major update + if excludeOnMajorUpdate: + # There can be regex in the excludeOnMajorUpdate list (e.g. apache.*), so we need to convert it to a regex pattern + # https://www.geeksforgeeks.org/python-check-if-string-matches-regex-list/ + regex = '(?:% s)' % '|'.join(excludeOnMajorUpdate) + + # Check if the package name matches the regex pattern + if re.match(regex, package['name']): + # Retrieve the first digit of the current and available versions + # If the first digit is different then it is a major update, exclude the package + if package['current_version'].split('.')[0] != package['available_version'].split('.')[0]: + self.myPackageManagerController.exclude(package['name']) + excluded = True + + # If the package is in the list of packages to exclude (always), exclude it + if excludeAlways: + # There can be regex in the excludeAlways list (e.g. apache.*), so we need to convert it to a regex pattern + # https://www.geeksforgeeks.org/python-check-if-string-matches-regex-list/ + regex = '(?:% s)' % '|'.join(excludeAlways) + + # Check if the package name matches the regex pattern + if re.match(regex, package['name']): + self.myPackageManagerController.exclude(package['name']) + excluded = True + + # Add the package to the list of packages to update + packagesToUpdateList.append({ + 'name': package['name'], + 'current_version': package['current_version'], + 'available_version': package['available_version'], + 'excluded': excluded + }) + + # Replace the list of packages to update with the new list + self.packagesToUpdateList = packagesToUpdateList + + del configuration, excludeAlways, excludeOnMajorUpdate, packagesToUpdateList + except Exception as e: + raise Exception('error while excluding packages: ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Remove all package exclusions + # + #------------------------------------------------------------------------------------------------------------------- + def remove_all_exclusions(self): + try: + # Remove all exclusions + self.myPackageManagerController.remove_all_exclusions() + except Exception as e: + raise Exception('could not remove all package exclusions: ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Get installed packages + # + #------------------------------------------------------------------------------------------------------------------- + def getInstalledPackages(self): + try: + # Get a list of installed packages + return self.myPackageManagerController.getInstalledPackages() + + except Exception as e: + raise Exception('error while getting installed packages: ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Get available packages + # + #------------------------------------------------------------------------------------------------------------------- + def getAvailablePackages(self): + try: + # First, clear package manager cache + self.myPackageManagerController.updateCache() + + # Get a list of available packages + return self.myPackageManagerController.getAvailablePackages() + + except Exception as e: + raise Exception('error while retrieving available packages: ' + str(e)) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Update packages + # + #------------------------------------------------------------------------------------------------------------------- + def update(self, assume_yes: bool = False, ignore_exclude: bool = False, check_updates: bool = False, dist_upgrade: bool = False, keep_oldconf: bool = True): + # Package update summary + self.summary = { + 'update': { + 'status': 'running', + 'success': { + 'count': 0, + 'packages': [] + }, + 'failed': { + 'count': 0, + 'packages': [] + } + } + } + + try: + # Retrieve configuration + configuration = self.appConfigController.getConf() + + # Retrieve the update method + update_method = configuration['update']['method'] + + # Retrieve the exit_on_package_update_error option + exit_on_package_update_error = configuration['update']['exit_on_package_update_error'] + + # Remove all exclusions before starting (could be some left from a previous run that failed) + self.remove_all_exclusions() + + # Retrieve available packages + self.packagesToUpdateList = self.getAvailablePackages() + + # Check for package exclusions + self.exclude(ignore_exclude) + + # Count packages to update and packages excluded + self.packagesToUpdateCount = 0 + self.packagesExcludedCount = 0 + + for package in self.packagesToUpdateList: + if 'excluded' in package and package['excluded']: + self.packagesExcludedCount += 1 + else: + self.packagesToUpdateCount += 1 + + # Print the number of packages to update + print('\n ' + Fore.GREEN + str(self.packagesToUpdateCount) + Style.RESET_ALL + ' packages will be updated, ' + Fore.YELLOW + str(self.packagesExcludedCount) + Style.RESET_ALL + ' will be excluded \n') + + # Convert the list of packages to a table + table = [] + for package in self.packagesToUpdateList: + # If package is excluded + if 'excluded' in package and package['excluded']: + installOrExclude = Fore.YELLOW + '✕ (excluded)' + Style.RESET_ALL + else: + installOrExclude = Fore.GREEN + '✔' + Style.RESET_ALL + + table.append(['', package['name'], package['current_version'], package['available_version'], installOrExclude]) + + # Print the table list of packages to update + # TODO : check prettytable for table with width control https://pypi.org/project/prettytable/ + print(tabulate(table, headers=["", "Package", "Current version", "Available version", "Install decision"], tablefmt="simple"), end='\n\n') + + # Quit if there are no packages to update + if self.packagesToUpdateCount == 0: + print(Fore.GREEN + ' No package updates \n' + Style.RESET_ALL) + self.summary['status'] = 'nothing-to-do' + + # Remove all exclusions before exiting + self.remove_all_exclusions() + + return + + # Quit if --check-updates param has been specified + if check_updates == True: + # Remove all exclusions before exiting + # self.remove_all_exclusions() + self.exitController.cleanExit(0, False) + + # If --assume-yes param has not been specified, then ask for confirmation before installing the printed packages update list + if not assume_yes: + # Ask for confirmation + print('\n ' + Fore.YELLOW + 'Update now [y/N]' + Style.RESET_ALL, end=' ') + + answer = input() + + # Quit if the answer is not 'y' + if answer.lower() != 'y': + print(Fore.YELLOW + ' Cancelled' + Style.RESET_ALL) + # Remove all exclusions before exiting + self.remove_all_exclusions() + self.exitController.cleanExit(0, False) + + print('\n Updating packages...') + + # Execute the packages update + self.myPackageManagerController.dist_upgrade = dist_upgrade + self.myPackageManagerController.keep_oldconf = keep_oldconf + self.myPackageManagerController.update(self.packagesToUpdateList, update_method, exit_on_package_update_error) + + print('\n' + Fore.GREEN + ' Packages update completed' + Style.RESET_ALL) + + # Update the summary status + self.summary['update']['status'] = 'done' + + except Exception as e: + print('\n' + Fore.RED + ' Packages update failed: ' + str(e) + Style.RESET_ALL) + self.summary['update']['status'] = 'failed' + + # If update method is 'one_by_one', it will be possible to print precise information about the number of packages updated and failed + if update_method == 'one_by_one': + # Update the summary with the number of packages updated and failed + self.summary['update']['success']['count'] = self.myPackageManagerController.summary['update']['success']['count'] + self.summary['update']['failed']['count'] = self.myPackageManagerController.summary['update']['failed']['count'] + + # Print the number of packages updated and failed + # If there was a failed package, print the number in red + if self.summary['update']['failed']['count'] > 0: + print('\n ' + Fore.GREEN + str(self.summary['update']['success']['count']) + Style.RESET_ALL + ' packages updated, ' + Fore.RED + str(self.summary['update']['failed']['count']) + Style.RESET_ALL + ' packages failed' + Style.RESET_ALL) + else: + print('\n ' + Fore.GREEN + str(self.summary['update']['success']['count']) + Style.RESET_ALL + ' packages updated, ' + str(self.summary['update']['failed']['count']) + ' packages failed' + Style.RESET_ALL) + + # If update method is 'global', just print success or failure + if update_method == 'global': + # If there was a failed package, print the number in red + if self.summary['update']['status'] == 'done': + print('\n ' + Fore.GREEN + 'All packages updated' + Style.RESET_ALL) + else: + print('\n ' + Fore.RED + 'Some packages failed to update' + Style.RESET_ALL) + + # If there was a failed package update and the package update error is critical (set to true), then raise an exception to exit + if exit_on_package_update_error == True and self.summary['update']['failed']['count'] > 0: + raise Exception('Critical error: package update failed') + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return history items (log file or history Ids) for a specific order + # + #------------------------------------------------------------------------------------------------------------------- + def get_history(self, order): + return self.myPackageManagerController.get_history(order) + + + #------------------------------------------------------------------------------------------------------------------- + # + # Parse history entries + # + #------------------------------------------------------------------------------------------------------------------- + def parse_history(self, entries, entries_limit): + return self.myPackageManagerController.parse_history(entries, entries_limit) diff --git a/src/controllers/Service/Service.py b/src/controllers/Service/Service.py new file mode 100644 index 0000000..689ac7d --- /dev/null +++ b/src/controllers/Service/Service.py @@ -0,0 +1,75 @@ +# coding: utf-8 + +# Import libraries +import subprocess +import re +from colorama import Fore, Style + +# Import classes +from src.controllers.App.Config import Config + +class Service: + #------------------------------------------------------------------------------------------------------------------- + # + # Restart services + # + #------------------------------------------------------------------------------------------------------------------- + def restart(self, update_summary: list): + # Retrieve services to restart + services = Config().getServiceToRestart() + + # Retrieve updated packages list from update summary + updated_packages = update_summary['update']['success']['packages'] + + # Quit if no packages were updated + if not updated_packages: + return + + print('\n Restarting services') + + # If no services to restart, skip + if not services: + print(' ▪ No services to restart') + return + + # Restart services + for service in services: + # Check if there is a condition to restart the service (got a : in the service name) + if ':' in service: + # Split service name and package name + service, package = service.split(':') + + # Check if the package is in the list of updated packages + regex = '(?:% s)' % '|'.join(updated_packages) + + # If the package is not in the list of updated packages, skip the service + if not re.match(regex, package): + continue + + print(' ▪ Restarting ' + Fore.YELLOW + service + Style.RESET_ALL + ':', end=' ') + + # Check if service is active + result = subprocess.run( + ["systemctl", "is-active", service], + capture_output = True, + text = True + ) + + # If service is unknown or inactive, skip it + if result.returncode != 0: + print('service does not exist or is not active') + continue + + # Restart service + result = subprocess.run( + ["systemctl", "restart", service, "--quiet"], + capture_output = True, + text = True + ) + + # If service failed to restart, print error message + if result.returncode != 0: + print(Fore.RED + 'failed with error: ' + Style.RESET_ALL + result.stderr) + continue + + print(Fore.GREEN + 'done' + Style.RESET_ALL) diff --git a/src/controllers/Service/__init__.py b/src/controllers/Service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/controllers/System.py b/src/controllers/System.py new file mode 100644 index 0000000..415cf07 --- /dev/null +++ b/src/controllers/System.py @@ -0,0 +1,122 @@ +# coding: utf-8 + +# Import libraries +import os +import platform +import distro +import subprocess + +class System: + #------------------------------------------------------------------------------------------------------------------- + # + # Return true if current user is root or sudo + # + #------------------------------------------------------------------------------------------------------------------- + def isRoot(self): + if os.geteuid() == 0: + return True + + return False + + + #------------------------------------------------------------------------------------------------------------------- + # + # Check if the program is running on a supported Linux distribution + # + #------------------------------------------------------------------------------------------------------------------- + def check(self): + # Check if the program is running on Linux + if (platform.system() != 'Linux'): + raise Exception('This program only works on Linux') + + # Check if the program is running on a supported Linux distribution (will raise an exception if not supported) + self.getOsFamily() + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return the OS family + # + #------------------------------------------------------------------------------------------------------------------- + def getOsFamily(self): + if (distro.name() in ['Debian GNU/Linux', 'Debian', 'Ubuntu', 'Kubuntu', 'Xubuntu', 'Linux Mint']): + return 'Debian' + + if (distro.name() in ['Red Hat Enterprise Linux', 'Centos', 'CentOS Stream', 'Fedora', 'Alma Linux', 'Rocky Linux', 'Oracle Linux Server']): + return 'Redhat' + + raise Exception('This program does not support your Linux distribution "' + distro.name() + '" yet.') + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return the OS name + # + #------------------------------------------------------------------------------------------------------------------- + def getOsName(self): + return distro.name() + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return the OS version + # + #------------------------------------------------------------------------------------------------------------------- + def getOsVersion(self): + return distro.version() + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return the kernel version + # + #------------------------------------------------------------------------------------------------------------------- + def getKernel(self): + return platform.release() + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return the architecture + # + #------------------------------------------------------------------------------------------------------------------- + def getArch(self): + return platform.machine() + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return the virtualization type + # + #------------------------------------------------------------------------------------------------------------------- + def getVirtualization(self): + # Detect virtualization type + if os.path.isfile("/usr/sbin/virt-what"): + virt = os.popen('/usr/sbin/virt-what').read().replace('\n', ' ') + if not virt: + virt = "Bare-metal" + + return virt + + + #------------------------------------------------------------------------------------------------------------------- + # + # Return True if a reboot is required + # + #------------------------------------------------------------------------------------------------------------------- + def rebootRequired(self): + if self.getOsFamily() == 'Debian' and os.path.isfile('/var/run/reboot-required'): + return True + + if self.getOsFamily() == 'Redhat' and os.path.isfile('/usr/bin/needs-restarting'): + result = subprocess.run( + ["/usr/bin/needs-restarting", "-r"], + capture_output = True, + text = True + ) + + if result.returncode != 0: + return True + + return False + \ No newline at end of file diff --git a/src/controllers/__init__.py b/src/controllers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/version b/version index 3c3ae88..56fea8a 100644 --- a/version +++ b/version @@ -1 +1 @@ -2.2.13 \ No newline at end of file +3.0.0 \ No newline at end of file