├── .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 |
42 |
43 |
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 |
64 |
65 |
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 |
145 |
146 |
147 | {% else %}
148 |
149 |
150 |
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 |
12 | {{ label }}
13 |
14 | {% endif %}
15 |
16 | {# Render the field itself #}
17 |
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 |
15 | {% endblock controls %}
16 |
17 |
18 | {% block content %}
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | Job ID
28 | {{ object.job_id }}
29 |
30 |
31 | User
32 | {{ object.user }}
33 |
34 |
35 | Device
36 | {{ object.device }}
37 |
38 |
39 | Platform
40 | {{ object.device.device_type }}
41 |
42 |
43 | IP Address
44 | {{ object.device.primary_ip.address.ip }}
45 |
46 |
47 | Image
48 | {{ object.device.device_type.golden_image.sw.filename }}
49 |
50 |
51 | Transfer Method
52 | {{ object.transfer_method }}
53 |
54 |
55 | Job Status
56 | {{ object.status }}
57 |
58 |
59 | Message
60 | {{ object.message }}
61 |
62 |
63 | Action
64 | {{ object.task_type }}
65 |
66 |
67 | Created Time
68 | {{ object.timestamp|date:"M d, Y H:i:s" }}
69 |
70 |
71 | Scheduled Time
72 | {{ object.scheduled_time|date:"M d, Y H:i:s" }}
73 |
74 |
75 | Start Time
76 | {{ object.start_time|date:"M d, Y H:i:s" }}
77 |
78 |
79 | End Time
80 | {{ object.end_time|date:"M d, Y H:i:s" }}
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
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 |
10 |
11 |
12 | Schedule Tasks
13 |
14 |
15 |
16 |
17 | Selected Objects {% badge table.rows|length %}
18 |
19 |
20 |
21 | {% endblock tabs %}
22 |
23 | {% block content-wrapper %}
24 |
25 | {% block content %}
26 |
27 | {# Schedule form #}
28 |
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 |
12 | {% endblock controls %}
13 |
14 | {% block title %}Scheduled Tasks{% endblock title %}
15 |
16 | {% block bulk_buttons %}
17 |
18 | Re-Schedule Selected
19 |
20 |
21 | Delete Selected
22 |
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 |
10 |
11 |
12 |
13 | Version
14 | {{ object.version|placeholder }}
15 |
16 |
17 | Image Name
18 | {{ object.filename|placeholder }}
19 |
20 |
21 | Image Size
22 |
23 | {% if object.image.name %}
24 | {{ object.image.size|filesizeformat }}
25 | {% else %}
26 | —
27 | {% endif %}
28 |
29 |
30 |
31 | Expected MD5
32 | {{ object.md5sum|placeholder }}
33 |
34 |
35 | Calculated MD5
36 | {{ object.md5sum_calculated|placeholder }}
37 |
38 |
39 | Added Date
40 | {{ object.created|placeholder }}
41 |
42 |
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 |
12 | {% endblock controls %}
13 |
14 | {% block title %}Upgrade Devices{% endblock title %}
15 |
16 | {% block bulk_buttons %}
17 |
18 | Schedule Tasks
19 |
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 |
{{ widget.clear_checkbox_label }}
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
--------------------------------------------------------------------------------