Closes #22603: Add experimental Python packaging support for NetBox (#22605)

Add initial Python package support for NetBox, including wheel and sdist
builds, generated package metadata, and Test PyPI publishing for maintainer
validation.

Add package-aware CLI support, `netbox setup` scaffolding for instance-local
files, and centralized wheel-vs-checkout path handling while preserving the
existing source/archive install layout.

Bundle pre-rendered embedded documentation in the wheel, and extend CI to
verify dependency pins, wheel metadata, artifact contents, CLI behavior, sdist
rebuilds, and smoke-test upgrades.
This commit is contained in:
Martin Hauser 2026-07-13 16:28:04 +02:00 committed by GitHub
parent 84bbaaa5a0
commit 48ecc712bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 2525 additions and 51 deletions

328
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,328 @@
name: Build and publish Python package
# Least-privilege default for every job; the publish job grants itself id-token below.
permissions:
contents: read
on:
pull_request:
paths:
- '.github/workflows/release.yml'
- 'pyproject.toml'
- 'README.md'
- 'LICENSE.txt'
- 'base_requirements.txt'
- 'requirements.txt'
- 'upgrade.sh'
- 'contrib/**'
- 'docs/**'
- 'mkdocs.yml'
- 'netbox/**'
- 'scripts/packaging/**'
- 'scripts/verify_*.py'
- 'scripts/smoketest_configuration.py'
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
name: Build package artifacts
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
cache: pip
- name: Install build tooling
run: python -m pip install --upgrade build twine
- name: Install documentation toolchain
run: python -m pip install -r requirements.txt
- name: Render the documentation
# -c = clean cache, -s = strict (abort on warnings); verify_wheel_contents.py
# additionally guards against a partial render reaching the wheel.
run: zensical build -c -s
- name: Build sdist and wheel
run: python -m build
- name: Check package metadata
run: twine check dist/*
- name: Upload package artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: python-package-distributions
path: dist/
if-no-files-found: error
verify-dependencies:
name: Verify dependency pins are in sync
runs-on: ubuntu-latest
needs: build
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
cache: pip
- name: Install packaging
run: python -m pip install packaging
- name: Verify requirements.txt is consistent with base_requirements.txt
run: python scripts/verify_dependencies.py
- name: Download package artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: python-package-distributions
path: dist/
- name: Verify wheel Requires-Dist matches requirements.txt
run: python scripts/verify_wheel_metadata.py dist/*.whl
- name: Verify wheel excludes live configuration files
run: python scripts/verify_wheel_contents.py dist/*.whl
verify-sdist:
name: Verify the sdist builds a wheel
runs-on: ubuntu-latest
needs: build
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
cache: pip
- name: Install tooling
run: python -m pip install --upgrade pip packaging
- name: Download package artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: python-package-distributions
path: dist/
- name: Verify the sdist contents
run: |
python scripts/verify_sdist_contents.py dist/*.tar.gz
- name: Build a wheel from the sdist
run: |
python -m pip wheel --no-deps dist/*.tar.gz -w sdist-wheel/
- name: Verify the sdist-built wheel
run: |
python scripts/verify_wheel_metadata.py sdist-wheel/*.whl
python scripts/verify_wheel_contents.py sdist-wheel/*.whl
cli-smoke-test:
name: Smoke test wheel CLI (no dependencies)
runs-on: ubuntu-latest
needs: build
# The pre-configuration CLI paths are stdlib-only, so a --no-deps install suffices.
# Unlike smoke-test, this job also runs on pull requests.
steps:
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Download package artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: python-package-distributions
path: dist/
- name: Install wheel without dependencies
run: |
python -m venv "$RUNNER_TEMP/netbox-cli-venv"
"$RUNNER_TEMP/netbox-cli-venv/bin/python" -m pip install --no-deps dist/*.whl
- name: Exercise the pre-configuration CLI
run: |
"$RUNNER_TEMP/netbox-cli-venv/bin/netbox" --version
"$RUNNER_TEMP/netbox-cli-venv/bin/netbox" version
"$RUNNER_TEMP/netbox-cli-venv/bin/python" -m netbox --version
"$RUNNER_TEMP/netbox-cli-venv/bin/netbox" secret-key | grep -Eq '^.{50}$' || { echo "secret-key not 50 chars"; exit 1; }
- name: Smoke-test netbox setup from the wheel
run: |
"$RUNNER_TEMP/netbox-cli-venv/bin/netbox" setup --target "$RUNNER_TEMP/nbroot"
for f in "$RUNNER_TEMP/nbroot/conf/__init__.py" "$RUNNER_TEMP/nbroot/conf/configuration.py" "$RUNNER_TEMP/nbroot/local_requirements.txt"; do
test -f "$f" || { echo "missing $f"; exit 1; }
done
for f in apache.conf gunicorn.py netbox-rq.service netbox.env netbox.service nginx.conf uwsgi.ini; do
test -s "$RUNNER_TEMP/nbroot/contrib/$f" || { echo "missing or empty contrib/$f"; exit 1; }
done
smoke-test:
name: Smoke test wheel install
runs-on: ubuntu-latest
needs: build
# The wheel install + database migration is expensive; only run it for tag
# pushes and manual dispatch, not on every packaging-related pull request.
# cli-smoke-test provides lightweight, dependency-free CLI coverage on every PR instead.
if: github.event_name != 'pull_request'
services:
postgres:
image: postgres:17
env:
POSTGRES_DB: netbox
POSTGRES_USER: netbox
POSTGRES_PASSWORD: netbox
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U netbox -d netbox"
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
NETBOX_CONFIGURATION: smoketest_configuration
POSTGRES_DB: netbox
POSTGRES_USER: netbox
POSTGRES_PASSWORD: netbox
POSTGRES_HOST: 127.0.0.1
POSTGRES_PORT: 5432
REDIS_HOST: 127.0.0.1
REDIS_PORT: 6379
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
cache: pip
- name: Download package artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: python-package-distributions
path: dist/
- name: Install system build dependencies for psycopg
run: sudo apt-get update && sudo apt-get install -y libpq-dev
- name: Install wheel into a clean virtual environment
run: |
python -m venv "$RUNNER_TEMP/netbox-wheel-venv"
"$RUNNER_TEMP/netbox-wheel-venv/bin/python" -m pip install --upgrade pip
"$RUNNER_TEMP/netbox-wheel-venv/bin/python" -m pip install dist/*.whl
- name: Run NetBox smoke checks
env:
# STATIC_ROOT is not a configuration parameter; NETBOX_ROOT places it under the scratch base.
NETBOX_ROOT: ${{ runner.temp }}/netbox-smoketest
NETBOX_SMOKETEST_BASE: ${{ runner.temp }}/netbox-smoketest
PYTHONPATH: ${{ github.workspace }}/scripts
run: |
"$RUNNER_TEMP/netbox-wheel-venv/bin/netbox" check
"$RUNNER_TEMP/netbox-wheel-venv/bin/netbox" upgrade --no-input
test -f "$NETBOX_SMOKETEST_BASE/static/docs/index.html" || { echo "bundled documentation was not collected to STATIC_ROOT"; exit 1; }
test -f "$NETBOX_SMOKETEST_BASE/static/docs/models/dcim/device/index.html" || { echo "model documentation page was not collected"; exit 1; }
- name: Smoke-test netbox setup from the wheel
run: |
"$RUNNER_TEMP/netbox-wheel-venv/bin/netbox" setup --target "$RUNNER_TEMP/nbroot"
diff -q "$RUNNER_TEMP/nbroot/conf/configuration.py" netbox/netbox/configuration_example.py
for f in apache.conf gunicorn.py netbox-rq.service netbox.env netbox.service nginx.conf uwsgi.ini; do
diff -q "$RUNNER_TEMP/nbroot/contrib/$f" "contrib/$f"
done
publish-testpypi:
name: Publish package to Test PyPI
runs-on: ubuntu-latest
needs: [smoke-test, cli-smoke-test, verify-dependencies, verify-sdist]
# Publishing always requires a v* tag ref: a tag push publishes to Test PyPI
# automatically, and a manual dispatch does the same when the chosen ref is a v* tag.
# Branch dispatches still run the build, verify, and smoke-test jobs (a useful dry run)
# but the publish job is skipped. Production PyPI publishing is intentionally absent
# during the v4.6.x preview; it arrives with the v4.7.0 feature branch.
# startsWith() only routes to this job (workflow `if:` expressions cannot regex-match);
# the exact tag format (v<release.yaml version>) is enforced below by the "Enforce
# release tag format" step and scripts/verify_release_tag.py before any upload.
if: startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
environment:
name: testpypi
url: https://test.pypi.org/p/netbox
permissions:
contents: read
id-token: write
steps:
- name: Enforce release tag format
env:
TAG: ${{ github.ref_name }}
run: |
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || {
echo "Ref '$TAG' is not a release tag of the form vX.Y.Z[-designation]"
exit 1
}
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Install tooling
run: python -m pip install --upgrade pip packaging
- name: Download package artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: python-package-distributions
path: dist/
- name: Verify the git tag matches the built version
run: python scripts/verify_release_tag.py "${{ github.ref_name }}" dist/*.whl
- name: Publish package distributions to Test PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
repository-url: https://test.pypi.org/legacy/

5
.gitignore vendored
View File

@ -64,3 +64,8 @@ yarn-error.log*
.idea/
.vscode/
.python-version
# Python package build artifacts
/dist/
/build/
*.egg-info/

3
contrib/netbox.env Normal file
View File

@ -0,0 +1,3 @@
# Optional overrides for a pip-installed NetBox. Do not put secrets here.
# NetBox loads conf/configuration.py from NETBOX_ROOT automatically.
NETBOX_ROOT=/opt/netbox

View File

@ -0,0 +1,126 @@
# Building the Package
NetBox package artifacts (a wheel and a source distribution) can be built and verified locally. During the v4.6.x preview period, published artifacts are for maintainer validation only. Installing NetBox via pip is not a supported installation path yet. Experimental support for installing from production PyPI is planned for NetBox v4.7.0. This page is intended for maintainers and contributors working on the packaging itself; routine development does not require building a package.
The artifacts are always built by CI from a clean checkout (see `.github/workflows/release.yml`). A local build is useful for testing packaging changes before they are merged.
## Prerequisites
Install the minimum local build tooling (all three are also included in the `dev` optional dependency group):
```no-highlight
python -m pip install --upgrade build packaging twine
```
Building also requires a freshly rendered copy of the documentation site (see [Building](#building) below). The documentation toolchain (`zensical`, `mkdocs`, `mkdocs-material`, `mkdocstrings`) is pinned in `requirements.txt` rather than the `dev` group because it is also needed outside packaging, such as documentation previews and CI's `docs` job.
## Building
Render the documentation site at the repository root before building; both the wheel and the sdist bundle the rendered output, and the release workflow's `build` job renders in the same way:
```no-highlight
python -m pip install -r requirements.txt
zensical build -c -s
```
Always render with `-c` (clean cache) and `-s` (strict mode, abort on warnings) so a stale cache or a degraded build cannot slip into the artifacts. This writes `netbox/project-static/docs/` (gitignored). Building without a prior render fails because the rendered docs directory is a required Hatch force-include: Hatchling raises `FileNotFoundError: Forced include not found` for the missing directory. A render that exits successfully but produces a partial site is caught by `scripts/verify_wheel_contents.py`, which requires both the site root (`index.html`) and a model documentation page (`models/dcim/device/index.html`) in the wheel.
Build both the source distribution (sdist) and the wheel into `dist/`:
```no-highlight
python -m build
```
To build only the wheel (faster, and the form most useful for a quick local install test):
```no-highlight
python -m build --wheel
```
The package version and the wheel's runtime dependency metadata are both computed at build time by a Hatchling hook; see [Dynamic metadata](#dynamic-metadata) below.
## Clean-tree caveat
Always build release artifacts from a clean checkout. The build configuration keeps deployment-local files out of the artifacts: the Hatch excludes drop every `configuration*.py` and `ldap_config*.py` except the two tracked configuration templates (`configuration_example.py` and `configuration_testing.py`, which are force-included explicitly), and CI verifies the contents of both the wheel and the sdist before anything is published.
These checks are defense in depth, not a license to build from a dirty tree: other untracked files under `netbox/` can still be picked up by a local build. CI builds from a clean checkout, so the published artifacts are unaffected. For a comparable local build, use a fresh `git clone` or a separate clean worktree rather than your day-to-day development tree.
## Verifying
Check the built artifacts for valid package metadata and README rendering:
```no-highlight
twine check dist/*
```
Confirm the wheel's version, dependency metadata, and extras match `netbox/release.yaml`, the pinned `requirements.txt`, and the declared optional-dependency groups:
```no-highlight
python scripts/verify_wheel_metadata.py dist/*.whl
```
Confirm the artifacts ship only the two tracked configuration templates, and that the wheel carries the runtime-critical bundled data: `_data/release.yaml`, templates, translations, static assets, and the pre-rendered documentation site under `_data/docs/`. These are the same content checks CI runs before publishing:
```no-highlight
python scripts/verify_wheel_contents.py dist/*.whl
python scripts/verify_sdist_contents.py dist/*.tar.gz
```
Confirm `requirements.txt` is still consistent with the maintainer policy in `base_requirements.txt` (the same drift guard CI runs before publishing):
```no-highlight
python scripts/verify_dependencies.py
```
## Test-installing the wheel
Install the wheel into a throwaway virtual environment and run the system checks to confirm the package is importable and runnable:
```no-highlight
python -m venv /tmp/netbox-build-test
/tmp/netbox-build-test/bin/python -m pip install --upgrade pip
/tmp/netbox-build-test/bin/python -m pip install dist/*.whl
PYTHONPATH=$PWD/scripts \
NETBOX_CONFIGURATION=smoketest_configuration \
NETBOX_ROOT=/tmp/netbox-build-test-root \
NETBOX_SMOKETEST_BASE=/tmp/netbox-build-test-root \
/tmp/netbox-build-test/bin/netbox check
```
Without configuration, a wheel-installed NetBox looks for `$NETBOX_ROOT/conf/configuration.py` (default `/opt/netbox/conf/configuration.py`), which normally does not exist on a development workstation. The environment variables above point `netbox check` at the same minimal configuration module used by the release workflow's smoke-test job (`scripts/smoketest_configuration.py`); run the command from the repository root so `PYTHONPATH` can find it. `NETBOX_SMOKETEST_BASE` sets the writable scratch directory under which the module creates its media, reports, and scripts roots; `NETBOX_ROOT` points the fixed collected-static root at the same directory. Any other importable configuration module works the same way via `NETBOX_CONFIGURATION` (and `PYTHONPATH`, if the configuration lives outside the package). To exercise the full post-install task sequence from the wheel, run `netbox upgrade --no-input` with the same environment against a throwaway database (the collected static files land under `$NETBOX_ROOT/static`); this is what the release workflow's smoke-test job does. The documentation ships pre-rendered in the wheel, so there is nothing to build on the instance; `--build-docs` remains a checkout-only convenience for rendering the documentation from its sources.
## Packaging architecture
This section is a developer-facing overview of how the package is assembled and how a pip-installed NetBox behaves at runtime. User-facing installation documentation for the pip install path will be added alongside experimental PyPI support (planned for NetBox v4.7.0); this page does not cover end-user installation steps.
### Dynamic metadata
`scripts/packaging/hatch_metadata.py` is a Hatchling metadata hook (wired in via `[tool.hatch.metadata.hooks.custom]`). It computes the package version from `netbox/release.yaml` and the runtime dependencies from the pinned `requirements.txt`, so the published wheel's `Requires-Dist` carries the exact versions NetBox is tested against. Both fields are declared `dynamic` in `pyproject.toml`; the optional-dependency extras stay static.
### sdist and the sdist-to-wheel guard
`python -m build` produces both an sdist and a wheel, with the wheel built from the sdist. The release workflow's `verify-sdist` job rebuilds a wheel from the candidate sdist and runs `scripts/verify_wheel_metadata.py` and `scripts/verify_wheel_contents.py` against it, so a missing build input (for example the metadata hook or `base_requirements.txt`) cannot regress unnoticed. The rendered documentation site is one such build input: it reaches the sdist through its own force-include (`[tool.hatch.build.targets.sdist.force-include]`), so this guard also fails if that force-include is removed or broken.
### Wheel data layout
Source assets that are not Python modules are force-included with a `netbox/netbox/_data/` target path by `[tool.hatch.build.targets.wheel.force-include]`; because the wheel's `sources = ["netbox"]` setting strips one leading `netbox/`, they install under `netbox/_data/`: templates, translations, the compiled `project-static` bundles, `release.yaml`, the pre-rendered documentation site (rendered by `zensical build` into `netbox/project-static/docs/` before packaging; see [Building](#building) above), the bundled deployment examples (`contrib/`, seven files, unmodified), and the two tracked configuration templates.
The wheel bundles the rendered site itself, not the documentation sources. The documentation build is not run from the installed wheel, and there is nothing to build on the instance. In wheel mode, the default `DOCS_ROOT` and the STATICFILES `docs` prefix source both resolve to the same bundled `_data/docs` directory (see `resolve_install_paths()` in `netbox/netbox/settings_utils.py`), which `collectstatic` then picks up the same way it does for a checkout build. The sdist force-includes the same rendered site (`netbox/project-static/docs/`, kept alongside the markdown sources it was rendered from), so a wheel built from the sdist (the `verify-sdist` job, or `pip install <sdist>`) is identical in this respect.
At runtime `settings.py` detects the bundled `_data` directory and resolves the install mode, `BASE_DIR`, `NETBOX_ROOT`, and the documentation roots through `resolve_install_paths()` in `netbox/netbox/settings_utils.py`: a wheel install (`_data` present) keeps package data under `_data` and mutable instance files under `NETBOX_ROOT`; a source checkout (no `_data`) keeps the historical layout, where both roots are the project directory.
### Wheel-mode runtime
A pip-installed NetBox keeps mutable instance state out of the immutable, disposable virtual environment. `settings.py` resolves `NETBOX_ROOT` (default `/opt/netbox`, overridable via the environment) as the instance root, defaults the writable paths (`MEDIA_ROOT`, `REPORTS_ROOT`, `SCRIPTS_ROOT`) beneath it, and fixes `STATIC_ROOT` to `$NETBOX_ROOT/static`; `STATIC_ROOT` is intentionally not a `configuration.py` parameter, so the collected static path cannot drift from the instance layout the bundled deployment examples expect. In a checkout `NETBOX_ROOT` equals `BASE_DIR`, so archive and Git installs are unaffected.
Configuration loading is handled by `load_configuration()` in `netbox/netbox/settings_utils.py`. An explicit `NETBOX_CONFIGURATION` module always wins; otherwise, in wheel mode it prefers `NETBOX_ROOT/conf/configuration.py`, loading it by file path, and falls back to a legacy `NETBOX_ROOT/netbox/netbox/configuration.py` with a migration warning. The configuration directory is added to `sys.path` only while the configuration file executes, so sibling imports can resolve; `NETBOX_ROOT` itself is never added, which avoids a stale source tree shadowing the installed package. A checkout keeps importing `netbox.configuration`. For LDAP deployments, `settings.py` exposes the active configuration file's directory as the `CONFIGURATION_DIR` setting, and `load_ldap_config()` loads `ldap_config.py` from that same directory by default. This keeps the active LDAP configuration beside the active NetBox configuration, regardless of install method. One compatibility exception remains: in checkout mode only, when no sibling file exists, the historical `netbox/netbox/ldap_config.py` module is imported with a `RuntimeWarning`, so existing source installs that use a custom `NETBOX_CONFIGURATION` keep working.
### Console script
`pyproject.toml` registers a single entry point, `netbox` (`netbox.cli:main`). The wrapper resolves a few commands itself before importing Django, so they work without a configuration present:
* `netbox version` / `netbox --version` print the installed package version.
* `netbox setup` creates the local configuration files for the instance: `conf/__init__.py`, `conf/configuration.py` copied verbatim from the bundled `configuration_example.py` template, and an empty `local_requirements.txt`. It also copies the bundled deployment examples (gunicorn, systemd units, nginx, apache, uwsgi, `netbox.env`) unmodified into `<target>/contrib/`. The examples are copied as-is, and existing files are never overwritten; adapting and installing the examples (paths, systemd, the web server) remains the administrator's responsibility.
* `netbox secret-key` prints a new 50-character `SECRET_KEY` value.
These names are reserved by the wrapper. Every other command falls through to the Django management commands (`netbox upgrade`, `netbox check`, and so on), which require a valid configuration.

View File

@ -97,14 +97,23 @@ Notify the [`netbox-docker`](https://github.com/netbox-community/netbox-docker)
### Update Python Dependencies
Before each release, update each of NetBox's Python dependencies to its most recent stable version. These are defined in `requirements.txt`, which is updated from `base_requirements.txt` using `pip`. To do this:
Before each release, update each of NetBox's Python dependencies to its most recent stable version. Loose runtime constraints (and per-package descriptions) live in `base_requirements.txt`; `requirements.txt` is the pinned, top-level dependency file consumed by the release archive, the git install flow (`upgrade.sh`), and the published wheel's dependency metadata. Optional dependency groups (for example `ldap`, `saml2`) are declared in `pyproject.toml`.
1. Upgrade the installed version of all required packages in your environment (`pip install -U -r base_requirements.txt`).
2. Run all tests and check that the UI and API function as expected.
3. Review each requirement's release notes for any breaking or otherwise noteworthy changes.
4. Update the package versions in `requirements.txt` as appropriate.
To update the pinned requirements:
In cases where upgrading a dependency to its most recent release is breaking, it should be constrained to its current minor version in `base_requirements.txt` with an explanatory comment and revisited for the next major NetBox release (see the [Address Constrained Dependencies](#address-constrained-dependencies) section above).
1. Review each constraint in `base_requirements.txt`.
2. Upgrade the installed version of all required packages in your environment (`pip install -U -r base_requirements.txt`).
3. Run all tests and check that the UI and API function as expected.
4. Review each requirement's release notes for any breaking or otherwise noteworthy changes.
5. If upgrading a dependency is breaking, constrain it in `base_requirements.txt` with an explanatory comment and revisit it for the next major NetBox release (see the [Address Constrained Dependencies](#address-constrained-dependencies) section above).
6. Update the pinned versions in `requirements.txt` to the versions you just tested. Keep `requirements.txt` in the existing bare `package==version` format (one top-level package per line, the same package set as `base_requirements.txt`).
7. Verify there is no drift between the policy file and the pins:
```no-highlight
python3 scripts/verify_dependencies.py
```
The published wheel's `Requires-Dist` is generated from `requirements.txt` at build time, so the package installs the same tested pins as the archive and git flows.
### Update UI Dependencies
@ -143,7 +152,7 @@ Then, compile these portable (`.po`) files for use in the application:
### Update Version and Changelog
* Update the version number and published date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable.
* Copy the version number from `release.yaml` to `pyproject.toml` in the project root.
* No manual `pyproject.toml` version edit is needed: the package version is derived automatically from `release.yaml` (`version` plus any `designation`) by the build backend.
* Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.
!!! tip
@ -196,4 +205,25 @@ Create a [new release](https://github.com/netbox-community/netbox/releases/new)
* **Title:** Version and date (e.g. `v4.2.1 - 2025-01-17`)
* **Description:** Copy from the pull request body, then promote the `###` headers to `##` ones
Once created, the release will become available for users to install.
Once created, the release will become available for users to install from GitHub.
### Publish to Test PyPI
Pushing a release tag triggers the Python package publishing workflow, which publishes the tagged release automatically to **Test PyPI** for maintainer validation. Installing NetBox via pip is not a supported installation path during the v4.6.x preview period; production PyPI publishing is planned for the v4.7.0 feature branch. A manual `workflow_dispatch` run publishes to Test PyPI only when the selected ref is a `v*` release tag; dispatching from a branch runs the build and verification jobs as a dry run without publishing.
After a publish run completes:
* Verify that the build, CLI smoke-test (`cli-smoke-test`), smoke-test, dependency-verification (`verify-dependencies`), and sdist-verification (`verify-sdist`) jobs succeeded. The dependency-verification job fails the release if `requirements.txt` has drifted from `base_requirements.txt` or if the built wheel's `Requires-Dist` does not match `requirements.txt`; the sdist-verification job fails it if the sdist ships unexpected configuration files or cannot rebuild a valid wheel.
* Verify that the publish job used the expected trusted-publishing environment (`testpypi`).
* Confirm that the new version is visible on Test PyPI.
* Install the published wheel into a fresh virtual environment and run `netbox check` against a minimal configuration module. The preview artifact is published to Test PyPI while NetBox's pinned runtime dependencies are expected to resolve from PyPI; to avoid mixed-index dependency resolution during validation, install the pinned dependencies from PyPI first, then install the Test PyPI artifact without resolving dependencies again:
```no-highlight
pip install -r requirements.txt
pip install --no-deps --index-url https://test.pypi.org/simple/ netbox==<version>
```
!!! note "Trusted publishing prerequisites"
Publishing requires a one-time setup by the project owners: a `netbox` project and a configured GitHub trusted publisher on Test PyPI, plus the corresponding `testpypi` GitHub Actions environment.
The published package version is derived from `netbox/release.yaml` (the `version` field plus any `designation`, e.g. `beta1` becomes `4.7.0b1`), not from the git tag. Ensure the tag and `release.yaml` agree before tagging a pre-release.

View File

@ -331,6 +331,7 @@ nav:
- Internationalization: 'development/internationalization.md'
- Translations: 'development/translations.md'
- Release Checklist: 'development/release-checklist.md'
- Building the Package: 'development/building-the-package.md'
- git Cheat Sheet: 'development/git-cheat-sheet.md'
- Release Notes:
- Summary: 'release-notes/index.md'

View File

@ -0,0 +1,107 @@
"""Run the NetBox application tasks required after installing or upgrading NetBox.
The command runs the NetBox application-level tasks that prepare the database and
static assets after the package and configuration are already in place - for both a
fresh installation and an upgrade. It does not perform host or bootstrap work
(creating the virtual environment, installing packages, configuring services); that
stays in upgrade.sh and the documented pip steps.
"""
import os
import subprocess
from django.conf import settings
from django.core.management import call_command
from django.core.management.base import BaseCommand
def _docs_source_root():
# mkdocs.yml sits beside the application root in a checkout. Wheels ship the
# pre-rendered site instead of the sources: no mkdocs.yml, the build is skipped.
candidate = os.path.dirname(settings.BASE_DIR)
if os.path.isfile(os.path.join(candidate, 'mkdocs.yml')):
return candidate
return None
class Command(BaseCommand):
help = "Run the NetBox application tasks required after installing or upgrading NetBox."
def add_arguments(self, parser):
parser.add_argument('--no-input', action='store_true', dest='no_input',
help="Do not prompt for user input.")
parser.add_argument('--readonly', action='store_true', dest='readonly',
help="Skip all tasks that modify the database or filesystem "
"(no migrations, no static collection).")
parser.add_argument('--skip-migrations', action='store_true', dest='skip_migrations',
help="Skip applying database migrations.")
parser.add_argument('--skip-static', action='store_true', dest='skip_static',
help="Skip collecting static files.")
parser.add_argument('--skip-reindex', action='store_true', dest='skip_reindex',
help="Skip rebuilding the search index.")
parser.add_argument('--build-docs', action='store_true', dest='build_docs',
help="Build the local documentation (requires the documentation source tree).")
def handle(self, *args, **options):
out, style = self.stdout, self.style
out.write(style.SUCCESS("Running NetBox upgrade tasks..."))
# Database migrations (writes to the database)
if options['skip_migrations'] or options['readonly']:
out.write("Skipping database migrations.")
else:
out.write("Applying database migrations...")
call_command('migrate', interactive=not options['no_input'], stdout=out)
# Missing cable paths (writes to the database)
if options['readonly']:
out.write("Skipping cable path check.")
else:
out.write("Checking for missing cable paths...")
call_command('trace_paths', no_input=options['no_input'], stdout=out)
# Documentation (filesystem; needs the documentation source tree)
if options['readonly'] and options['build_docs']:
out.write("Skipping documentation build.")
elif options['build_docs']:
docs_root = _docs_source_root()
if docs_root is None:
out.write(style.WARNING(
"Skipping documentation build; the documentation source tree is not available "
"in this installation."
))
else:
out.write("Building documentation...")
# -c cleans the cache; -s (strict) is deliberately omitted so a docs
# warning cannot abort an instance upgrade.
subprocess.run(['zensical', 'build', '-c'], cwd=docs_root, check=True)
# Static files (filesystem)
if options['skip_static'] or options['readonly']:
out.write("Skipping static file collection.")
else:
out.write("Collecting static files...")
call_command('collectstatic', interactive=not options['no_input'], stdout=out)
# Stale content types (writes to the database)
if options['readonly']:
out.write("Skipping stale content type removal.")
else:
out.write("Removing stale content types...")
call_command('remove_stale_contenttypes', interactive=not options['no_input'], stdout=out)
# Search index (writes to the database)
if options['skip_reindex'] or options['readonly']:
out.write("Skipping search index rebuild.")
else:
out.write("Rebuilding the search index (lazily)...")
call_command('reindex', lazy=True, stdout=out)
# Expired sessions (writes to the database)
if options['readonly']:
out.write("Skipping expired session cleanup.")
else:
out.write("Clearing expired sessions...")
call_command('clearsessions', stdout=out)
out.write(style.SUCCESS("Finished NetBox upgrade tasks."))

View File

@ -1,3 +1,5 @@
import os
import tempfile
from io import StringIO
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@ -7,7 +9,7 @@ from django.core.management.base import CommandError
from django.test import TestCase, override_settings
from core.choices import DataSourceStatusChoices
from core.management.commands import nbshell
from core.management.commands import nbshell, upgrade
from core.management.commands.rqworker import DEFAULT_QUEUES
@ -315,3 +317,76 @@ class SyncDataSourceTestCase(TestCase):
self.assertIn('[1] Syncing source-a', out.getvalue())
self.assertIn('[2] Syncing source-b', out.getvalue())
self.assertIn('Finished.', out.getvalue())
class UpgradeCommandTest(TestCase):
"""The upgrade command orchestrates the application task sequence for installs and upgrades."""
def _run(self, **kwargs):
out = StringIO()
with (
patch('core.management.commands.upgrade.call_command') as cc,
patch('core.management.commands.upgrade.subprocess.run') as sub,
):
call_command('upgrade', stdout=out, **kwargs)
return [c.args[0] for c in cc.call_args_list], cc, sub, out.getvalue()
def test_full_sequence_order(self):
seq, _, sub, _ = self._run()
self.assertEqual(seq, [
'migrate', 'trace_paths',
'collectstatic', 'remove_stale_contenttypes', 'reindex', 'clearsessions',
])
sub.assert_not_called() # docs not built by default
def test_readonly_skips_all_tasks_including_static(self):
"""--readonly prints a skip message for every task, including the three without dedicated flags."""
seq, _, sub, out = self._run(readonly=True)
self.assertEqual(seq, [])
sub.assert_not_called()
self.assertIn('Skipping database migrations.', out)
self.assertIn('Skipping cable path check.', out)
self.assertIn('Skipping static file collection.', out)
self.assertIn('Skipping stale content type removal.', out)
self.assertIn('Skipping search index rebuild.', out)
self.assertIn('Skipping expired session cleanup.', out)
def test_skip_flags(self):
seq, _, _, _ = self._run(skip_migrations=True, skip_static=True, skip_reindex=True)
self.assertEqual(
seq,
['trace_paths', 'remove_stale_contenttypes', 'clearsessions'],
)
def test_build_docs_invokes_zensical_when_sources_present(self):
with patch('core.management.commands.upgrade._docs_source_root', return_value='/repo'):
_, _, sub, _ = self._run(build_docs=True)
sub.assert_called_once()
self.assertEqual(sub.call_args.args[0], ['zensical', 'build', '-c'])
def test_build_docs_skipped_when_sources_absent(self):
with patch('core.management.commands.upgrade._docs_source_root', return_value=None):
_, _, sub, _ = self._run(build_docs=True)
sub.assert_not_called()
def test_readonly_with_build_docs_skips_docs(self):
with patch('core.management.commands.upgrade._docs_source_root', return_value='/repo'):
_, _, sub, _ = self._run(readonly=True, build_docs=True)
sub.assert_not_called()
def test_docs_source_root_checkout_shaped(self):
"""mkdocs.yml beside the application root (checkout layout) is found."""
with tempfile.TemporaryDirectory() as root:
base_dir = os.path.join(root, 'netbox')
os.mkdir(base_dir)
open(os.path.join(root, 'mkdocs.yml'), 'w').close()
with override_settings(BASE_DIR=base_dir):
self.assertEqual(upgrade._docs_source_root(), root)
def test_docs_source_root_none_when_absent(self):
"""No mkdocs.yml beside the application root returns None."""
with tempfile.TemporaryDirectory() as root:
base_dir = os.path.join(root, 'netbox')
os.mkdir(base_dir)
with override_settings(BASE_DIR=base_dir):
self.assertIsNone(upgrade._docs_source_root())

View File

@ -1,6 +1,5 @@
#!/usr/bin/env python3
# This script will generate a random 50-character string suitable for use as a SECRET_KEY.
import secrets
from utilities.secret_key import generate_secret_key
charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
print(''.join(secrets.choice(charset) for _ in range(50)))
print(generate_secret_key())

View File

@ -0,0 +1,8 @@
"""Allow `python -m netbox` to behave like the `netbox` console script."""
import sys
from .cli import main
if __name__ == '__main__':
sys.exit(main())

View File

@ -9,6 +9,7 @@ from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Q
from netbox.settings_utils import load_ldap_config
from users.constants import CONSTRAINT_TOKEN_USER
from users.models import Group, ObjectPermission, User
from utilities.permissions import (
@ -344,15 +345,10 @@ class LDAPBackend:
)
raise e
try:
from netbox import ldap_config
except ModuleNotFoundError as e:
if getattr(e, 'name') == 'ldap_config':
raise ImproperlyConfigured(
"LDAP configuration file not found: Check that ldap_config.py has been created alongside "
"configuration.py."
)
raise e
ldap_config = load_ldap_config(
settings.CONFIGURATION_DIR,
allow_legacy_fallback=settings.NETBOX_INSTALL_MODE == 'checkout',
)
try:
getattr(ldap_config, 'AUTH_LDAP_SERVER_URI')
@ -364,11 +360,11 @@ class LDAPBackend:
obj = NBLDAPBackend()
# Read LDAP configuration parameters from ldap_config.py instead of settings.py
settings = LDAPSettings()
ldap_settings = LDAPSettings()
for param in dir(ldap_config):
if param.startswith(settings._prefix):
setattr(settings, param[10:], getattr(ldap_config, param))
obj.settings = settings
if param.startswith(ldap_settings._prefix):
setattr(ldap_settings, param[10:], getattr(ldap_config, param))
obj.settings = ldap_settings
# Optionally disable strict certificate checking
if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False):

98
netbox/netbox/cli.py Normal file
View File

@ -0,0 +1,98 @@
"""Console entry point for pip-installed NetBox."""
import argparse
import os
import sys
from importlib.metadata import PackageNotFoundError, version
# Commands handled here must not require Django settings. These names are intentionally
# reserved by the console wrapper and are never dispatched to Django management commands.
# 'setup' is not listed: the early-dispatch branch in main() owns it and always returns
# before this tuple is consulted.
_RESERVED_COMMANDS = ('secret-key', 'version')
_EPILOG = """Any other command is dispatched to the Django management commands, which
require a valid NetBox configuration, e.g.:
{prog} upgrade
{prog} check
{prog} createsuperuser
Run "{prog} help" (once configured) for the full management command listing."""
def _prog():
if sys.argv and sys.argv[0]:
name = os.path.basename(sys.argv[0])
# `python -m netbox` executes __main__.py; show the user-facing name instead.
if name != '__main__.py':
return name
return 'netbox'
def _print_version():
try:
print(version('netbox'))
except PackageNotFoundError: # pragma: no cover - only in a non-installed checkout
print('unknown')
def _build_parser(prog):
parser = argparse.ArgumentParser(
prog=prog,
description='NetBox command line interface.',
epilog=_EPILOG.format(prog=prog),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
subparsers = parser.add_subparsers(
dest='command',
title='pre-configuration commands (no NetBox configuration required)',
)
subparsers.add_parser(
'version', help='Print the installed NetBox package version.',
description='Print the installed NetBox package version.')
subparsers.add_parser(
'setup', add_help=False,
help='Create the local configuration files for a pip-installed instance.')
subparsers.add_parser(
'secret-key', help='Generate a new 50-character SECRET_KEY value.',
description='Generate a new 50-character SECRET_KEY value.')
return parser
def main(argv=None):
prog = _prog()
args = list(sys.argv[1:] if argv is None else argv)
# `setup` owns its own parser (netbox.scaffold); dispatch before the wrapper parser.
if args and args[0] == 'setup':
# Deferred so the command works before Django or a configuration exists.
from netbox.scaffold import main as setup_main
return setup_main(args[1:], prog=f'{prog} setup')
if not args:
_build_parser(prog).print_help()
return 0
if args[0] in _RESERVED_COMMANDS or args[0] in ('-h', '--help', '--version'):
parser = _build_parser(prog)
if args[0] == '--version':
args = ['version', *args[1:]]
try:
options = parser.parse_args(args)
except SystemExit as e: # argparse already printed help (0) or an error (2)
return int(e.code or 0)
if options.command == 'version':
_print_version()
elif options.command == 'secret-key':
# Deferred so the command works before Django or a configuration exists.
from utilities.secret_key import generate_secret_key
print(generate_secret_key())
return 0
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'netbox.settings')
# Deferred on purpose: Django must not import until DJANGO_SETTINGS_MODULE is set.
from django.core.management import execute_from_command_line
execute_from_command_line([prog, *args])
return 0

View File

@ -179,7 +179,7 @@ LOGOUT_REDIRECT_URL = 'home'
# The file path where uploaded media such as image attachments are stored. A trailing slash is not needed. Note that
# the default value of this setting is derived from the installed location.
# MEDIA_ROOT = '/opt/netbox/netbox/media'
# MEDIA_ROOT = '/path/to/netbox/media'
# Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics'
METRICS_ENABLED = False
@ -214,14 +214,14 @@ RELEASE_CHECK_URL = None
# The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of
# this setting is derived from the installed location.
# REPORTS_ROOT = '/opt/netbox/netbox/reports'
# REPORTS_ROOT = '/path/to/netbox/reports'
# Maximum execution time for background tasks, in seconds.
RQ_DEFAULT_TIMEOUT = 300
# The file path where custom scripts will be stored. A trailing slash is not needed. Note that the default value of
# this setting is derived from the installed location.
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
# SCRIPTS_ROOT = '/path/to/netbox/scripts'
# The name to use for the session cookie.
SESSION_COOKIE_NAME = 'sessionid'

88
netbox/netbox/scaffold.py Normal file
View File

@ -0,0 +1,88 @@
"""`netbox setup`: create the local configuration files for a pip-installed NetBox instance.
Runs before Django or a local configuration exists (dispatched from the `netbox` console
script, netbox.cli). Scaffolds conf/__init__.py and conf/configuration.py (copied verbatim
from the bundled configuration_example.py template) and an empty local_requirements.txt,
then copies the bundled deployment examples (gunicorn, systemd units, nginx, apache, uwsgi,
netbox.env) unmodified into <target>/contrib/. Nothing is generated or rewritten; adapting
and installing the examples (paths, systemd, the web server) remains the administrator's
job. Existing files are never overwritten.
"""
import argparse
import sys
from importlib.resources import files
from pathlib import Path
def _bundled_data_dir():
return files('netbox') / '_data'
def _config_template():
return files('netbox') / 'configuration_example.py'
def _contrib_dir():
return files('netbox') / '_data' / 'contrib'
def _write(destination, data):
if destination.exists():
print(f'Skipping existing {destination}')
return False
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(data)
print(f'Wrote {destination}')
return True
def scaffold_instance(target):
target = Path(target)
conf = target / 'conf'
items = [
(conf / '__init__.py', b''),
(conf / 'configuration.py', _config_template().read_bytes()),
# An empty local_requirements.txt makes the optional plugin step discoverable and
# the documented "pip install -r local_requirements.txt" safe.
(target / 'local_requirements.txt', b''),
# Bundled deployment examples, copied byte-verbatim; adapting them is the admin's job.
# is_file() skips __pycache__: pip's post-install bytecode compilation of gunicorn.py
# (the only .py file among the examples) leaves one alongside the real files.
*(
(target / 'contrib' / example.name, example.read_bytes())
for example in sorted(_contrib_dir().iterdir(), key=lambda entry: entry.name)
if example.is_file()
),
]
return [str(destination) for destination, data in items if _write(destination, data)]
def main(argv=None, *, prog='netbox setup'):
parser = argparse.ArgumentParser(
prog=prog,
description=(
'Create the local configuration files for a pip-installed NetBox instance '
'(conf/configuration.py copied verbatim from the bundled template, plus an empty '
'local_requirements.txt), and copy the bundled deployment examples (gunicorn, '
'systemd units, nginx, apache, uwsgi, netbox.env) unmodified into '
"<target>/contrib/. Nothing is generated or rewritten; adapting and installing "
"the examples is the administrator's job. Existing files are never overwritten."
),
)
parser.add_argument('--target', default='/opt/netbox', help='NetBox instance root (NETBOX_ROOT).')
args = parser.parse_args(argv)
if not _bundled_data_dir().is_dir():
print(
f'{prog}: this NetBox installation does not include the bundled package data. '
'This command is available only from the installed netbox package (pip/wheel); '
'for an archive or Git installation, follow the standard installation guide instead.',
file=sys.stderr,
)
return 1
if not Path(args.target).is_absolute():
parser.error(f"--target must be an absolute path (got '{args.target}')")
scaffold_instance(args.target)
return 0

View File

@ -18,6 +18,7 @@ from netbox.config import PARAMS as CONFIG_PARAMS
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
from netbox.plugins import PluginConfig
from netbox.registry import registry
from netbox.settings_utils import get_configuration_dir, load_configuration, resolve_install_paths, secret_key_hint
from utilities.release import load_release_data
from utilities.security import validate_peppers
from utilities.string import trailing_slash
@ -30,8 +31,18 @@ from .monkey import get_unique_validators
RELEASE = load_release_data()
VERSION = RELEASE.full_version # Retained for backward compatibility
# Set the base directory two levels up
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Settings package directory (settings.py lives here in both checkout & wheel).
_SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__))
# All wheel-vs-checkout path branching is centralized in resolve_install_paths(): a wheel
# bundles package data under netbox/_data and keeps mutable instance files under an external
# instance root (NETBOX_ROOT, default /opt/netbox); a checkout keeps both roots as the project
# directory, so archive/git behavior is unchanged.
_PATHS = resolve_install_paths(_SETTINGS_DIR, os.environ)
NETBOX_INSTALL_MODE = _PATHS.install_mode
BASE_DIR = _PATHS.base_dir
# Instance root for wheel installs (holds conf/, media/, reports/, scripts/, static/, units).
NETBOX_ROOT = _PATHS.netbox_root
# Validate the Python version
if sys.version_info < (3, 12): # noqa: UP036
@ -43,17 +54,15 @@ if sys.version_info < (3, 12): # noqa: UP036
# Configuration import
#
# Import the configuration module
config_path = os.getenv('NETBOX_CONFIGURATION', 'netbox.configuration')
try:
configuration = importlib.import_module(config_path)
except ModuleNotFoundError as e:
if getattr(e, 'name') == config_path:
raise ImproperlyConfigured(
f"Specified configuration module ({config_path}) not found. Please define netbox/netbox/configuration.py "
f"per the documentation, or specify an alternate module in the NETBOX_CONFIGURATION environment variable."
)
raise
# Import the configuration module (wheel mode prefers NETBOX_ROOT/conf/configuration.py).
configuration = load_configuration(
install_mode=NETBOX_INSTALL_MODE,
install_root=NETBOX_ROOT,
environ=os.environ,
)
# The directory holding the active configuration.py; ldap_config.py lives beside it.
CONFIGURATION_DIR = get_configuration_dir(configuration)
# Check for missing/conflicting required configuration parameters
for parameter in ('ALLOWED_HOSTS', 'SECRET_KEY', 'REDIS'):
@ -119,7 +128,7 @@ DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', {
'users.delete_token': ({'user': '$user'},),
})
DEVELOPER = getattr(configuration, 'DEVELOPER', False)
DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', _PATHS.docs_root)
EMAIL = getattr(configuration, 'EMAIL', {})
STREAMING_EXPORTS = getattr(configuration, 'STREAMING_EXPORTS', False)
EVENTS_PIPELINE = getattr(configuration, 'EVENTS_PIPELINE', [
@ -150,7 +159,7 @@ LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', True)
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
LOGIN_FORM_HIDDEN = getattr(configuration, 'LOGIN_FORM_HIDDEN', False)
LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home')
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(NETBOX_ROOT, 'media')).rstrip('/')
METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
PLUGINS = getattr(configuration, 'PLUGINS', [])
PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
@ -175,7 +184,7 @@ REMOTE_AUTH_USER_EMAIL = getattr(configuration, 'REMOTE_AUTH_USER_EMAIL', 'HTTP_
REMOTE_AUTH_USER_FIRST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_FIRST_NAME', 'HTTP_REMOTE_USER_FIRST_NAME')
REMOTE_AUTH_USER_LAST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_LAST_NAME', 'HTTP_REMOTE_USER_LAST_NAME')
# Required by extras/migrations/0109_script_models.py
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(NETBOX_ROOT, 'reports')).rstrip('/')
RQ = getattr(configuration, 'RQ', {})
if 'WORKER_CLASS' in RQ and RQ['WORKER_CLASS'] != 'utilities.rqworker.NetBoxRQWorker':
warnings.warn(
@ -187,7 +196,7 @@ else:
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(NETBOX_ROOT, 'scripts')).rstrip('/')
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
SECRET_KEY = getattr(configuration, 'SECRET_KEY') # Required
SECURE_HSTS_INCLUDE_SUBDOMAINS = getattr(configuration, 'SECURE_HSTS_INCLUDE_SUBDOMAINS', False)
@ -232,7 +241,7 @@ if type(SECRET_KEY) is not str:
if len(SECRET_KEY) < 50:
raise ImproperlyConfigured(
f"SECRET_KEY must be at least 50 characters in length. To generate a suitable key, run the following command:\n"
f" python {BASE_DIR}/generate_secret_key.py"
f" {secret_key_hint(NETBOX_INSTALL_MODE, BASE_DIR)}"
)
# Validate API token peppers
@ -594,13 +603,16 @@ USE_X_FORWARDED_HOST = True
X_FRAME_OPTIONS = 'SAMEORIGIN'
# Static files (CSS, JavaScript, Images)
STATIC_ROOT = BASE_DIR + '/static'
# STATIC_ROOT is deliberately not a configuration parameter; static files are collected to <NETBOX_ROOT>/static.
STATIC_ROOT = os.path.join(NETBOX_ROOT, 'static')
STATIC_URL = f'/{BASE_PATH}static/'
STATICFILES_DIRS = (
os.path.join(BASE_DIR, 'project-static', 'dist'),
os.path.join(BASE_DIR, 'project-static', 'img'),
os.path.join(BASE_DIR, 'project-static', 'js'),
('docs', os.path.join(BASE_DIR, 'project-static', 'docs')), # Prefix with /docs
# May not exist on a checkout until `manage.py upgrade --build-docs` runs (wheels bundle
# the pre-rendered site); collectstatic tolerates that.
('docs', _PATHS.static_docs_root), # Prefix with /docs
)
# Media URL

View File

@ -0,0 +1,204 @@
"""Startup helpers for settings.py. Import-safe: no Django settings access at import time."""
import importlib
import importlib.util
import os
import sys
import warnings
from typing import NamedTuple
from django.core.exceptions import ImproperlyConfigured
__all__ = (
'InstallPaths',
'get_configuration_dir',
'load_configuration',
'load_ldap_config',
'resolve_install_paths',
'secret_key_hint',
)
class InstallPaths(NamedTuple):
"""Filesystem layout resolved from the install mode (wheel vs. source checkout)."""
install_mode: str # 'wheel' or 'checkout'
base_dir: str # package data root (BASE_DIR)
netbox_root: str # instance root for mutable files (NETBOX_ROOT)
docs_root: str # documentation sources on a checkout, the pre-rendered site in a wheel (DOCS_ROOT default)
static_docs_root: str # built documentation, source of the STATICFILES 'docs' prefix
def resolve_install_paths(settings_dir, environ):
"""Resolve the install mode and filesystem roots for this NetBox installation.
A wheel bundles package data (including the pre-rendered documentation site)
under netbox/_data and keeps mutable instance files under an external instance root
(NETBOX_ROOT, default /opt/netbox); a source checkout keeps the historical layout,
where both roots are the project directory. All wheel-vs-checkout branching lives
here so settings.py stays declarative.
"""
bundled_data = os.path.join(settings_dir, '_data')
if os.path.isdir(bundled_data):
install_mode = 'wheel'
base_dir = bundled_data
netbox_root = os.path.abspath(environ.get('NETBOX_ROOT', '/opt/netbox'))
docs_root = os.path.join(base_dir, 'docs')
# The wheel bundles the pre-rendered documentation site at _data/docs; it serves as
# both the DOCS_ROOT default and the STATICFILES 'docs' prefix source.
static_docs_root = docs_root
else:
install_mode = 'checkout'
base_dir = os.path.dirname(settings_dir)
netbox_root = base_dir
docs_root = os.path.join(os.path.dirname(base_dir), 'docs')
static_docs_root = os.path.join(base_dir, 'project-static', 'docs')
return InstallPaths(
install_mode=install_mode,
base_dir=base_dir,
netbox_root=netbox_root,
docs_root=docs_root,
static_docs_root=static_docs_root,
)
def secret_key_hint(install_mode, base_dir):
"""Return the command to suggest in the SECRET_KEY-too-short error, based on install mode.
generate_secret_key.py is not packaged in a wheel, so a wheel install points at the
`netbox secret-key` console command instead of the (nonexistent) script path.
"""
if install_mode == 'wheel':
return 'netbox secret-key'
return f'python {base_dir}/generate_secret_key.py'
def _import_module(name):
"""Import a configuration module by dotted path.
Preserve NetBox's historical behavior: a friendly ImproperlyConfigured when the module
itself is absent, but re-raise the original error when the module exists yet imports
something else that is missing.
"""
try:
return importlib.import_module(name)
except ModuleNotFoundError as e:
if e.name == name:
raise ImproperlyConfigured(
f"Specified configuration module ({name}) not found. Please define "
f"netbox/netbox/configuration.py per the documentation, or specify an alternate "
f"module in the NETBOX_CONFIGURATION environment variable."
)
raise
def _import_from_path(module_name, path):
"""Load a configuration module from an explicit file path.
The module is registered in sys.modules (and removed again if execution fails), and the
file's directory is placed on sys.path for the duration of execution so the module can
import siblings, matching normal import semantics closely enough for configuration files.
"""
path = os.path.abspath(path)
module_dir = os.path.dirname(path)
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise ImproperlyConfigured(f"Unable to load configuration file {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
sys.path.insert(0, module_dir)
try:
spec.loader.exec_module(module)
except Exception:
if sys.modules.get(module_name) is module:
del sys.modules[module_name]
raise
finally:
# Remove only the entry this helper inserted at index 0.
if sys.path and sys.path[0] == module_dir:
sys.path.pop(0)
return module
def get_configuration_dir(module):
"""Return the directory containing a loaded configuration module (None if unknown)."""
source = getattr(module, '__file__', None)
return os.path.dirname(os.path.abspath(source)) if source else None
def load_configuration(*, install_mode, install_root, environ):
"""Import and return NetBox's configuration module.
An explicit NETBOX_CONFIGURATION module always wins. In wheel mode, prefer
<install_root>/conf/configuration.py, loaded by file path (so a stale source tree at
<install_root>/netbox cannot shadow it and no generic 'configuration' module is left in
sys.modules), then
fall back to the legacy <install_root>/netbox/netbox/configuration.py with a migration
warning. In checkout mode, keep the historical default module.
"""
explicit = environ.get('NETBOX_CONFIGURATION')
if explicit:
return _import_module(explicit)
if install_mode == 'wheel':
conf_dir = os.path.join(install_root, 'conf')
preferred = os.path.join(conf_dir, 'configuration.py')
legacy = os.path.join(install_root, 'netbox', 'netbox', 'configuration.py')
if os.path.isfile(preferred):
if os.path.isfile(legacy):
warnings.warn(
f"Both {preferred} and the legacy {legacy} exist; using {preferred} and "
f"ignoring the legacy file.",
RuntimeWarning,
)
return _import_from_path('netbox_local_configuration', preferred)
if os.path.isfile(legacy):
warnings.warn(
f"Loaded NetBox configuration from the legacy source-tree path {legacy}. For a "
f"pip-installed NetBox, move it to {preferred}.",
RuntimeWarning,
)
return _import_from_path('netbox_legacy_configuration', legacy)
raise ImproperlyConfigured(
f"No NetBox configuration found. For a pip-installed NetBox, create {preferred}, "
f"or set NETBOX_CONFIGURATION to an importable module."
)
return _import_module('netbox.configuration')
def load_ldap_config(config_dir, *, allow_legacy_fallback=False):
"""Load ldap_config.py from the active configuration directory (settings.CONFIGURATION_DIR).
One rule for every install method: the active ldap_config.py is the one next to the
active configuration.py. Checkout installs may additionally allow a legacy fallback to
the historical netbox/netbox/ldap_config.py module, because a custom NETBOX_CONFIGURATION
can live outside the source tree while LDAP config stayed inside it; the fallback warns
so those installs can migrate to the sibling rule.
"""
path = os.path.join(config_dir, 'ldap_config.py') if config_dir else None
if path and os.path.isfile(path):
return _import_from_path('netbox.ldap_config', path)
if allow_legacy_fallback:
try:
module = importlib.import_module('netbox.ldap_config')
except ModuleNotFoundError as e:
if e.name != 'netbox.ldap_config':
raise
else:
warnings.warn(
"Loaded LDAP configuration from the legacy netbox/netbox/ldap_config.py module. "
"Move ldap_config.py into the directory containing the active configuration.py; "
"this fallback may be removed in a future release.",
RuntimeWarning,
)
return module
if not config_dir:
raise ImproperlyConfigured(
"LDAP configuration file not found: unable to determine the directory containing "
"configuration.py."
)
raise ImproperlyConfigured(
"LDAP configuration file not found: Check that ldap_config.py has been created "
"alongside configuration.py. For a pip-installed NetBox, this is "
"NETBOX_ROOT/conf/ldap_config.py."
)

View File

@ -1,5 +1,7 @@
import datetime
from unittest.mock import MagicMock
import sys
from types import ModuleType
from unittest.mock import MagicMock, patch
from django.conf import settings
from django.contrib.messages.storage.fallback import FallbackStorage
@ -13,6 +15,7 @@ from core.choices import ManagedFileRootPathChoices
from core.models import ManagedFile, ObjectType
from dcim.models import Rack, Site
from extras.models import ScriptModule
from netbox.authentication import LDAPBackend
from netbox.authentication.misc import _mirror_groups
from netbox.middleware import SocialAuthExceptionMiddleware
from users.constants import TOKEN_PREFIX
@ -562,6 +565,43 @@ class LDAPMirrorGroupsTestCase(TestCase):
)
class LDAPBackendTest(SimpleTestCase):
"""The LDAP backend reads ldap_config.py from the active configuration directory."""
def test_backend_loads_ldap_config_from_configuration_dir(self):
with override_settings(CONFIGURATION_DIR='/srv/netbox/conf', NETBOX_INSTALL_MODE='checkout'):
backend, loader = self._build_backend()
loader.assert_called_once_with('/srv/netbox/conf', allow_legacy_fallback=True)
self.assertEqual(backend.settings.SERVER_URI, 'ldaps://example')
def test_backend_disables_legacy_fallback_for_wheel_installs(self):
with override_settings(CONFIGURATION_DIR='/opt/netbox/conf', NETBOX_INSTALL_MODE='wheel'):
backend, loader = self._build_backend()
loader.assert_called_once_with('/opt/netbox/conf', allow_legacy_fallback=False)
self.assertEqual(backend.settings.SERVER_URI, 'ldaps://example')
def _build_backend(self):
fake_ldap = ModuleType('ldap')
fake_ldap.set_option = MagicMock()
backend_module = ModuleType('django_auth_ldap.backend')
backend_module.LDAPSettings = type('LDAPSettings', (), {'_prefix': 'AUTH_LDAP_'})
package = ModuleType('django_auth_ldap')
package.backend = backend_module
ldap_config = ModuleType('netbox.ldap_config')
ldap_config.AUTH_LDAP_SERVER_URI = 'ldaps://example'
with (
patch.dict(sys.modules, {
'ldap': fake_ldap,
'django_auth_ldap': package,
'django_auth_ldap.backend': backend_module,
}),
patch('netbox.authentication.NBLDAPBackend', MagicMock(), create=True),
patch('netbox.authentication.load_ldap_config', return_value=ldap_config) as loader,
):
backend = LDAPBackend()
return backend, loader
class ObjectPermissionAPIViewTestCase(TestCase):
client_class = APIClient

View File

@ -0,0 +1,131 @@
from contextlib import redirect_stderr, redirect_stdout
from io import StringIO
from unittest.mock import patch
from django.test import SimpleTestCase
from netbox import cli
class CliDispatchTest(SimpleTestCase):
"""The console script resolves pre-configuration commands before importing Django."""
def _main(self, args, argv0='netbox'):
# prog derives from sys.argv[0] even when argv is passed explicitly, so pin both.
stdout, stderr = StringIO(), StringIO()
with (
patch.object(cli.sys, 'argv', [argv0, *args]),
patch('django.core.management.execute_from_command_line') as execute,
redirect_stdout(stdout), redirect_stderr(stderr),
):
rc = cli.main(args)
return rc, execute, stdout.getvalue(), stderr.getvalue()
def test_version_flag_prints_version_without_django(self):
with patch('netbox.cli.version', return_value='4.7.0b1'):
rc, execute, out, _ = self._main(['--version'])
execute.assert_not_called()
self.assertEqual(out, '4.7.0b1\n')
self.assertEqual(rc, 0)
def test_version_command_prints_version_without_django(self):
with patch('netbox.cli.version', return_value='4.7.0b1'):
rc, execute, out, _ = self._main(['version'])
execute.assert_not_called()
self.assertEqual(out, '4.7.0b1\n')
self.assertEqual(rc, 0)
def test_version_help(self):
rc, execute, out, _ = self._main(['version', '--help'])
execute.assert_not_called()
self.assertEqual(rc, 0)
self.assertIn('usage: netbox version', out)
def test_version_rejects_unexpected_arguments(self):
rc, execute, _, err = self._main(['version', 'bogus'])
execute.assert_not_called()
self.assertEqual(rc, 2)
self.assertIn('unrecognized arguments: bogus', err)
def test_no_arguments_prints_help_without_django(self):
rc, execute, out, _ = self._main([])
execute.assert_not_called()
self.assertEqual(rc, 0)
self.assertIn('usage: netbox', out)
for command in ('version', 'setup', 'secret-key'):
self.assertIn(command, out)
def test_help_flags_print_help_without_django(self):
for flag in ('-h', '--help'):
with self.subTest(flag=flag):
rc, execute, out, _ = self._main([flag])
execute.assert_not_called()
self.assertEqual(rc, 0)
self.assertIn('usage: netbox', out)
for command in ('version', 'setup', 'secret-key'):
self.assertIn(command, out)
def test_secret_key_prints_50_char_key_without_django(self):
rc, execute, out, _ = self._main(['secret-key'])
execute.assert_not_called()
self.assertEqual(rc, 0)
self.assertEqual(len(out.strip()), 50)
def test_secret_key_help(self):
rc, execute, out, _ = self._main(['secret-key', '--help'])
execute.assert_not_called()
self.assertEqual(rc, 0)
self.assertIn('usage: netbox secret-key', out)
def test_secret_key_rejects_unexpected_arguments(self):
rc, execute, _, err = self._main(['secret-key', 'bogus'])
execute.assert_not_called()
self.assertEqual(rc, 2)
self.assertIn('unrecognized arguments: bogus', err)
def test_setup_dispatches_to_scaffold_with_prog(self):
with patch('netbox.scaffold.main', return_value=0) as setup_main:
rc, execute, _, _ = self._main(['setup', '--target', '/srv/netbox'])
execute.assert_not_called()
setup_main.assert_called_once_with(['--target', '/srv/netbox'], prog='netbox setup')
self.assertEqual(rc, 0)
def test_setup_return_code_propagates(self):
with patch('netbox.scaffold.main', return_value=3):
rc, execute, _, _ = self._main(['setup'])
execute.assert_not_called()
self.assertEqual(rc, 3)
def test_other_commands_dispatch_to_django(self):
with (
patch.object(cli.sys, 'argv', ['/opt/netbox/venv/bin/netbox', 'check', '--deploy']),
patch.dict(cli.os.environ),
patch('django.core.management.execute_from_command_line') as execute,
):
cli.os.environ.pop('DJANGO_SETTINGS_MODULE', None)
rc = cli.main(['check', '--deploy'])
self.assertEqual(cli.os.environ['DJANGO_SETTINGS_MODULE'], 'netbox.settings')
execute.assert_called_once_with(['netbox', 'check', '--deploy'])
self.assertEqual(rc, 0)
def test_subcommand_help_falls_through_to_django(self):
rc, execute, _, _ = self._main(['migrate', '--help'])
execute.assert_called_once_with(['netbox', 'migrate', '--help'])
self.assertEqual(rc, 0)
def test_prog_falls_back_for_python_m_invocation(self):
with patch('netbox.scaffold.main', return_value=0) as setup_main:
self._main(['setup'], argv0='/x/netbox/__main__.py')
setup_main.assert_called_once_with([], prog='netbox setup')
def test_prog_falls_back_when_argv_is_empty(self):
out = StringIO()
with (
patch.object(cli.sys, 'argv', []),
patch('django.core.management.execute_from_command_line') as execute,
redirect_stdout(out),
):
rc = cli.main([])
execute.assert_not_called()
self.assertEqual(rc, 0)
self.assertIn('usage: netbox', out.getvalue())

View File

@ -0,0 +1,150 @@
import contextlib
import tempfile
from io import StringIO
from pathlib import Path
from unittest.mock import patch
from django.test import SimpleTestCase
from netbox import scaffold
_CONFIG_TEMPLATE_TEXT = (
"# example configuration\n"
"STORAGE_ROOT = '/opt/netbox/netbox/media'\n"
"NETBOX_ROOT = '/opt/netbox'\n"
)
_CONTRIB_FILENAMES = (
'apache.conf', 'gunicorn.py', 'netbox-rq.service', 'netbox.env', 'netbox.service', 'nginx.conf', 'uwsgi.ini',
)
class ScaffoldInstanceTest(SimpleTestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
root = Path(tmp.name)
bundled = root / '_data'
contrib_src = bundled / 'contrib'
contrib_src.mkdir(parents=True)
for name in _CONTRIB_FILENAMES:
(contrib_src / name).write_text(f'# {name} contents\n')
# A real install leaves __pycache__ here too: pip's post-install bytecode compilation
# runs on gunicorn.py, the only .py file among the examples.
(contrib_src / '__pycache__').mkdir()
self.contrib_src = contrib_src
template = root / 'configuration_example.py'
template.write_text(_CONFIG_TEMPLATE_TEXT)
self.target = root / 'opt'
self.target.mkdir()
self.enterContext(patch('netbox.scaffold._bundled_data_dir', return_value=bundled))
self.enterContext(patch('netbox.scaffold._config_template', return_value=template))
self.enterContext(patch('netbox.scaffold._contrib_dir', return_value=contrib_src))
def test_scaffolds_configuration_and_contrib_examples(self):
"""A fresh target gets conf/__init__.py, conf/configuration.py, local_requirements.txt, and contrib/."""
written = scaffold.scaffold_instance(self.target)
self.assertEqual((self.target / 'conf' / '__init__.py').read_bytes(), b'')
self.assertEqual(
(self.target / 'conf' / 'configuration.py').read_bytes(),
_CONFIG_TEMPLATE_TEXT.encode('utf-8'),
)
self.assertEqual((self.target / 'local_requirements.txt').read_bytes(), b'')
for name in _CONTRIB_FILENAMES:
self.assertEqual(
(self.target / 'contrib' / name).read_bytes(),
(self.contrib_src / name).read_bytes(),
)
expected = [
str(self.target / 'conf' / '__init__.py'),
str(self.target / 'conf' / 'configuration.py'),
str(self.target / 'local_requirements.txt'),
*(str(self.target / 'contrib' / name) for name in _CONTRIB_FILENAMES),
]
self.assertEqual(written, expected)
self.assertFalse((self.target / 'contrib' / '__pycache__').exists())
def test_never_clobbers_existing_configuration(self):
"""An existing conf/configuration.py is left untouched."""
(self.target / 'conf').mkdir(parents=True)
(self.target / 'conf' / 'configuration.py').write_text('SECRET = 1')
written = scaffold.scaffold_instance(self.target)
self.assertEqual((self.target / 'conf' / 'configuration.py').read_text(), 'SECRET = 1')
self.assertNotIn(str(self.target / 'conf' / 'configuration.py'), written)
def test_never_clobbers_existing_conf_init(self):
"""An existing conf/__init__.py is left untouched."""
(self.target / 'conf').mkdir(parents=True)
(self.target / 'conf' / '__init__.py').write_text('# user-owned\n')
written = scaffold.scaffold_instance(self.target)
self.assertEqual((self.target / 'conf' / '__init__.py').read_text(), '# user-owned\n')
self.assertNotIn(str(self.target / 'conf' / '__init__.py'), written)
def test_never_clobbers_existing_local_requirements(self):
"""An existing local_requirements.txt is left untouched."""
(self.target / 'local_requirements.txt').write_text('django-auth-ldap\n')
written = scaffold.scaffold_instance(self.target)
self.assertEqual((self.target / 'local_requirements.txt').read_text(), 'django-auth-ldap\n')
self.assertNotIn(str(self.target / 'local_requirements.txt'), written)
def test_never_clobbers_existing_contrib_file(self):
"""An existing <target>/contrib/<name> file is left untouched; siblings are still written."""
(self.target / 'contrib').mkdir(parents=True)
(self.target / 'contrib' / 'gunicorn.py').write_text('# edited\n')
written = scaffold.scaffold_instance(self.target)
self.assertEqual((self.target / 'contrib' / 'gunicorn.py').read_text(), '# edited\n')
self.assertNotIn(str(self.target / 'contrib' / 'gunicorn.py'), written)
self.assertIn(str(self.target / 'contrib' / 'nginx.conf'), written)
def test_main_uses_arguments(self):
"""main() dispatches --target through to scaffold_instance."""
result = scaffold.main(['--target', str(self.target)])
self.assertEqual(result, 0)
self.assertEqual(
(self.target / 'conf' / 'configuration.py').read_bytes(),
_CONFIG_TEMPLATE_TEXT.encode('utf-8'),
)
def test_main_rejects_relative_target(self):
"""A relative --target is rejected with rc 2."""
with (
contextlib.redirect_stderr(StringIO()),
self.assertRaises(SystemExit) as cm,
):
scaffold.main(['--target', 'relative/path'])
self.assertEqual(cm.exception.code, 2)
def test_main_help_uses_netbox_setup_prog(self):
"""--help shows the netbox setup prog name."""
out = StringIO()
with contextlib.redirect_stdout(out), self.assertRaises(SystemExit) as cm:
scaffold.main(['--help'])
self.assertEqual(cm.exception.code, 0)
self.assertIn('usage: netbox setup', out.getvalue())
def test_main_fails_friendly_without_bundled_data(self):
"""Without bundled package data, main() reports rc 1 and a friendly stderr message."""
err = StringIO()
with (
patch('netbox.scaffold._bundled_data_dir', return_value=self.target / 'missing'),
contextlib.redirect_stderr(err),
):
rc = scaffold.main(['--target', '/tmp/x'])
self.assertEqual(rc, 1)
self.assertIn('installed netbox package', err.getvalue())
class ScaffoldResourceHelpersTest(SimpleTestCase):
"""The unpatched resource helpers resolve real paths under the installed netbox package."""
def test_bundled_data_dir_resolves_under_package(self):
self.assertTrue(str(scaffold._bundled_data_dir()).endswith('_data'))
def test_config_template_resolves_under_package(self):
self.assertTrue(str(scaffold._config_template()).endswith('configuration_example.py'))
def test_contrib_dir_resolves_under_bundled_data(self):
self.assertTrue(str(scaffold._contrib_dir()).endswith('_data/contrib'))

View File

@ -0,0 +1,281 @@
import os
import sys
import tempfile
from types import ModuleType
from unittest.mock import patch
from django.conf import settings as django_settings
from django.core.exceptions import ImproperlyConfigured
from django.test import SimpleTestCase
from netbox import settings_utils
class LoadConfigurationTest(SimpleTestCase):
def test_explicit_module_wins(self):
with patch('netbox.settings_utils.importlib.import_module') as import_module:
settings_utils.load_configuration(
install_mode='wheel', install_root='/opt/netbox',
environ={'NETBOX_CONFIGURATION': 'my.config'},
)
import_module.assert_called_once_with('my.config')
def test_checkout_uses_default_module(self):
with patch('netbox.settings_utils.importlib.import_module') as import_module:
settings_utils.load_configuration(
install_mode='checkout', install_root='/repo', environ={},
)
import_module.assert_called_once_with('netbox.configuration')
def test_checkout_missing_module_raises_improperly_configured(self):
with patch(
'netbox.settings_utils.importlib.import_module',
side_effect=ModuleNotFoundError("No module named 'netbox.configuration'", name='netbox.configuration'),
):
with self.assertRaises(ImproperlyConfigured):
settings_utils.load_configuration(
install_mode='checkout', install_root='/repo', environ={},
)
def test_wheel_prefers_conf_dir(self):
with tempfile.TemporaryDirectory() as root:
conf = os.path.join(root, 'conf')
os.mkdir(conf)
preferred = os.path.join(conf, 'configuration.py')
open(preferred, 'w').close()
saved = list(sys.path)
try:
with patch('netbox.settings_utils._import_from_path') as import_from_path:
settings_utils.load_configuration(
install_mode='wheel', install_root=root, environ={},
)
import_from_path.assert_called_once_with('netbox_local_configuration', preferred)
self.assertEqual(sys.path, saved)
finally:
sys.path[:] = saved
def test_wheel_falls_back_to_legacy_with_warning(self):
with tempfile.TemporaryDirectory() as root:
legacy_dir = os.path.join(root, 'netbox', 'netbox')
os.makedirs(legacy_dir)
legacy = os.path.join(legacy_dir, 'configuration.py')
open(legacy, 'w').close()
with (
patch('netbox.settings_utils._import_from_path') as importer,
self.assertWarns(RuntimeWarning),
):
settings_utils.load_configuration(
install_mode='wheel', install_root=root, environ={},
)
self.assertEqual(importer.call_args.args[1], legacy)
def test_wheel_missing_configuration_raises(self):
with tempfile.TemporaryDirectory() as root:
with self.assertRaisesMessage(ImproperlyConfigured, 'conf/configuration.py'):
settings_utils.load_configuration(
install_mode='wheel', install_root=root, environ={},
)
def test_explicit_module_reraises_other_import_error(self):
# A missing dependency of the config module must propagate, not become a friendly error.
with patch(
'netbox.settings_utils.importlib.import_module',
side_effect=ModuleNotFoundError("No module named 'missing_dep'", name='missing_dep'),
):
with self.assertRaises(ModuleNotFoundError):
settings_utils.load_configuration(
install_mode='checkout', install_root='/repo',
environ={'NETBOX_CONFIGURATION': 'my.config'},
)
def test_import_from_path_loads_module_and_restores_sys_path(self):
with tempfile.TemporaryDirectory() as root:
path = os.path.join(root, 'legacy_cfg.py')
with open(path, 'w') as handle:
handle.write('ALLOWED_HOSTS = ["example"]\n')
self.addCleanup(sys.modules.pop, 'netbox_test_legacy_cfg', None)
saved = list(sys.path)
module = settings_utils._import_from_path('netbox_test_legacy_cfg', path)
self.assertEqual(module.ALLOWED_HOSTS, ['example'])
self.assertEqual(sys.path, saved)
self.assertIs(sys.modules['netbox_test_legacy_cfg'], module)
def test_import_from_path_removes_module_on_failure(self):
with tempfile.TemporaryDirectory() as root:
path = os.path.join(root, 'broken_cfg.py')
with open(path, 'w') as handle:
handle.write('raise RuntimeError("Simulated configuration error")\n')
with self.assertRaisesMessage(RuntimeError, 'Simulated configuration error'):
settings_utils._import_from_path('netbox_test_broken_cfg', path)
self.assertNotIn('netbox_test_broken_cfg', sys.modules)
def test_import_from_path_rejects_unloadable_path(self):
# A suffix-less file yields no loader; the helper must fail cleanly.
with tempfile.TemporaryDirectory() as root:
path = os.path.join(root, 'noext')
open(path, 'w').close()
with self.assertRaisesMessage(ImproperlyConfigured, 'Unable to load'):
settings_utils._import_from_path('netbox_test_noext_cfg', path)
def test_import_from_path_preserves_preexisting_sys_path_entry(self):
# Only the index-0 entry this helper inserted is popped; a pre-existing entry survives.
with tempfile.TemporaryDirectory() as root:
path = os.path.join(root, 'preexisting_cfg.py')
with open(path, 'w') as handle:
handle.write('ALLOWED_HOSTS = ["example"]\n')
self.addCleanup(sys.modules.pop, 'netbox_test_preexisting_cfg', None)
saved = list(sys.path)
sys.path.append(root)
try:
settings_utils._import_from_path('netbox_test_preexisting_cfg', path)
self.assertEqual(sys.path, saved + [root])
finally:
sys.path[:] = saved
def test_wheel_both_configs_present_warns_and_prefers_conf(self):
with tempfile.TemporaryDirectory() as root:
conf = os.path.join(root, 'conf')
os.mkdir(conf)
preferred = os.path.join(conf, 'configuration.py')
open(preferred, 'w').close()
legacy_dir = os.path.join(root, 'netbox', 'netbox')
os.makedirs(legacy_dir)
open(os.path.join(legacy_dir, 'configuration.py'), 'w').close()
saved = list(sys.path)
try:
with (
patch('netbox.settings_utils._import_from_path') as import_from_path,
self.assertWarns(RuntimeWarning),
):
settings_utils.load_configuration(install_mode='wheel', install_root=root, environ={})
import_from_path.assert_called_once_with('netbox_local_configuration', preferred)
finally:
sys.path[:] = saved
class ConfigurationDirTest(SimpleTestCase):
def test_returns_directory_of_module_file(self):
module = ModuleType('cfg')
module.__file__ = '/srv/netbox/conf/configuration.py'
self.assertEqual(settings_utils.get_configuration_dir(module), '/srv/netbox/conf')
def test_returns_none_without_file(self):
self.assertIsNone(settings_utils.get_configuration_dir(ModuleType('cfg')))
class ResolveInstallPathsTest(SimpleTestCase):
"""resolve_install_paths() centralizes wheel-vs-checkout filesystem layout decisions."""
def test_checkout_roots(self):
with tempfile.TemporaryDirectory() as root:
settings_dir = os.path.join(root, 'netbox', 'netbox')
os.makedirs(settings_dir)
base_dir = os.path.join(root, 'netbox')
paths = settings_utils.resolve_install_paths(settings_dir, {})
self.assertEqual(paths.install_mode, 'checkout')
self.assertEqual(paths.base_dir, base_dir)
self.assertEqual(paths.netbox_root, base_dir)
self.assertEqual(paths.docs_root, os.path.join(root, 'docs'))
self.assertEqual(paths.static_docs_root, os.path.join(base_dir, 'project-static', 'docs'))
def test_wheel_roots_default_netbox_root(self):
with tempfile.TemporaryDirectory() as root:
settings_dir = os.path.join(root, 'site-packages', 'netbox')
base_dir = os.path.join(settings_dir, '_data')
os.makedirs(base_dir)
paths = settings_utils.resolve_install_paths(settings_dir, {})
self.assertEqual(paths.install_mode, 'wheel')
self.assertEqual(paths.base_dir, base_dir)
self.assertEqual(paths.netbox_root, '/opt/netbox')
self.assertEqual(paths.docs_root, os.path.join(base_dir, 'docs'))
self.assertEqual(paths.static_docs_root, os.path.join(base_dir, 'docs'))
def test_netbox_root_env_override_is_abspathed(self):
with tempfile.TemporaryDirectory() as root:
settings_dir = os.path.join(root, 'site-packages', 'netbox')
os.makedirs(os.path.join(settings_dir, '_data'))
paths = settings_utils.resolve_install_paths(settings_dir, {'NETBOX_ROOT': 'relative/root'})
self.assertEqual(paths.netbox_root, os.path.abspath('relative/root'))
class SecretKeyHintTest(SimpleTestCase):
"""secret_key_hint() picks the SECRET_KEY-too-short hint by install mode."""
def test_wheel_mode_suggests_console_command(self):
self.assertEqual(settings_utils.secret_key_hint('wheel', '/opt/netbox/lib/netbox'), 'netbox secret-key')
def test_checkout_mode_suggests_generate_secret_key_script(self):
self.assertEqual(
settings_utils.secret_key_hint('checkout', '/repo/netbox'),
'python /repo/netbox/generate_secret_key.py',
)
class LoadLdapConfigTest(SimpleTestCase):
def test_loads_sibling_ldap_config(self):
with tempfile.TemporaryDirectory() as conf_dir:
with open(os.path.join(conf_dir, 'ldap_config.py'), 'w') as handle:
handle.write('AUTH_LDAP_SERVER_URI = "ldaps://example"\n')
self.addCleanup(sys.modules.pop, 'netbox.ldap_config', None)
module = settings_utils.load_ldap_config(conf_dir)
self.assertEqual(module.AUTH_LDAP_SERVER_URI, 'ldaps://example')
self.assertIs(sys.modules['netbox.ldap_config'], module)
def test_legacy_fallback_loads_historical_module_with_warning(self):
legacy = ModuleType('netbox.ldap_config')
legacy.AUTH_LDAP_SERVER_URI = 'ldaps://legacy'
with tempfile.TemporaryDirectory() as conf_dir:
with patch.dict(sys.modules, {'netbox.ldap_config': legacy}), self.assertWarns(RuntimeWarning):
module = settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
self.assertIs(module, legacy)
def test_legacy_fallback_prefers_sibling_file(self):
legacy = ModuleType('netbox.ldap_config')
legacy.AUTH_LDAP_SERVER_URI = 'ldaps://legacy'
with tempfile.TemporaryDirectory() as conf_dir:
with open(os.path.join(conf_dir, 'ldap_config.py'), 'w') as handle:
handle.write('AUTH_LDAP_SERVER_URI = "ldaps://sibling"\n')
with patch.dict(sys.modules, {'netbox.ldap_config': legacy}):
module = settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
self.assertEqual(module.AUTH_LDAP_SERVER_URI, 'ldaps://sibling')
def test_legacy_fallback_disabled_raises(self):
legacy = ModuleType('netbox.ldap_config')
with tempfile.TemporaryDirectory() as conf_dir:
with patch.dict(sys.modules, {'netbox.ldap_config': legacy}):
with self.assertRaisesMessage(ImproperlyConfigured, 'alongside configuration.py'):
settings_utils.load_ldap_config(conf_dir)
def test_legacy_fallback_missing_module_raises(self):
with tempfile.TemporaryDirectory() as conf_dir:
with patch(
'netbox.settings_utils.importlib.import_module',
side_effect=ModuleNotFoundError("No module named 'netbox.ldap_config'", name='netbox.ldap_config'),
):
with self.assertRaisesMessage(ImproperlyConfigured, 'alongside configuration.py'):
settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
def test_legacy_fallback_reraises_broken_dependency(self):
with tempfile.TemporaryDirectory() as conf_dir:
with patch(
'netbox.settings_utils.importlib.import_module',
side_effect=ModuleNotFoundError("No module named 'missing_dep'", name='missing_dep'),
):
with self.assertRaises(ModuleNotFoundError):
settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
def test_none_config_dir_raises(self):
with self.assertRaisesMessage(ImproperlyConfigured, 'unable to determine'):
settings_utils.load_ldap_config(None)
def test_missing_file_raises(self):
with tempfile.TemporaryDirectory() as conf_dir:
with self.assertRaisesMessage(ImproperlyConfigured, 'ldap_config.py'):
settings_utils.load_ldap_config(conf_dir)
def test_configuration_dir_setting_matches_active_configuration(self):
from netbox import configuration_testing
self.assertEqual(
django_settings.CONFIGURATION_DIR,
os.path.dirname(os.path.abspath(configuration_testing.__file__)),
)

View File

@ -1,4 +1,5 @@
import datetime
import importlib.util
import os
from dataclasses import asdict, dataclass, field
@ -11,6 +12,28 @@ RELEASE_PATH = 'release.yaml'
LOCAL_RELEASE_PATH = 'local/release.yaml'
def _find_release_base_path():
"""
Return the directory containing release.yaml.
In a source checkout, release.yaml lives under the NetBox application root
beside manage.py. In a wheel install, release.yaml is bundled under the
installed netbox package's _data directory.
"""
checkout_base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if os.path.isfile(os.path.join(checkout_base_path, RELEASE_PATH)):
return checkout_base_path
spec = importlib.util.find_spec('netbox') # pragma: no cover
locations = spec.submodule_search_locations if spec is not None else () # pragma: no cover
for location in locations or (): # pragma: no cover
bundled_base_path = os.path.join(location, '_data')
if os.path.isfile(os.path.join(bundled_base_path, RELEASE_PATH)):
return bundled_base_path
raise ImproperlyConfigured(f"Unable to locate {RELEASE_PATH} for this NetBox installation.") # pragma: no cover
@dataclass
class FeatureSet:
"""
@ -53,7 +76,7 @@ def load_release_data():
"""
Load any locally-defined release attributes and return a ReleaseInfo instance.
"""
base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
base_path = _find_release_base_path()
# Load canonical release attributes
with open(os.path.join(base_path, RELEASE_PATH)) as release_file:

View File

@ -0,0 +1,14 @@
"""Generate SECRET_KEY values. Import-safe (stdlib only), so it works before Django or a NetBox
configuration exists: backs `netbox secret-key` and the netbox/generate_secret_key.py script."""
import secrets
__all__ = ('SECRET_KEY_CHARSET', 'SECRET_KEY_LENGTH', 'generate_secret_key')
SECRET_KEY_CHARSET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
SECRET_KEY_LENGTH = 50
def generate_secret_key():
"""Return a random 50-character string suitable for SECRET_KEY or an API token pepper."""
return ''.join(secrets.choice(SECRET_KEY_CHARSET) for _ in range(SECRET_KEY_LENGTH))

View File

@ -0,0 +1,29 @@
import os
from unittest.mock import patch
from django.core.exceptions import ImproperlyConfigured
from django.test import TestCase
from utilities.release import RELEASE_PATH, _find_release_base_path, load_release_data
class ReleaseDataTestCase(TestCase):
def test_find_release_base_path_locates_release_yaml(self):
"""The resolved base path contains release.yaml in a source checkout."""
base_path = _find_release_base_path()
self.assertTrue(os.path.isfile(os.path.join(base_path, RELEASE_PATH)))
def test_find_release_base_path_raises_when_release_yaml_missing(self):
"""Neither a checkout release.yaml nor a bundled _data copy resolves."""
with (
patch('utilities.release.os.path.isfile', return_value=False),
patch('utilities.release.importlib.util.find_spec', return_value=None),
):
with self.assertRaisesMessage(ImproperlyConfigured, RELEASE_PATH):
_find_release_base_path()
def test_load_release_data_returns_version(self):
"""Release data loads and exposes a non-empty version string."""
release = load_release_data()
self.assertTrue(release.version)
self.assertTrue(release.full_version)

View File

@ -0,0 +1,19 @@
from unittest.mock import patch
from django.test import SimpleTestCase
from utilities.secret_key import SECRET_KEY_CHARSET, SECRET_KEY_LENGTH, generate_secret_key
class GenerateSecretKeyTest(SimpleTestCase):
def test_generate_secret_key_length(self):
self.assertEqual(SECRET_KEY_LENGTH, 50)
self.assertEqual(len(generate_secret_key()), SECRET_KEY_LENGTH)
def test_generate_secret_key_uses_only_charset_characters(self):
self.assertTrue(set(generate_secret_key()) <= set(SECRET_KEY_CHARSET))
def test_generate_secret_key_draws_every_character_via_secrets_choice(self):
with patch('utilities.secret_key.secrets.choice', side_effect=lambda charset: charset[0]) as choice:
self.assertEqual(generate_secret_key(), SECRET_KEY_CHARSET[0] * SECRET_KEY_LENGTH)
self.assertEqual(choice.call_count, SECRET_KEY_LENGTH)

View File

@ -1,9 +1,13 @@
# See PEP 518 for the spec of this file
# https://www.python.org/dev/peps/pep-0518/
[build-system]
requires = ["hatchling>=1.27", "packaging"]
build-backend = "hatchling.build"
[project]
name = "netbox"
version = "4.6.4"
dynamic = ["version", "dependencies"]
requires-python = ">=3.12"
description = "The premier source of truth powering network automation."
readme = "README.md"
@ -20,6 +24,39 @@ classifiers = [
"Programming Language :: Python :: 3.14",
]
[project.optional-dependencies]
ldap = ["django-auth-ldap"]
saml2 = ["python3-saml"]
remote-auth = ["django-auth-ldap", "python3-saml"]
sentry = ["sentry-sdk"]
swift = ["django-storage-swift"]
s3 = ["boto3"]
git = ["dulwich"]
# NetBox Labs plugins (proprietary, opt-in). Minor-bounded to the tested series.
branching = ["netboxlabs-netbox-branching>=1.1.0,<1.2.0"]
custom-objects = ["netboxlabs-netbox-custom-objects>=0.5.0,<0.6.0"]
# Convenience aggregate of the recommended NetBox Labs plugins (NOT a catch-all of every extra).
# The component pins are duplicated literally, as remote-auth duplicates ldap and saml2;
# scripts/verify_wheel_metadata.py verifies in CI that this aggregate equals the union of the
# branching and custom-objects extras, guarding the duplication against drift.
recommended-plugins = [
"netboxlabs-netbox-branching>=1.1.0,<1.2.0",
"netboxlabs-netbox-custom-objects>=0.5.0,<0.6.0",
]
dev = [
"build",
"coverage",
"packaging",
"pre-commit",
"ruff==0.15.20",
"tblib",
"twine",
"uv",
]
[project.scripts]
netbox = "netbox.cli:main"
[project.urls]
Homepage = "https://netboxlabs.com/products/netbox/"
Documentation = "https://netboxlabs.com/docs/netbox/"
@ -42,11 +79,105 @@ omit = [
"netbox/scripts/*", # SCRIPTS_ROOT: user/generated scripts
"*/netbox/configuration*.py", # settings/config files (template, testing, local)
"*/netbox/wsgi.py", # WSGI entrypoint
"*/netbox/__main__.py", # `python -m netbox` entry point
"*/generate_secret_key.py", # standalone CLI helper
"*/utilities/debug.py", # debug-toolbar hook, active only when DEBUG=True
"*/extras/management/commands/housekeeping.py", # deprecated; will not be tested
]
[tool.hatch.metadata.hooks.custom]
# Implemented in scripts/packaging/hatch_metadata.py; computes a PEP 440 version from
# netbox/release.yaml (version + optional designation) and runtime deps from requirements.txt.
path = "scripts/packaging/hatch_metadata.py"
[tool.hatch.build.targets.wheel]
sources = ["netbox"]
packages = [
"netbox/account",
"netbox/circuits",
"netbox/core",
"netbox/dcim",
"netbox/extras",
"netbox/ipam",
"netbox/netbox",
"netbox/tenancy",
"netbox/users",
"netbox/utilities",
"netbox/virtualization",
"netbox/vpn",
"netbox/wireless",
]
# Never ship a live or local configuration file: configuration.py holds SECRET_KEY
# and database credentials, ldap_config.py holds LDAP bind credentials, and developers
# often keep ad-hoc configuration*.py copies in this directory. Exclude both sets, then
# re-include only the two tracked templates. (verify_wheel_contents.py enforces this in CI.)
exclude = [
"netbox/netbox/configuration*.py",
"netbox/netbox/ldap_config*.py",
]
# Bundle runtime data inside the installed netbox package at netbox/_data/.
# Destinations carry an extra leading "netbox/" because the wheel `sources`
# setting above strips one "netbox/" prefix from every path (including these
# force-include targets); after stripping they resolve to netbox/_data/...
[tool.hatch.build.targets.wheel.force-include]
"netbox/project-static/dist" = "netbox/netbox/_data/project-static/dist"
# The pre-rendered documentation site ships in the wheel; render it with `zensical build`
# before `python -m build` (the release workflow's build job does; see
# docs/development/building-the-package.md for local builds).
"netbox/project-static/docs" = "netbox/netbox/_data/docs"
"netbox/project-static/img" = "netbox/netbox/_data/project-static/img"
"netbox/project-static/js" = "netbox/netbox/_data/project-static/js"
"netbox/release.yaml" = "netbox/netbox/_data/release.yaml"
"netbox/templates" = "netbox/netbox/_data/templates"
"netbox/translations" = "netbox/netbox/_data/translations"
# Deployment examples, bundled verbatim for `netbox setup` (no adaptation).
"contrib/apache.conf" = "netbox/netbox/_data/contrib/apache.conf"
"contrib/gunicorn.py" = "netbox/netbox/_data/contrib/gunicorn.py"
"contrib/netbox-rq.service" = "netbox/netbox/_data/contrib/netbox-rq.service"
"contrib/netbox.env" = "netbox/netbox/_data/contrib/netbox.env"
"contrib/netbox.service" = "netbox/netbox/_data/contrib/netbox.service"
"contrib/nginx.conf" = "netbox/netbox/_data/contrib/nginx.conf"
"contrib/uwsgi.ini" = "netbox/netbox/_data/contrib/uwsgi.ini"
# Force the two tracked config templates in over the configuration*.py exclude above.
"netbox/netbox/configuration_example.py" = "netbox/netbox/configuration_example.py"
"netbox/netbox/configuration_testing.py" = "netbox/netbox/configuration_testing.py"
[tool.hatch.build.targets.sdist]
include = [
"/.github/workflows/release.yml",
"/CHANGELOG.md",
"/LICENSE.txt",
"/README.md",
"/base_requirements.txt",
"/contrib",
"/docs",
"/mkdocs.yml",
"/netbox",
"/pyproject.toml",
"/requirements.txt",
"/scripts",
"/upgrade.sh",
]
# Keep live/local configuration*.py and ldap_config*.py out of the sdist (same rule as the
# wheel, keeping only the two tracked templates), and drop the local netbox/configuration.py
# symlink that otherwise breaks `python -m build --sdist` with an AbsoluteLinkError.
exclude = [
"netbox/netbox/configuration*.py",
"netbox/netbox/ldap_config*.py",
"netbox/configuration.py",
"netbox/ldap_config.py",
]
[tool.hatch.build.targets.sdist.force-include]
# Force the two tracked config templates in over the configuration*.py exclude above.
"netbox/netbox/configuration_example.py" = "netbox/netbox/configuration_example.py"
"netbox/netbox/configuration_testing.py" = "netbox/netbox/configuration_testing.py"
# The rendered site is gitignored, so the plain /netbox include skips it; force it in so a
# wheel rebuilt from the sdist (verify-sdist job, `pip install <sdist>`) ships the docs.
"netbox/project-static/docs" = "netbox/project-static/docs"
[tool.pyright]
include = ["netbox"]
exclude = [

View File

@ -0,0 +1,63 @@
"""Hatchling metadata hook: derive a PEP 440 version from netbox/release.yaml.
NetBox stores the release version and any pre-release designation (for example
"beta1") separately in netbox/release.yaml. This hook combines them into a
canonical PEP 440 version so wheels publish correctly, including pre-releases.
"""
import re
from pathlib import Path
from packaging.version import Version
try:
from hatchling.metadata.plugin.interface import MetadataHookInterface
except ModuleNotFoundError:
# hatchling is only installed inside the isolated build environment. Fall back
# so this module (and compute_version) remains importable for unit testing.
MetadataHookInterface = object
def compute_version(version, designation):
"""Return a canonical PEP 440 version from a version and optional designation."""
raw = f"{version}{designation}" if designation else version
return str(Version(raw))
def _read_release_field(text, field):
match = re.search(rf'^{field}:\s*"?([^"\n]+?)"?\s*$', text, re.MULTILINE)
return match.group(1).strip() if match else None
def read_requirements(text):
"""Parse a pinned requirements.txt body into PEP 508 dependency specifiers.
Assumes NetBox's flat "package==version" format (one top-level pin per line).
Blank lines, comments, and pip option lines (starting with "-") are skipped.
"""
dependencies = []
for raw_line in text.splitlines():
line = raw_line.split('#', 1)[0].strip()
if not line or line.startswith('-'):
continue
dependencies.append(line)
return dependencies
class NetBoxMetadataHook(MetadataHookInterface):
def update(self, metadata):
root = Path(self.root)
# Version: derived from release.yaml (version + optional designation).
release_path = root / 'netbox' / 'release.yaml'
text = release_path.read_text()
version = _read_release_field(text, 'version')
if not version:
raise ValueError(f"Unable to read 'version' from {release_path}")
designation = _read_release_field(text, 'designation')
metadata['version'] = compute_version(version, designation)
# Dependencies: requirements.txt is the single pinned source of truth, so the
# published wheel's Requires-Dist matches the tested pins, not loose ranges.
requirements_path = root / 'requirements.txt'
metadata['dependencies'] = read_requirements(requirements_path.read_text())

View File

@ -0,0 +1,51 @@
"""Minimal NetBox configuration used by the wheel-install smoke test."""
import os
from pathlib import Path
BASE_DIR = Path(os.getenv('NETBOX_SMOKETEST_BASE', '/tmp/netbox-smoketest'))
BASE_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_HOSTS = ['*']
API_TOKEN_PEPPERS = {
1: os.getenv('NETBOX_SMOKETEST_API_TOKEN_PEPPER', 'a' * 50),
}
SECRET_KEY = os.getenv('NETBOX_SMOKETEST_SECRET_KEY', 'b' * 50)
DATABASES = {
'default': {
'NAME': os.getenv('POSTGRES_DB', 'netbox'),
'USER': os.getenv('POSTGRES_USER', 'netbox'),
'PASSWORD': os.getenv('POSTGRES_PASSWORD', 'netbox'),
'HOST': os.getenv('POSTGRES_HOST', '127.0.0.1'),
'PORT': os.getenv('POSTGRES_PORT', '5432'),
'CONN_MAX_AGE': 0,
}
}
REDIS_HOST = os.getenv('REDIS_HOST', '127.0.0.1')
REDIS_PORT = int(os.getenv('REDIS_PORT', '6379'))
REDIS = {
'tasks': {
'HOST': REDIS_HOST,
'PORT': REDIS_PORT,
'PASSWORD': os.getenv('REDIS_PASSWORD', ''),
'DATABASE': int(os.getenv('REDIS_TASKS_DATABASE', '0')),
'SSL': False,
},
'caching': {
'HOST': REDIS_HOST,
'PORT': REDIS_PORT,
'PASSWORD': os.getenv('REDIS_PASSWORD', ''),
'DATABASE': int(os.getenv('REDIS_CACHING_DATABASE', '1')),
'SSL': False,
},
}
# STATIC_ROOT is fixed to <NETBOX_ROOT>/static; the workflow sets NETBOX_ROOT to the scratch base.
MEDIA_ROOT = str(BASE_DIR / 'media')
REPORTS_ROOT = str(BASE_DIR / 'reports')
SCRIPTS_ROOT = str(BASE_DIR / 'scripts')
for path in (MEDIA_ROOT, REPORTS_ROOT, SCRIPTS_ROOT):
Path(path).mkdir(parents=True, exist_ok=True)

View File

@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Verify requirements.txt is consistent with base_requirements.txt.
Guards against dependency drift before publishing. The wheel sources its pinned
dependencies from requirements.txt, which must stay in sync with the maintainer
policy in base_requirements.txt (same package set, and every pin satisfies its
declared constraint).
"""
import importlib.util
import sys
from pathlib import Path
from packaging.requirements import Requirement
def load_hatch_metadata():
"""Load scripts/packaging/hatch_metadata.py by path.
scripts/packaging is not a package (no __init__.py), and importing it by name would
collide with the third-party packaging distribution, so load it from its file path.
"""
path = Path(__file__).resolve().parent / 'packaging' / 'hatch_metadata.py'
spec = importlib.util.spec_from_file_location('netbox_hatch_metadata', path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def parse(path, hatch_metadata):
reqs = {}
for line in hatch_metadata.read_requirements(Path(path).read_text()):
req = Requirement(line)
reqs[req.name.lower().replace('_', '-')] = req
return reqs
def check(base, pinned):
errors = []
only_base = sorted(set(base) - set(pinned))
only_pinned = sorted(set(pinned) - set(base))
if only_base:
errors.append(f"In base_requirements.txt but not requirements.txt: {only_base}")
if only_pinned:
errors.append(f"In requirements.txt but not base_requirements.txt: {only_pinned}")
for name in sorted(set(base) & set(pinned)):
if base[name].extras != pinned[name].extras:
errors.append(
f"{name}: extras differ (base {sorted(base[name].extras)} vs "
f"requirements.txt {sorted(pinned[name].extras)})"
)
spec = pinned[name].specifier
if not spec or not all(s.operator == '==' for s in spec):
errors.append(f"{name}: requirements.txt must pin exactly (got '{pinned[name]}')")
continue
version = next(iter(spec)).version
if not base[name].specifier.contains(version, prereleases=True):
errors.append(f"{name}: pinned {version} violates base constraint '{base[name].specifier}'")
return errors
def main():
root = Path(__file__).resolve().parent.parent
hatch_metadata = load_hatch_metadata()
errors = check(
parse(root / 'base_requirements.txt', hatch_metadata),
parse(root / 'requirements.txt', hatch_metadata),
)
if errors:
print("Dependency drift detected:")
for error in errors:
print(f" - {error}")
return 1
print("OK: requirements.txt is consistent with base_requirements.txt")
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Verify a git tag matches the built wheel's version before publishing.
Usage: verify_release_tag.py <git-ref-or-tag> <wheel>
Normalizes the tag (strips a leading 'v' and PEP 440-normalizes it, so v4.7.0-beta1
becomes 4.7.0b1) and asserts it equals the wheel's Version metadata.
"""
import sys
import zipfile
from email.parser import Parser
from packaging.version import Version
def wheel_version(wheel_path):
with zipfile.ZipFile(wheel_path) as archive:
meta_name = next(name for name in archive.namelist() if name.endswith('.dist-info/METADATA'))
metadata = Parser().parsestr(archive.read(meta_name).decode())
return metadata['Version']
def normalize_tag(ref):
tag = ref.rsplit('/', 1)[-1]
if tag.startswith('v'):
tag = tag[1:]
return str(Version(tag))
def main(argv):
if len(argv) != 3:
print('usage: verify_release_tag.py <git-ref-or-tag> <wheel>')
return 2
tag_version = normalize_tag(argv[1])
built_version = str(Version(wheel_version(argv[2])))
if tag_version != built_version:
print(f'Tag/version mismatch: tag -> {tag_version}, wheel -> {built_version}')
return 1
print(f'OK: tag matches wheel version ({built_version})')
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv))

View File

@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Verify a built sdist ships only the intended configuration templates.
The sdist is a published artifact in its own right. It must contain the two tracked
configuration templates and must NOT contain a live configuration.py (which holds
SECRET_KEY and database credentials), any other local configuration*.py variant, or
any ldap_config*.py (which holds LDAP bind credentials). The wheel guard alone is not
enough: a wheel rebuilt from the sdist re-applies the wheel excludes, so it can come
out clean even when the sdist itself leaks a file.
"""
import sys
import tarfile
from pathlib import PurePosixPath
# Allowed members, relative to the sdist's netbox-<version>/ root directory. The sdist
# keeps the full repository layout (no `sources` strip), unlike the wheel.
ALLOWED = {
'netbox/netbox/configuration_example.py',
'netbox/netbox/configuration_testing.py',
}
def configuration_members(sdist_path):
"""Return the set of configuration*.py members anywhere inside the sdist."""
with tarfile.open(sdist_path) as archive:
names = archive.getnames()
members = set()
for name in names:
path = PurePosixPath(name)
if path.suffix == '.py' and (path.name.startswith('configuration') or path.name.startswith('ldap_config')):
# Strip the leading netbox-<version>/ directory for a stable comparison.
members.add(str(PurePosixPath(*path.parts[1:])))
return members
def main(argv):
if len(argv) != 2:
print('usage: verify_sdist_contents.py <sdist>')
return 2
found = configuration_members(argv[1])
missing = sorted(ALLOWED - found)
unexpected = sorted(found - ALLOWED)
if missing or unexpected:
print('Sdist configuration files are not as expected:')
if missing:
print(f' - missing templates: {missing}')
if unexpected:
print(f' - unexpected (possible secret leak): {unexpected}')
return 1
print(f'OK: sdist ships only the {len(ALLOWED)} configuration templates')
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv))

View File

@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Verify a built wheel ships the required bundled data and only the intended configuration templates.
The wheel must contain the tracked configuration templates and must NOT contain a
live configuration.py (which holds SECRET_KEY and database credentials), any other
local configuration*.py variant, or any ldap_config*.py (which holds LDAP bind
credentials). This guards against a dirty or manual build leaking secrets into a
published artifact. The wheel must also ship the runtime-critical bundled data
(release metadata, templates, translations, static assets, the pre-rendered
documentation site, deployment examples), so a broken build fails here with a
precise message instead of at smoke-test time. main() also cross-checks
pyproject.toml's wheel force-include table against
REQUIRED_FILES/REQUIRED_PREFIXES/ALLOWED, so an addition there without matching
verifier coverage fails too.
"""
import sys
import tomllib
import zipfile
from pathlib import Path, PurePosixPath
# The scan covers the entire wheel; only these two tracked templates (at netbox/<name> after the
# wheel `sources = ["netbox"]` strip) are allowed to ship.
ALLOWED = {
'netbox/configuration_example.py',
'netbox/configuration_testing.py',
}
# Runtime-critical bundled data; mirrors the force-include table in pyproject.toml.
# Hand-maintained, not derived: main() cross-checks pyproject.toml's wheel
# force-include table against these sets (plus ALLOWED) in the other direction, so a
# force-include added there without matching coverage here also fails.
REQUIRED_FILES = {
'netbox/_data/contrib/apache.conf',
'netbox/_data/contrib/gunicorn.py',
'netbox/_data/contrib/netbox-rq.service',
'netbox/_data/contrib/netbox.env',
'netbox/_data/contrib/netbox.service',
'netbox/_data/contrib/nginx.conf',
'netbox/_data/contrib/uwsgi.ini',
'netbox/_data/docs/index.html',
'netbox/_data/docs/models/dcim/device/index.html',
'netbox/_data/release.yaml',
}
REQUIRED_PREFIXES = (
'netbox/_data/docs/',
'netbox/_data/project-static/dist/',
'netbox/_data/project-static/img/',
'netbox/_data/project-static/js/',
'netbox/_data/templates/',
'netbox/_data/translations/',
)
def configuration_members(names):
"""Return the set of configuration*.py members anywhere inside the wheel."""
members = set()
for name in names:
path = PurePosixPath(name)
# Scan the whole wheel: any configuration*.py outside the two tracked templates, or any
# ldap_config*.py at all, is a leak, wherever it sits in the archive.
if path.suffix == '.py' and (path.name.startswith('configuration') or path.name.startswith('ldap_config')):
members.add(name)
return members
def missing_runtime_data(names):
"""Return the sorted list of required bundled files and prefixes absent from the wheel."""
missing = sorted(REQUIRED_FILES - names)
missing += [prefix for prefix in REQUIRED_PREFIXES if not any(name.startswith(prefix) for name in names)]
return missing
def uncovered_force_includes(pyproject_path):
"""Return wheel force-include destinations in pyproject.toml not covered by this verifier.
REQUIRED_FILES/REQUIRED_PREFIXES/ALLOWED stay hand-owned and independent of pyproject.toml
(a full derivation would let a deleted force-include line shrink the expectations with it
and go silently green), so this only catches drift in one direction: a force-include added
to pyproject.toml without matching verifier coverage. A deleted force-include line still
fails the REQUIRED_FILES/REQUIRED_PREFIXES checks in missing_runtime_data().
"""
with open(pyproject_path, 'rb') as handle:
pyproject = tomllib.load(handle)
force_include = pyproject['tool']['hatch']['build']['targets']['wheel']['force-include']
uncovered = []
for destination in force_include.values():
# The wheel's `sources = ["netbox"]` setting strips one leading "netbox/" segment from
# every path, including these force-include destinations.
stripped = destination.removeprefix('netbox/')
if stripped in REQUIRED_FILES or stripped in ALLOWED or f'{stripped}/' in REQUIRED_PREFIXES:
continue
uncovered.append(destination)
return sorted(uncovered)
def main(argv):
if len(argv) != 2:
print('usage: verify_wheel_contents.py <wheel>')
return 2
with zipfile.ZipFile(argv[1]) as archive:
names = set(archive.namelist())
found = configuration_members(names)
missing = sorted(ALLOWED - found)
unexpected = sorted(found - ALLOWED)
missing_data = missing_runtime_data(names)
pyproject_path = Path(__file__).resolve().parents[1] / 'pyproject.toml'
uncovered = uncovered_force_includes(pyproject_path)
if missing or unexpected or missing_data or uncovered:
print('Wheel contents are not as expected:')
if missing:
print(f' - missing templates: {missing}')
if unexpected:
print(f' - unexpected (possible secret leak): {unexpected}')
if missing_data:
print(f' - missing runtime data: {missing_data}')
if uncovered:
print(f' - pyproject.toml force-includes not covered by this verifier: {uncovered}')
return 1
print(f'OK: wheel ships the required bundled data and only the {len(ALLOWED)} configuration templates')
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv))

View File

@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Verify a built wheel's metadata matches the repository's declared inputs.
Checks:
1. Version equals the PEP 440 version computed from netbox/release.yaml, reusing the
same compute_version the hatchling metadata hook uses at build time.
2. Core Requires-Dist entries (those without an "extra ==" marker) match
requirements.txt exactly, so the published wheel pins the tested dependency set.
3. Provides-Extra equals the expected set of optional-dependency groups.
4. Each aggregate extra equals the union of its component extras, comparing the wheel
metadata against itself (immune to backend specifier normalization). pyproject.toml
duplicates these requirement strings literally; this catches drift, for example a
plugin pin bumped in one place only. Aggregates must not reference netbox itself,
which would defeat this guard.
"""
import importlib.util
import re
import sys
import zipfile
from collections import defaultdict
from email.parser import Parser
from pathlib import Path
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
# Every optional-dependency group in pyproject.toml, as normalized (PEP 685) extra names.
EXPECTED_EXTRAS = frozenset({
'branching',
'custom-objects',
'dev',
'git',
'ldap',
'recommended-plugins',
'remote-auth',
's3',
'saml2',
'sentry',
'swift',
})
# Aggregate extra -> the component extras whose entries it must equal the union of.
AGGREGATE_EXTRAS = {
'remote-auth': ('ldap', 'saml2'),
'recommended-plugins': ('branching', 'custom-objects'),
}
# hatchling 1.30 writes extra markers with single quotes; other tools use double quotes.
EXTRA_MARKER = re.compile(r'\bextra\s*==\s*["\']([^"\']+)["\']')
def read_metadata(wheel_path):
with zipfile.ZipFile(wheel_path) as archive:
name = next(n for n in archive.namelist() if n.endswith('.dist-info/METADATA'))
return Parser().parsestr(archive.read(name).decode())
def load_hatch_metadata():
"""Load scripts/packaging/hatch_metadata.py by path.
scripts/packaging is not a package (no __init__.py), and importing it by name would
collide with the third-party packaging distribution, so load it from its file path.
"""
path = Path(__file__).resolve().parent / 'packaging' / 'hatch_metadata.py'
spec = importlib.util.spec_from_file_location('netbox_hatch_metadata', path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def normalize(requirement):
return requirement.strip().lower().replace(' ', '')
def split_requires(metadata):
"""Split Requires-Dist entries into core requirements and a per-extra mapping."""
core = set()
by_extra = defaultdict(set)
for entry in metadata.get_all('Requires-Dist') or []:
requirement, _, marker = entry.partition(';')
match = EXTRA_MARKER.search(marker)
if match:
by_extra[match.group(1)].add(normalize(requirement))
else:
core.add(normalize(entry))
return core, by_extra
def check_version(metadata, root, hatch_metadata):
release_text = (root / 'netbox' / 'release.yaml').read_text()
version = hatch_metadata._read_release_field(release_text, 'version')
if not version:
return ['unable to read version from netbox/release.yaml']
designation = hatch_metadata._read_release_field(release_text, 'designation')
expected = hatch_metadata.compute_version(version, designation)
if metadata['Version'] != expected:
return [f'version mismatch: wheel has {metadata["Version"]}, release.yaml computes {expected}']
return []
def _diff_errors(expected, actual, label):
"""Build 'missing'/'unexpected' error messages for the set difference of expected vs actual."""
errors = []
if missing := sorted(expected - actual):
errors.append(f'{label} missing from wheel: {missing}')
if unexpected := sorted(actual - expected):
errors.append(f'unexpected {label} in wheel: {unexpected}')
return errors
def check_core_requires(core, root, hatch_metadata):
# Parse with the hook's own parser so the verifier cannot drift from the build.
pins = hatch_metadata.read_requirements((root / 'requirements.txt').read_text())
return _diff_errors({normalize(pin) for pin in pins}, core, 'core requirements')
def check_extras(metadata, by_extra):
provided = frozenset(metadata.get_all('Provides-Extra') or [])
errors = _diff_errors(EXPECTED_EXTRAS, provided, 'extras')
for aggregate, components in AGGREGATE_EXTRAS.items():
expected = set().union(*(by_extra[component] for component in components))
actual = by_extra[aggregate]
if actual != expected:
errors.append(
f'extra [{aggregate}] must equal the union of {list(components)}: '
f'missing {sorted(expected - actual)}, unexpected {sorted(actual - expected)}'
)
if self_refs := sorted(r for r in actual if canonicalize_name(Requirement(r).name) == 'netbox'):
errors.append(f'extra [{aggregate}] must not reference netbox itself: {self_refs}')
return errors
def main(argv):
if len(argv) != 2:
print('usage: verify_wheel_metadata.py <wheel>')
return 2
root = Path(__file__).resolve().parent.parent
hatch_metadata = load_hatch_metadata()
metadata = read_metadata(argv[1])
core, by_extra = split_requires(metadata)
errors = [
*check_version(metadata, root, hatch_metadata),
*check_core_requires(core, root, hatch_metadata),
*check_extras(metadata, by_extra),
]
if errors:
print('Wheel metadata does not match the repository:')
for error in errors:
print(f' - {error}')
return 1
print(f'OK: wheel {metadata["Version"]} matches release.yaml, requirements.txt, and expected extras')
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv))