├── .gitignore ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── ftp ├── dockerfile ├── start_vsftpd.sh └── vsftpd.conf ├── http ├── default.conf └── dockerfile ├── nginx-unit.json ├── requirements.txt ├── rq.sh ├── setup.py ├── software_manager ├── __init__.py ├── api │ ├── __init__.py │ ├── serializers.py │ ├── urls.py │ └── views.py ├── choices.py ├── filtersets.py ├── forms.py ├── logger.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── navigation.py ├── tables.py ├── task_exceptions.py ├── task_executor.py ├── templates │ └── software_manager │ │ ├── my_render_field.html │ │ ├── scheduledtask.html │ │ ├── scheduledtask_add.html │ │ ├── scheduledtask_list.html │ │ ├── softwareimage.html │ │ ├── upgradedevice_list.html │ │ └── widgets │ │ └── clearable_file_input.html ├── templatetags │ ├── __init__.py │ └── my_form_helpers.py ├── urls.py ├── views.py └── worker.py ├── static ├── golden_images.png ├── scheduled_task_add.png ├── scheduled_task_info.png ├── scheduled_task_list.png ├── software_add.png ├── software_details.png ├── software_repository.png └── upgrade_devices.png └── upgrade.log /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea/ 3 | .vscode/ 4 | setup.cfg 5 | netbox-software-manager.code-workspace 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | pip-wheel-metadata/ 30 | share/python-wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .nox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 101 | __pypackages__/ 102 | 103 | # Celery stuff 104 | celerybeat-schedule 105 | celerybeat.pid 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | .env 112 | .venv 113 | env/ 114 | venv/ 115 | ENV/ 116 | env.bak/ 117 | venv.bak/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | requirements-dev.txt 137 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM netboxcommunity/netbox:v3.4.3-2.4.0 2 | 3 | COPY requirements.txt /requirements.txt 4 | RUN /opt/netbox/venv/bin/python -m pip install -r /requirements.txt 5 | 6 | RUN mkdir /opt/netbox/netbox/media/software-images/ 7 | RUN chown -R unit:root /opt/netbox/netbox/media/software-images 8 | 9 | RUN echo '\n\ 10 | RQ_QUEUES["software_manager"]=RQ_PARAMS\n\ 11 | ' >> /opt/netbox/netbox/netbox/settings.py 12 | 13 | #--SoftwareManager 14 | COPY ./software_manager/ /source/SoftwareManager/software_manager/ 15 | COPY ./setup.py /source/SoftwareManager/ 16 | COPY ./README.md /source/SoftwareManager/ 17 | COPY ./MANIFEST.in /source/SoftwareManager/ 18 | 19 | #--Pip 20 | RUN /opt/netbox/venv/bin/python -m pip install /source/SoftwareManager/ 21 | 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alexander Ignatov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | recursive-include software_manager/templates * 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Software Manager - NetBox plugin 2 | 3 | [NetBox](https://github.com/netbox-community/netbox) plugin to manage software for your Cisco devices in following aspects: 4 | 5 | - software repository 6 | - assign target (golden) image on created Device Types 7 | - scheduled upload golden image on devices 8 | - scheduled reload devices with golden image 9 | 10 | ## Important notes 11 | 12 | 1. Developed on NetBox 3.4.3 and compatible (probably) with other versions, but not tested. 13 | 2. Plugin works with standalone IOS/IOS-XE boxes only. No Stacks/VSS/StackWiseVirtual at present. 14 | 3. Plugin operates with software version only, and does not consider feature set (lanlite vs lanbase, K9 vs NPE). 15 | 4. Test in your local sandbox, do not jump to production network. 16 | 5. Use this plugin on production network at one's own risk with take responsibility for any potential outages due to reload of devices. 17 | 6. Don't hesitate to contact me for any bugs/feature requests. 18 | 19 | ## Software Repository 20 | 21 | ### Main page 22 | 23 | 24 | 25 | ### Add/Edit software page 26 | 27 | 28 | 29 | Select *.bin file, specify expected MD5 (from cisco site) and verbose version. Plugin calculates MD5 when file uploading and result should matches with entered MD5, otherwise MD5 will be redded. 30 | 31 | ### Software image details 32 | 33 | 34 | 35 | ## Golden Images 36 | 37 | ### Main page 38 | 39 | 40 | 41 | Assigned Image/Version for all created DeviceTypes with upgrade progress for particular DeviceType. 42 | 43 | ## Upgrade Devices 44 | 45 | ### Main page 46 | 47 | 48 | 49 | provides information about Target/Current versions (green = match, yellow = upgrade is required) 50 | 52 | 53 | ### Add scheduled task 54 | 55 | 56 | 57 | - Select job type: 58 | 59 | - upload (transfer golden image to the box) 60 | - upgrade (reload with golden image w/o transfer) 61 | 62 | - select time to start or set "Start Now". Time is based on NetBox TimeZone, not your browser/hostPC. 63 | - select MW duration. All tasks will be skipped after this time (countdown starts from scheduled time, not from time of creation tasks) 64 | 65 | ### Scheduled tasks list 66 | 67 | 68 | 69 | List of all scheduled/completed tasks. 70 | 71 | > Tasks with Running status can be deleted in admin view only. 72 | 73 | > Plugin has acknowledgment logic to try to prevent mass outage. ACK flag become True only in case of getting expected result. In case of any unknown error/traceback job will be finished with ACK=False. Any new job checks number of non-ACK and can be skpped if this number crossed threshold. ACK flag can be changed manually by clicking on "V" or "X". 74 | 75 | ### Scheduled tasks info 76 | 77 | 78 | 79 | Detailed information about task and execution log. 80 | 81 | # Installation 82 | 83 | Instruction below describes installation for Docker-compose instance of NetBox. 84 | 85 | For bare-metal setup pip can be used for installation. But some changes (ftp/redis etc) will be requared anyway. 86 | ```shell 87 | pip install netbox-plugin-software-manager 88 | ``` 89 | 90 | ## 1. Create new docker image based on latest-ldap netbox image 91 | 92 | ```shell 93 | cd {{ your-netbox-locaton }} 94 | git clone https://github.com/alsigna/netbox-software-manager.git 95 | cd netbox-software-manager 96 | docker build -t netbox-plugin . 97 | ``` 98 | > Dockerfile: 99 | > 100 | > ```dockerfile 101 | > FROM netboxcommunity/netbox:v3.4.3-2.4.0 102 | > 103 | > # install scrapli[paramiko], scrapli[textfsm] 104 | > COPY requirements.txt /requirements.txt 105 | > RUN /opt/netbox/venv/bin/python -m pip install -r /requirements.txt 106 | > 107 | > # make folder for image location. Should be in django media folder 108 | > # folder name (software-images in example). 109 | > RUN mkdir /opt/netbox/netbox/media/software-images/ 110 | > RUN chown -R unit:unit /opt/netbox/netbox/media/software-images 111 | > 112 | > # Add additional queue (software_manager in example). This name should be copied to NetBox configuration.py. 113 | > RUN echo '\n\ 114 | > RQ_QUEUES["software_manager"]=RQ_PARAMS\n\ 115 | > ' >> /opt/netbox/netbox/netbox/settings.py 116 | > 117 | > # Install plugin from local repository 118 | > #--SoftwareManager 119 | > COPY ./software_manager/ /source/SoftwareManager/software_manager/ 120 | > COPY ./setup.py /source/SoftwareManager/ 121 | > COPY ./README.md /source/SoftwareManager/ 122 | > COPY ./MANIFEST.in /source/SoftwareManager/ 123 | > 124 | > #--Pip 125 | > RUN /opt/netbox/venv/bin/python -m pip install /source/SoftwareManager/ 126 | 127 | ## 2. Create FTP or HTTP docker image 128 | 129 | for FTP [docker-alpine-ftp-server](https://github.com/delfer/docker-alpine-ftp-server) is used. For HTTP nginx base image is used. 130 | 131 | >Originally scp was used to transfer files, but based on experience, FTP/HTTP is much faster. 132 | 133 | ```shell 134 | cd ftp 135 | docker build -t ftp_for_netbox . 136 | cd ../../ 137 | ``` 138 | 139 | or 140 | 141 | ```shell 142 | cd http 143 | docker build -t http_for_netbox . 144 | cd ../../ 145 | ``` 146 | 147 | ## 3. Change docker-compose.yml 148 | 149 | ```dockerfile 150 | ... 151 | netbox-server: &netbox-server 152 | # Change image name to "customized" image from step 1. 153 | image: netbox-plugin 154 | volumes: 155 | # Mount log file. Path have to match with specified in configuration.py 156 | - ./netbox-software-manager/upgrade.log:/var/log/upgrade.log:z 157 | # Replace nginx-unit config 158 | - ./netbox-software-manager/nginx-unit.json:/etc/unit/nginx-unit.json:z,ro 159 | # Mount folder with IOS images. NetBox will upload/delete images. 160 | # Format: /opt/netbox/netbox/media/{{ IMAGE_FOLDER }} 161 | - ./netbox-software-manager/software-images:/opt/netbox/netbox/media/software-images:z 162 | # Mount script for rq 163 | - ./netbox-software-manager/rq.sh:/etc/netbox/rq.sh:z,ro 164 | ... 165 | 166 | netbox-worker: 167 | # Run script instead of single worker 168 | command: /bin/bash /etc/netbox/rq.sh 169 | # Comment original config: 170 | # entrypoint: 171 | # - /opt/netbox/venv/bin/python 172 | # - /opt/netbox/netbox/manage.py 173 | # command: 174 | # - rqworker 175 | ... 176 | 177 | # FTP server from step 2. 178 | ftp: 179 | image: ftp_for_netbox 180 | ports: 181 | - "21:21" 182 | - "21000-21100:21000-21100" 183 | privileged: true 184 | volumes: 185 | # Mount folder with IOS images. FTP has RO only. 186 | - ./netbox-software-manager/software-images:/ftp/software-images:z,ro 187 | # Mount configuration files 188 | - ./netbox-software-manager/ftp/start_vsftpd.sh:/etc/vsftpd/start_vsftpd.sh:z,ro 189 | - ./netbox-software-manager/ftp/vsftpd.conf:/etc/vsftpd/vsftpd.conf:z,ro 190 | entrypoint: /etc/vsftpd/start_vsftpd.sh 191 | environment: 192 | # Specify user/password 193 | - USERS=software-images|ftp_password 194 | # Your external (host) IP address, not contaner's IP 195 | - ADDRESS=192.168.0.1 196 | 197 | # OR HTTP server 198 | http: 199 | image: http_for_netbox 200 | ports: 201 | - "80:80" 202 | volumes: 203 | # Mount folder with IOS images. FTP has RO only. 204 | - ./netbox-software-manager/software-images:/usr/share/nginx/html:z,ro 205 | 206 | ``` 207 | 208 | ## 4. Change NetBox configuration.py 209 | 210 | ```python 211 | PLUGINS = [ 212 | "software_manager", 213 | ] 214 | 215 | PLUGINS_CONFIG = { 216 | "software_manager": { 217 | # Device credentials 218 | "DEVICE_USERNAME": "cisco", 219 | "DEVICE_PASSWORD": "cisco", 220 | # FTP credentials (can be skipped if HTTP is used) 221 | "FTP_USERNAME": "ftp-user", 222 | "FTP_PASSWORD": "ftp_password", 223 | "FTP_SERVER": "192.168.0.1", 224 | # HTTP server name with patch to images (can be skipped if FTP is used) 225 | "HTTP_SERVER": "http://10.8.0.1:8001/", 226 | # Default transport method, [tfp|http] 227 | "DEFAULT_TRANSFER_METHOD": "http", 228 | # Log file 229 | "UPGRADE_LOG_FILE": "/var/log/upgrade.log", 230 | # Queue name. Check step 1 (dockerfile). Should be the same 231 | "UPGRADE_QUEUE": "software_manager", 232 | # Custom field name which is used for store current SW version 233 | "CF_NAME_SW_VERSION": "sw_version", 234 | # folder name for image storing. located in netbox media. 235 | "IMAGE_FOLDER": "software-images", 236 | # Threshold for non-ACK check 237 | "UPGRADE_THRESHOLD": 2, 238 | # Number of tries to connect to device before declare that we lost it. 239 | "UPGRADE_MAX_ATTEMPTS_AFTER_RELOAD": 10, 240 | # Hold timer between tries 241 | "UPGRADE_SECONDS_BETWEEN_ATTEMPTS": 60, 242 | } 243 | } 244 | ``` 245 | 246 | ## 6. Restart docker-compose 247 | 248 | ## 7. Add Custom Field 249 | 250 | | Name | Type | Label | Object(s) | 251 | |---------------|--------|---------------|-----------------| 252 | | sw_version | Text | SW Version | dcim > device | 253 | 254 | ## 8. Try to use 255 | 256 | - - - 257 | 258 | ## nginx-unit.json 259 | 260 | Original NetBox config is used with max_body_size: 261 | 262 | ```json 263 | "settings": { 264 | "http": { 265 | "max_body_size": 1073741824 266 | } 267 | } 268 | ``` 269 | 270 | ## rq.sh script 271 | 272 | ```shell 273 | #!/bin/bash 274 | 275 | # start multply rqworkers for "software_manager", check name if you customized it. There are 5 workers. This means up to 5 concurrent jobs can be run at the same time. 276 | /opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py rqworker software_manager & 277 | /opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py rqworker software_manager & 278 | /opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py rqworker software_manager & 279 | /opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py rqworker software_manager & 280 | /opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py rqworker software_manager & 281 | 282 | # start default netbox worker 283 | /opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py rqworker high default low 284 | exec "$@" 285 | ``` 286 | -------------------------------------------------------------------------------- /ftp/dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.10 2 | RUN apk --no-cache add vsftpd 3 | -------------------------------------------------------------------------------- /ftp/start_vsftpd.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | #Remove all ftp users 4 | grep '/ftp/' /etc/passwd | cut -d':' -f1 | xargs -n1 deluser 5 | 6 | #Create users 7 | #USERS='name1|password1|[folder1][|uid1] name2|password2|[folder2][|uid2]' 8 | #may be: 9 | # user|password foo|bar|/home/foo 10 | #OR 11 | # user|password|/home/user/dir|10000 12 | #OR 13 | # user|password||10000 14 | 15 | #Default user 'ftp' with password 'alpineftp' 16 | 17 | if [ -z "$USERS" ]; then 18 | USERS="ftp|alpineftp" 19 | fi 20 | 21 | for i in $USERS ; do 22 | NAME=$(echo $i | cut -d'|' -f1) 23 | PASS=$(echo $i | cut -d'|' -f2) 24 | FOLDER=$(echo $i | cut -d'|' -f3) 25 | UID=$(echo $i | cut -d'|' -f4) 26 | 27 | if [ -z "$FOLDER" ]; then 28 | FOLDER="/ftp/$NAME" 29 | fi 30 | 31 | if [ ! -z "$UID" ]; then 32 | UID_OPT="-u $UID" 33 | fi 34 | 35 | echo -e "$PASS\n$PASS" | adduser -h $FOLDER -s /sbin/nologin $UID_OPT $NAME 36 | # mkdir -p $FOLDER 37 | chown $NAME:$NAME $FOLDER 38 | unset NAME PASS FOLDER UID 39 | done 40 | 41 | 42 | if [ -z "$MIN_PORT" ]; then 43 | MIN_PORT=21000 44 | fi 45 | 46 | if [ -z "$MAX_PORT" ]; then 47 | MAX_PORT=21100 48 | fi 49 | 50 | if [ ! -z "$ADDRESS" ]; then 51 | ADDR_OPT="-opasv_address=$ADDRESS" 52 | fi 53 | 54 | # Used to run custom commands inside container 55 | if [ ! -z "$1" ]; then 56 | exec "$@" 57 | else 58 | exec /usr/sbin/vsftpd -opasv_min_port=$MIN_PORT -opasv_max_port=$MAX_PORT $ADDR_OPT /etc/vsftpd/vsftpd.conf 59 | fi -------------------------------------------------------------------------------- /ftp/vsftpd.conf: -------------------------------------------------------------------------------- 1 | # Allow anonymous FTP? (Beware - allowed by default if you comment this out). 2 | anonymous_enable=NO 3 | # 4 | #test 5 | 6 | # Uncomment this to allow local users to log in. 7 | local_enable=YES 8 | # 9 | # Uncomment this to enable any form of FTP write command. 10 | write_enable=YES 11 | # 12 | # Default umask for local users is 077. You may wish to change this to 022, 13 | # if your users expect that (022 is used by most other ftpd's) 14 | local_umask=022 15 | # 16 | # Activate directory messages - messages given to remote users when they 17 | # go into a certain directory. 18 | dirmessage_enable=YES 19 | # 20 | # Activate logging of uploads/downloads. 21 | xferlog_enable=YES 22 | # 23 | # Make sure PORT transfer connections originate from port 20 (ftp-data). 24 | connect_from_port_20=YES 25 | # 26 | # If you want, you can arrange for uploaded anonymous files to be owned by 27 | # a different user. Note! Using "root" for uploaded files is not 28 | # recommended! 29 | #chown_uploads=YES 30 | #chown_username=whoever 31 | # 32 | # You may override where the log file goes if you like. The default is shown 33 | # below. 34 | #xferlog_file=/dev/stdout 35 | vsftpd_log_file=/proc/1/fd/1 36 | # 37 | # If you want, you can have your log file in standard ftpd xferlog format. 38 | # Note that the default log file location is /var/log/xferlog in this case. 39 | #xferlog_std_format=YES 40 | # 41 | # You may change the default value for timing out an idle session. 42 | #idle_session_timeout=600 43 | # 44 | # You may change the default value for timing out a data connection. 45 | #data_connection_timeout=120 46 | # 47 | # Enable this and the server will recognise asynchronous ABOR requests. Not 48 | # recommended for security (the code is non-trivial). Not enabling it, 49 | # however, may confuse older FTP clients. 50 | #async_abor_enable=YES 51 | # 52 | # By default the server will pretend to allow ASCII mode but in fact ignore 53 | # the request. Turn on the below options to have the server actually do ASCII 54 | # mangling on files when in ASCII mode. 55 | # Beware that on some FTP servers, ASCII support allows a denial of service 56 | # attack (DoS) via the command "SIZE /big/file" in ASCII mode. vsftpd 57 | # predicted this attack and has always been safe, reporting the size of the 58 | # raw file. 59 | # ASCII mangling is a horrible feature of the protocol. 60 | # Windows explorer uses ascii mode 61 | #ascii_upload_enable=YES 62 | #ascii_download_enable=YES 63 | # 64 | # You may fully customise the login banner string: 65 | ftpd_banner=Welcome Alpine ftp server 66 | # 67 | # You may specify an explicit list of local users to chroot() to their home 68 | # directory. If chroot_local_user is YES, then this list becomes a list of 69 | # users to NOT chroot(). 70 | # (Warning! chroot'ing can be very dangerous. If using chroot, make sure that 71 | # the user does not have write access to the top level directory within the 72 | # chroot) 73 | #chroot_local_user=YES 74 | #chroot_list_enable=YES 75 | # (default follows) 76 | #chroot_list_file=/etc/vsftpd.chroot_list 77 | # 78 | # You may activate the "-R" option to the builtin ls. This is disabled by 79 | # default to avoid remote users being able to cause excessive I/O on large 80 | # sites. However, some broken FTP clients such as "ncftp" and "mirror" assume 81 | # the presence of the "-R" option, so there is a strong case for enabling it. 82 | #ls_recurse_enable=YES 83 | # 84 | # When "listen" directive is enabled, vsftpd runs in standalone mode and 85 | # listens on IPv4 sockets. This directive cannot be used in conjunction 86 | # with the listen_ipv6 directive. 87 | listen=YES 88 | # 89 | ## Enable passive mode 90 | pasv_enable=YES 91 | pasv_addr_resolve=YES 92 | # 93 | ## Disable seccomp filter sanboxing 94 | seccomp_sandbox=NO 95 | # Run in background 96 | background=NO -------------------------------------------------------------------------------- /http/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | autoindex on; 4 | 5 | location / { 6 | root /usr/share/nginx/html; 7 | } 8 | } -------------------------------------------------------------------------------- /http/dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx 2 | 3 | ADD default.conf /etc/nginx/conf.d/ 4 | -------------------------------------------------------------------------------- /nginx-unit.json: -------------------------------------------------------------------------------- 1 | { 2 | "listeners": { 3 | "0.0.0.0:8080": { 4 | "pass": "routes" 5 | }, 6 | "[::]:8080": { 7 | "pass": "routes" 8 | } 9 | }, 10 | 11 | "routes": [ 12 | { 13 | "match": { 14 | "uri": "/static/*" 15 | }, 16 | "action": { 17 | "share": "/opt/netbox/netbox${uri}" 18 | } 19 | }, 20 | 21 | { 22 | "action": { 23 | "pass": "applications/netbox" 24 | } 25 | } 26 | ], 27 | 28 | "applications": { 29 | "netbox": { 30 | "type": "python 3", 31 | "path": "/opt/netbox/netbox/", 32 | "module": "netbox.wsgi", 33 | "home": "/opt/netbox/venv", 34 | "processes": { 35 | "max": 4, 36 | "spare": 1, 37 | "idle_timeout": 120 38 | } 39 | } 40 | }, 41 | 42 | "access_log": "/dev/stdout", 43 | 44 | "settings": { 45 | "http": { 46 | "max_body_size": 1073741824 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | scrapli[paramiko] 2 | scrapli[textfsm] 3 | -------------------------------------------------------------------------------- /rq.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | /opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py rqworker software_manager & 3 | /opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py rqworker software_manager & 4 | /opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py rqworker software_manager & 5 | /opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py rqworker software_manager & 6 | /opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py rqworker software_manager & 7 | 8 | /opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py rqworker high default low 9 | exec "$@" 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | with open("README.md", "r") as f: 4 | long_description = f.read() 5 | 6 | setup( 7 | name="netbox-plugin-software-manager", 8 | version="0.0.4", 9 | description="Software Manager for Cisco IOS/IOS-XE devices", 10 | long_description=long_description, 11 | long_description_content_type="text/markdown", 12 | author="Alexander Ignatov", 13 | license="MIT", 14 | install_requires=[ 15 | "scrapli[paramiko]", 16 | "scrapli[textfsm]", 17 | ], 18 | packages=find_packages(), 19 | include_package_data=True, 20 | url="https://github.com/alsigna/netbox-software-manager", 21 | ) 22 | -------------------------------------------------------------------------------- /software_manager/__init__.py: -------------------------------------------------------------------------------- 1 | from extras.plugins import PluginConfig 2 | 3 | 4 | class SoftwareManager(PluginConfig): 5 | name = "software_manager" 6 | verbose_name = "Software Manager" 7 | description = "Software Manager for Cisco IOS/IOS-XE devices" 8 | version = "0.0.4" 9 | author = "Alexander Ignatov" 10 | author_email = "ignatov.alx@gmail.com" 11 | required_settings = [] 12 | default_settings = {} 13 | base_url = "software-manager" 14 | caching_config = {} 15 | 16 | 17 | config = SoftwareManager 18 | -------------------------------------------------------------------------------- /software_manager/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alsigna/netbox-software-manager/ab79483875df10a53668669b9846ad7823bdf939/software_manager/api/__init__.py -------------------------------------------------------------------------------- /software_manager/api/serializers.py: -------------------------------------------------------------------------------- 1 | # from django.contrib.auth.models import User 2 | # from django.contrib.contenttypes.models import ContentType 3 | # from django.core.exceptions import ObjectDoesNotExist 4 | # from drf_yasg.utils import swagger_serializer_method 5 | # from rest_framework import serializers 6 | 7 | # from dcim.api.nested_serializers import ( 8 | # NestedDeviceRoleSerializer, 9 | # NestedDeviceTypeSerializer, 10 | # NestedPlatformSerializer, 11 | # NestedRegionSerializer, 12 | # NestedSiteSerializer, 13 | # NestedSiteGroupSerializer, 14 | # ) 15 | # from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup 16 | # from extras.choices import * 17 | # from extras.models import * 18 | # from extras.utils import FeatureQuery 19 | # from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField 20 | # from netbox.api.exceptions import SerializerNotFound 21 | # from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer 22 | # from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer 23 | # from tenancy.models import Tenant, TenantGroup 24 | # from users.api.nested_serializers import NestedUserSerializer 25 | # from utilities.api import get_serializer_for_model 26 | # from virtualization.api.nested_serializers import ( 27 | # NestedClusterGroupSerializer, 28 | # NestedClusterSerializer, 29 | # NestedClusterTypeSerializer, 30 | # ) 31 | # from virtualization.models import Cluster, ClusterGroup, ClusterType 32 | # from .nested_serializers import * 33 | 34 | 35 | from netbox.api.serializers import ValidatedModelSerializer 36 | from rest_framework import serializers 37 | 38 | # from netbox.api import ContentTypeField 39 | from ..models import GoldenImage, ScheduledTask, SoftwareImage 40 | 41 | # from django.contrib.contenttypes.models import ContentType 42 | 43 | 44 | class SoftwareImageSerializer(ValidatedModelSerializer): 45 | url = serializers.HyperlinkedIdentityField(view_name="plugins-api:software_manager-api:softwareimage-detail") 46 | 47 | class Meta: 48 | model = SoftwareImage 49 | fields = [ 50 | "id", 51 | "url", 52 | "created", 53 | "last_updated", 54 | "image", 55 | "md5sum", 56 | "md5sum_calculated", 57 | "version", 58 | "filename", 59 | ] 60 | 61 | 62 | class GoldenImageSerializer(ValidatedModelSerializer): 63 | url = serializers.HyperlinkedIdentityField(view_name="plugins-api:software_manager-api:goldenimage-detail") 64 | 65 | class Meta: 66 | model = GoldenImage 67 | fields = [ 68 | "id", 69 | "url", 70 | "created", 71 | "last_updated", 72 | "pid", 73 | "sw", 74 | ] 75 | 76 | 77 | class ScheduledTaskSerializer(ValidatedModelSerializer): 78 | url = serializers.HyperlinkedIdentityField(view_name="plugins-api:software_manager-api:scheduledtask-detail") 79 | 80 | class Meta: 81 | model = ScheduledTask 82 | fields = [ 83 | "id", 84 | "url", 85 | "created", 86 | "last_updated", 87 | "job_id", 88 | "status", 89 | ] 90 | -------------------------------------------------------------------------------- /software_manager/api/urls.py: -------------------------------------------------------------------------------- 1 | from netbox.api.routers import NetBoxRouter 2 | 3 | from .views import GoldenImageViewSet, ScheduledTaskViewSet, SoftwareImageViewSet 4 | 5 | app_name = "software_manager" 6 | 7 | router = NetBoxRouter() 8 | 9 | router.register(r"software-image", SoftwareImageViewSet) 10 | router.register(r"golden-image", GoldenImageViewSet) 11 | router.register(r"scheduled-task", ScheduledTaskViewSet) 12 | 13 | urlpatterns = router.urls 14 | -------------------------------------------------------------------------------- /software_manager/api/views.py: -------------------------------------------------------------------------------- 1 | from netbox.api.viewsets import NetBoxModelViewSet 2 | 3 | from ..filtersets import SoftwareImageFilterSet 4 | from ..models import GoldenImage, ScheduledTask, SoftwareImage 5 | from .serializers import GoldenImageSerializer, ScheduledTaskSerializer, SoftwareImageSerializer 6 | 7 | 8 | class SoftwareImageViewSet(NetBoxModelViewSet): 9 | queryset = SoftwareImage.objects.all() 10 | serializer_class = SoftwareImageSerializer 11 | filterset_class = SoftwareImageFilterSet 12 | 13 | 14 | class GoldenImageViewSet(NetBoxModelViewSet): 15 | queryset = GoldenImage.objects.all() 16 | serializer_class = GoldenImageSerializer 17 | 18 | 19 | class ScheduledTaskViewSet(NetBoxModelViewSet): 20 | queryset = ScheduledTask.objects.all() 21 | serializer_class = ScheduledTaskSerializer 22 | -------------------------------------------------------------------------------- /software_manager/choices.py: -------------------------------------------------------------------------------- 1 | from utilities.choices import ChoiceSet 2 | 3 | 4 | class TaskTypeChoices(ChoiceSet): 5 | TYPE_UPLOAD = "upload" 6 | TYPE_UPGRADE = "upgrade" 7 | 8 | CHOICES = ( 9 | (TYPE_UPLOAD, "upload"), 10 | (TYPE_UPGRADE, "upgrade"), 11 | ) 12 | 13 | 14 | class TaskTransferMethod(ChoiceSet): 15 | METHOD_FTP = "ftp" 16 | METHOD_HTTP = "http" 17 | 18 | CHOICES = ( 19 | (METHOD_FTP, "ftp"), 20 | (METHOD_HTTP, "http"), 21 | ) 22 | 23 | 24 | class TaskStatusChoices(ChoiceSet): 25 | STATUS_UNKNOWN = "unknown" 26 | STATUS_SCHEDULED = "scheduled" 27 | STATUS_FAILED = "failed" 28 | STATUS_RUNNING = "running" 29 | STATUS_SUCCEEDED = "succeeded" 30 | STATUS_SKIPPED = "skipped" 31 | 32 | CHOICES = ( 33 | (STATUS_UNKNOWN, "unknown"), 34 | (STATUS_SCHEDULED, "scheduled"), 35 | (STATUS_FAILED, "failed"), 36 | (STATUS_RUNNING, "running"), 37 | (STATUS_SUCCEEDED, "succeeded"), 38 | (STATUS_SKIPPED, "skipped"), 39 | ) 40 | 41 | 42 | class TaskFailReasonChoices(ChoiceSet): 43 | FAIL_UNKNOWN = "fail-unknown" 44 | FAIL_CHECK = "fail-check" 45 | FAIL_LOGIN = "fail-login" 46 | FAIL_CONFIG = "fail-config" 47 | FAIL_CONNECT = "fail-connect" 48 | FAIL_GENERAL = "fail-general" 49 | FAIL_ADD = "fail-add" 50 | FAIL_UPGRADE = "fail-upgrade" 51 | FAIL_UPLOAD = "fail-upload" 52 | 53 | CHOICES = ( 54 | (FAIL_UNKNOWN, "fail-unknown"), 55 | (FAIL_CHECK, "fail-check"), 56 | (FAIL_LOGIN, "fail-login"), 57 | (FAIL_CONFIG, "fail-config"), 58 | (FAIL_CONNECT, "fail-connect"), 59 | (FAIL_GENERAL, "fail-general"), 60 | (FAIL_ADD, "fail-add"), 61 | (FAIL_UPGRADE, "fail-upgrade"), 62 | (FAIL_UPLOAD, "fail-upload"), 63 | ) 64 | -------------------------------------------------------------------------------- /software_manager/filtersets.py: -------------------------------------------------------------------------------- 1 | from dcim.models import DeviceType 2 | from django.db.models import Q 3 | from django_filters import DateTimeFromToRangeFilter 4 | from netbox.filtersets import NetBoxModelFilterSet 5 | 6 | from .models import ScheduledTask, SoftwareImage 7 | 8 | 9 | class SoftwareImageFilterSet(NetBoxModelFilterSet): 10 | class Meta: 11 | model = SoftwareImage 12 | fields = ( 13 | "filename", 14 | "md5sum", 15 | "version", 16 | "comments", 17 | ) 18 | 19 | def search(self, queryset, name, value): 20 | if not value.strip(): 21 | return queryset 22 | qs_filter = ( 23 | Q(filename__icontains=value) 24 | | Q(md5sum__icontains=value) 25 | | Q(version__icontains=value) 26 | | Q(comments__icontains=value) 27 | ) 28 | return queryset.filter(qs_filter) 29 | 30 | 31 | class GoldenImageFilterSet(NetBoxModelFilterSet): 32 | class Meta: 33 | model = DeviceType 34 | fields = ( 35 | "id", 36 | "part_number", 37 | "model", 38 | "manufacturer_id", 39 | "golden_image", 40 | ) 41 | 42 | def search(self, queryset, name, value): 43 | if not value.strip(): 44 | return queryset 45 | qs_filter = ( 46 | Q(id__in=value) 47 | | Q(part_number__icontains=value) 48 | | Q(manufacturer_id__in=value) 49 | | Q(model__icontains=value) 50 | | Q(golden_image__sw__version__icontains=value) 51 | | Q(golden_image__sw__filename__icontains=value) 52 | ) 53 | return queryset.filter(qs_filter) 54 | 55 | 56 | class ScheduledTaskFilterSet(NetBoxModelFilterSet): 57 | scheduled_time = DateTimeFromToRangeFilter() 58 | start_time = DateTimeFromToRangeFilter() 59 | end_time = DateTimeFromToRangeFilter() 60 | 61 | class Meta: 62 | model = ScheduledTask 63 | fields = ( 64 | "status", 65 | "task_type", 66 | "confirmed", 67 | "job_id", 68 | "scheduled_time", 69 | "start_time", 70 | "end_time", 71 | ) 72 | 73 | def search(self, queryset, name, value): 74 | if not value.strip(): 75 | return queryset 76 | qs_filter = ( 77 | Q(device__name__icontains=value) 78 | | Q(task_type__icontains=value) 79 | | Q(status__icontains=value) 80 | | Q(job_id__icontains=value) 81 | ) 82 | return queryset.filter(qs_filter) 83 | -------------------------------------------------------------------------------- /software_manager/forms.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from dcim.models import Device, DeviceType, Manufacturer 4 | from django import forms 5 | from django.conf import settings 6 | from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm 7 | from utilities.forms import ( 8 | BOOLEAN_WITH_BLANK_CHOICES, 9 | BootstrapMixin, 10 | CommentField, 11 | DateTimePicker, 12 | DynamicModelMultipleChoiceField, 13 | MultipleChoiceField, 14 | StaticSelect, 15 | TagFilterField, 16 | ) 17 | 18 | from .choices import TaskStatusChoices, TaskTransferMethod, TaskTypeChoices 19 | from .models import GoldenImage, ScheduledTask, SoftwareImage 20 | 21 | PLUGIN_SETTINGS = settings.PLUGINS_CONFIG.get("software_manager", dict()) 22 | CF_NAME_SW_VERSION = PLUGIN_SETTINGS.get("CF_NAME_SW_VERSION", "") 23 | DEFAULT_TRANSFER_METHOD = PLUGIN_SETTINGS.get("DEFAULT_TRANSFER_METHOD", TaskTransferMethod.METHOD_FTP) 24 | IMAGE_FOLDER = PLUGIN_SETTINGS.get("IMAGE_FOLDER", "") 25 | 26 | IMAGE_FORMATS = ".bin" 27 | 28 | 29 | class ClearableFileInput(forms.ClearableFileInput): 30 | template_name = "software_manager/widgets/clearable_file_input.html" 31 | 32 | 33 | class SoftwareImageEditForm(NetBoxModelForm): 34 | image = forms.FileField( 35 | required=False, 36 | label="Image", 37 | help_text="Image File, with .bin extension", 38 | widget=ClearableFileInput(attrs={"accept": IMAGE_FORMATS}), 39 | ) 40 | md5sum = forms.CharField( 41 | required=False, 42 | label="MD5 Checksum", 43 | help_text="Expected MD5 Checksum, ex: 0f58a02f3d3f1e1be8f509d2e5b58fb8", 44 | ) 45 | version = forms.CharField( 46 | required=True, 47 | label="Version", 48 | help_text="Verbose Software Version, ex: 15.5(3)M10", 49 | ) 50 | comments = CommentField( 51 | label="Comments", 52 | ) 53 | 54 | class Meta: 55 | model = SoftwareImage 56 | fields = [ 57 | "image", 58 | "md5sum", 59 | "version", 60 | "tags", 61 | "comments", 62 | ] 63 | 64 | def __init__(self, *args, **kwargs): 65 | super().__init__(*args, **kwargs) 66 | 67 | # Once uploaded, image cannot be changed. Otherwise a lot of logical issues can be appeared. 68 | if self.instance.image_exists: 69 | self.fields["image"].widget.attrs["disabled"] = True 70 | self.fields["image"].initial = self.instance.image 71 | 72 | def clean(self): 73 | cleaned_data = super().clean() 74 | print(f"{cleaned_data=}") 75 | print(f"{self.instance=}") 76 | print(f"{self.instance.pk=}") 77 | image = cleaned_data.get("image", None) 78 | version = cleaned_data.get("version", None) 79 | if not version: 80 | raise forms.ValidationError( 81 | {"version": f"Version is requared"}, 82 | ) 83 | 84 | # if trying to upload image, but this filename already exists in DB 85 | if image and SoftwareImage.objects.filter(filename__iexact=image.name).exists(): 86 | raise forms.ValidationError( 87 | {"image": f"Record '{image.name}' already exists. Contact with NetBox admins."}, 88 | ) 89 | 90 | # if trying to upload image, but this file already exists on a disk 91 | if image and Path(settings.MEDIA_ROOT, IMAGE_FOLDER, image.name).is_file(): 92 | raise forms.ValidationError( 93 | {"image": f"File '{image.name}' already exists. Contact with NetBox admins."}, 94 | ) 95 | 96 | # if file not specified, version need to unique 97 | if ( 98 | not image 99 | and SoftwareImage.objects.filter(version__iexact=version, image__exact="") 100 | .exclude(pk=self.instance.pk) 101 | .exists() 102 | ): 103 | raise forms.ValidationError( 104 | {"version": f"Version '{version}' without image already exists."}, 105 | ) 106 | 107 | 108 | class SoftwareImageFilterForm(NetBoxModelFilterSetForm): 109 | filename = forms.CharField( 110 | required=False, 111 | ) 112 | md5sum = forms.CharField( 113 | required=False, 114 | label="MD5", 115 | ) 116 | version = forms.CharField( 117 | required=False, 118 | label="SW Version", 119 | ) 120 | 121 | model = SoftwareImage 122 | tag = TagFilterField(SoftwareImage) 123 | fieldsets = ( 124 | (None, ("q", "tag")), 125 | ("Exact Match", ("md5sum", "version")), 126 | ) 127 | 128 | class Meta: 129 | model = SoftwareImage 130 | fields = ( 131 | "filename", 132 | "md5sum", 133 | "version", 134 | ) 135 | 136 | 137 | class GoldenImageFilterForm(NetBoxModelFilterSetForm): 138 | manufacturer_id = DynamicModelMultipleChoiceField( 139 | queryset=Manufacturer.objects.all(), 140 | required=False, 141 | label="Manufacturer", 142 | ) 143 | id = DynamicModelMultipleChoiceField( 144 | queryset=DeviceType.objects.all(), 145 | required=False, 146 | label="PID (Model)", 147 | query_params={"manufacturer_id": "$manufacturer_id"}, 148 | ) 149 | # TODO 150 | # software_image = DynamicModelMultipleChoiceField( 151 | # queryset=SoftwareImage.objects.all(), 152 | # required=False, 153 | # label="Software Image", 154 | # ) 155 | 156 | model = DeviceType 157 | fieldsets = ((None, ("q", "manufacturer_id", "id")),) 158 | 159 | class Meta: 160 | model = DeviceType 161 | fields = ( 162 | "manufacturer_id", 163 | "id", 164 | ) 165 | 166 | 167 | class GoldenImageAddForm(BootstrapMixin, forms.ModelForm): 168 | device_pid = forms.CharField( 169 | required=True, 170 | label="Device PID", 171 | ) 172 | sw = forms.ModelChoiceField( 173 | required=True, 174 | queryset=SoftwareImage.objects.all(), 175 | label="Image/Version", 176 | ) 177 | 178 | class Meta: 179 | model = GoldenImage 180 | fields = ["device_pid", "sw"] 181 | 182 | def __init__(self, *args, **kwargs): 183 | super().__init__(*args, **kwargs) 184 | self.fields["device_pid"].widget.attrs["readonly"] = True 185 | self.fields["device_pid"].initial = self.instance.pid 186 | 187 | 188 | class ScheduledTaskCreateForm(BootstrapMixin, forms.Form): 189 | model = ScheduledTask 190 | pk = forms.ModelMultipleChoiceField( 191 | queryset=Device.objects.all(), 192 | widget=forms.MultipleHiddenInput(), 193 | ) 194 | task_type = forms.ChoiceField( 195 | choices=TaskTypeChoices, 196 | required=True, 197 | label="Job Type", 198 | initial="", 199 | widget=StaticSelect(), 200 | ) 201 | scheduled_time = forms.DateTimeField( 202 | label="Scheduled Time", 203 | required=False, 204 | widget=DateTimePicker(), 205 | ) 206 | mw_duration = forms.IntegerField( 207 | required=True, 208 | initial=6, 209 | label="MW Duration, Hrs.", 210 | ) 211 | 212 | start_now = ["scheduled_time"] 213 | 214 | transfer_method = forms.ChoiceField( 215 | choices=TaskTransferMethod, 216 | required=True, 217 | label="Transfer Method", 218 | initial=DEFAULT_TRANSFER_METHOD, 219 | widget=StaticSelect(), 220 | ) 221 | 222 | class Meta: 223 | start_now = ["scheduled_time"] 224 | 225 | def __init__(self, *args, **kwargs): 226 | super().__init__(*args, **kwargs) 227 | self.fields["mw_duration"].widget.attrs["max"] = 8 228 | self.fields["mw_duration"].widget.attrs["min"] = 1 229 | 230 | 231 | class ScheduledTaskFilterForm(NetBoxModelFilterSetForm): 232 | status = MultipleChoiceField( 233 | choices=TaskStatusChoices, 234 | required=False, 235 | ) 236 | task_type = MultipleChoiceField( 237 | choices=TaskTypeChoices, 238 | required=False, 239 | ) 240 | confirmed = forms.NullBooleanField( 241 | required=False, 242 | label="Is Confirmed (ACK)", 243 | widget=StaticSelect(choices=BOOLEAN_WITH_BLANK_CHOICES), 244 | ) 245 | scheduled_time_after = forms.DateTimeField( 246 | label="After", 247 | required=False, 248 | widget=DateTimePicker(), 249 | ) 250 | scheduled_time_before = forms.DateTimeField( 251 | label="Before", 252 | required=False, 253 | widget=DateTimePicker(), 254 | ) 255 | start_time_after = forms.DateTimeField( 256 | label="After", 257 | required=False, 258 | widget=DateTimePicker(), 259 | ) 260 | start_time_before = forms.DateTimeField( 261 | label="Before", 262 | required=False, 263 | widget=DateTimePicker(), 264 | ) 265 | end_time_after = forms.DateTimeField( 266 | label="After", 267 | required=False, 268 | widget=DateTimePicker(), 269 | ) 270 | end_time_before = forms.DateTimeField( 271 | label="Before", 272 | required=False, 273 | widget=DateTimePicker(), 274 | ) 275 | model = ScheduledTask 276 | fieldsets = ( 277 | (None, ("q", "status", "task_type", "confirmed")), 278 | ("Scheduled Time", ("scheduled_time_after", "scheduled_time_before")), 279 | ("Start Time", ("start_time_after", "start_time_before")), 280 | ("End Time", ("end_time_after", "end_time_before")), 281 | ) 282 | 283 | class Meta: 284 | model = ScheduledTask 285 | fields = ( 286 | "status", 287 | "task_type", 288 | "confirmed", 289 | "scheduled_time_after", 290 | "scheduled_time_before", 291 | "start_time_after", 292 | "start_time_before", 293 | "end_time_after", 294 | "end_time_before", 295 | ) 296 | -------------------------------------------------------------------------------- /software_manager/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | 4 | import pytz 5 | from django.conf import settings 6 | 7 | from .models import ScheduledTask 8 | 9 | PLUGIN_SETTINGS = settings.PLUGINS_CONFIG.get("software_manager", dict()) 10 | UPGRADE_LOG_FILE = PLUGIN_SETTINGS.get("UPGRADE_LOG_FILE", "") 11 | 12 | 13 | def CustomTimeZone(*args): 14 | utc_dt = pytz.utc.localize(datetime.utcnow()) 15 | my_tz = pytz.timezone(settings.TIME_ZONE) 16 | converted = utc_dt.astimezone(my_tz) 17 | return converted.timetuple() 18 | 19 | 20 | log_f = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s", "%Y-%m-%d %H:%M:%S") 21 | log_fh = logging.FileHandler(UPGRADE_LOG_FILE) 22 | log_fh.setFormatter(log_f) 23 | log_fh.formatter.converter = CustomTimeZone 24 | log = logging.getLogger("upgrade") 25 | log.setLevel(logging.DEBUG) 26 | log.addHandler(log_fh) 27 | 28 | 29 | class TaskLoggerMixIn: 30 | def __init__(self, task: ScheduledTask) -> None: 31 | self.task = task 32 | if task.device is not None: 33 | hostname = task.device.name 34 | else: 35 | hostname = "unknown-device" 36 | self.log_id = f"{task.job_id} - {hostname}" 37 | 38 | def debug(self, msg: str) -> None: 39 | log.debug(f"{self.log_id} - {msg}") 40 | self.task.log += ( 41 | f'{datetime.now(pytz.timezone(settings.TIME_ZONE)).strftime("%Y-%m-%d %H:%M:%S")} - DEBUG - {msg}\n' 42 | ) 43 | self.task.save() 44 | 45 | def info(self, msg: str) -> None: 46 | log.info(f"{self.log_id} - {msg}") 47 | self.task.log += ( 48 | f'{datetime.now(pytz.timezone(settings.TIME_ZONE)).strftime("%Y-%m-%d %H:%M:%S")} - INFO - {msg}\n' 49 | ) 50 | self.task.save() 51 | 52 | def warning(self, msg: str) -> None: 53 | log.warning(f"{self.log_id} - {msg}") 54 | self.task.log += ( 55 | f'{datetime.now(pytz.timezone(settings.TIME_ZONE)).strftime("%Y-%m-%d %H:%M:%S")} - WARNING - {msg}\n' 56 | ) 57 | self.task.save() 58 | 59 | def error(self, msg: str) -> None: 60 | log.error(f"{self.log_id} - {msg}") 61 | self.task.log += ( 62 | f'{datetime.now(pytz.timezone(settings.TIME_ZONE)).strftime("%Y-%m-%d %H:%M:%S")} - ERROR - {msg}\n' 63 | ) 64 | self.task.save() 65 | -------------------------------------------------------------------------------- /software_manager/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-01-25 13:24 2 | 3 | import django.core.validators 4 | import django.db.models.deletion 5 | import taggit.managers 6 | import utilities.json 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ("extras", "0084_staging"), 16 | ("dcim", "0167_module_status"), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name="SoftwareImage", 22 | fields=[ 23 | ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), 24 | ("created", models.DateTimeField(auto_now_add=True, null=True)), 25 | ("last_updated", models.DateTimeField(auto_now=True, null=True)), 26 | ( 27 | "custom_field_data", 28 | models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), 29 | ), 30 | ( 31 | "image", 32 | models.FileField( 33 | blank=True, 34 | null=True, 35 | upload_to="software-images/", 36 | validators=[django.core.validators.FileExtensionValidator(allowed_extensions=["bin"])], 37 | ), 38 | ), 39 | ("md5sum", models.CharField(blank=True, max_length=36)), 40 | ("md5sum_calculated", models.CharField(blank=True, max_length=36)), 41 | ("version", models.CharField(blank=True, max_length=32)), 42 | ("filename", models.CharField(blank=True, max_length=256)), 43 | ("comments", models.TextField(blank=True)), 44 | ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), 45 | ], 46 | options={ 47 | "ordering": ["-filename", "-version"], 48 | }, 49 | ), 50 | migrations.CreateModel( 51 | name="ScheduledTask", 52 | fields=[ 53 | ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), 54 | ("created", models.DateTimeField(auto_now_add=True, null=True)), 55 | ("last_updated", models.DateTimeField(auto_now=True, null=True)), 56 | ( 57 | "custom_field_data", 58 | models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), 59 | ), 60 | ("task_type", models.CharField(default="upload", max_length=255)), 61 | ("job_id", models.CharField(blank=True, max_length=255)), 62 | ("status", models.CharField(default="unknown", max_length=255)), 63 | ("message", models.CharField(blank=True, max_length=512)), 64 | ("fail_reason", models.CharField(default="fail-unknown", max_length=255)), 65 | ("confirmed", models.BooleanField(default=False)), 66 | ("scheduled_time", models.DateTimeField(null=True)), 67 | ("start_time", models.DateTimeField(null=True)), 68 | ("end_time", models.DateTimeField(null=True)), 69 | ("mw_duration", models.PositiveIntegerField(null=True)), 70 | ("log", models.TextField(blank=True)), 71 | ("user", models.CharField(blank=True, max_length=255)), 72 | ("transfer_method", models.CharField(default="ftp", max_length=8)), 73 | ( 74 | "device", 75 | models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to="dcim.device"), 76 | ), 77 | ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), 78 | ], 79 | options={ 80 | "verbose_name": "Scheduled Task", 81 | "ordering": ["-scheduled_time", "-start_time", "-end_time", "job_id"], 82 | }, 83 | ), 84 | migrations.CreateModel( 85 | name="GoldenImage", 86 | fields=[ 87 | ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), 88 | ("created", models.DateTimeField(auto_now_add=True, null=True)), 89 | ("last_updated", models.DateTimeField(auto_now=True, null=True)), 90 | ( 91 | "custom_field_data", 92 | models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), 93 | ), 94 | ( 95 | "pid", 96 | models.OneToOneField( 97 | on_delete=django.db.models.deletion.CASCADE, related_name="golden_image", to="dcim.devicetype" 98 | ), 99 | ), 100 | ( 101 | "sw", 102 | models.ForeignKey( 103 | blank=True, 104 | null=True, 105 | on_delete=django.db.models.deletion.CASCADE, 106 | to="software_manager.softwareimage", 107 | ), 108 | ), 109 | ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), 110 | ], 111 | options={ 112 | "verbose_name": "Golden Image", 113 | "ordering": ["pid"], 114 | }, 115 | ), 116 | ] 117 | -------------------------------------------------------------------------------- /software_manager/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alsigna/netbox-software-manager/ab79483875df10a53668669b9846ad7823bdf939/software_manager/migrations/__init__.py -------------------------------------------------------------------------------- /software_manager/models.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from pathlib import Path 3 | 4 | from dcim.models import Device, DeviceType 5 | from django.conf import settings 6 | from django.core.validators import FileExtensionValidator 7 | from django.db import models 8 | from django.db.models import Q 9 | from django.urls import reverse 10 | from django_rq import get_queue 11 | from netbox.models import NetBoxModel 12 | from rq.exceptions import NoSuchJobError 13 | from rq.job import Job 14 | from utilities.querysets import RestrictedQuerySet 15 | 16 | from .choices import TaskFailReasonChoices, TaskStatusChoices, TaskTransferMethod, TaskTypeChoices 17 | 18 | PLUGIN_SETTINGS = settings.PLUGINS_CONFIG.get("software_manager", dict()) 19 | CF_NAME_SW_VERSION = PLUGIN_SETTINGS.get("CF_NAME_SW_VERSION", "") 20 | FTP_USERNAME = PLUGIN_SETTINGS.get("FTP_USERNAME", "") 21 | IMAGE_FOLDER = PLUGIN_SETTINGS.get("IMAGE_FOLDER", "") 22 | UPGRADE_QUEUE = PLUGIN_SETTINGS.get("UPGRADE_QUEUE", "") 23 | 24 | 25 | class SoftwareImage(NetBoxModel): 26 | image = models.FileField( 27 | upload_to=f"{IMAGE_FOLDER}/", 28 | validators=[FileExtensionValidator(allowed_extensions=["bin"])], 29 | null=True, 30 | blank=True, 31 | ) 32 | md5sum = models.CharField( 33 | max_length=36, 34 | blank=True, 35 | ) 36 | md5sum_calculated = models.CharField( 37 | max_length=36, 38 | blank=True, 39 | ) 40 | version = models.CharField( 41 | max_length=32, 42 | blank=True, 43 | ) 44 | filename = models.CharField( 45 | max_length=256, 46 | blank=True, 47 | ) 48 | comments = models.TextField( 49 | blank=True, 50 | ) 51 | 52 | class Meta: 53 | ordering = ["-filename", "-version"] 54 | 55 | def save(self, *args, **kwargs) -> None: 56 | if not self.image_exists: 57 | self.filename = "" 58 | self.md5sum_calculated = "" 59 | self.md5sum = "" 60 | super().save(*args, **kwargs) 61 | return 62 | 63 | self.filename = self.image.name.rsplit("/", 1)[-1] 64 | 65 | md5 = hashlib.md5() 66 | for chunk in self.image.chunks(): 67 | md5.update(chunk) 68 | self.md5sum_calculated = md5.hexdigest() 69 | super().save(*args, **kwargs) 70 | 71 | def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]: 72 | if self.image_exists: 73 | Path(self.image.path).unlink(missing_ok=True) 74 | return super().delete(*args, **kwargs) 75 | 76 | def __str__(self) -> str: 77 | if self.image_exists: 78 | return self.image.name.rsplit("/", 1)[-1] 79 | else: 80 | return f"{self.version} (no image)" 81 | 82 | @property 83 | def image_exists(self) -> bool: 84 | return bool(self.image.name) 85 | 86 | def __repr__(self) -> str: 87 | return f"<{self.__class__.__name__}: {str(self)}>" 88 | 89 | def get_absolute_url(self) -> str: 90 | return reverse("plugins:software_manager:softwareimage", kwargs={"pk": self.pk}) 91 | 92 | 93 | class GoldenImage(NetBoxModel): 94 | pid = models.OneToOneField( 95 | to=DeviceType, 96 | on_delete=models.CASCADE, 97 | related_name="golden_image", 98 | ) 99 | sw = models.ForeignKey( 100 | to=SoftwareImage, 101 | on_delete=models.CASCADE, 102 | blank=True, 103 | null=True, 104 | ) 105 | 106 | class Meta: 107 | ordering = ["pid"] 108 | verbose_name = "Golden Image" 109 | 110 | def __str__(self) -> str: 111 | return f"{self.pid.model} - {self.sw}" 112 | 113 | def __repr__(self) -> str: 114 | return f"<{self.__class__.__name__}: {str(self)}>" 115 | 116 | def get_progress(self) -> float: 117 | total = self.pid.instances.count() 118 | if total == 0: 119 | return 0.0 120 | upgraded = Device.objects.filter( 121 | **{f"custom_field_data__{CF_NAME_SW_VERSION}": self.sw.version}, 122 | device_type=self.pid, 123 | ).count() 124 | return round(upgraded / total * 100, 2) 125 | 126 | 127 | class ScheduledTaskQuerySet(RestrictedQuerySet): 128 | def delete(self): 129 | exclude_list = [] 130 | queue = get_queue(UPGRADE_QUEUE) 131 | for i in self: 132 | try: 133 | j = Job.fetch(i.job_id, queue.connection) 134 | if not j.is_started: 135 | j.delete() 136 | else: 137 | exclude_list.append(i.job_id) 138 | except NoSuchJobError: 139 | pass 140 | return super(ScheduledTaskQuerySet, self.exclude(Q(job_id__in=exclude_list))).delete() 141 | 142 | 143 | class ScheduledTaskManager(models.Manager): 144 | def get_queryset(self): 145 | return ScheduledTaskQuerySet(self.model, using=self._db) 146 | 147 | 148 | class ScheduledTask(NetBoxModel): 149 | device = models.ForeignKey( 150 | to=Device, 151 | on_delete=models.SET_NULL, 152 | null=True, 153 | ) 154 | task_type = models.CharField( 155 | max_length=255, 156 | choices=TaskTypeChoices, 157 | default=TaskTypeChoices.TYPE_UPLOAD, 158 | ) 159 | job_id = models.CharField( 160 | max_length=255, 161 | blank=True, 162 | ) 163 | status = models.CharField( 164 | max_length=255, 165 | choices=TaskStatusChoices, 166 | default=TaskStatusChoices.STATUS_UNKNOWN, 167 | ) 168 | message = models.CharField( 169 | max_length=512, 170 | blank=True, 171 | ) 172 | fail_reason = models.CharField( 173 | max_length=255, 174 | choices=TaskFailReasonChoices, 175 | default=TaskFailReasonChoices.FAIL_UNKNOWN, 176 | ) 177 | confirmed = models.BooleanField( 178 | default=False, 179 | ) 180 | scheduled_time = models.DateTimeField( 181 | null=True, 182 | ) 183 | start_time = models.DateTimeField( 184 | null=True, 185 | ) 186 | end_time = models.DateTimeField( 187 | null=True, 188 | ) 189 | mw_duration = models.PositiveIntegerField( 190 | null=True, 191 | ) 192 | log = models.TextField( 193 | blank=True, 194 | ) 195 | user = models.CharField( 196 | max_length=255, 197 | blank=True, 198 | ) 199 | transfer_method = models.CharField( 200 | max_length=8, 201 | choices=TaskTransferMethod, 202 | default=TaskTransferMethod.METHOD_FTP, 203 | ) 204 | 205 | objects = ScheduledTaskManager() 206 | 207 | def __str__(self): 208 | if not self.device: 209 | return "unknown" 210 | else: 211 | return f"{self.device}: {self.job_id}" 212 | 213 | def delete(self): 214 | queue = get_queue(UPGRADE_QUEUE) 215 | try: 216 | j = Job.fetch(self.job_id, queue.connection) 217 | if not j.is_started: 218 | j.delete() 219 | return super().delete() 220 | except NoSuchJobError: 221 | return super().delete() 222 | 223 | def get_absolute_url(self) -> str: 224 | return reverse("plugins:software_manager:scheduledtask", kwargs={"pk": self.pk}) 225 | 226 | class Meta: 227 | ordering = [ 228 | "-scheduled_time", 229 | "-start_time", 230 | "-end_time", 231 | "job_id", 232 | ] 233 | verbose_name = "Scheduled Task" 234 | -------------------------------------------------------------------------------- /software_manager/navigation.py: -------------------------------------------------------------------------------- 1 | from extras.plugins import PluginMenuItem 2 | 3 | menu_items = ( 4 | PluginMenuItem( 5 | link="plugins:software_manager:softwareimage_list", 6 | link_text="Software Repository", 7 | permissions=["software_manager.view_softwareimage"], 8 | ), 9 | PluginMenuItem( 10 | link="plugins:software_manager:goldenimage_list", 11 | link_text="Golden Images", 12 | permissions=["software_manager.view_goldenimage"], 13 | ), 14 | PluginMenuItem( 15 | link="plugins:software_manager:upgradedevice_list", 16 | link_text="Upgrade Devices", 17 | permissions=["software_manager.view_device"], 18 | ), 19 | PluginMenuItem( 20 | link="plugins:software_manager:scheduledtask_list", 21 | link_text="Scheduled Tasks", 22 | permissions=["software_manager.view_scheduledtask"], 23 | ), 24 | ) 25 | -------------------------------------------------------------------------------- /software_manager/tables.py: -------------------------------------------------------------------------------- 1 | import django_tables2 as tables 2 | from dcim.models import Device, DeviceType 3 | from django_tables2.utils import Accessor 4 | from netbox.tables import NetBoxTable 5 | from netbox.tables.columns import ActionsColumn, ColoredLabelColumn, TagColumn, ToggleColumn 6 | from tenancy.tables import TenantColumn 7 | 8 | from .models import ScheduledTask, SoftwareImage 9 | 10 | SW_LIST_FILENAME = """ 11 | {% if record.image_exists %} 12 | {{ record.filename }} 13 | {% else %} 14 | {{ record }} 15 | {% endif %} 16 | """ 17 | 18 | 19 | SW_LIST_SIZE = """ 20 | {% if record.image_exists %} 21 | {{ record.image.size|filesizeformat }} 22 | {% else %} 23 | 24 | {% endif %} 25 | """ 26 | 27 | SW_LIST_MD5SUM = """ 28 | {% if record.md5sum == record.md5sum_calculated %} 29 | {{ record.md5sum }} 30 | {% else %} 31 | {{ record.md5sum|default:"—" }} 32 | {% endif %} 33 | """ 34 | 35 | SW_LIST_DOWNLOAD_IMAGE = """ 36 | {% if record.image_exists %} 37 | 38 | 39 | 40 | {% else %} 41 | 44 | {% endif %} 45 | """ 46 | 47 | GOLDEN_IMAGE_FILENAME = """ 48 | {{ record.golden_image.sw.filename|default:"—" }} 49 | """ 50 | 51 | GOLDEN_IMAGE_ACTION = """ 52 | {% if record.golden_image %} 53 | 54 | 55 | 56 | 57 | 58 | 59 | {% else %} 60 | 61 | 62 | 63 | 66 | {% endif %} 67 | """ 68 | 69 | GOLDEN_IMAGE_MD5SUM = """ 70 | {% if record.golden_image.sw.md5sum == record.golden_image.sw.md5sum_calculated %} 71 | {{ record.golden_image.sw.md5sum }} 72 | {% else %} 73 | {{ record.golden_image.sw.md5sum|default:"—" }} 74 | {% endif %} 75 | """ 76 | 77 | GOLDEN_IMAGE_PROGRESS_GRAPH = """ 78 | {% if record.golden_image %} 79 | {% if record.instances.count %} 80 | {% progress_graph record.golden_image.get_progress %} 81 | {% else %} 82 | No instances 83 | {% endif %} 84 | {% else %} 85 | — 86 | {% endif %} 87 | """ 88 | 89 | UPGRADE_TARGET_SOFTWARE = """ 90 | {{ record.device_type.golden_image.sw.version|default:"—" }} 91 | """ 92 | 93 | UPGRADE_CURRENT_SOFTWARE = """ 94 | {% if record|get_current_version %} 95 | {% if record|get_current_version == record.device_type.golden_image.sw.version %} 96 | {{ record|get_current_version }} 97 | {% else %} 98 | {{ record|get_current_version }} 99 | {% endif %} 100 | {% else %} 101 | — 102 | {% endif %} 103 | """ 104 | 105 | SCHEDULED_TASK_TIME = """ 106 | {{ value|date:"SHORT_DATETIME_FORMAT" }} 107 | """ 108 | 109 | SCHEDULED_TASK_TYPE = """ 110 | {% if record.task_type == 'upload' %} 111 | Upload 112 | {% elif record.task_type == 'upgrade' %} 113 | Upgrade 114 | {% else %} 115 | Unknown 116 | {% endif %} 117 | """ 118 | 119 | SCHEDULED_TASK_STATUS = """ 120 | {% if record.status == 'succeeded' %} 121 | Succeeded 122 | {% elif record.status == 'skipped' %} 123 | Skipped 124 | {% elif record.status == 'failed' %} 125 | Failed 126 | {% elif record.status == 'scheduled' %} 127 | Scheduled 128 | {% elif record.status == 'running' %} 129 | Running 130 | {% else %} 131 | Unknown 132 | {% endif %} 133 | """ 134 | 135 | SCHEDULED_TASK_ACTION = """ 136 | 137 | 138 | 139 | """ 140 | 141 | 142 | SCHEDULED_TASK_CONFIRMED = """ 143 | {% if record.confirmed %} 144 | 147 | {% else %} 148 | 151 | {% endif %} 152 | """ 153 | 154 | SCHEDULED_TASK_JOB_ID = """ 155 | {{ record.job_id|cut_job_id }} 156 | """ 157 | 158 | 159 | class SoftwareImageListTable(NetBoxTable): 160 | filename = tables.TemplateColumn( 161 | verbose_name="Filename", 162 | template_code=SW_LIST_FILENAME, 163 | orderable=False, 164 | ) 165 | version = tables.Column( 166 | verbose_name="Version", 167 | orderable=False, 168 | ) 169 | size = tables.TemplateColumn( 170 | verbose_name="Size", 171 | template_code=SW_LIST_SIZE, 172 | orderable=False, 173 | ) 174 | md5sum = tables.TemplateColumn( 175 | verbose_name="MD5 Checksum", 176 | template_code=SW_LIST_MD5SUM, 177 | orderable=False, 178 | ) 179 | tags = TagColumn( 180 | url_name="plugins:software_manager:softwareimage_list", 181 | ) 182 | actions = ActionsColumn( 183 | extra_buttons=SW_LIST_DOWNLOAD_IMAGE, 184 | ) 185 | 186 | class Meta(NetBoxTable.Meta): 187 | model = SoftwareImage 188 | fields = ( 189 | "pk", 190 | "id", 191 | "filename", 192 | "version", 193 | "size", 194 | "md5sum", 195 | "tags", 196 | "actions", 197 | "created", 198 | "last_updated", 199 | ) 200 | default_columns = ( 201 | "filename", 202 | "version", 203 | "size", 204 | "md5sum", 205 | "tags", 206 | "actions", 207 | ) 208 | 209 | 210 | class GoldenImageListTable(NetBoxTable): 211 | model = tables.LinkColumn( 212 | viewname="dcim:devicetype", 213 | args=[Accessor("pk")], 214 | verbose_name="Device PID", 215 | ) 216 | image = tables.TemplateColumn( 217 | verbose_name="Image File", 218 | template_code=GOLDEN_IMAGE_FILENAME, 219 | orderable=False, 220 | ) 221 | version = tables.Column( 222 | verbose_name="Version", 223 | accessor="golden_image.sw.version", 224 | orderable=False, 225 | ) 226 | md5sum = tables.TemplateColumn( 227 | verbose_name="MD5 Checksum", 228 | template_code=GOLDEN_IMAGE_MD5SUM, 229 | orderable=False, 230 | ) 231 | progress = tables.TemplateColumn( 232 | template_code=GOLDEN_IMAGE_PROGRESS_GRAPH, 233 | orderable=False, 234 | verbose_name="Progress", 235 | ) 236 | 237 | actions = tables.TemplateColumn( 238 | template_code=GOLDEN_IMAGE_ACTION, 239 | verbose_name="", 240 | ) 241 | 242 | class Meta(NetBoxTable.Meta): 243 | model = DeviceType 244 | fields = ( 245 | "pk", 246 | "id", 247 | "model", 248 | "version", 249 | "image", 250 | "md5sum", 251 | "progress", 252 | "actions", 253 | ) 254 | default_columns = ( 255 | "model", 256 | "version", 257 | "image", 258 | "md5sum", 259 | "progress", 260 | "actions", 261 | ) 262 | 263 | 264 | class UpgradeDeviceListTable(NetBoxTable): 265 | pk = ToggleColumn(visible=True) 266 | name = tables.Column(linkify=True) 267 | t_sw = tables.TemplateColumn( 268 | verbose_name="Target Version", 269 | template_code=UPGRADE_TARGET_SOFTWARE, 270 | orderable=False, 271 | ) 272 | c_sw = tables.TemplateColumn( 273 | verbose_name="Curent Version", 274 | template_code=UPGRADE_CURRENT_SOFTWARE, 275 | orderable=False, 276 | ) 277 | tenant = TenantColumn() 278 | 279 | device_role = ColoredLabelColumn(verbose_name="Role") 280 | device_type = tables.LinkColumn(verbose_name="Device Type", text=lambda record: record.device_type.model) 281 | 282 | tags = TagColumn(url_name="plugins:software_manager:upgradedevice_list") 283 | 284 | actions = ActionsColumn( 285 | actions=(), 286 | ) 287 | 288 | class Meta(NetBoxTable.Meta): 289 | model = Device 290 | fields = ( 291 | "pk", 292 | "id", 293 | "name", 294 | "t_sw", 295 | "c_sw", 296 | "tenant", 297 | "device_role", 298 | "device_type", 299 | "tags", 300 | ) 301 | default_columns = ( 302 | "pk", 303 | "name", 304 | "t_sw", 305 | "c_sw", 306 | "tenant", 307 | "device_role", 308 | "device_type", 309 | "tags", 310 | ) 311 | 312 | 313 | class ScheduleTasksTable(UpgradeDeviceListTable): 314 | pk = ToggleColumn(visible=False) 315 | 316 | class Meta(NetBoxTable.Meta): 317 | model = Device 318 | fields = ( 319 | "name", 320 | "t_sw", 321 | "c_sw", 322 | "tenant", 323 | "device_role", 324 | "device_type", 325 | "tags", 326 | ) 327 | 328 | 329 | class ScheduledTaskTable(NetBoxTable): 330 | pk = ToggleColumn(visible=True) 331 | device = tables.LinkColumn( 332 | verbose_name="Device", 333 | ) 334 | scheduled_time = tables.TemplateColumn( 335 | verbose_name="Scheduled Time", 336 | template_code=SCHEDULED_TASK_TIME, 337 | ) 338 | start_time = tables.TemplateColumn( 339 | verbose_name="Start Time", 340 | template_code=SCHEDULED_TASK_TIME, 341 | ) 342 | end_time = tables.TemplateColumn( 343 | verbose_name="End Time", 344 | template_code=SCHEDULED_TASK_TIME, 345 | ) 346 | task_type = tables.TemplateColumn( 347 | verbose_name="Task Type", 348 | template_code=SCHEDULED_TASK_TYPE, 349 | attrs={ 350 | "td": {"align": "center"}, 351 | "th": {"style": "text-align: center"}, 352 | }, 353 | ) 354 | status = tables.TemplateColumn( 355 | verbose_name="Status", 356 | template_code=SCHEDULED_TASK_STATUS, 357 | attrs={ 358 | "td": {"align": "center"}, 359 | "th": {"style": "text-align: center"}, 360 | }, 361 | ) 362 | confirmed = tables.TemplateColumn( 363 | verbose_name="ACK", 364 | template_code=SCHEDULED_TASK_CONFIRMED, 365 | orderable=True, 366 | attrs={ 367 | "td": {"align": "center"}, 368 | "th": {"style": "text-align: center"}, 369 | }, 370 | ) 371 | job_id = tables.TemplateColumn( 372 | verbose_name="Job ID", 373 | template_code=SCHEDULED_TASK_JOB_ID, 374 | ) 375 | actions = tables.TemplateColumn( 376 | template_code=SCHEDULED_TASK_ACTION, 377 | attrs={"td": {"class": "text-right noprint"}}, 378 | verbose_name="", 379 | ) 380 | 381 | class Meta(NetBoxTable.Meta): 382 | model = ScheduledTask 383 | fields = ( 384 | "pk", 385 | "device", 386 | "scheduled_time", 387 | "start_time", 388 | "end_time", 389 | "task_type", 390 | "status", 391 | "confirmed", 392 | "job_id", 393 | "actions", 394 | ) 395 | default_columns = ( 396 | "pk", 397 | "device", 398 | "scheduled_time", 399 | "start_time", 400 | "end_time", 401 | "task_type", 402 | "status", 403 | "confirmed", 404 | "job_id", 405 | "actions", 406 | ) 407 | 408 | 409 | class ScheduledTaskBulkDeleteTable(ScheduledTaskTable): 410 | pk = ToggleColumn(visible=False) 411 | actions = None 412 | 413 | class Meta(NetBoxTable.Meta): 414 | model = ScheduledTask 415 | fields = ( 416 | "device", 417 | "scheduled_time", 418 | "start_time", 419 | "end_time", 420 | "task_type", 421 | "status", 422 | "confirmed", 423 | "job_id", 424 | ) 425 | -------------------------------------------------------------------------------- /software_manager/task_exceptions.py: -------------------------------------------------------------------------------- 1 | class TaskException(Exception): 2 | def __init__(self, reason, message, **kwargs): 3 | super().__init__(kwargs) 4 | self.reason = reason 5 | self.message = message 6 | 7 | def __str__(self): 8 | return f"{self.__class__.__name__}: {self.reason}: {self.message}" 9 | -------------------------------------------------------------------------------- /software_manager/task_executor.py: -------------------------------------------------------------------------------- 1 | import re 2 | import socket 3 | import time 4 | from datetime import timedelta 5 | from functools import wraps 6 | from pathlib import Path 7 | from typing import Callable 8 | 9 | from django.conf import settings 10 | from django_rq import get_queue 11 | from scrapli.driver.core import IOSXEDriver 12 | from scrapli.exceptions import ScrapliAuthenticationFailed, ScrapliConnectionError, ScrapliTimeout 13 | from scrapli.response import MultiResponse, Response 14 | 15 | from .choices import TaskFailReasonChoices, TaskStatusChoices, TaskTransferMethod, TaskTypeChoices 16 | from .logger import TaskLoggerMixIn 17 | from .models import ScheduledTask 18 | from .task_exceptions import TaskException 19 | 20 | PLUGIN_SETTINGS = settings.PLUGINS_CONFIG.get("software_manager", dict()) 21 | DEVICE_USERNAME = PLUGIN_SETTINGS.get("DEVICE_USERNAME", "") 22 | DEVICE_PASSWORD = PLUGIN_SETTINGS.get("DEVICE_PASSWORD", "") 23 | UPGRADE_QUEUE = PLUGIN_SETTINGS.get("UPGRADE_QUEUE", "") 24 | UPGRADE_THRESHOLD = PLUGIN_SETTINGS.get("UPGRADE_THRESHOLD", 2) 25 | FTP_USERNAME = PLUGIN_SETTINGS.get("FTP_USERNAME", "") 26 | FTP_PASSWORD = PLUGIN_SETTINGS.get("FTP_PASSWORD", "") 27 | FTP_SERVER = PLUGIN_SETTINGS.get("FTP_SERVER", "") 28 | HTTP_SERVER = PLUGIN_SETTINGS.get("HTTP_SERVER", "") 29 | CF_NAME_SW_VERSION = PLUGIN_SETTINGS.get("CF_NAME_SW_VERSION", "") 30 | UPGRADE_MAX_ATTEMPTS_AFTER_RELOAD = PLUGIN_SETTINGS.get("UPGRADE_MAX_ATTEMPTS_AFTER_RELOAD", 10) 31 | UPGRADE_SECONDS_BETWEEN_ATTEMPTS = PLUGIN_SETTINGS.get("UPGRADE_SECONDS_BETWEEN_ATTEMPTS", 60) 32 | 33 | 34 | class TaskExecutor(TaskLoggerMixIn): 35 | def __init__(self, task: ScheduledTask) -> None: 36 | super().__init__(task) 37 | self.task = task 38 | self.cli = None 39 | self.scrapli = { 40 | "auth_username": DEVICE_USERNAME, 41 | "auth_password": DEVICE_PASSWORD, 42 | "auth_strict_key": False, 43 | "port": 22, 44 | "timeout_socket": 5, 45 | "transport": "paramiko", 46 | "transport_options": { 47 | "open_cmd": [ 48 | "-o", 49 | "KexAlgorithms=+diffie-hellman-group-exchange-sha1", 50 | ], 51 | }, 52 | } 53 | if self.task.device is not None and self.task.device.primary_ip is not None: 54 | self.scrapli["host"] = str(self.task.device.primary_ip.address.ip) 55 | else: 56 | self.scrapli["host"] = None 57 | 58 | self.file_system = None 59 | self.target_image = None 60 | self.image_on_device = None 61 | self.total_free = 0 62 | 63 | def _action_task(self, status: str, msg: str, reason: str) -> None: 64 | self.task.status = status 65 | self.task.message = msg 66 | self.task.fail_reason = reason 67 | self.task.save() 68 | raise TaskException( 69 | reason=reason, 70 | message=msg, 71 | ) 72 | 73 | def skip_task(self, msg: str = "", reason: str = "") -> None: 74 | self._close_cli() 75 | self._action_task(TaskStatusChoices.STATUS_SKIPPED, msg, reason) 76 | 77 | def drop_task(self, msg: str = "", reason: str = "") -> None: 78 | self._close_cli() 79 | self._action_task(TaskStatusChoices.STATUS_FAILED, msg, reason) 80 | 81 | def _check_device_exists(self) -> None: 82 | if self.task.device is None: 83 | msg = "_check_device_exists - FAIL: No device is assigned to task" 84 | self.warning(msg) 85 | self.skip_task(msg, TaskFailReasonChoices.FAIL_CHECK) 86 | else: 87 | self.debug(f"_check_device_exists - OK: device='{self.task.device}'") 88 | 89 | def _check_primary_ip_exists(self) -> None: 90 | if self.task.device is None: 91 | return 92 | if self.task.device.primary_ip is None: 93 | msg = "_check_primary_ip_exists - FAIL: No primary (mgmt) address" 94 | self.warning(msg) 95 | self.skip_task(msg, TaskFailReasonChoices.FAIL_CHECK) 96 | else: 97 | ip = str(self.task.device.primary_ip.address.ip) 98 | self.debug(f"_check_primary_ip_exists - OK: {ip=}") 99 | 100 | def _check_golden_image_is_set(self) -> None: 101 | if self.task.device is None: 102 | return 103 | device_type = self.task.device.device_type 104 | if not hasattr(device_type, "golden_image"): 105 | msg = f"_check_golden_image_is_set - FAIL: No Golden Image for '{device_type.model}'" 106 | self.warning(msg) 107 | self.skip_task(msg, TaskFailReasonChoices.FAIL_CHECK) 108 | self.debug( 109 | f"_check_golden_image_is_set - OK: Golden Image for '{device_type.model}' is '{device_type.golden_image.sw}'" 110 | ) 111 | 112 | def _check_software_image_file_exists(self) -> None: 113 | if self.task.device is None: 114 | return 115 | sw = self.task.device.device_type.golden_image.sw 116 | 117 | if not sw.image_exists: 118 | msg = "_check_software_image_file_exists - OK: SoftwareImage was created without file, no need to check" 119 | self.warning(msg) 120 | return 121 | 122 | if not Path(settings.MEDIA_ROOT, sw.image.name).is_file(): 123 | msg = "_check_software_image_file_exists - FAIL: Image file does not exist in NetBox media directory" 124 | self.warning(msg) 125 | self.skip_task(msg, TaskFailReasonChoices.FAIL_CHECK) 126 | self.debug("_check_software_image_file_exists - OK: Image file exists in NetBox media directory") 127 | 128 | def _check_mw_is_active(self) -> None: 129 | if not all([self.task.scheduled_time, self.task.mw_duration, self.task.start_time]): 130 | msg = "_check_mw_is_active - FAIL: issue with datetimes" 131 | self.warning(msg) 132 | self.skip_task(msg, TaskFailReasonChoices.FAIL_CHECK) 133 | if self.task.start_time > self.task.scheduled_time + timedelta(hours=int(self.task.mw_duration)): # type: ignore 134 | msg = "_check_mw_is_active - FAIL: Maintenance Window is over" 135 | self.warning(msg) 136 | self.skip_task(msg, TaskFailReasonChoices.FAIL_CHECK) 137 | self.debug("_check_mw_is_active - OK: Maintenance Window is still active") 138 | 139 | def _check_failure_theshold(self) -> None: 140 | if UPGRADE_THRESHOLD is None: 141 | msg = "_check_failure_theshold - FAIL: UPGRADE_THRESHOLD is not set" 142 | self.warning(msg) 143 | self.skip_task(msg, TaskFailReasonChoices.FAIL_CHECK) 144 | if self.task.task_type == TaskTypeChoices.TYPE_UPGRADE: 145 | queue = get_queue(UPGRADE_QUEUE) 146 | active_jobs = queue.started_job_registry.count 147 | non_ack = ScheduledTask.objects.filter(start_time__isnull=False, confirmed=False).count() 148 | if non_ack >= active_jobs + UPGRADE_THRESHOLD: 149 | msg = ( 150 | f"_check_failure_theshold - FAIL: Reached failure threshold, Unconfirmed: {non_ack}, " 151 | f"Active: {active_jobs}, Failed: {non_ack-active_jobs}, Threshold: {UPGRADE_THRESHOLD}" 152 | ) 153 | self.warning(msg) 154 | self.skip_task(msg, TaskFailReasonChoices.FAIL_CHECK) 155 | else: 156 | msg = ( 157 | f"_check_failure_theshold - OK: Unconfirmed: {non_ack}, Active: {active_jobs}, " 158 | f"Failed: {non_ack - active_jobs}, Threshold: {UPGRADE_THRESHOLD}" 159 | ) 160 | self.debug(msg) 161 | else: 162 | self.debug(f"_check_failure_theshold - OK: Task type is '{self.task.task_type}', no need to check") 163 | 164 | def _check_device_is_alive(self) -> None: 165 | if self.task.device is None or self.task.device.primary_ip is None: 166 | return 167 | if (port := self._is_alive()) is not None: 168 | msg = f"_check_device_is_alive - OK: device is reachable via TCP/{port}" 169 | self.debug(msg) 170 | else: 171 | msg = "_check_device_is_alive - FAIL: device is not reachable" 172 | self.warning(msg) 173 | self.skip_task(msg, TaskFailReasonChoices.FAIL_CHECK) 174 | 175 | def _is_port_open(self, port: int) -> bool: 176 | try: 177 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 178 | s.settimeout(self.scrapli.get("timeout_socket", 5)) 179 | s.connect((self.scrapli["host"], port)) 180 | except Exception: 181 | time.sleep(2) 182 | raise 183 | return True 184 | 185 | def _is_alive(self, ports: tuple = (22, 23)) -> int | None: 186 | for port in ports: 187 | try: 188 | self._is_port_open(port) 189 | except Exception: 190 | pass 191 | else: 192 | return port 193 | 194 | def _get_cli(self, **kwargs) -> None | IOSXEDriver: 195 | def fallback_to_telnet(cli, **kwargs) -> None | IOSXEDriver: 196 | try: 197 | cli.close() 198 | except Exception: 199 | pass 200 | cli = None 201 | if self.scrapli["port"] != 23: 202 | self.debug("Fallback to telnet") 203 | self.scrapli["port"] = 23 204 | self.scrapli["transport"] = "telnet" 205 | cli = self._get_cli(**kwargs) 206 | return cli 207 | 208 | cli = IOSXEDriver(**self.scrapli, **kwargs) 209 | try: 210 | self.debug(f'Trying to connect via TCP/{self.scrapli["port"]} ...') 211 | cli.open() 212 | except ScrapliAuthenticationFailed: 213 | self.debug(f'Incorrect username while connecting to the device via TCP/{self.scrapli["port"]}') 214 | cli = fallback_to_telnet(cli, **kwargs) 215 | except ScrapliConnectionError: 216 | self.debug(f'Device closed connection on TCP/{self.scrapli["port"]}') 217 | cli = fallback_to_telnet(cli, **kwargs) 218 | except Exception: 219 | self.debug(f'Unknown error while connecting to the device via TCP/{self.scrapli["port"]}') 220 | cli = fallback_to_telnet(cli, **kwargs) 221 | else: 222 | self.debug(f'Login successful while connecting to the device via TCP/{self.scrapli["port"]}') 223 | return cli 224 | 225 | @staticmethod 226 | def _check_cli_is_active(func: Callable) -> Callable: 227 | @wraps(func) 228 | def wrapper(self, *args, **kwargs): 229 | if self.cli is None: 230 | self.cli = self._get_cli() 231 | 232 | if self.cli is not None: 233 | try: 234 | _ = self.cli.get_prompt() 235 | except: 236 | self._close_cli() 237 | self.cli = self._get_cli() 238 | 239 | if self.cli is None: 240 | msg = "_check_cli_is_active - FAIL: Cannot establish cli session" 241 | self.warning(msg) 242 | self.skip_task(msg, TaskFailReasonChoices.FAIL_CONNECT) 243 | 244 | return func(self, *args, **kwargs) 245 | 246 | return wrapper 247 | 248 | def _backup_cli_args(self, **kwargs) -> dict: 249 | backup = {} 250 | if self.cli is None: 251 | return backup 252 | for arg in kwargs: 253 | backup[arg] = getattr(self.cli, arg, None) 254 | return backup 255 | 256 | def _set_cli_args(self, backup: dict) -> None: 257 | if self.cli is None: 258 | return 259 | for arg, value in backup.items(): 260 | setattr(self.cli, arg, value) 261 | 262 | def _close_cli(self) -> None: 263 | if self.cli is not None: 264 | try: 265 | self.cli.close() 266 | except: 267 | pass 268 | 269 | @_check_cli_is_active 270 | def _send_commands(self, commands: list[str], **kwargs) -> None | MultiResponse: 271 | if self.cli is None: 272 | return 273 | cli_backup = self._backup_cli_args(**kwargs) 274 | self._set_cli_args(kwargs) 275 | try: 276 | outputs = self.cli.send_commands(commands) 277 | except: 278 | raise 279 | else: 280 | return outputs 281 | finally: 282 | self._set_cli_args(cli_backup) 283 | 284 | @_check_cli_is_active 285 | def _send_configs(self, configs: list[str], **kwargs) -> None | MultiResponse: 286 | if self.cli is None: 287 | return 288 | cli_backup = self._backup_cli_args(**kwargs) 289 | self._set_cli_args(kwargs) 290 | try: 291 | outputs = self.cli.send_configs(configs) 292 | except: 293 | raise 294 | else: 295 | return outputs 296 | finally: 297 | self._set_cli_args(cli_backup) 298 | 299 | def _parse_pid(self, output: Response) -> str: 300 | if output.failed: 301 | msg = f"Cannot get '{output.channel_input}' output" 302 | self.error(msg) 303 | self.skip_task(msg, reason=TaskFailReasonChoices.FAIL_CONFIG) 304 | 305 | if match := re.search(r"\n\w+\s+(\S+)\s+.*\(revision\s+", output.result): 306 | pid = match.group(1) 307 | self.info(f"PID: '{pid}'") 308 | return pid 309 | else: 310 | msg = "Cannot get device PID" 311 | self.error(msg) 312 | self.skip_task(msg, reason=TaskFailReasonChoices.FAIL_CONFIG) 313 | return "" 314 | 315 | def _parse_sn(self, output: Response) -> str: 316 | if output.failed: 317 | msg = f"Cannot get '{output.channel_input}' output" 318 | self.error(msg) 319 | self.skip_task(msg, reason=TaskFailReasonChoices.FAIL_CONFIG) 320 | 321 | if match := re.search(r"\n.*\s+board\s+ID\s+(\S+)", output.result): 322 | sn = match.group(1) 323 | self.info(f"SN: '{sn}'") 324 | return sn 325 | else: 326 | msg = "Can not get device SN" 327 | self.error(msg) 328 | self.skip_task(msg, reason=TaskFailReasonChoices.FAIL_CONFIG) 329 | return "" 330 | 331 | def _validate_pid_sn(self, show_version_output: Response) -> None: 332 | if self.task.device is None: 333 | return 334 | 335 | pid = self._parse_pid(show_version_output) 336 | sn = self._parse_sn(show_version_output) 337 | 338 | if pid.lower() != self.task.device.device_type.model.lower() or sn.lower() != self.task.device.serial.lower(): 339 | msg = "Device PID/SN does not match with NetBox data" 340 | self.error(msg) 341 | self.skip_task(msg, reason=TaskFailReasonChoices.FAIL_CONFIG) 342 | self.info(f"Device '{pid}/{sn}' matches with NetBox data") 343 | 344 | def _validate_image_file(self, dir_all_output: Response) -> None: 345 | if self.task.device is None: 346 | return 347 | if not self.task.device.device_type.golden_image.sw.image_exists: 348 | self.debug(f"SoftwareImage was created without file, no need to validate against device files") 349 | return 350 | 351 | device_files: list[dict] = dir_all_output.textfsm_parse_output() # type: ignore 352 | if len(device_files) == 0: 353 | msg = "No any files on device flash" 354 | self.warning(msg) 355 | self.skip_task(msg, TaskFailReasonChoices.FAIL_CONFIG) 356 | 357 | self.file_system = device_files[0]["file_system"].strip("/") 358 | self.total_free = int(device_files[0]["total_free"]) 359 | self.target_image = self.task.device.device_type.golden_image.sw.filename 360 | target_path = self.task.device.device_type.golden_image.sw.image.path 361 | self.image_on_device = list(filter(lambda x: x["name"] == self.target_image, device_files)) 362 | 363 | self.debug(f"Filesystem: {self.file_system}") 364 | self.debug(f"Target Image: {self.target_image}") 365 | self.debug(f"Target Path: {target_path}") 366 | self.debug(f"Target Image on box: {self.image_on_device}") 367 | 368 | def _validate_device(self) -> None: 369 | self.info("Device valiation...") 370 | 371 | commands = ["show version", "dir /all"] 372 | outputs = self._send_commands(commands) 373 | self.debug("----------vv Outputs vv----------") 374 | for output in outputs: 375 | self.debug("\n" + output.result) 376 | self.debug("----------^^ Outputs ^^----------") 377 | self._validate_pid_sn(outputs[0]) 378 | self._validate_image_file(outputs[1]) 379 | 380 | self.info("Device has been validated") 381 | 382 | def _initial_check(self) -> None: 383 | self.info("Initial checking...") 384 | self._check_device_exists() 385 | self._check_primary_ip_exists() 386 | self._check_golden_image_is_set() 387 | self._check_software_image_file_exists() 388 | self._check_mw_is_active() 389 | self._check_failure_theshold() 390 | self._check_device_is_alive() 391 | self.info("Initial checks have been completed") 392 | 393 | def _file_upload(self) -> None: 394 | self.info("Uploading image to the box...") 395 | cmd_copy = "" 396 | if self.task.transfer_method == TaskTransferMethod.METHOD_FTP: 397 | cmd_copy = f"copy ftp://{FTP_USERNAME}:{FTP_PASSWORD}@{FTP_SERVER}/{self.target_image} {self.file_system}/{self.target_image}" 398 | elif self.task.transfer_method == TaskTransferMethod.METHOD_HTTP: 399 | cmd_copy = f"copy {HTTP_SERVER}{self.target_image} {self.file_system}/{self.target_image}" 400 | else: 401 | msg = "Unknown transfer method" 402 | self.error(msg) 403 | self.skip_task(msg, reason=TaskFailReasonChoices.FAIL_UPLOAD) 404 | 405 | configs = [ 406 | "file prompt quiet", 407 | "line vty 0 15", 408 | "exec-timeout 180 0", 409 | ] 410 | configs_undo = [ 411 | "no file prompt quiet", 412 | "line vty 0 15", 413 | "exec-timeout 30 0", 414 | ] 415 | outputs = self._send_configs(configs) 416 | self.debug(f"Preparing for copy:\n{outputs.result}") 417 | if outputs.failed: 418 | self._close_cli() 419 | msg = "Can not change configuration" 420 | self.error(msg) 421 | self.skip_task(msg, TaskFailReasonChoices.FAIL_UPLOAD) 422 | 423 | self.debug(f"Copy command: {cmd_copy}") 424 | self.info(f"Copy in progress...") 425 | 426 | outputs = self._send_commands( 427 | [cmd_copy], 428 | timeout_ops=7200, 429 | timeout_transport=7200, 430 | ) 431 | self.debug(f"Copy logs:\n{outputs.result}") 432 | if outputs.failed or not (re.search(r"OK", outputs.result) or re.search(r"bytes copied in", outputs.result)): 433 | self._close_cli() 434 | msg = "Can not download image from server" 435 | self.error(msg) 436 | self.skip_task(msg, TaskFailReasonChoices.FAIL_UPLOAD) 437 | 438 | outputs = self._send_configs(configs_undo) 439 | self.debug(f"Rollback after copy:\n{outputs.result}") 440 | if outputs.failed: 441 | self._close_cli() 442 | msg = "Can not do rollback configuration" 443 | self.error(msg) 444 | self.skip_task(msg, TaskFailReasonChoices.FAIL_UPLOAD) 445 | 446 | def _check_md5(self, filename: str, expected_md5: str) -> None: 447 | outputs = self._send_commands( 448 | [f"verify /md5 {filename} {expected_md5}"], 449 | timeout_ops=1800, 450 | timeout_transport=1800, 451 | ) 452 | 453 | self.debug(f"MD5 verication result:\n{outputs.result[-150:]}") 454 | if outputs.failed: 455 | self._close_cli() 456 | msg = "Can not check MD5" 457 | self.error(msg) 458 | self.skip_task(msg, TaskFailReasonChoices.FAIL_CHECK) 459 | 460 | if re.search(r"Verified", outputs.result): 461 | self.info("MD5 was verified") 462 | else: 463 | self._close_cli() 464 | msg = "Wrong M5" 465 | self.error(msg) 466 | self.skip_task(msg, TaskFailReasonChoices.FAIL_CHECK) 467 | 468 | def _upload(self) -> None: 469 | if self.task.device is None: 470 | return 471 | if not self.task.device.device_type.golden_image.sw.image_exists: 472 | msg = f"SoftwareImage was created without file, upload is not applicable" 473 | self.warning(msg) 474 | self.skip_task(msg, TaskFailReasonChoices.FAIL_UPLOAD) 475 | if not all([self.target_image, self.file_system, self.total_free]): 476 | msg = "Was not able to parse files on device flash." 477 | self.warning(msg) 478 | self.skip_task(msg, TaskFailReasonChoices.FAIL_UPLOAD) 479 | 480 | if self.image_on_device is not None and len(self.image_on_device) == 0: 481 | self.info("No image on the device. Need to transfer") 482 | image_size = int(self.task.device.device_type.golden_image.sw.image.size) * 1.1 483 | self.debug(f"Free on {self.file_system} - {self.total_free}, Image size (+10%) - {int(image_size)}") 484 | 485 | if int(self.total_free) < int(image_size): 486 | self._close_cli() 487 | msg = f"No enough space on {self.file_system}" 488 | self.error(msg) 489 | self.skip_task(msg, TaskFailReasonChoices.FAIL_UPLOAD) 490 | else: 491 | self.debug("Enough space for uploading, contunue proccessing") 492 | self._file_upload() 493 | else: 494 | self.info(f"Image {self.target_image} already exists") 495 | 496 | self.info("MD5 verification...") 497 | self._check_md5( 498 | filename=f"{self.file_system}/{self.target_image}", 499 | expected_md5=self.task.device.device_type.golden_image.sw.md5sum, 500 | ) 501 | self.info("File was uploaded and verified") 502 | self._close_cli() 503 | 504 | def _compare_sw(self, show_version_output: Response, should_match: bool) -> None: 505 | if self.task.device is None: 506 | return 507 | show_ver_parsed = show_version_output.textfsm_parse_output() 508 | sw_current = show_ver_parsed[0].get("version", "N/A") # type: ignore 509 | sw_target = self.task.device.device_type.golden_image.sw.version 510 | self.debug(f"Current version is '{sw_current}'") 511 | self.debug(f"Target version is '{sw_target}'") 512 | if self.task.device.custom_field_data[CF_NAME_SW_VERSION] != sw_current: 513 | self.info("Updating custom field") 514 | self.task.device.custom_field_data[CF_NAME_SW_VERSION] = sw_current 515 | self.task.device.save() 516 | if should_match and sw_current.lower() != sw_target.lower(): 517 | msg = f"Current version '{sw_current}' does not match with target '{sw_target}' after upgrade" 518 | self.error(msg) 519 | self.drop_task(msg, TaskFailReasonChoices.FAIL_UPGRADE) 520 | elif not should_match and sw_current.lower() == sw_target.lower(): 521 | msg = f"Current version '{sw_current}' matches with target '{sw_target}'" 522 | self.warning(msg) 523 | self.skip_task(msg, TaskFailReasonChoices.FAIL_UPGRADE) 524 | 525 | def _change_bootvar(self, show_boot_output: Response) -> None: 526 | self.info("Preparing boot system config") 527 | new_boot_lines = [] 528 | old_boot_lines = show_boot_output.result.splitlines() 529 | self.debug(f"Orginal boot lines:\n{old_boot_lines}") 530 | for line in old_boot_lines: 531 | new_boot_lines.append(f"no {line}") 532 | new_boot_lines.append(f"boot system {self.file_system}/{self.target_image}") 533 | if len(old_boot_lines) != 0: 534 | new_boot_lines.append(old_boot_lines[0]) 535 | self.debug(f"New boot lines:\n{new_boot_lines}") 536 | 537 | output = self._send_configs(new_boot_lines) 538 | self.debug(f"Changnig Boot vars:\n{output.result}") 539 | if output.failed: 540 | msg = "Unable to change bootvar" 541 | self.error(msg) 542 | self.drop_task(msg, TaskFailReasonChoices.FAIL_UPGRADE) 543 | else: 544 | self.info("Bootvar was changed") 545 | 546 | @_check_cli_is_active 547 | def _write_memory(self) -> None: 548 | self.info("Write memory") 549 | if self.cli is None: 550 | return 551 | 552 | try: 553 | output = self.cli.send_command(command="write memory", timeout_ops=60) 554 | except (ScrapliTimeout, ScrapliConnectionError): 555 | self.info("Trying interactive prompt") 556 | time.sleep(2) 557 | self._close_cli() 558 | self.cli = self._get_cli() 559 | try: 560 | output = self.cli.send_interactive( # type: ignore 561 | [ 562 | ("write memory", "]", False), 563 | ("\n", "#", False), 564 | ("\n", "#", False), 565 | ], 566 | timeout_ops=60, 567 | ) 568 | except (ScrapliTimeout, ScrapliConnectionError): 569 | msg = "Unable to save config: ScrapliTimeout" 570 | self.error(msg) 571 | self.drop_task(msg, TaskFailReasonChoices.FAIL_UPGRADE) 572 | except Exception as exc: 573 | msg = f"Unable to save config, unknown exception: {str(exc)}" 574 | self.error(msg) 575 | self.drop_task(msg, TaskFailReasonChoices.FAIL_UPGRADE) 576 | except Exception as exc: 577 | msg = f"Unable to save config, unknown exception: {str(exc)}" 578 | self.error(msg) 579 | self.drop_task(msg, TaskFailReasonChoices.FAIL_UPGRADE) 580 | 581 | self.debug("----------vv Outputs vv----------") 582 | self.debug("\n" + output.result) # type: ignore 583 | self.debug("----------^^ Outputs ^^----------") 584 | 585 | if re.search(r"\[OK\]", output.result): # type: ignore 586 | self.info("Config was saved") 587 | else: 588 | msg = "Can not save config" 589 | self.error(msg) 590 | self.drop_task(msg, TaskFailReasonChoices.FAIL_UPGRADE) 591 | 592 | @_check_cli_is_active 593 | def _reload_in(self) -> None: 594 | self.info("Reloading the box") 595 | if self.cli is None: 596 | return 597 | 598 | try: 599 | output = self.cli.send_interactive( 600 | [ 601 | ("reload in 1", "[confirm]", False), 602 | ("\n", "#", False), 603 | ("\n", "#", False), 604 | ], 605 | timeout_ops=30, 606 | ) 607 | except Exception as exc: 608 | msg = f"Unable to reload, exception: {str(exc)}" 609 | self.error(msg) 610 | self.drop_task(msg, TaskFailReasonChoices.FAIL_UPGRADE) 611 | else: 612 | self.info("Reload was requested") 613 | self.debug("----------vv Outputs vv----------") 614 | self.debug("\n" + output.result) # type: ignore 615 | self.debug("----------^^ Outputs ^^----------") 616 | 617 | def _wait_for_device_up(self) -> None: 618 | hold_timer = 30 619 | self.info(f"Hold for {hold_timer} seconds") 620 | time.sleep(hold_timer) 621 | for try_number in range(1, UPGRADE_MAX_ATTEMPTS_AFTER_RELOAD + 1): 622 | self.info(f"Connecting after reload {try_number}/{UPGRADE_MAX_ATTEMPTS_AFTER_RELOAD}...") 623 | if not self._is_alive(): 624 | self.info(f"Device is not online, next try in {UPGRADE_SECONDS_BETWEEN_ATTEMPTS} seconds") 625 | time.sleep(UPGRADE_SECONDS_BETWEEN_ATTEMPTS) 626 | else: 627 | self.info("Device became online") 628 | time.sleep(10) 629 | return 630 | if not self._is_alive(): 631 | msg = "Device was lost after reload" 632 | self.error(msg) 633 | self.drop_task(msg, TaskFailReasonChoices.FAIL_UPGRADE) 634 | 635 | def _post_checking(self) -> None: 636 | self.info("Checking after reload") 637 | 638 | commands = ["show version"] 639 | outputs = self._send_commands(commands) 640 | if outputs[0].failed: 641 | msg = "Can not collect outputs for post-chech" 642 | self.error(msg) 643 | self.drop_task(msg, TaskFailReasonChoices.FAIL_UPGRADE) 644 | self.debug("----------vv Outputs vv----------") 645 | for output in outputs: 646 | self.debug("\n" + output.result) 647 | self.debug("----------^^ Outputs ^^----------") 648 | 649 | self._write_memory() 650 | self._compare_sw(outputs[0], should_match=True) 651 | self.info("Post-checks have been done") 652 | 653 | def _upgrade(self) -> None: 654 | if self.task.device is None: 655 | return 656 | commands = [ 657 | "show run | i boot system", 658 | "show version", 659 | ] 660 | outputs = self._send_commands(commands) 661 | 662 | self.debug("----------vv Outputs vv----------") 663 | for output in outputs: 664 | self.debug("\n" + output.result) 665 | self.debug("----------^^ Outputs ^^----------") 666 | 667 | if outputs.failed: 668 | self._close_cli() 669 | msg = "Can not collect outputs for upgrade" 670 | self.error(msg) 671 | self.skip_task(msg, TaskFailReasonChoices.FAIL_UPGRADE) 672 | 673 | self._compare_sw(outputs[1], should_match=False) 674 | 675 | if not self.task.device.device_type.golden_image.sw.image_exists: 676 | msg = f"SoftwareImage was created without file, upgrade is not applicable" 677 | self.warning(msg) 678 | self.skip_task(msg, TaskFailReasonChoices.FAIL_UPGRADE) 679 | 680 | if self.image_on_device is None or len(self.image_on_device) == 0: 681 | msg = "No target image on the box" 682 | self.error(msg) 683 | self.skip_task(msg, TaskFailReasonChoices.FAIL_UPGRADE) 684 | else: 685 | self.info("Image exists on the box") 686 | 687 | self._check_md5( 688 | filename=f"{self.file_system}/{self.target_image}", 689 | expected_md5=self.task.device.device_type.golden_image.sw.md5sum, 690 | ) 691 | self._check_failure_theshold() 692 | self._change_bootvar(outputs[0]) 693 | self._write_memory() 694 | self._reload_in() 695 | self._close_cli() 696 | self._wait_for_device_up() 697 | self._post_checking() 698 | 699 | def execute_task(self) -> bool: 700 | self.info(f"New Job {self.task.job_id} was started. Type {self.task.task_type}") 701 | self._initial_check() 702 | self._validate_device() 703 | 704 | if self.task.task_type == TaskTypeChoices.TYPE_UPLOAD: 705 | self.info("Upload task") 706 | self._upload() 707 | elif self.task.task_type == TaskTypeChoices.TYPE_UPGRADE: 708 | self.info("Upgrade task") 709 | self._upgrade() 710 | 711 | return True 712 | -------------------------------------------------------------------------------- /software_manager/templates/software_manager/my_render_field.html: -------------------------------------------------------------------------------- 1 | {% load form_helpers %} 2 | {% load helpers %} 3 | 4 |
5 | 6 | {# Render the field label, except for: #} 7 | {# 1. Checkboxes (label appears to the right of the field #} 8 | {# 2. Textareas with no label set (will expand across entire row) #} 9 | {% if field|widget_type == 'checkboxinput' or field|widget_type == 'textarea' and not label %} 10 | {% else %} 11 | 14 | {% endif %} 15 | 16 | {# Render the field itself #} 17 |
18 | {# Include the "regenerate" button on slug fields #} 19 | {% if field|widget_type == 'slugwidget' %} 20 |
21 | {{ field }} 22 | 25 |
26 | {# Render checkbox labels to the right of the field #} 27 | {% elif field|widget_type == 'checkboxinput' %} 28 |
29 | {{ field }} 30 | 33 |
34 | {# Default field rendering #} 35 | {% else %} 36 | {{ field }} 37 | {% endif %} 38 | 39 | {# Display any error messages #} 40 | {% if field.errors %} 41 |
42 | {% for error in field.errors %}{{ error }}{% if not forloop.last %}
{% endif %}{% endfor %} 43 |
44 | {% elif field.field.required %} 45 |
46 | This field is required. 47 |
48 | {% endif %} 49 | 50 | {# Help text #} 51 | {% if field.help_text %} 52 | {{ field.help_text|safe }} 53 | {% endif %} 54 | 55 | {# For bulk edit forms, include an option to nullify the field #} 56 | {% if bulk_nullable %} 57 |
58 | 59 | 60 |
61 | {% endif %} 62 | 63 |
64 | 65 |
66 | -------------------------------------------------------------------------------- /software_manager/templates/software_manager/scheduledtask.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load render_table from django_tables2 %} 3 | 4 | {% block controls %} 5 |
6 |
7 | 8 | Scheduled Tasks List 9 | 10 | 11 | Delete 12 | 13 |
14 |
15 | {% endblock controls %} 16 | 17 | 18 | {% block content %} 19 | 20 |
21 |
22 |
23 |
Task
24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 |
Job ID{{ object.job_id }}
User{{ object.user }}
Device{{ object.device }}
Platform{{ object.device.device_type }}
IP Address{{ object.device.primary_ip.address.ip }}
Image{{ object.device.device_type.golden_image.sw.filename }}
Transfer Method{{ object.transfer_method }}
Job Status{{ object.status }}
Message{{ object.message }}
Action{{ object.task_type }}
Created Time{{ object.timestamp|date:"M d, Y H:i:s" }}
Scheduled Time{{ object.scheduled_time|date:"M d, Y H:i:s" }}
Start Time{{ object.start_time|date:"M d, Y H:i:s" }}
End Time{{ object.end_time|date:"M d, Y H:i:s" }}
83 |
84 |
85 |
86 |
87 |
88 |
89 | Execution Log 90 |
91 |
92 |
{{ object.log }}
93 |
94 |
95 |
96 |
97 | 98 | 99 | {% endblock content %} -------------------------------------------------------------------------------- /software_manager/templates/software_manager/scheduledtask_add.html: -------------------------------------------------------------------------------- 1 | {% extends 'base/layout.html' %} 2 | {% load helpers %} 3 | {% load my_form_helpers %} 4 | {% load render_table from django_tables2 %} 5 | 6 | {% block title %}Schedule {{ table.rows|length }} Tasks{% endblock %} 7 | 8 | {% block tabs %} 9 | 21 | {% endblock tabs %} 22 | 23 | {% block content-wrapper %} 24 |
25 | {% block content %} 26 | 27 | {# Schedule form #} 28 |
29 |
30 | {% csrf_token %} 31 | 32 | 33 | 34 | {% for field in form.hidden_fields %} 35 | {{ field }} 36 | {% endfor %} 37 | 38 |
39 |
40 |
41 |
42 | {% for field in form.visible_fields %} 43 | {% if field.name in form.start_now %} 44 | {% my_render_field field bulk_nullable=True label_text="Start Now" %} 45 | {% else %} 46 | {% my_render_field field %} 47 | {% endif %} 48 | {% endfor %} 49 |
50 |
51 | 52 |
53 | 54 | Cancel 55 |
56 |
57 |
58 | 59 |
60 |
61 | 62 | {# Selected objects list #} 63 |
64 |
65 |
66 | {% render_table table 'inc/table.html' %} 67 |
68 |
69 |
70 | 71 | {% endblock content %} 72 |
73 | {% endblock content-wrapper %} -------------------------------------------------------------------------------- /software_manager/templates/software_manager/scheduledtask_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object_list.html' %} 2 | {% load my_form_helpers %} 3 | 4 | {% block controls %} 5 |
6 |
7 | 8 | Upgrade Device 9 | 10 |
11 |
12 | {% endblock controls %} 13 | 14 | {% block title %}Scheduled Tasks{% endblock title %} 15 | 16 | {% block bulk_buttons %} 17 | 20 | 23 | {% endblock bulk_buttons %} -------------------------------------------------------------------------------- /software_manager/templates/software_manager/softwareimage.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load render_table from django_tables2 %} 3 | 4 | {% block content %} 5 | 6 |
7 |
8 |
9 |
Software Image
10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 |
Version{{ object.version|placeholder }}
Image Name{{ object.filename|placeholder }}
Image Size 23 | {% if object.image.name %} 24 | {{ object.image.size|filesizeformat }} 25 | {% else %} 26 | 27 | {% endif %} 28 |
Expected MD5{{ object.md5sum|placeholder }}
Calculated MD5{{ object.md5sum_calculated|placeholder }}
Added Date{{ object.created|placeholder }}
43 |
44 |
45 |
46 |
47 | {% include 'inc/panels/comments.html' %} 48 | {% include 'inc/panels/tags.html' %} 49 |
50 |
51 | 52 | 53 | {% endblock content %} -------------------------------------------------------------------------------- /software_manager/templates/software_manager/upgradedevice_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object_list.html' %} 2 | {% load my_form_helpers %} 3 | 4 | {% block controls %} 5 |
6 |
7 | 8 | Scheduled Tasks 9 | 10 |
11 |
12 | {% endblock controls %} 13 | 14 | {% block title %}Upgrade Devices{% endblock title %} 15 | 16 | {% block bulk_buttons %} 17 | 20 | {% endblock bulk_buttons %} -------------------------------------------------------------------------------- /software_manager/templates/software_manager/widgets/clearable_file_input.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {% if widget.is_initial %} 4 | {{ widget.value }} 5 | {% if not widget.required and not widget.attrs.disabled %} 6 |
7 | 8 | 9 | {% else %} 10 |
11 | Can not be changed. Please delete and create a new one. 12 | {% endif %} 13 | {% else %} 14 | None assigned 15 | {% endif %} 16 |
17 |
18 | 27 |
28 |
29 | -------------------------------------------------------------------------------- /software_manager/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alsigna/netbox-software-manager/ab79483875df10a53668669b9846ad7823bdf939/software_manager/templatetags/__init__.py -------------------------------------------------------------------------------- /software_manager/templatetags/my_form_helpers.py: -------------------------------------------------------------------------------- 1 | from django.template.defaulttags import register 2 | 3 | 4 | @register.inclusion_tag("helpers/utilization_graph.html") 5 | def progress_graph(utilization, warning_threshold=101, danger_threshold=101): 6 | return { 7 | "utilization": utilization, 8 | "warning_threshold": warning_threshold, 9 | "danger_threshold": danger_threshold, 10 | } 11 | 12 | 13 | @register.inclusion_tag("software_manager/my_render_field.html") 14 | def my_render_field(field, bulk_nullable=False, label=None, label_text=None): 15 | return { 16 | "field": field, 17 | "label": label or field.label, 18 | "bulk_nullable": bulk_nullable, 19 | "label_text": label_text or "Set Null", 20 | } 21 | 22 | 23 | @register.filter 24 | def get_current_version(device): 25 | return device.custom_field_data.get("sw_version", None) 26 | 27 | 28 | @register.filter 29 | def cut_job_id(job_id): 30 | try: 31 | if len(job_id) > 10: 32 | return f"{job_id[:4]}...{job_id[-4:]}" 33 | except Exception: 34 | pass 35 | return job_id 36 | -------------------------------------------------------------------------------- /software_manager/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from netbox.views.generic import ObjectChangeLogView 3 | 4 | from .models import SoftwareImage 5 | from .views import ( 6 | GoldenImageAdd, 7 | GoldenImageDelete, 8 | GoldenImageEdit, 9 | GoldenImageList, 10 | ScheduledTaskBulkDelete, 11 | ScheduledTaskDelete, 12 | ScheduledTaskInfo, 13 | ScheduledTaskList, 14 | SoftwareImageAdd, 15 | SoftwareImageBulkDelete, 16 | SoftwareImageDelete, 17 | SoftwareImageEdit, 18 | SoftwareImageList, 19 | SoftwareImageView, 20 | UpgradeDeviceList, 21 | UpgradeDeviceScheduler, 22 | ) 23 | 24 | app_name = "software_manager" 25 | 26 | urlpatterns = [ 27 | # software image 28 | path("software-image/", SoftwareImageList.as_view(), name="softwareimage_list"), 29 | path("software-image/add", SoftwareImageAdd.as_view(), name="softwareimage_add"), 30 | path("software-image/delete", SoftwareImageBulkDelete.as_view(), name="softwareimage_bulk_delete"), 31 | path("software-image//", SoftwareImageView.as_view(), name="softwareimage"), 32 | path("software-image//edit", SoftwareImageEdit.as_view(), name="softwareimage_edit"), 33 | path("software-image//delete", SoftwareImageDelete.as_view(), name="softwareimage_delete"), 34 | path( 35 | "software-image//changelog/", 36 | ObjectChangeLogView.as_view(), 37 | name="softwareimage_changelog", 38 | kwargs={"model": SoftwareImage}, 39 | ), 40 | # golden image 41 | path("golden-image/", GoldenImageList.as_view(), name="goldenimage_list"), 42 | path("golden-image//add", GoldenImageAdd.as_view(), name="goldenimage_add"), 43 | path("golden-image//edit", GoldenImageEdit.as_view(), name="goldenimage_edit"), 44 | path("golden-image//delete", GoldenImageDelete.as_view(), name="goldenimage_delete"), 45 | # upgrade device 46 | path("upgrade-device/", UpgradeDeviceList.as_view(), name="upgradedevice_list"), 47 | path("upgrade-device/scheduler", UpgradeDeviceScheduler.as_view(), name="upgradedevice_scheduler"), 48 | # scheduled tsaks 49 | path("scheduled-task/", ScheduledTaskList.as_view(), name="scheduledtask_list"), 50 | path("scheduled-task//", ScheduledTaskInfo.as_view(), name="scheduledtask"), 51 | path("scheduled-task//delete", ScheduledTaskDelete.as_view(), name="scheduledtask_delete"), 52 | path("scheduled-task/delete", ScheduledTaskBulkDelete.as_view(), name="scheduledtask_bulk_delete"), 53 | ] 54 | -------------------------------------------------------------------------------- /software_manager/views.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from datetime import datetime 3 | 4 | import pytz 5 | from dcim.models import Device, DeviceType 6 | from django.conf import settings 7 | from django.contrib import messages 8 | from django.core.handlers.wsgi import WSGIRequest 9 | from django.http import HttpResponse, HttpResponseRedirect 10 | from django.shortcuts import redirect, render 11 | from django.urls import reverse 12 | from django.views import View 13 | from django_rq import get_queue 14 | from netbox.views.generic import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectView 15 | 16 | from .choices import TaskStatusChoices 17 | from .filtersets import GoldenImageFilterSet, ScheduledTaskFilterSet, SoftwareImageFilterSet 18 | from .forms import ( 19 | GoldenImageAddForm, 20 | GoldenImageFilterForm, 21 | ScheduledTaskCreateForm, 22 | ScheduledTaskFilterForm, 23 | SoftwareImageEditForm, 24 | SoftwareImageFilterForm, 25 | ) 26 | from .models import GoldenImage, ScheduledTask, SoftwareImage 27 | from .tables import ( 28 | GoldenImageListTable, 29 | ScheduledTaskBulkDeleteTable, 30 | ScheduledTaskTable, 31 | ScheduleTasksTable, 32 | SoftwareImageListTable, 33 | UpgradeDeviceListTable, 34 | ) 35 | 36 | PLUGIN_SETTINGS = settings.PLUGINS_CONFIG.get("software_manager", dict()) 37 | CF_NAME_SW_VERSION = PLUGIN_SETTINGS.get("CF_NAME_SW_VERSION", "") 38 | UPGRADE_QUEUE = PLUGIN_SETTINGS.get("UPGRADE_QUEUE", "") 39 | 40 | ######################################################################## 41 | # SoftwareImage 42 | ######################################################################## 43 | 44 | 45 | class SoftwareImageView(ObjectView): 46 | queryset = SoftwareImage.objects.all() 47 | 48 | 49 | class SoftwareImageList(ObjectListView): 50 | queryset = SoftwareImage.objects.all() 51 | table = SoftwareImageListTable 52 | filterset = SoftwareImageFilterSet 53 | filterset_form = SoftwareImageFilterForm 54 | actions = ("add", "bulk_delete") 55 | 56 | 57 | class SoftwareImageAdd(ObjectEditView): 58 | queryset = SoftwareImage.objects.all() 59 | form = SoftwareImageEditForm 60 | 61 | def get_return_url(self, *args, **kwargs) -> str: 62 | return reverse("plugins:software_manager:softwareimage_list") 63 | 64 | 65 | class SoftwareImageEdit(ObjectEditView): 66 | queryset = SoftwareImage.objects.all() 67 | form = SoftwareImageEditForm 68 | 69 | 70 | class SoftwareImageDelete(ObjectDeleteView): 71 | queryset = SoftwareImage.objects.all() 72 | 73 | def get_return_url(self, *args, **kwargs) -> str: 74 | return reverse("plugins:software_manager:softwareimage_list") 75 | 76 | 77 | class SoftwareImageBulkDelete(BulkDeleteView): 78 | queryset = SoftwareImage.objects.all() 79 | table = SoftwareImageListTable 80 | 81 | 82 | ######################################################################## 83 | # GoldenImage 84 | ######################################################################## 85 | 86 | 87 | class GoldenImageList(ObjectListView): 88 | queryset = DeviceType.objects.all() 89 | table = GoldenImageListTable 90 | filterset = GoldenImageFilterSet 91 | filterset_form = GoldenImageFilterForm 92 | actions = () 93 | 94 | 95 | class GoldenImageAdd(ObjectEditView): 96 | queryset = GoldenImage.objects.all() 97 | form = GoldenImageAddForm 98 | default_return_url = "plugins:software_manager:goldenimage_list" 99 | 100 | def get(self, request, pid_pk: int, *args, **kwargs): 101 | instance = GoldenImage(pid=DeviceType.objects.get(pk=pid_pk)) 102 | form = GoldenImageAddForm(instance=instance) 103 | return render( 104 | request, 105 | "generic/object_edit.html", 106 | { 107 | "object": instance, 108 | "form": form, 109 | "return_url": reverse("plugins:software_manager:goldenimage_list"), 110 | }, 111 | ) 112 | 113 | def post(self, request, *args, **kwargs): 114 | pid = request.POST.get("device_pid", None) 115 | if not pid: 116 | messages.error(request, "No PID") 117 | return redirect(reverse("plugins:software_manager:goldenimage_list")) 118 | 119 | sw = request.POST.get("sw", None) 120 | if not sw: 121 | messages.error(request, "No SW") 122 | return redirect(reverse("plugins:software_manager:goldenimage_list")) 123 | 124 | if not DeviceType.objects.filter(model__iexact=pid).exists(): 125 | messages.error(request, "Incorrect PID") 126 | return redirect(reverse("plugins:software_manager:goldenimage_list")) 127 | 128 | if not SoftwareImage.objects.filter(pk=sw).exists(): 129 | messages.error(request, "Incorrect SW") 130 | return redirect(reverse("plugins:software_manager:goldenimage_list")) 131 | 132 | gi = GoldenImage.objects.create( 133 | pid=DeviceType.objects.get(model__iexact=pid), sw=SoftwareImage.objects.get(pk=sw) 134 | ) 135 | gi.save() 136 | 137 | messages.success(request, f"Assigned Golden Image for {pid}: {gi.sw}") 138 | return redirect(reverse("plugins:software_manager:goldenimage_list")) 139 | 140 | 141 | class GoldenImageEdit(ObjectEditView): 142 | queryset = GoldenImage.objects.all() 143 | form = GoldenImageAddForm 144 | default_return_url = "plugins:software_manager:goldenimage_list" 145 | 146 | 147 | class GoldenImageDelete(ObjectDeleteView): 148 | queryset = GoldenImage.objects.all() 149 | default_return_url = "plugins:software_manager:goldenimage_list" 150 | 151 | 152 | ######################################################################## 153 | # UpgradeDevice 154 | ######################################################################## 155 | 156 | 157 | class UpgradeDeviceList(ObjectListView): 158 | queryset = ( 159 | Device.objects.all() 160 | .prefetch_related( 161 | "primary_ip4", 162 | "tenant", 163 | "device_type", 164 | "device_type__golden_image", 165 | ) 166 | .order_by("name") 167 | ) 168 | actions = () 169 | table = UpgradeDeviceListTable 170 | template_name = "software_manager/upgradedevice_list.html" 171 | 172 | 173 | def submit_tasks(request: WSGIRequest) -> HttpResponseRedirect: 174 | filled_form = ScheduledTaskCreateForm(request.POST) 175 | if not filled_form.is_valid(): 176 | messages.error(request, "Error form is not valid") 177 | return redirect( 178 | to=reverse("plugins:software_manager:upgradedevice_list"), 179 | permanent=False, 180 | ) 181 | 182 | checked_fields = request.POST.getlist("_nullify") 183 | data = deepcopy(filled_form.cleaned_data) 184 | 185 | if "scheduled_time" not in checked_fields and not data["scheduled_time"]: 186 | messages.error(request, "Job start-time was not set") 187 | return redirect( 188 | to=reverse("plugins:software_manager:upgradedevice_list"), 189 | permanent=False, 190 | ) 191 | 192 | if "scheduled_time" in checked_fields: 193 | start_now = datetime.now().replace(microsecond=0).astimezone(pytz.timezone(settings.TIME_ZONE)) 194 | else: 195 | start_now = None 196 | 197 | for device in data["pk"]: 198 | if start_now is not None: 199 | data["scheduled_time"] = start_now 200 | 201 | task = ScheduledTask( 202 | device=device, 203 | task_type=data["task_type"], 204 | scheduled_time=data["scheduled_time"], 205 | mw_duration=int(data["mw_duration"]), 206 | status=TaskStatusChoices.STATUS_SCHEDULED, 207 | user=request.user.username, # type: ignore 208 | transfer_method=data["transfer_method"], 209 | ) 210 | task.save() 211 | 212 | queue = get_queue(UPGRADE_QUEUE) 213 | queue_args = { 214 | "f": "software_manager.worker.upgrade_device", 215 | "job_timeout": 3600, 216 | "args": [task.pk], 217 | } 218 | if start_now is not None: 219 | job = queue.enqueue(**queue_args) 220 | else: 221 | job = queue.enqueue_at( 222 | datetime=data["scheduled_time"], 223 | **queue_args, 224 | ) 225 | 226 | task.job_id = job.id 227 | task.save() 228 | 229 | return redirect( 230 | to=reverse("plugins:software_manager:scheduledtask_list"), 231 | permanent=False, 232 | ) 233 | 234 | 235 | class UpgradeDeviceScheduler(View): 236 | def post(self, request: WSGIRequest) -> HttpResponse | HttpResponseRedirect: 237 | if "_create" in request.POST: 238 | return submit_tasks(request=request) 239 | else: 240 | if "_devices" in request.POST: 241 | device_list = [int(pk) for pk in request.POST.getlist("pk")] 242 | elif "_tasks" in request.POST: 243 | device_list = [int(ScheduledTask.objects.get(pk=pk).device.pk) for pk in request.POST.getlist("pk")] 244 | else: 245 | device_list = [] 246 | 247 | selected_devices = Device.objects.filter(pk__in=device_list) 248 | 249 | if not selected_devices: 250 | if "_tasks" in request.POST: 251 | messages.warning(request, "No Scheduled Tasks were selected for re-scheduling.") 252 | return redirect(reverse("plugins:software_manager:scheduledtask_list")) 253 | else: 254 | messages.warning(request, "No devices were selected.") 255 | return redirect(reverse("plugins:software_manager:upgradedevice_list")) 256 | 257 | return render( 258 | request=request, 259 | template_name="software_manager/scheduledtask_add.html", 260 | context={ 261 | "form": ScheduledTaskCreateForm(initial={"pk": device_list}), 262 | "table": ScheduleTasksTable(selected_devices), 263 | "return_url": reverse("plugins:software_manager:upgradedevice_list"), 264 | "next_url": reverse("plugins:software_manager:scheduledtask_list"), 265 | }, 266 | ) 267 | 268 | 269 | ######################################################################## 270 | # ScheduledTask 271 | ######################################################################## 272 | 273 | 274 | class ScheduledTaskList(ObjectListView): 275 | queryset = ScheduledTask.objects.all() 276 | table = ScheduledTaskTable 277 | filterset = ScheduledTaskFilterSet 278 | filterset_form = ScheduledTaskFilterForm 279 | actions = () 280 | template_name = "software_manager/scheduledtask_list.html" 281 | 282 | def post(self, request, *args, **kwargs): 283 | if "_confirm" in request.POST: 284 | pk = request.POST.get("_confirm", None) 285 | if pk is not None: 286 | task = ScheduledTask.objects.get(pk=int(pk)) 287 | task.confirmed = not task.confirmed 288 | task.save() 289 | messages.success(request, f'ACK changed to "{task.confirmed}" for job id "{task.job_id}"') 290 | else: 291 | messages.warning(request, "Missed pk, unknow Error") 292 | return redirect(request.get_full_path()) 293 | 294 | 295 | class ScheduledTaskInfo(ObjectView): 296 | queryset = ScheduledTask.objects.all() 297 | 298 | 299 | class ScheduledTaskDelete(ObjectDeleteView): 300 | queryset = ScheduledTask.objects.all() 301 | default_return_url = "plugins:software_manager:scheduledtask_list" 302 | 303 | 304 | class ScheduledTaskBulkDelete(BulkDeleteView): 305 | queryset = ScheduledTask.objects.all() 306 | table = ScheduledTaskBulkDeleteTable 307 | default_return_url = "plugins:software_manager:scheduledtask_list" 308 | -------------------------------------------------------------------------------- /software_manager/worker.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytz 4 | from django.conf import settings 5 | from django.db.models import Count 6 | from django_rq import get_queue, job 7 | 8 | from .choices import TaskStatusChoices 9 | from .models import ScheduledTask 10 | from .task_exceptions import TaskException 11 | from .task_executor import TaskExecutor 12 | 13 | PLUGIN_SETTINGS = settings.PLUGINS_CONFIG.get("software_manager", dict()) 14 | UPGRADE_QUEUE = PLUGIN_SETTINGS.get("UPGRADE_QUEUE", "") 15 | 16 | 17 | @job(UPGRADE_QUEUE) 18 | def upgrade_device(task_id): 19 | def add_summary(status): 20 | queue = get_queue(UPGRADE_QUEUE) 21 | total = ScheduledTask.objects.filter(scheduled_time=task.scheduled_time).count() 22 | field = "status" 23 | stats = ( 24 | ScheduledTask.objects.filter(scheduled_time=task.scheduled_time) 25 | .values(field) 26 | .order_by(field) 27 | .annotate(sum=Count(field)) 28 | ) 29 | overall = "" 30 | for i in stats: 31 | overall = f'{overall} / {i["status"]} {i["sum"]}' 32 | overall = f"total {total}{overall}" 33 | executor.info(f'Task ended with status "{status}"') 34 | executor.info(f"Summary: {overall}") 35 | if queue.count == 0: 36 | if queue.started_job_registry.count == 1: 37 | executor.info("All tasks have been completed.") 38 | else: 39 | executor.info("No queued tasks were remained") 40 | else: 41 | executor.info(f"Remained task: {queue.count}. Taking the next one.") 42 | 43 | try: 44 | task = ScheduledTask.objects.get(id=task_id) 45 | except Exception: 46 | raise 47 | 48 | task.start_time = datetime.now().replace(microsecond=0).astimezone(pytz.utc) 49 | task.status = TaskStatusChoices.STATUS_RUNNING 50 | task.save() 51 | 52 | executor = TaskExecutor(task) 53 | try: 54 | executor.execute_task() 55 | except TaskException as exc: 56 | if task.status == TaskStatusChoices.STATUS_SKIPPED: 57 | task.end_time = datetime.now().replace(microsecond=0).astimezone(pytz.utc) 58 | task.confirmed = True 59 | task.save() 60 | add_summary(task.status) 61 | return f"Task was skipped. {exc.reason}: {exc.message}" 62 | task.end_time = datetime.now().replace(microsecond=0).astimezone(pytz.utc) 63 | task.save() 64 | add_summary(task.status) 65 | raise 66 | except Exception: 67 | task.status = TaskStatusChoices.STATUS_FAILED 68 | task.message = "Unknown Error" 69 | task.end_time = datetime.now().replace(microsecond=0).astimezone(pytz.utc) 70 | task.save() 71 | add_summary(task.status) 72 | raise 73 | 74 | task.end_time = datetime.now().replace(microsecond=0).astimezone(pytz.utc) 75 | task.status = TaskStatusChoices.STATUS_SUCCEEDED 76 | task.confirmed = True 77 | task.save() 78 | add_summary(task.status) 79 | 80 | return f"{task.device.name}/{task.task_type}: Done" 81 | -------------------------------------------------------------------------------- /static/golden_images.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alsigna/netbox-software-manager/ab79483875df10a53668669b9846ad7823bdf939/static/golden_images.png -------------------------------------------------------------------------------- /static/scheduled_task_add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alsigna/netbox-software-manager/ab79483875df10a53668669b9846ad7823bdf939/static/scheduled_task_add.png -------------------------------------------------------------------------------- /static/scheduled_task_info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alsigna/netbox-software-manager/ab79483875df10a53668669b9846ad7823bdf939/static/scheduled_task_info.png -------------------------------------------------------------------------------- /static/scheduled_task_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alsigna/netbox-software-manager/ab79483875df10a53668669b9846ad7823bdf939/static/scheduled_task_list.png -------------------------------------------------------------------------------- /static/software_add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alsigna/netbox-software-manager/ab79483875df10a53668669b9846ad7823bdf939/static/software_add.png -------------------------------------------------------------------------------- /static/software_details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alsigna/netbox-software-manager/ab79483875df10a53668669b9846ad7823bdf939/static/software_details.png -------------------------------------------------------------------------------- /static/software_repository.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alsigna/netbox-software-manager/ab79483875df10a53668669b9846ad7823bdf939/static/software_repository.png -------------------------------------------------------------------------------- /static/upgrade_devices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alsigna/netbox-software-manager/ab79483875df10a53668669b9846ad7823bdf939/static/upgrade_devices.png -------------------------------------------------------------------------------- /upgrade.log: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alsigna/netbox-software-manager/ab79483875df10a53668669b9846ad7823bdf939/upgrade.log --------------------------------------------------------------------------------