Repository: arista-eosplus/ztpserver (language: python)

Issue:
Release 2.0.2



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml
new file mode 100644
index 00000000..200caded
--- /dev/null
+++ b/.github/workflows/pre-commit.yml
@@ -0,0 +1,20 @@
+name: pre-commit
+
+on:
+  push:
+    branches:
+      - main
+      - develop
+  pull_request:
+
+jobs:
+  main:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: "3.x"
+      - uses: pre-commit/action@v3.0.1
+      - uses: pre-commit-ci/lite-action@v1.0.2
+        if: always()
diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml
new file mode 100644
index 00000000..98b3f689
--- /dev/null
+++ b/.github/workflows/publish-to-pypi.yml
@@ -0,0 +1,127 @@
+name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI
+
+on: push
+
+jobs:
+  build:
+    name: Build distribution 📦
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+          fetch-tags: true
+      - name: Set up Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: "3.x"
+      - name: Install pypa/build
+        run: >-
+          python3 -m
+          pip install
+          build
+          --user
+      - name: Install dunamai
+        run: >-
+          python3 -m
+          pip install
+          dunamai
+          --user
+      - name: Build a binary wheel and a source tarball
+        run: DYNAMIC_VERSION=$(dunamai from git --no-metadata) python3 -m build
+      - name: Store the distribution packages
+        uses: actions/upload-artifact@v4
+        with:
+          name: python-package-distributions
+          path: dist/
+
+  publish-to-pypi:
+    name: >-
+      Publish Python 🐍 distribution 📦 to PyPI
+    if: startsWith(github.ref, 'refs/tags/')  # only publish to PyPI on tag pushes
+    needs:
+      - build
+    runs-on: ubuntu-latest
+    environment:
+      name: pypi
+      url: https://pypi.org/p/ztpserver
+    permissions:
+      id-token: write  # IMPORTANT: mandatory for trusted publishing
+
+    steps:
+      - name: Download all the dists
+        uses: actions/download-artifact@v4
+        with:
+          name: python-package-distributions
+          path: dist/
+      - name: Publish distribution 📦 to PyPI
+        uses: pypa/gh-action-pypi-publish@release/v1
+
+  github-release:
+    name: >-
+      Sign the Python 🐍 distribution 📦 with Sigstore
+      and upload them to GitHub Release
+    needs:
+      - publish-to-pypi
+    runs-on: ubuntu-latest
+
+    permissions:
+      contents: write  # IMPORTANT: mandatory for making GitHub Releases
+      id-token: write  # IMPORTANT: mandatory for sigstore
+
+    steps:
+      - name: Download all the dists
+        uses: actions/download-artifact@v4
+        with:
+          name: python-package-distributions
+          path: dist/
+      - name: Sign the dists with Sigstore
+        uses: sigstore/gh-action-sigstore-python@v1.2.3
+        with:
+          inputs: >-
+            ./dist/*.tar.gz
+            ./dist/*.whl
+      - name: Create GitHub Release
+        env:
+          GITHUB_TOKEN: ${{ github.token }}
+        run: >-
+          gh release create
+          '${{ github.ref_name }}'
+          --repo '${{ github.repository }}'
+          --notes ""
+      - name: Upload artifact signatures to GitHub Release
+        env:
+          GITHUB_TOKEN: ${{ github.token }}
+        # Upload to GitHub Release using the `gh` CLI.
+        # `dist/` contains the built packages, and the
+        # sigstore-produced signatures and certificates.
+        run: >-
+          gh release upload
+          '${{ github.ref_name }}' dist/**
+          --repo '${{ github.repository }}'
+
+  publish-to-testpypi:
+    name: Publish Python 🐍 distribution 📦 to TestPyPI
+    if: ${{ github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/') }}
+    needs:
+      - build
+    runs-on: ubuntu-latest
+
+    environment:
+      name: testpypi
+      url: https://test.pypi.org/p/ztpserver
+
+    permissions:
+      id-token: write  # IMPORTANT: mandatory for trusted publishing
+
+    steps:
+      - name: Download all the dists
+        uses: actions/download-artifact@v4
+        with:
+          name: python-package-distributions
+          path: dist/
+      - name: Publish distribution 📦 to TestPyPI
+        uses: pypa/gh-action-pypi-publish@release/v1
+        with:
+          repository-url: https://test.pypi.org/legacy/
diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml
index 95ce21f9..5e0cee20 100644
--- a/.github/workflows/pylint.yml
+++ b/.github/workflows/pylint.yml
@@ -1,6 +1,11 @@
 name: Pylint
 
-on: [push]
+on:
+  push:
+    branches:
+      - main
+      - develop
+  pull_request:
 
 jobs:
   build:
@@ -9,16 +14,16 @@ jobs:
       matrix:
         python-version: ["3.7", "3.8", "3.9"]
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v3
+      uses: actions/setup-python@v5
       with:
         python-version: ${{ matrix.python-version }}
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip
         pip install pylint
-        pip install -r requirements.txt
+        pip install .
         pip install -r requirements-node.txt
     - name: Analysing the code with pylint
       run: |
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 74575208..8b419474 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,17 +1,17 @@
 repos:
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.4.0
+    rev: v4.5.0
     hooks:
       - id: check-yaml
       - id: end-of-file-fixer
       - id: trailing-whitespace
   - repo: https://github.com/psf/black
-    rev: 23.3.0
+    rev: 24.4.2
     hooks:
       - id: black
         args: ["-l", "100"]
   - repo: https://github.com/pycqa/isort
-    rev: 5.12.0
+    rev: 5.13.2
     hooks:
       - id: isort
         name: isort (python)
diff --git a/.pylintrc b/.pylintrc
index bd2dee26..2107c24f 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -1,5 +1,3 @@
-[MASTER]
-init-hook="from pylint.config import find_pylintrc;import os, sys; sys.path.append(os.path.dirname(find_pylintrc()))"
 [BASIC]
 method-rgx=[a-z_][a-z0-9_]{2,50}$
 
diff --git a/.readthedocs.yml b/.readthedocs.yml
new file mode 100644
index 00000000..2421d028
--- /dev/null
+++ b/.readthedocs.yml
@@ -0,0 +1,31 @@
+# .readthedocs.yaml
+# Read the Docs configuration file
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+
+# Required
+version: 2
+
+# Set the OS, Python version and other tools you might need
+build:
+  os: ubuntu-22.04
+  tools:
+    python: "3.12"
+  jobs:
+    pre_build:
+      - cd docs && make actionlinks
+
+# Build documentation in the "docs/" directory with Sphinx
+sphinx:
+  configuration: docs/conf.py
+
+# Optionally build your docs in additional formats such as PDF and ePub
+# formats:
+#    - pdf
+#    - epub
+
+# Optional but recommended, declare the Python requirements required
+# to build your documentation
+# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
+python:
+  install:
+    - requirements: docs/requirements.txt
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 92db5989..24ae13f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,4 +5,3 @@
 ## 1.1.0
 
 See http://ztpserver.readthedocs.org/en/v1.1.0/ReleaseNotes1.1.html
-
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6cff4535..f66c1ad6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,9 +4,9 @@ Arista EOS+ ZTPServer provides a community based implementation of a bootstrap s
 
 Contributing Code
 =================
-Arista EOS+ ZTPServer provides all of its source available to anyone on Github at github.com/arista-eosplus/ztpserver.   The project is freely available to anyone to fork and use in their own implementations.  The Arista EOS+ community gladly accepts pull requests that add new features, enhance existing features or fix bugs.  
+Arista EOS+ ZTPServer provides all of its source available to anyone on Github at github.com/arista-eosplus/ztpserver.   The project is freely available to anyone to fork and use in their own implementations.  The Arista EOS+ community gladly accepts pull requests that add new features, enhance existing features or fix bugs.
 
-All contributed code should be done using pull requests.  Once a pull request is initiated, a member of the Arista EOS+ community will review the code and either accept it as is or provide feedback on things to change.  
+All contributed code should be done using pull requests.  Once a pull request is initiated, a member of the Arista EOS+ community will review the code and either accept it as is or provide feedback on things to change.
 
 As a procedural note, all pull requests that add new features and/or enhance the operation of ZTPServer are expected to have corresponding test cases with them.  Pull requests will be not accepted without them.
 
@@ -16,7 +16,7 @@ Another available way to contribute to this project is to provide requests for f
 
 Bugs
 ====
-If you happen to find a bug with ZTPServer, please open an issue and flag it as a bug.  In the issue description please provide details about what you were trying to do, any errors and/or tracebacks and any other information necessary to replicate the bug.  
+If you happen to find a bug with ZTPServer, please open an issue and flag it as a bug.  In the issue description please provide details about what you were trying to do, any errors and/or tracebacks and any other information necessary to replicate the bug.
 
 Contact
 =======
diff --git a/INSTALL.md b/INSTALL.md
index 3520b4b6..8fa81852 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -15,4 +15,4 @@ $ sudo easy_install PyYaml
 $ sudo make install
 
 $ ztps
-``````
\ No newline at end of file
+``````
diff --git a/Makefile b/Makefile
index c033c098..996f2ae6 100644
--- a/Makefile
+++ b/Makefile
@@ -96,10 +96,10 @@ install:
 	$(PYTHON) setup.py install
 
 sdist: clean ztpserver.spec
-	$(PYTHON) setup.py sdist
+	DYNAMIC_VERSION=$$(dunamai from git --no-metadata) $(PYTHON) setup.py sdist
 
 sdist-dev: clean ztpserver.spec
-	DEV_VERSION_HASH=$$(git rev-parse --short HEAD) $(PYTHON) setup.py sdist
+	DYNAMIC_VERSION=$$(dunamai from git) $(PYTHON) setup.py sdist
 
 docker_dev: sdist
 	@docker build -t ${IMG} .
diff --git a/VERSION b/VERSION
index 38f77a65..6c51535a 100644
--- a/VERSION
+++ b/VERSION
@@ -1,1 +1,1 @@
-2.0.1
+0.0.0 #uses dynamic version with: dunamai from git --no-metadata
diff --git a/actions/install_image b/actions/install_image
index 62f88c40..4030a080 100644
--- a/actions/install_image
+++ b/actions/install_image
@@ -67,6 +67,8 @@ def main(attributes):
         version: EOS version of new image file
         downgrade: Boolean - Should EOS images be downgraded to match?
                    (Default: True)
+        image_name_from_url: Boolean - Should EOS images be named from URL?
+                   (Default: False)
 
     Special_attributes:
         NODE: API object - see documentation for details
@@ -90,6 +92,7 @@ def main(attributes):
 
     node = attributes.get("NODE")
     url = attributes.get("url")
+    image_name_from_url = attributes.get("image_name_from_url", False)
 
     if not url:
         raise RuntimeError("Missing attribute('url')")
@@ -113,7 +116,11 @@ def main(attributes):
             return
 
     # In all other cases, copy the image
-    image = "EOS-{}.swi".format(version)
+    if image_name_from_url:
+        image = url.rsplit("/", 1)[-1]
+    else:
+        image = "EOS-{}.swi".format(version)
+
     try:
         node.retrieve_url(url, "{}/{}".format(node.flash(), image))
     except Exception as exc:
diff --git a/conf/bootstrap.conf b/conf/bootstrap.conf
index 3f3b47ae..3bbb8057 100644
--- a/conf/bootstrap.conf
+++ b/conf/bootstrap.conf
@@ -15,4 +15,3 @@
 #      ...
 #
 # See documentation for the detailed list of possible values.
-
diff --git a/conf/neighbordb b/conf/neighbordb
index f874ec90..5337fb51 100644
--- a/conf/neighbordb
+++ b/conf/neighbordb
@@ -22,4 +22,3 @@
 #     ...
 #
 # See documentation for the detailed list of possible values.
-
diff --git a/conf/ztpserver.conf b/conf/ztpserver.conf
index 3df9ef1a..13fbec9e 100644
--- a/conf/ztpserver.conf
+++ b/conf/ztpserver.conf
@@ -23,10 +23,10 @@ disable_topology_validation = False
 
 
 [server]
-# Note: this section only applies to using the standalone server.  If 
+# Note: this section only applies to using the standalone server.  If
 # running under a WSGI server, these values are ignored
 
-# Interface to which the server will bind to (0:0:0:0 will bind to 
+# Interface to which the server will bind to (0:0:0:0 will bind to
 # all available IPv4 addresses on the local machine)
 interface = 0.0.0.0
 
diff --git a/conf/ztpserver.wsgi b/conf/ztpserver.wsgi
index de23d187..c206f8cb 100644
--- a/conf/ztpserver.wsgi
+++ b/conf/ztpserver.wsgi
@@ -35,7 +35,7 @@ import sys
 
 from ztpserver.app import start_wsgiapp
 
-sys.stdout.write('Starting ZTPServer...')
+sys.stdout.write("Starting ZTPServer...")
 
 application = start_wsgiapp()
 # To enable debug output, use:
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
index 95c93c5e..1319a9bf 100644
--- a/docs/CONTRIBUTING.md
+++ b/docs/CONTRIBUTING.md
@@ -56,4 +56,3 @@ Building / Publishing Docs locally
 * `make` \(default make target is now `make html`\)
 * Open file://_build/html/index.html in your browser to view.
 * Publish by copying `docs/_build/html/*` to the `gh-pages` branch
-
diff --git a/docs/README.md b/docs/README.md
index 5307b83f..da9bb53a 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -19,4 +19,3 @@ Documenting REST APIs
 ---------------------
 
 REST APIs are documented via the [httpdomain](https://pythonhosted.org/sphinxcontrib-httpdomain/) plugin for sphinx.
-
diff --git a/docs/ReleaseNotes1.1.rst b/docs/ReleaseNotes1.1.rst
index 253036eb..a072a47f 100644
--- a/docs/ReleaseNotes1.1.rst
+++ b/docs/ReleaseNotes1.1.rst
@@ -11,7 +11,7 @@ Enhancements
 * V1.1.0 docs (`181 <https://github.com/arista-eosplus/ztpserver/pull/181>`_)
     Documentation has been completely restructured and is now hosted at http://ztpserver.readthedocs.org/.
 * refresh_ztps - util script to refresh ZTP Server installation (`177 <https://github.com/arista-eosplus/ztpserver/issues/177>`_)
-    /utils/refresh_ztps can be used in order to automatically refresh the installation of ZTP Server to the latest code on GitHub.  This can be useful in order to pull bug fixes or run the latest version of various development branches.                                      
+    /utils/refresh_ztps can be used in order to automatically refresh the installation of ZTP Server to the latest code on GitHub.  This can be useful in order to pull bug fixes or run the latest version of various development branches.
 * Et49 does not match Ethernet49 in neighbordb/pattern files (`172 <https://github.com/arista-eosplus/ztpserver/issues/172>`_)
     The local interface in an interface pattern does not have to use the long interface name. For example, all of the following will be treated similarly: Et1, e1, et1, eth1, Eth1, ethernet1, Ethernet1.
 
@@ -100,8 +100,8 @@ Enhancements
                     “domain”*:          <DOMAIN>,
                     “password”*:        <PASSWORD>,
                     “nickname”:         <NICKNAME>,             // REMOVED
-                    “rooms”*:           [ <ROOM>, … ]   
-                    “msg_type”:         [ “info” | “debug” ]    // Optional, default “debug”     
+                    “rooms”*:           [ <ROOM>, … ]
+                    “msg_type”:         [ “info” | “debug” ]    // Optional, default “debug”
 
                 }
             }
@@ -224,4 +224,3 @@ Resolved issues
     .. comment
 * fix issue with Pattern creation from neighbordb (`44 <https://github.com/arista-eosplus/ztpserver/issues/44>`_)
     .. comment
-
diff --git a/docs/ReleaseNotes1.2.rst b/docs/ReleaseNotes1.2.rst
index f9b76d70..d051a38c 100644
--- a/docs/ReleaseNotes1.2.rst
+++ b/docs/ReleaseNotes1.2.rst
@@ -72,7 +72,7 @@ Enhancements
         s7056.lab.local - - [03/Nov/2014 21:05:33] "POST /nodes HTTP/1.1" 400 0
 
 * Deal more gracefully with DNS/connectivity errors while trying to access remote syslog servers (`215 <https://github.com/arista-eosplus/ztpserver/issues/215>`_)
-    Logging errors (e.g. bogus destination) will not be automatically logged by the bootstrap script. In order to debug logging issues, simply uncomment the following lines in the bootstrap script: 
+    Logging errors (e.g. bogus destination) will not be automatically logged by the bootstrap script. In order to debug logging issues, simply uncomment the following lines in the bootstrap script:
     ::
 
         #---------------------------------SYSLOG----------------------
@@ -179,4 +179,3 @@ Fixed
 
 * ZTPS server fails to write .node because lack of permissions (`126 <https://github.com/arista-eosplus/ztpserver/issues/126>`_)
     .. comment
-
diff --git a/docs/ReleaseNotes1.3.1.rst b/docs/ReleaseNotes1.3.1.rst
index 7128db97..6680200d 100644
--- a/docs/ReleaseNotes1.3.1.rst
+++ b/docs/ReleaseNotes1.3.1.rst
@@ -9,4 +9,3 @@ Bug fixes
 ^^^^^^^^^
 
 * fixes *pip* install/uninstall issues
-
diff --git a/docs/ReleaseNotes1.3.2.rst b/docs/ReleaseNotes1.3.2.rst
index ea862096..ca4f0316 100644
--- a/docs/ReleaseNotes1.3.2.rst
+++ b/docs/ReleaseNotes1.3.2.rst
@@ -31,4 +31,3 @@ Bug fixes
         .. comment
     - ZTP Server benchmarking results
         .. comment
-
diff --git a/docs/ReleaseNotes1.3.rst b/docs/ReleaseNotes1.3.rst
index 5bb6b83c..13b5b242 100644
--- a/docs/ReleaseNotes1.3.rst
+++ b/docs/ReleaseNotes1.3.rst
@@ -30,7 +30,7 @@ Enhancements
   Validating definitions...
   Validating /usr/share/ztpserver/definitions/leaf.definition... Ok!
   Validating /usr/share/ztpserver/definitions/leaf-no_vars.definition... Ok!
-  
+
   Validating resources...
   Validating /usr/share/ztpserver/resources/leaf_man_ip... Ok!
   Validating /usr/share/ztpserver/resources/leaf_spine_ip...
@@ -40,11 +40,11 @@ Enhancements
   10.0.0.53/24: null
   dfdsf dsfsd
   10.0.0.54/24: JPE14140273
-  
+
   Error:
   while scanning a simple key
   in "<string>", line 3, column 1:
-  dfdsf dsfsd 
+  dfdsf dsfsd
   could not found expected ':'
   in "<string>", line 5, column 1:
   10.0.0.54/24: JPE14140273
@@ -97,4 +97,3 @@ Bug fixes
   logging:
   xmpp:
     ...
-
diff --git a/docs/ReleaseNotes1.5.0.rst b/docs/ReleaseNotes1.5.0.rst
index 3f217010..4d7ebf5c 100644
--- a/docs/ReleaseNotes1.5.0.rst
+++ b/docs/ReleaseNotes1.5.0.rst
@@ -25,5 +25,3 @@ Fixed
 
 Known Caveats
 ^^^^^^^^^^^^^
-
-
diff --git a/docs/ReleaseNotes2.0.2.rst b/docs/ReleaseNotes2.0.2.rst
new file mode 100644
index 00000000..06261fda
--- /dev/null
+++ b/docs/ReleaseNotes2.0.2.rst
@@ -0,0 +1,20 @@
+Release 2.0.2
+-------------
+
+New Modules
+^^^^^^^^^^^
+
+Enhancements
+^^^^^^^^^^^^
+
+* install_image change image to reference filename provided by url attribute (`406 <https://github.com/arista-eosplus/ztpserver/pull/406>`_)[`siguroot <https://github.com/siguroot>`_]
+* Added model filter in neighbordb (`403 <https://github.com/arista-eosplus/ztpserver/pull/403>`_)[`az-blip <https://github.com/az-blip>`_]
+
+Fixed
+^^^^^
+
+* Fix controller.py:put_config body decoding (`407 <https://github.com/arista-eosplus/ztpserver/pull/407>`_)[`dlobato <https://github.com/dlobato>`_]
+
+
+Known Caveats
+^^^^^^^^^^^^^
diff --git a/docs/actions.rst b/docs/actions.rst
index 180a8a38..3877bae7 100644
--- a/docs/actions.rst
+++ b/docs/actions.rst
@@ -69,4 +69,3 @@ Actions
    #
    #.. automodule:: template
    #    :members:
-
diff --git a/docs/api.rst b/docs/api.rst
index 553da357..7874c2e8 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -130,16 +130,16 @@ provisioned.
 
         Content-Type: application/json
         {
-            “model”*:             <MODEL_NAME>, 
-            “serialnumber”*:      <SERIAL_NUMBER>, 
+            “model”*:             <MODEL_NAME>,
+            “serialnumber”*:      <SERIAL_NUMBER>,
             “systemmac”*:         <SYSTEM_MAC>,
-            “version”*:           <INTERNAL_VERSION>, 
+            “version”*:           <INTERNAL_VERSION>,
             “neighbors”*: {
                 <INTERFACE_NAME(LOCAL)>: [ {
-                    'device':             <DEVICE_NAME>, 
+                    'device':             <DEVICE_NAME>,
                     'remote_interface':   <INTERFACE_NAME(REMOTE)>
                 } ]
-            }, 
+            },
         }
 
     **Note**: \* Items are mandatory (even if value is empty list/dict)
@@ -148,7 +148,7 @@ provisioned.
 
     Status: 201 Created OR 409 Conflict will both return:
 
-    .. sourcecode:: http 
+    .. sourcecode:: http
 
         Content-Type: text/html
         Location: <url>
@@ -233,7 +233,7 @@ This is used to retrieve the startup-config that was backed-up from a node to th
 
     Status: 201 Created OR 409 Conflict will both return:
 
-    .. sourcecode:: http 
+    .. sourcecode:: http
 
         Content-Type: text/plain
         <startup-config contents>
diff --git a/docs/client.rst b/docs/client.rst
index cea40fed..bfd68c7a 100644
--- a/docs/client.rst
+++ b/docs/client.rst
@@ -8,4 +8,3 @@ Bootstrap Client
 
 .. autoclass:: Node
     :members:
-
diff --git a/docs/conf.py b/docs/conf.py
index 4e222c2a..39def73c 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -13,38 +13,33 @@
 # All configuration values have a default; values that are commented out
 # serve to show the default.
 
-import sys, os
+import os
 import re
-# on_rtd is whether we are on readthedocs.org, this line of code grabbed from docs.readthedocs.org
-on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
+import sys
 
-if not on_rtd:  # only import and set the theme if we're building docs locally
-    import sphinx_rtd_theme
-    html_theme = 'sphinx_rtd_theme'
-    html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
-
-#sys.path.insert(0, os.path.join('ztpserver', 'lib'))
-#sys.path.insert(0, os.path.join('ztpserver'))
-sys.path.insert(0, os.path.join('..', 'actions'))
-sys.path.insert(0, os.path.join('..', 'client'))
-sys.path.insert(0, os.path.join('actions'))
-sys.path.insert(0, os.path.join('client'))
-sys.path.insert(0, os.path.abspath('..'))
+html_theme = "sphinx_rtd_theme"
 
+# sys.path.insert(0, os.path.join('ztpserver', 'lib'))
+# sys.path.insert(0, os.path.join('ztpserver'))
+sys.path.insert(0, os.path.join("..", "actions"))
+sys.path.insert(0, os.path.join("..", "client"))
+sys.path.insert(0, os.path.join("actions"))
+sys.path.insert(0, os.path.join("client"))
+sys.path.insert(0, os.path.abspath(".."))
 
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
 # documentation root, use os.path.abspath to make it absolute, like shown here.
-#sys.path.insert(0, os.path.abspath('.'))
+# sys.path.insert(0, os.path.abspath('.'))
 
 # -- General configuration -----------------------------------------------------
 
 # If your documentation needs a minimal Sphinx version, state it here.
-#needs_sphinx = '1.0'
+# needs_sphinx = '1.0'
 
 # Add any Sphinx extension module names here, as strings. They can be extensions
 # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
-#extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode']
+# extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode']
 extensions = [
     "sphinx.ext.autodoc",
     "sphinx.ext.viewcode",
@@ -53,211 +48,204 @@
 ]
 
 # Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
+templates_path = ["_templates"]
 
 # The suffix of source filenames.
-source_suffix = '.rst'
+source_suffix = ".rst"
 
 # The encoding of source files.
-#source_encoding = 'utf-8-sig'
+# source_encoding = 'utf-8-sig'
 
 # The master toctree document.
-master_doc = 'index'
+master_doc = "index"
 
 # General information about the project.
-project = u'ZTPServer'
-copyright = u'2015, Arista Networks'       # pylint: disable=W0622
+project = "ZTPServer"
+copyright = "2024, Arista Networks"  # pylint: disable=W0622
 
 # The version info for the project you're documenting, acts as replacement for
 # |version| and |release|, also used in various other places throughout the
 # built documents.
 #
 # The short X.Y version.
-#version = '1.2.0'
+# version = '1.2.0'
 # The full version, including alpha/beta/rc tags.
-#release = '1.2.0'
+# release = '1.2.0'
 
-release = open('../VERSION').read().split()[0].strip()
+release = open("../VERSION").read().split()[0].strip()
 # Assume PEP 440 version strings
-p = re.compile('(\d+!)?((\d+)(.\d+)*(.\d+)*)(.?[a|b|rc]\d*)?(.post\d*)?(.dev\d*)?', re.IGNORECASE)
-vers =  p.search(release)
+p = re.compile(r"(\d+!)?((\d+)(.\d+)*(.\d+)*)(.?(a|b|rc)\d*)?(.post\d*)?(.dev\d*)?", re.IGNORECASE)
+vers = p.search(release)
 version = vers.group(2)
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
-#language = None
+# language = None
 
 # There are two options for replacing |today|: either, you set today to some
 # non-false value, then it is used:
-#today = ''
+# today = ''
 # Else, today_fmt is used as the format for a strftime call.
-today_fmt = '%B %d, %Y'
+today_fmt = "%B %d, %Y"
 
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.
-exclude_patterns = ['_build', 'old', 'exts', 'cookbook/_template.rst']
+exclude_patterns = ["_build", "old", "exts", "cookbook/_template.rst"]
 
 # The reST default role (used for this markup: `text`) to use for all documents.
-#default_role = None
+# default_role = None
 
 # If true, '()' will be appended to :func: etc. cross-reference text.
-#add_function_parentheses = True
+# add_function_parentheses = True
 
 # If true, the current module name will be prepended to all description
 # unit titles (such as .. function::).
-#add_module_names = True
+# add_module_names = True
 add_module_names = False
 
 # If true, sectionauthor and moduleauthor directives will be shown in the
 # output. They are ignored by default.
-#show_authors = False
+# show_authors = False
 show_authors = True
 
 # The name of the Pygments (syntax highlighting) style to use.
-pygments_style = 'sphinx'
+pygments_style = "sphinx"
 
 # A list of ignored prefixes for module index sorting.
-#modindex_common_prefix = []
+# modindex_common_prefix = []
 
 
 # -- Options for HTML output ---------------------------------------------------
 
 # The theme to use for HTML and HTML Help pages.  See the documentation for
 # a list of builtin themes.
-#html_theme = 'default'
+# html_theme = 'default'
 
 # Theme options are theme-specific and customize the look and feel of a theme
 # further.  For a list of options available for each theme, see the
 # documentation.
-#html_theme_options = {}
+# html_theme_options = {}
 
 # Add any paths that contain custom themes here, relative to this directory.
-#html_theme_path = []
+# html_theme_path = []
 
 # The name for this set of Sphinx documents.  If None, it defaults to
 # "<project> v<release> documentation".
-#html_title = None
+# html_title = None
 
 # A shorter title for the navigation bar.  Default is the same as html_title.
-#html_short_title = None
+# html_short_title = None
 
 # The name of an image file (relative to this directory) to place at the top
 # of the sidebar.
-#html_logo = None
-#html_logo = '_static/AristaLogo.png'
-html_logo = '_static/arista_logo_11-trans-w.png'
+# html_logo = None
+# html_logo = '_static/AristaLogo.png'
+html_logo = "_static/arista_logo_11-trans-w.png"
 
 # The name of an image file (within the static path) to use as favicon of the
 # docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32
 # pixels large.
-#html_favicon = None
-#html_favicon = 'favicon.ico'
-html_favicon = '_static/favicon.ico'
+# html_favicon = None
+# html_favicon = 'favicon.ico'
+html_favicon = "_static/favicon.ico"
 
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
 # so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+html_static_path = ["_static"]
 
 # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
 # using the given strftime format.
-#html_last_updated_fmt = '%b %d, %Y'
-html_last_updated_fmt = '%b %d, %Y'
+# html_last_updated_fmt = '%b %d, %Y'
+html_last_updated_fmt = "%b %d, %Y"
 
 # If true, SmartyPants will be used to convert quotes and dashes to
 # typographically correct entities.
-#html_use_smartypants = True
+# html_use_smartypants = True
 
 # Custom sidebar templates, maps document names to template names.
-#html_sidebars = {}
+# html_sidebars = {}
 
 # Additional templates that should be rendered to pages, maps page names to
 # template names.
-#html_additional_pages = {}
+# html_additional_pages = {}
 
 # If false, no module index is generated.
-#html_domain_indices = True
+# html_domain_indices = True
 
 # If false, no index is generated.
-#html_use_index = True
+# html_use_index = True
 
 # If true, the index is split into individual pages for each letter.
-#html_split_index = False
+# html_split_index = False
 
 # If true, links to the reST sources are added to the pages.
-#html_show_sourcelink = True
+# html_show_sourcelink = True
 
 # If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
-#html_show_sphinx = True
+# html_show_sphinx = True
 
 # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
-#html_show_copyright = True
+# html_show_copyright = True
 
 # If true, an OpenSearch description file will be output, and all pages will
 # contain a <link> tag referring to it.  The value of this option must be the
 # base URL from which the finished HTML is served.
-#html_use_opensearch = ''
+# html_use_opensearch = ''
 
 # This is the file name suffix for HTML files (e.g. ".xhtml").
-#html_file_suffix = None
+# html_file_suffix = None
 
 # Output file base name for HTML help builder.
-htmlhelp_basename = 'ZTPServerdoc'
-
+htmlhelp_basename = "ZTPServerdoc"
 
 # -- Options for LaTeX output --------------------------------------------------
 
 latex_elements = {
-# The paper size ('letterpaper' or 'a4paper').
-#'papersize': 'letterpaper',
-
-# The font size ('10pt', '11pt' or '12pt').
-#'pointsize': '10pt',
-
-# Additional stuff for the LaTeX preamble.
-#'preamble': '',
+    # The paper size ('letterpaper' or 'a4paper').
+    #'papersize': 'letterpaper',
+    # The font size ('10pt', '11pt' or '12pt').
+    #'pointsize': '10pt',
+    # Additional stuff for the LaTeX preamble.
+    #'preamble': '',
 }
 
 # Grouping the document tree into LaTeX files. List of tuples
 # (source start file, target name, title, author, documentclass [howto/manual]).
 latex_documents = [
-  ('index', 'ZTPServer.tex', u'ZTPServer Documentation',
-   u'Arista Networks', 'manual'),
+    ("index", "ZTPServer.tex", "ZTPServer Documentation", "Arista Networks", "manual"),
 ]
 
 # The name of an image file (relative to this directory) to place at the top of
 # the title page.
-#latex_logo = None
-latex_logo = '_static/arista_logo_jpg-11.jpg'
+# latex_logo = None
+latex_logo = "_static/arista_logo_jpg-11.jpg"
 
 # For "manual" documents, if this is true, then toplevel headings are parts,
 # not chapters.
-#latex_use_parts = False
+# latex_use_parts = False
 
 # If true, show page references after internal links.
-#latex_show_pagerefs = False
+# latex_show_pagerefs = False
 
 # If true, show URL addresses after external links.
-#latex_show_urls = False
+# latex_show_urls = False
 
 # Documents to append as an appendix to all manuals.
-#latex_appendices = []
+# latex_appendices = []
 
 # If false, no module index is generated.
-#latex_domain_indices = True
+# latex_domain_indices = True
 
 
 # -- Options for manual page output --------------------------------------------
 
 # One entry per manual page. List of tuples
 # (source start file, name, description, authors, manual section).
-man_pages = [
-    ('index', 'ztpserver', u'ZTPServer Documentation',
-     [u'Arista Networks'], 1)
-]
+man_pages = [("index", "ztpserver", "ZTPServer Documentation", ["Arista Networks"], 1)]
 
 # If true, show URL addresses after external links.
-#man_show_urls = False
+# man_show_urls = False
 
 
 # -- Options for Texinfo output ------------------------------------------------
@@ -266,16 +254,22 @@
 # (source start file, target name, title, author,
 #  dir menu entry, description, category)
 texinfo_documents = [
-  ('index', 'ZTPServer', u'ZTPServer Documentation',
-   u'Arista Networks', 'ZTPServer', 'One line description of project.',
-   'Miscellaneous'),
+    (
+        "index",
+        "ZTPServer",
+        "ZTPServer Documentation",
+        "Arista Networks",
+        "ZTPServer",
+        "One line description of project.",
+        "Miscellaneous",
+    ),
 ]
 
 # Documents to append as an appendix to all manuals.
-#texinfo_appendices = []
+# texinfo_appendices = []
 
 # If false, no module index is generated.
-#texinfo_domain_indices = True
+# texinfo_domain_indices = True
 
 # How to display URL addresses: 'footnote', 'no', or 'inline'.
-#texinfo_show_urls = 'footnote'
+# texinfo_show_urls = 'footnote'
diff --git a/docs/config.rst b/docs/config.rst
index 2ced5cda..4cb75782 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -477,7 +477,9 @@ Rules:
 
  - if multiple node-specific entries reference the same unique_id, only the first will be in effect - all others will be ignored
  - if both the **node** and **interfaces** attributes are specified and a node's unique_id is a match, but the topology information is not, then the overall match will fail and the global patterns will not be considered
+ - if both the **model** and **interfaces** attributes are specified and a node's model is a match, but the topology information is not, then the overall match will fail and the global patterns will not be considered
  - if there is no matching node-specific pattern for a node's unique_id, then the server will attempt to match the node against the global patterns (in the order they are specified in ``neighbordb``)
+ - **node** and **model** are mutually exclusive and can't be specified in the same pattern
  - if a node-specific pattern matches, the server will automatically generate an open pattern in the node's folder. This pattern will match any device with at least one LLDP-capable neighbor.  Example: ``any: any:any``
 
 .. code-block:: yaml
@@ -488,8 +490,9 @@ Rules:
     ...
     patterns:
         - name: <single line description of pattern>
-          definition: <defintion_url>
+          definition: <definition_url>
           node: <unique_id>
+          model: <model_regexp>
           config-handler: <config-handler>
           variables:
             <variable_name>: <function>
@@ -504,7 +507,7 @@ Rules:
 
     Mandatory attributes: **name**, **definition**, and either **node**, **interfaces** or both.
 
-    Optional attributes: **variables**, **config-handler**.
+    Optional attributes: **variables**, **config-handler**, **model**.
 
 variables
 '''''''''
@@ -528,6 +531,11 @@ node: unique_id
 
 Serial number or MAC address, depending on the global 'identifier' attribute in **ztpserver.conf**.
 
+model: model_regexp
+'''''''''''''''''''
+
+Defines a regex pattern to match the node model against.
+
 interfaces: port\_name
 ''''''''''''''''''''''
 
diff --git a/docs/cookbook/actions.rst b/docs/cookbook/actions.rst
index 72d6c49f..04efd0d5 100644
--- a/docs/cookbook/actions.rst
+++ b/docs/cookbook/actions.rst
@@ -154,7 +154,7 @@ The ZTPServer will then write the SYSTEM_ID as the value, overwriting ``null``.
 If you wanted to use the assigned value elsewhere in the definition, simply call
 ``allocate(mgmt_subnet)`` and the plugin will not assign a new value, rather it
 will return the key already assigned. Note that this is an implementation-detail
-specific to this particular plugin - other plugins might vary (please read the 
+specific to this particular plugin - other plugins might vary (please read the
 associated documentation for each).
 
 The result would look like:
diff --git a/docs/cookbook/resourcePools.rst b/docs/cookbook/resourcePools.rst
index 44912143..2c62c45a 100644
--- a/docs/cookbook/resourcePools.rst
+++ b/docs/cookbook/resourcePools.rst
@@ -86,6 +86,6 @@ Explanation
 
 Clearing all resource pools can be done via the command line on the ZTPServer.
 The command will analyze ``data_root/resources`` and any file that exists in
-that directory that resembles a ZTPServer resource pool will be cleared. 
+that directory that resembles a ZTPServer resource pool will be cleared.
 
 .. End of Clearing a Resource Pool
diff --git a/docs/cookbook/ztpsVMonEOS.rst b/docs/cookbook/ztpsVMonEOS.rst
index b1ceb98b..ded0330f 100644
--- a/docs/cookbook/ztpsVMonEOS.rst
+++ b/docs/cookbook/ztpsVMonEOS.rst
@@ -56,4 +56,4 @@ Explanation
 
 The USB key method leverages the Arista Password Recovery mechanism.  When the ``fullrecover`` and ``boot-config`` file is present on the USB key, the system will check the timestamp on the ``boot-config`` file.If the timestamp is different, all files on the USB key will be copied to the flash on the switch, and the switch will be rebooted and come up with the ``startup-config`` and the ``EOS.swi`` included on the USB key.
 
-.. End of Deployment Steps
\ No newline at end of file
+.. End of Deployment Steps
diff --git a/docs/cookbook/ztpsVMonEOS/l2wom.rst b/docs/cookbook/ztpsVMonEOS/l2wom.rst
index 6ca953f8..234759a1 100644
--- a/docs/cookbook/ztpsVMonEOS/l2wom.rst
+++ b/docs/cookbook/ztpsVMonEOS/l2wom.rst
@@ -237,4 +237,4 @@ The reason we could not just bridge Vlan1 with the Linux bridge (and therefore j
 
 
 
-.. End of ztps.xml
\ No newline at end of file
+.. End of ztps.xml
diff --git a/docs/examples.rst b/docs/examples.rst
index 7059af3a..49057013 100644
--- a/docs/examples.rst
+++ b/docs/examples.rst
@@ -13,41 +13,41 @@ Global configuration file
     [default]
     # Location of all ztps boostrap process data files
     data_root = /usr/share/ztpserver
-    
+
     # UID used in the /nodes structure (serialnumber or systemmac)
     identifier = serialnumber
-    
+
     # Server URL to-be-advertised to clients (via POST replies) during the bootstrap process
     server_url = http://172.16.130.10:8080
-    
+
     # Enable local logging
     logging = True
-    
+
     # Enable console logging
     console_logging = True
-    
+
     # Console logging format
     console_logging_format = %(asctime)s:%(levelname)s:[%(module)s:%(lineno)d] %(message)s
 
 
     # Globally disable topology validation in the bootstrap process
     disable_topology_validation = False
-    
+
     [server]
     # Note: this section only applies to using the standalone server.  If
     # running under a WSGI server, these values are ignored
-    
+
     # Interface to which the server will bind to (0:0:0:0 will bind to
     # all available IPv4 addresses on the local machine)
     interface = 172.16.130.10
-    
+
     # TCP listening port
     port = 8080
-    
+
     [bootstrap]
     # Bootstrap filename (file located in <data_root>/bootstrap)
     filename = bootstrap
-    
+
     [neighbordb]
     # Neighbordb filename (file located in <data_root>)
     filename = neighbordb
@@ -181,7 +181,7 @@ Sample templates
 
 .. _resources_example:
 
-Sample resources 
+Sample resources
 ````````````````
 ::
 
@@ -219,11 +219,11 @@ Example #1
       node: ABC12345678
       interfaces:
         - Ethernet49: pod1-spine1:Ethernet1/1
-        - Ethernet50: 
+        - Ethernet50:
             device: pod1-spine2
             port: Ethernet1/1
 
-In example #1, the topology map would only apply to a node with system ID 
+In example #1, the topology map would only apply to a node with system ID
 equal to **ABC12345678**. The following interface map rules apply:
 
 -  Interface Ethernet49 must be connected to node pod1-spine1 on port
@@ -242,7 +242,7 @@ Example #2
       node: 001c73aabbcc
       interfaces:
         - any: regex('pod\d+-spine\d+'):Ethernet1/$
-        - any: 
+        - any:
             device: regex('pod\d+-spine1')
             port: Ethernet2/3
 
@@ -273,7 +273,7 @@ Example #3
         - Ethernet2: $pod1-spine2:any
         - any: excludes('spine1'):Ethernet49
         - any: excludes('spine2'):Ethernet49
-        - Ethernet49: 
+        - Ethernet49:
             device: $not_spine
             port: Ethernet49
         - Ethernet50:
@@ -367,9 +367,29 @@ Example #5
 In this case, the pattern matches if `any` local interface is connected to a
 device with `spine` in the hostname and to the 4th or 5th slot in the chassis.
 
+Example #6
+''''''''''
+
+.. code-block:: yaml
+
+    ---
+    - name: old switch
+      definition: old-switch
+      model: "DCS-7010T-48"
+      interfaces:
+        - Ethernet49: $uplink:any
+    - name: new switch
+      definition: new-switch
+      model: "DCS-7010TX-48-F"
+      interfaces:
+        - Ethernet49: $uplink:any
+
+In this case, the two patterns match the same uplink switch on the same
+local interface, but with a different model. This will allow to use a
+different definition to upload a version of EOS compatible with the device.
+
 
 More examples
 `````````````
 
 Additional ZTPServer file examples are available on GitHub at the `ZTPServer Demo <https://github.com/arista-eosplus/ztpserver-demo>`_.
-
diff --git a/docs/internals.rst b/docs/internals.rst
index 18055a50..b7fc381c 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -7,4 +7,3 @@ Internals
    implementation
    api
    modules
-
diff --git a/docs/overview.rst b/docs/overview.rst
index 3733562c..d921f40d 100644
--- a/docs/overview.rst
+++ b/docs/overview.rst
@@ -3,7 +3,7 @@ Overview
 
 ZTPServer provides a robust server which enables comprehensive bootstrap solutions for Arista network elements.  ZTPserver takes advantage of the the ZeroTouch Provisioning (ZTP) feature in Arista's EOS (Extensible Operating System) which enables a node to connect to a provisioning server whenever a valid configuration file is missing from the internal flash storage.
 
-ZTPServer provides a number of features that extend beyond simply loading a configuration file and a boot image on a node, including: 
+ZTPServer provides a number of features that extend beyond simply loading a configuration file and a boot image on a node, including:
 
 * sending an advanced bootstrap client to the node
 * mapping each node to an individual definition which describes the bootstrap steps specific to that node
@@ -12,7 +12,7 @@ ZTPServer provides a number of features that extend beyond simply loading a conf
 * validation topology using a simple syntax for expressing LLDP neighbor adjacencies
 * enabling Zero Touch Replacement, as well as configuration backup and management
 
-ZTPServer is written in Python and leverages standard protocols like DHCP (DHCP options for boot functions), HTTP(S) (for bi-directional transport), XMPP and syslog (for logging). Most of the configuration files are YAML-based. 
+ZTPServer is written in Python and leverages standard protocols like DHCP (DHCP options for boot functions), HTTP(S) (for bi-directional transport), XMPP and syslog (for logging). Most of the configuration files are YAML-based.
 
 **Highlights:**
 
@@ -51,7 +51,7 @@ See the `ZTP Tech Bulletin <https://www.arista.com/assets/data/pdf/TechBulletins
 Architecture
 ````````````
 
-There are 2 primary components of the ZTPServer implementation: 
+There are 2 primary components of the ZTPServer implementation:
 
 * the **server** or ZTPServer instance **AND**
 * the **client** or bootstrap (a process running on each node, which connects back to the server in order to provision the node)
@@ -71,7 +71,7 @@ The primary methods of provisioning a node are:
 * **statically** via mappings between node IDs (serial number or system MAC address) and configuration definitions OR
 * **dynamically**  via mapping between topology information (LLDP neighbors) and configuration definitions
 
-The definitions associated with the nodes contain a set of actions that can perform a variety of functions that ultimately lead to a final device configuration. Actions can use statically configured attributes or leverage configuration templates and dynamically allocated resources (via resource pools) in order to generate the system configuration. Definitions, actions, attributes, templates, and resources are all defined in YAML files. 
+The definitions associated with the nodes contain a set of actions that can perform a variety of functions that ultimately lead to a final device configuration. Actions can use statically configured attributes or leverage configuration templates and dynamically allocated resources (via resource pools) in order to generate the system configuration. Definitions, actions, attributes, templates, and resources are all defined in YAML files.
 
 Client
 ``````
@@ -97,14 +97,14 @@ The following diagram show the flow of information during the bootstrap process.
    :alt: Message Flow Diagram
 
 
-Topology Validation 
+Topology Validation
 ```````````````````
 
 .. image:: _static/LeafDefn.png
    :width: 353px
    :align: right
 
-ZTPServer provides a powerful topology validation engine via either ``neighbordb`` or ``pattern`` files.  As part of the bootstrap process for each node, the LLDP information received on all ports is sent to the ZTPServer and matched against either ``neighbordb`` or a node-specific ``pattern`` file (if a node is already configured on the server). Both are YAML files that are use a simple format to express strongly and loosely typed topology patterns. Pattern entries are processed top down and can include local or globally-defined variables (including regular expressions). 
+ZTPServer provides a powerful topology validation engine via either ``neighbordb`` or ``pattern`` files.  As part of the bootstrap process for each node, the LLDP information received on all ports is sent to the ZTPServer and matched against either ``neighbordb`` or a node-specific ``pattern`` file (if a node is already configured on the server). Both are YAML files that are use a simple format to express strongly and loosely typed topology patterns. Pattern entries are processed top down and can include local or globally-defined variables (including regular expressions).
 
 Patterns in ``neighbordb`` match nodes to definitions (dynamic mode), while node-specific pattern files are used for cabling and connectivity validation (static mode).
 
@@ -124,7 +124,7 @@ System ID-based provisioning with no topology validation
 **Via node-specific folder:**
 
 * a folder corresponding to the node's system ID is created on the server before bootstrap
-* a definition file, startup-config file or both is/are placed in the folder 
+* a definition file, startup-config file or both is/are placed in the folder
 * topology validation is disabled globally (in the global configuration file) or via an open pattern in the pattern file located in the node-specific folder
 
 **Via neighbordb:**
@@ -140,7 +140,7 @@ System ID-based provisioning with topology validation
 **Via node-specific folder:**
 
 * a folder corresponding to the node's system ID is created on the server before bootstrap
-* a definition file, startup-config file or both is/are placed in the folder 
+* a definition file, startup-config file or both is/are placed in the folder
 * topology validation is enabled globally (in the global configuration file) and the topology information is configured in the pattern file located in the node-specific folder
 
 **Via neighbordb:**
diff --git a/docs/setup_rtd_files.sh b/docs/setup_rtd_files.sh
index d891e86f..bcd9bbcc 100755
--- a/docs/setup_rtd_files.sh
+++ b/docs/setup_rtd_files.sh
@@ -15,4 +15,3 @@ for dir in client actions; do
     touch ${dir}/__init__.py
     #ln -s ${dir} docs/${dir}
 done
-
diff --git a/docs/support.rst b/docs/support.rst
index 4fa0afc7..970e098e 100644
--- a/docs/support.rst
+++ b/docs/support.rst
@@ -42,6 +42,7 @@ The authoritative state for any known issue can be found in `GitHub issues <http
     :maxdepth: 2
     :titlesonly:
 
+    ReleaseNotes2.0.2
     ReleaseNotes2.0.1
     ReleaseNotes2.0.0
     ReleaseNotes1.6.0
diff --git a/docs/tips.rst b/docs/tips.rst
index 6f111938..23db4b05 100644
--- a/docs/tips.rst
+++ b/docs/tips.rst
@@ -173,4 +173,3 @@ As of vEOS 4.14.0, the serial number and system mac address can be configured wi
 
     SERIALNUMBER=ABC12345678
     SYSTEMMACADDR=1122.3344.5566
-
diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst
index 963903d5..1e04ad30 100644
--- a/docs/troubleshooting.rst
+++ b/docs/troubleshooting.rst
@@ -28,7 +28,7 @@ Check whether ZTP has been disabled on the switch::
 Validate the ZTP Server configuration syntax
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-Many errors are simply due to typos or other syntax issues in config files. 
+Many errors are simply due to typos or other syntax issues in config files.
 It is good practice to use the --validate option to ztps and to paste configs
 in to `http://yamllint.com/` to ensure they are well-formed YAML::
 
@@ -37,7 +37,7 @@ in to `http://yamllint.com/` to ensure they are well-formed YAML::
 Other troubleshooting steps
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-A number of other troubleshooting steps including how to specify the separate 
+A number of other troubleshooting steps including how to specify the separate
 apache log files just for ZTP Server, and how to do a test run of ztpserver
 without reloading a switch are located on the :doc:`tips` page.
 
@@ -61,7 +61,7 @@ Server-side logs
 
 The location of server-side logs may vary depending on your specific environment.
 
-* If running ZTP Server via Apache, check the VirtualHost definition for 
+* If running ZTP Server via Apache, check the VirtualHost definition for
   CustomLog and ErrorLog entries, otherwise, look in the default Apache logs.
   On Fedora, those will be in /var/log/httpd/
 * If running the standalone ``ztps`` binary, a good choice for debugging, please
@@ -81,4 +81,3 @@ Configuration Files
 
 Please, also, include the files in /etc/ztpserver/ and /usr/share/ztpserver/
 directories.   ``tar czvf my_ztpserver_config.tgz /etc/ztpserver/ /usr/share/ztpserver/``
-
diff --git a/plugins/allocate b/plugins/allocate
index f5d7c656..e3ba5250 100644
--- a/plugins/allocate
+++ b/plugins/allocate
@@ -41,7 +41,7 @@ When a resource is allocated within a node definition, the first available
 null value will be replaced by the node unique_id.
 
     192.168.1.1/24: 001c731a2b3c
-    192.168.1.2/24: null    
+    192.168.1.2/24: null
 
 On subsequent attempts to allocate the resource to the same node,
 ztpserver will first check to see whether the node has already been
diff --git a/setup.py b/setup.py
index 8dfaa8c3..66e07ee0 100755
--- a/setup.py
+++ b/setup.py
@@ -99,6 +99,8 @@ def get_long_description():
     x.strip() for x in install_requirements if x.strip() and "dev only" not in x
 ]
 version = open("VERSION").read().split()[0].strip()
+if os.environ.get("DYNAMIC_VERSION"):
+    version = os.environ.get("DYNAMIC_VERSION")
 if os.environ.get("DEV_VERSION_HASH"):
     version = f"{version}+{os.environ.get('DEV_VERSION_HASH')}"
 
diff --git a/utils/create_db.py b/utils/create_db.py
index 4c50ddcd..e6502c17 100755
--- a/utils/create_db.py
+++ b/utils/create_db.py
@@ -4,11 +4,11 @@
 import sqlite3 as lite
 import sys
 
-con = lite.connect('/usr/share/ztpserver/db/resources.db')
+con = lite.connect("/usr/share/ztpserver/db/resources.db")
 
 with con:
 
-    for table in ['mgmt_subnet', 'tor_hostnames', 'ip_vlan100', 'ip_loopback']:
+    for table in ["mgmt_subnet", "tor_hostnames", "ip_vlan100", "ip_loopback"]:
         print("Working on: ", table)
         cur = con.cursor()
         sql = "DROP TABLE IF EXISTS `%s`" % table
@@ -28,7 +28,7 @@
             base = "1.1.1."
             subnet = "/32"
 
-        for x in range(1,500):
+        for x in range(1, 500):
             sql = "INSERT INTO %s VALUES('%s%s%s',NULL)" % (table, base, str(x), subnet)
             cur.execute(sql)
 
diff --git a/ztpserver/controller.py b/ztpserver/controller.py
index 29ab912b..da75f2b5 100644
--- a/ztpserver/controller.py
+++ b/ztpserver/controller.py
@@ -218,19 +218,19 @@ def put_config(self, request, **kwargs):
 
         fobj = None
         filename = self.expand(node_id, STARTUP_CONFIG_FN)
-        body = str(request.body)
+        body = request.body.decode("utf-8")
         content_type = str(request.content_type)
         try:
             fobj = self.repository.get_file(filename)
         except FileObjectNotFound:
             log.debug("%s: file not found: %s (adding it)", node_id, filename)
             fobj = self.repository.add_file(filename)
-        finally:
-            if fobj:
-                fobj.write(body, content_type)
-            else:
-                log.error("%s: unable to write %s", node_id, filename)
-                return self.http_bad_request()
+
+        try:
+            fobj.write(body, content_type)
+        except OSError:
+            log.error("%s: unable to write %s", node_id, filename)
+            return self.http_bad_request()
 
         # Execute event-handler
         script = self.repository.expand(self.expand(node_id, CONFIG_HANDLER_FN))
@@ -520,10 +520,11 @@ def dump_node(self, response, *args, **kwargs):
             fobj = self.repository.get_file(filename)
         except FileObjectNotFound:
             fobj = self.repository.add_file(filename)
-        finally:
-            if fobj and contents:
+
+        if contents:
+            try:
                 fobj.write(contents, CONTENT_TYPE_JSON)
-            else:
+            except OSError:
                 log.error("%s: unable to write %s", node_id, filename)
                 return self.http_bad_request()
 
diff --git a/ztpserver/topology.py b/ztpserver/topology.py
index 217162c2..a0221664 100644
--- a/ztpserver/topology.py
+++ b/ztpserver/topology.py
@@ -496,6 +496,7 @@ def __init__(
         node=None,
         variables=None,
         node_id=None,
+        model=None,
     ):
         self.name = name
         self.definition = definition
@@ -505,6 +506,8 @@ def __init__(
         self.node_id = node_id
         self.variables = variables or {}
 
+        self.model = model
+
         self.interfaces = []
         if interfaces:
             self.add_interfaces(interfaces)
@@ -551,6 +554,7 @@ def serialize(self):
             "definition": self.definition,
             "variables": self.variables,
             "node": self.node,
+            "model": self.model,
             "config-handler": self.config_handler,
         }
 
@@ -640,6 +644,10 @@ def match_node(self, node):
         # while selecting the set of nodes which are eligible for a
         # match.
 
+        # Match the model first
+        if self.model and not re.match(self.model, node.model):
+            return False
+
         patterns = []
         for entry in self.interfaces:
             for pattern in entry["patterns"]:
diff --git a/ztpserver/validators.py b/ztpserver/validators.py
index f88c47ea..ca9eafc4 100644
--- a/ztpserver/validators.py
+++ b/ztpserver/validators.py
@@ -40,7 +40,7 @@
 from ztpserver.utils import expand_range, parse_interface
 
 REQUIRED_PATTERN_ATTRIBUTES = ["name", "definition"]
-OPTIONAL_PATTERN_ATTRIBUTES = ["node", "variables", "interfaces"]
+OPTIONAL_PATTERN_ATTRIBUTES = ["node", "variables", "interfaces", "model", "config_handler"]
 INTERFACE_PATTERN_KEYWORDS = ["any", "none"]
 ANTINODE_PATTERN = rf"[^{string.hexdigits}]"
 KW_ANY_RE = re.compile(r" *any *")
@@ -170,17 +170,15 @@ def validate_attributes(self):
             if attr not in self.data:
                 raise ValidationError(f"missing attribute: {attr}")
 
-        if "node" not in self.data and "interfaces" not in self.data:
-            raise ValidationError("missing attribute: 'node' OR 'interfaces'")
+        if "node" not in self.data and "model" not in self.data and "interfaces" not in self.data:
+            raise ValidationError("missing attribute: 'node' OR 'model' OR 'interfaces'")
 
-        for attr in OPTIONAL_PATTERN_ATTRIBUTES:
-            if attr not in self.data:
-                log.warning(
-                    "%s: PatternValidator warning: '%s' is missing optional attribute (%s)",
-                    self.node_id,
-                    self.data["name"],
-                    attr,
-                )
+        if "node" in self.data and "model" in self.data:
+            raise ValidationError("'node' AND 'model' are mutually exclusive")
+
+        for attr in self.data:
+            if attr not in REQUIRED_PATTERN_ATTRIBUTES + OPTIONAL_PATTERN_ATTRIBUTES:
+                raise ValidationError(f"{attr} not allowed")
 
     def validate_name(self):
         if not self.data or "name" not in self.data:


Test patch:
diff --git a/.github/workflows/unittest-node-py2.yml b/.github/workflows/unittest-node-py2.yml
index 3986d516..c40b575b 100644
--- a/.github/workflows/unittest-node-py2.yml
+++ b/.github/workflows/unittest-node-py2.yml
@@ -1,6 +1,11 @@
 name: Unittest Node py2
 
-on: [push]
+on:
+  push:
+    branches:
+      - main
+      - develop
+  pull_request:
 
 jobs:
   build:
@@ -12,7 +17,7 @@ jobs:
       env:
         USER: root
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip
diff --git a/.github/workflows/unittest-node.yml b/.github/workflows/unittest-node.yml
index 3ddfcb9c..dc267180 100644
--- a/.github/workflows/unittest-node.yml
+++ b/.github/workflows/unittest-node.yml
@@ -1,6 +1,11 @@
 name: Unittest Node
 
-on: [push]
+on:
+  push:
+    branches:
+      - main
+      - develop
+  pull_request:
 
 jobs:
   build:
@@ -9,9 +14,9 @@ jobs:
       matrix:
         python-version: ["3.7", "3.8", "3.9"]
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v3
+      uses: actions/setup-python@v5
       with:
         python-version: ${{ matrix.python-version }}
     - name: Install dependencies
diff --git a/.github/workflows/unittest-server.yml b/.github/workflows/unittest-server.yml
index 4eac3ce5..e9a23fd3 100644
--- a/.github/workflows/unittest-server.yml
+++ b/.github/workflows/unittest-server.yml
@@ -1,6 +1,11 @@
 name: Unittest server
 
-on: [push]
+on:
+  push:
+    branches:
+      - main
+      - develop
+  pull_request:
 
 jobs:
   build:
@@ -9,9 +14,9 @@ jobs:
       matrix:
         python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"]
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v3
+      uses: actions/setup-python@v5
       with:
         python-version: ${{ matrix.python-version }}
     - name: Install dependencies
diff --git a/plugins/test b/plugins/test
index 9db98dab..f2de8288 100644
--- a/plugins/test
+++ b/plugins/test
@@ -30,7 +30,7 @@
 #
 
 """
-Simply returns a string made of the node ID and the name of 
+Simply returns a string made of the node ID and the name of
 the resource pool.
 
 Definition example:
diff --git a/test/actions/test_install_image.py b/test/actions/test_install_image.py
index 32775874..abbdd90d 100644
--- a/test/actions/test_install_image.py
+++ b/test/actions/test_install_image.py
@@ -150,6 +150,42 @@ def test_success(self):
             remove_file(image_file)
             bootstrap.end_test()
 
+    def test_image_name_from_url(self):
+        bootstrap = Bootstrap(ztps_default_config=True)
+        version = random_string()
+        image = random_string()
+        url = "http://{}/{}".format(bootstrap.server, image)
+        bootstrap.ztps.set_definition_response(
+            actions=[
+                {
+                    "action": "test_action",
+                    "attributes": {"url": url, "version": version, "image_name_from_url": True},
+                },
+                {"action": "startup_config_action"},
+            ]
+        )
+
+        action = get_action("install_image")
+        bootstrap.ztps.set_action_response("test_action", action)
+        bootstrap.ztps.set_action_response("startup_config_action", startup_config_action())
+        bootstrap.ztps.set_file_response(image, print_action())
+        bootstrap.start_test()
+
+        image_file = "{}/{}".format(bootstrap.flash, url.rsplit("/", 1)[-1])
+        try:
+            self.assertTrue(os.path.isfile(image_file))
+            self.assertTrue(bootstrap.success())
+            self.assertEqual(
+                eapi_log()[-1], "install source flash:{}".format(url.rsplit("/", 1)[-1])
+            )
+        except AssertionError as assertion:
+            print("Output: {}".format(bootstrap.output))
+            print("Error: {}".format(bootstrap.error))
+            raise_exception(assertion)
+        finally:
+            remove_file(image_file)
+            bootstrap.end_test()
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/test/neighbordb/README.md b/test/neighbordb/README.md
index b9d02fdb..ef1488f0 100644
--- a/test/neighbordb/README.md
+++ b/test/neighbordb/README.md
@@ -26,18 +26,16 @@ Example:
 
    nodes:
      - node: 2b3d.json
-       details: 
+       details:
          model: vEOS
          serialnumber: 1234567890
          systemmac: 00:1c:73:1a:2b:3d
          version: 4.12.0
          neighbors:
            Ethernet1:
-               - 
+               -
                  device: pod1-spine1
                  port: Ethernet1
        matches: 0
        match_includes:
          - sample node template 1
-
-
diff --git a/test/neighbordb/chassis_test.yml b/test/neighbordb/chassis_test.yml
index 061d5b58..2ee600b7 100644
--- a/test/neighbordb/chassis_test.yml
+++ b/test/neighbordb/chassis_test.yml
@@ -57,4 +57,3 @@ neighbordb:
       definition: spine2
       interfaces:
         - any: regex('TK5-WAN-CA0\d-A7504R-DIST\d'):regex('Ethernet[56]/\d+/1')
-
diff --git a/test/neighbordb/large_pattern_test.yml b/test/neighbordb/large_pattern_test.yml
index e397daa2..d25234b6 100644
--- a/test/neighbordb/large_pattern_test.yml
+++ b/test/neighbordb/large_pattern_test.yml
@@ -126,5 +126,3 @@ neighbordb:
       definition: test
       interfaces:
         - Ethernet1: none
-
-
diff --git a/test/neighbordb/model_pattern_test.yml b/test/neighbordb/model_pattern_test.yml
new file mode 100644
index 00000000..1ee14257
--- /dev/null
+++ b/test/neighbordb/model_pattern_test.yml
@@ -0,0 +1,30 @@
+tag: model_pattern_test
+tests:
+  - pattern
+valid_patterns:
+  nodes:
+    - node pattern
+  globals:
+    - pattern with model
+    - pattern with model and interfaces
+
+neighbordb:
+  patterns:
+    - name: node pattern
+      definition: test
+      node: 2b3c
+
+    - name: invalid pattern with node and model
+      definition: test
+      model: modelA
+      node: 2b3caa
+
+    - name: pattern with model
+      definition: test
+      model: modelA
+
+    - name: pattern with model and interfaces
+      definition: test
+      model: modelA
+      interfaces:
+        - Ethernet1: localhost:Ethernet1
diff --git a/test/neighbordb/model_topology_test.yml b/test/neighbordb/model_topology_test.yml
new file mode 100644
index 00000000..daad52fa
--- /dev/null
+++ b/test/neighbordb/model_topology_test.yml
@@ -0,0 +1,108 @@
+tag: model_pattern_test
+tests:
+  - pattern
+  - topology
+valid_patterns:
+  nodes:
+    - node pattern
+  globals:
+    - pattern with modelA
+    - pattern with modelB and neighbour localhost:Ethernet1
+    - pattern with modelB and neighbour localhost:Ethernet2
+    - pattern with modelB and no neighbours
+    - pattern with modelC and neighbour localhost:Ethernet1
+
+nodes:
+  pass:
+    - name: node_2b3c
+      match: node pattern
+    - name: node_3b3c
+      match: pattern with modelA
+    - name: node_4b3c
+      match: pattern with modelB and neighbour localhost:Ethernet1
+    - name: node_5b3c
+      match: pattern with modelB and neighbour localhost:Ethernet2
+    - name: node_6b3c
+      match: pattern with modelB and no neighbours
+  fail:
+    - name: node_7b3c
+
+node_2b3c:
+  serialnumber: 2b3c
+  model: modelA
+  neighbors:
+    Ethernet1:
+      - device: localhost
+        port: Ethernet1
+
+node_3b3c:
+  serialnumber: 3b3c
+  model: modelA
+  neighbors:
+    Ethernet1:
+      - device: localhost
+        port: Ethernet1
+
+node_4b3c:
+  serialnumber: 4b3c
+  model: modelB
+  neighbors:
+    Ethernet1:
+      - device: localhost
+        port: Ethernet1
+
+node_5b3c:
+  serialnumber: 5b3c
+  model: modelB
+  neighbors:
+    Ethernet1:
+      - device: localhost
+        port: Ethernet2
+
+node_6b3c:
+  serialnumber: 6b3c
+  model: modelB
+  neighbors:
+    Ethernet1:
+      - device: localhost
+        port: Ethernet3
+
+node_7b3c:
+  serialnumber: 7b3c
+  model: modelC
+  neighbors:
+    Ethernet1:
+      - device: localhost
+        port: Ethernet3
+
+neighbordb:
+  patterns:
+    - name: node pattern
+      definition: test
+      node: 2b3c
+
+    - name: pattern with modelA
+      definition: test
+      model: modelA
+
+    - name: pattern with modelB and neighbour localhost:Ethernet1
+      definition: test
+      model: modelB
+      interfaces:
+        - Ethernet1: localhost:Ethernet1
+
+    - name: pattern with modelB and neighbour localhost:Ethernet2
+      definition: test
+      model: modelB
+      interfaces:
+        - Ethernet1: localhost:Ethernet2
+
+    - name: pattern with modelB and no neighbours
+      definition: test
+      model: modelB
+
+    - name: pattern with modelC and neighbour localhost:Ethernet1
+      definition: test
+      model: modelC
+      interfaces:
+        - Ethernet1: localhost:Ethernet1
diff --git a/test/neighbordb/node_interfaces_test.yml b/test/neighbordb/node_interfaces_test.yml
index ff8dcc83..57b0fb76 100644
--- a/test/neighbordb/node_interfaces_test.yml
+++ b/test/neighbordb/node_interfaces_test.yml
@@ -36,4 +36,3 @@ neighbordb:
       definition: test
       interfaces:
         - any: any:any
-
diff --git a/test/neighbordb/node_test.yml b/test/neighbordb/node_test.yml
index 93d2384a..55c934fe 100644
--- a/test/neighbordb/node_test.yml
+++ b/test/neighbordb/node_test.yml
@@ -33,4 +33,3 @@ neighbordb:
     - name: wrong node pattern
       definition: test
       node:  001c731a2b00
-
diff --git a/test/neighbordb/small_pattern_test.yml b/test/neighbordb/small_pattern_test.yml
index 8d5872c2..7bc4e50f 100644
--- a/test/neighbordb/small_pattern_test.yml
+++ b/test/neighbordb/small_pattern_test.yml
@@ -170,5 +170,3 @@ neighbordb:
       definition: test
       interfaces:
         - Ethernet1: none
-
-
diff --git a/test/server/server_test_lib.py b/test/server/server_test_lib.py
index 7a97574b..9d0017e9 100644
--- a/test/server/server_test_lib.py
+++ b/test/server/server_test_lib.py
@@ -61,6 +61,10 @@ def random_string():
     )
 
 
+def random_bytes() -> bytes:
+    return random_string().encode("utf-8")
+
+
 def random_json(keys=None):
     data = {}
     if keys:
diff --git a/test/server/test_controller.py b/test/server/test_controller.py
index 9300405d..832b872c 100644
--- a/test/server/test_controller.py
+++ b/test/server/test_controller.py
@@ -42,6 +42,7 @@
     create_node,
     enable_logging,
     mock_match,
+    random_bytes,
     random_string,
     remove_all,
     write_file,
@@ -1006,18 +1007,23 @@ def test_do_resources_success(self):
         foo = resp["definition"]["actions"][0]["attributes"]["foo"]
         self.assertEqual(foo, var_foo)
 
+    @patch("ztpserver.controller.create_repository")
     @patch("os.path.isfile")
-    def test_put_config_success(self, m_is_file):
+    def test_put_config_success(self, m_is_file, m_create_repository):
         m_is_file.return_value = False
+        file_mock = MagicMock()
+        m_create_repository.return_value.get_file.return_value = file_mock
 
         resource = random_string()
-        body = random_string()
+        body = random_bytes()
         request = Mock(content_type=constants.CONTENT_TYPE_OTHER, body=body)
 
         controller = ztpserver.controller.NodesController()
+
         resp = controller.put_config(request, resource=resource)
 
         self.assertEqual(resp, {})
+        file_mock.write.assert_called_with(body.decode("utf-8"), constants.CONTENT_TYPE_OTHER)
 
 
 class NodesControllerPostFsmIntegrationTests(unittest.TestCase):