Skip to content

Commit 43abd6f

Browse files
authored
feat: dev fixes, added receiver for signal to provide mutated inteface name (#42)
* fix(devcontainer): proxy/DNS hardening, dep install, cross-plugin examples Several related devcontainer issues observed against a corporate network with HTTP proxy, MITM TLS interception, and a wildcard DNS search domain. docker-compose.yml: - Pin postgres/redis to static IPs on a 172.28.200.0/24 bridge subnet. Avoids the corporate MITM range at 172.30.x.x that Docker would otherwise auto-pick. - Add 'dns_search: ["."]' to netbox so bare names like 'postgres' and 'redis' don't get the host's DNS search domain appended (a wildcard search domain was returning the wrong IP for the bare service names). - Bump postgres max_connections to 200. devcontainer.json: - Move HTTP_PROXY/HTTPS_PROXY/NO_PROXY from containerEnv to remoteEnv. VS Code resolves '${localEnv:HTTP_PROXY}' to an empty string when the variable is unset on the host, which under containerEnv silently overrides the value docker-compose loaded from .env. remoteEnv runs after container start and can't shadow compose env. - Drop the three REQUESTS_CA_BUNDLE / SSL_CERT_FILE / CURL_CA_BUNDLE passes (they pointed at host paths that don't exist inside the container; setup.sh handles CA-bundle resolution itself). scripts/setup.sh: - Add a fallback that reads HTTP/HTTPS/NO_PROXY directly from .devcontainer/.env when remoteEnv left them empty (closes the same gap as above for users who don't have proxy vars on the host). - Pin postgres/redis hostnames in /etc/hosts. Backstop for the case Docker's embedded DNS doesn't alias 'redis' because of a collision with another container on the host of the same name. /etc/hosts is consulted before DNS by libc, so this also covers the search-domain case as defence in depth. - Install pytest-cov, reuse, and (for the agent loop) the rest of the test toolchain in the same install line — pyproject lists these as dev deps but the previous setup.sh didn't install them, so fresh containers shipped without 'pytest --cov' or the reuse-lint pre-commit hook working. In-source comment now tracks what each dep is for. .env.example: - Expand the commented NO_PROXY example to include 127.0.0.0/8 and ::1 loopback ranges (useful when container-local services bind there). config/*.py.example: - Add commented-out example wiring for an additional plugin mounted from a sibling workspace ('netbox-example-plugin' under /workspaces/myorg/…). Demonstrates the org-grouped mount pattern without naming any specific real plugin. scripts/process-helpers.sh: - Restore executable bit (file is sourced by setup.sh and was losing +x on some checkouts). * chore(devcontainer): ignore docker-compose.override.yml Allow a local-only compose override file alongside the committed docker-compose.yml. docker compose auto-merges it on top, so site- or plugin-specific env vars, mounts, or service tweaks can live in the developer's working tree without polluting the shared compose file. * feat(integration): bridge netbox-librenms-plugin module-name prediction netbox-librenms-plugin's module-adoption flow predicts what interface names a module's templates will produce so it can find pre-existing standalone interfaces to adopt into the module. That prediction calls NetBox's built-in template.instantiate(), which never sees our rename rules — so for any module covered by an InterfaceNameRule, adoption silently misses the post-rename name. Closes the gap on both sides without coupling either plugin to the other: engine.predict_rule_output(module, module_bay, raw_names): - Pure name-prediction function that mirrors apply_interface_name_rules' template evaluation but does no DB writes. Returns the names that would exist post-apply (channel_count > 0 expands one raw name into channel_count output names). - Falls back to the raw name if evaluate_name_template raises, matching the apply path's behavior of leaving the interface alone on failure. signals.on_librenms_predict_module_interface_names: - Receiver for netbox-librenms-plugin's predict_module_interface_names Signal. Routes the predicted names through predict_rule_output and returns the rewritten list. Returns None when module_bay is missing or the engine raises, so librenms-plugin keeps its original list. - Import is guarded so INR loads cleanly when librenms-plugin is absent. Tests cover: - predict_rule_output: no-rule passthrough, simple rename, breakout expansion (1→N), multi-name preservation, pure-function guarantee (no Interface row mutations), template-eval failure fallback. - LibrenmsPredictReceiverTest: end-to-end signal dispatch returns rewritten names, None when module_bay missing, None when engine raises. * fix: tighten validation of interface name in receiver * chore: adjust compose * fix: addressing review * fix: addressing review
1 parent 0a773ab commit 43abd6f

12 files changed

Lines changed: 314 additions & 7 deletions

File tree

.devcontainer/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ SKIP_SUPERUSER=false
2828
# Proxy Configuration (optional, for corporate networks)
2929
# HTTP_PROXY=http://proxy.example.com:8080
3030
# HTTPS_PROXY=http://proxy.example.com:8080
31-
# NO_PROXY=localhost,127.0.0.1,postgres,redis
31+
# NO_PROXY=localhost,127.0.0.1,127.0.0.0/8,::1,postgres,redis
3232
# REQUESTS_CA_BUNDLE=/path/to/ca-bundle.crt
3333
# SSL_CERT_FILE=/path/to/ca-bundle.crt
3434
# CURL_CA_BUNDLE=/path/to/ca-bundle.crt

.devcontainer/config/extra-plugins.py.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@ Examples:
2020

2121
EXTRA_PLUGINS = [
2222
# {"name": "netbox-librenms-plugin", "source": "/workspaces/netbox-librenms-plugin"},
23+
# {"name": "netbox-example-plugin", "source": "/workspaces/myorg/netbox-example-plugin"},
2324
# {"name": "netbox-secrets", "source": "pip"},
2425
]

.devcontainer/config/plugin-config.py.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@ Plugin configuration for the NetBox Interface Name Rules Plugin in the dev conta
1010

1111
PLUGINS = [
1212
"netbox_interface_name_rules",
13+
# "netbox_example_plugin", # uncomment once the package is in EXTRA_PLUGINS above
1314
]
1415

1516
PLUGINS_CONFIG = {
1617
"netbox_interface_name_rules": {},
18+
# "netbox_example_plugin": {
19+
# "api_url": "",
20+
# "api_token": "",
21+
# },
1722
}

.devcontainer/devcontainer.json

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,15 @@
3434
"SUPERUSER_NAME": "${localEnv:SUPERUSER_NAME:admin}",
3535
"SUPERUSER_EMAIL": "${localEnv:SUPERUSER_EMAIL:admin@example.com}",
3636
"SUPERUSER_PASSWORD": "${localEnv:SUPERUSER_PASSWORD:admin}",
37-
"SKIP_SUPERUSER": "${localEnv:SKIP_SUPERUSER:false}",
37+
"SKIP_SUPERUSER": "${localEnv:SKIP_SUPERUSER:false}"
38+
},
39+
"remoteEnv": {
3840
"HTTP_PROXY": "${localEnv:HTTP_PROXY}",
3941
"HTTPS_PROXY": "${localEnv:HTTPS_PROXY}",
4042
"http_proxy": "${localEnv:HTTP_PROXY}",
4143
"https_proxy": "${localEnv:HTTPS_PROXY}",
4244
"NO_PROXY": "${localEnv:NO_PROXY}",
43-
"no_proxy": "${localEnv:NO_PROXY}",
44-
"REQUESTS_CA_BUNDLE": "${localEnv:REQUESTS_CA_BUNDLE}",
45-
"SSL_CERT_FILE": "${localEnv:SSL_CERT_FILE}",
46-
"CURL_CA_BUNDLE": "${localEnv:CURL_CA_BUNDLE}"
45+
"no_proxy": "${localEnv:NO_PROXY}"
4746
},
4847
"features": {},
4948
"customizations": {

.devcontainer/docker-compose.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,13 @@ services:
4141
condition: service_healthy
4242
redis:
4343
condition: service_healthy
44+
dns_search: ["."]
4445
ports:
4546
- "8000:8000"
4647

4748
postgres:
4849
image: postgres:18
50+
command: postgres -c max_connections=200
4951
environment:
5052
POSTGRES_DB: ${DB_NAME:-netbox}
5153
POSTGRES_USER: ${DB_USER:-netbox}
@@ -57,6 +59,9 @@ services:
5759
interval: 30s
5860
timeout: 10s
5961
retries: 5
62+
networks:
63+
default:
64+
ipv4_address: 172.28.200.3
6065

6166
redis:
6267
image: redis:8-alpine
@@ -68,10 +73,21 @@ services:
6873
interval: 30s
6974
timeout: 10s
7075
retries: 5
76+
networks:
77+
default:
78+
ipv4_address: 172.28.200.2
7179

7280
volumes:
7381
postgres_data:
7482
redis_data:
7583
netbox_media:
7684
netbox_static:
7785
gh_config:
86+
87+
networks:
88+
default:
89+
driver: bridge
90+
ipam:
91+
config:
92+
# Pinned to avoid Docker auto-assigning 172.30.x.x
93+
- subnet: "172.28.200.0/24"

.devcontainer/scripts/process-helpers.sh

100644100755
File mode changed.

.devcontainer/scripts/setup.sh

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,24 @@ for _ca_var in REQUESTS_CA_BUNDLE SSL_CERT_FILE CURL_CA_BUNDLE; do
1818
done
1919
unset _ca_var _val
2020

21+
# VS Code containerEnv resolves ${localEnv:HTTP_PROXY} to "" when the variable is not
22+
# set on the host, silently overriding the value docker-compose got from .env.
23+
# Fall back to reading proxy settings directly from .devcontainer/.env.
24+
if [ -z "$HTTP_PROXY" ] && [ -z "$HTTPS_PROXY" ]; then
25+
_DOTENV="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/.env"
26+
if [ -f "$_DOTENV" ]; then
27+
_v=$(grep -m1 '^HTTP_PROXY=' "$_DOTENV" | cut -d'=' -f2-)
28+
[ -n "$_v" ] && export HTTP_PROXY="$_v" http_proxy="$_v"
29+
_v=$(grep -m1 '^HTTPS_PROXY=' "$_DOTENV" | cut -d'=' -f2-)
30+
[ -n "$_v" ] && export HTTPS_PROXY="$_v" https_proxy="$_v"
31+
_v=$(grep -m1 '^NO_PROXY=' "$_DOTENV" | cut -d'=' -f2-)
32+
[ -n "$_v" ] && export NO_PROXY="$_v" no_proxy="$_v"
33+
[ -n "$HTTP_PROXY" ] && echo "🌐 Loaded proxy settings from .env (containerEnv fallback)"
34+
unset _v
35+
fi
36+
unset _DOTENV
37+
fi
38+
2139
# Detect plugin workspace directory
2240
detect_plugin_workspace() {
2341
if [ -f "$PWD/pyproject.toml" ]; then
@@ -35,6 +53,21 @@ detect_plugin_workspace() {
3553
fi
3654
}
3755

56+
# Pin compose service hostnames in /etc/hosts.
57+
# Corporate DNS search domains (e.g. .cnad.dev) cause bare names like 'postgres' and 'redis'
58+
# to resolve to external corporate hosts. Docker's embedded DNS alias for 'redis' is also
59+
# blocked by a name collision with another container on the host named 'redis'.
60+
# These IPs are statically assigned via ipv4_address in docker-compose.yml.
61+
echo "📌 Pinning compose service hostnames in /etc/hosts..."
62+
for _entry in "172.28.200.3 postgres" "172.28.200.2 redis"; do
63+
_ip="${_entry%% *}"
64+
_host="${_entry##* }"
65+
sed -i "/[[:space:]]${_host}\$/d" /etc/hosts 2>/dev/null || true
66+
printf '%s\t%s\n' "$_ip" "$_host" >> /etc/hosts
67+
echo "${_host}${_ip}"
68+
done
69+
unset _entry _ip _host
70+
3871
# Proxy/CA setup
3972
if [ -n "$HTTP_PROXY" ] || [ -n "$HTTPS_PROXY" ]; then
4073
echo "🌐 Configuring proxy settings..."
@@ -105,7 +138,11 @@ fi
105138
echo "🔧 Installing development dependencies..."
106139
apt-get update -qq
107140
apt-get install -y -qq net-tools git
108-
$PIP_CMD install pytest pytest-django ruff pre-commit
141+
# Dev tools used by the agent loop and pre-commit hooks. Keep in sync with
142+
# the `dev` extras in pyproject.toml — at minimum, anything invoked by:
143+
# - test + coverage runs: pytest, pytest-django, pytest-cov
144+
# - pre-commit hooks (.pre-commit-config.yaml): ruff, pre-commit, reuse
145+
$PIP_CMD install pytest pytest-django pytest-cov ruff pre-commit reuse
109146

110147
# Install GitHub CLI
111148
if ! command -v gh >/dev/null 2>&1; then
@@ -228,6 +265,19 @@ if [ -f "$CONF_FILE" ]; then
228265
echo "✅ Plugin configuration injected into NetBox settings"
229266
fi
230267

268+
# Write local_settings.py to apply Django-level overrides that can't go through
269+
# NetBox's configuration module (settings.py only reads specific keys from it).
270+
LOCAL_SETTINGS="/opt/netbox/netbox/netbox/local_settings.py"
271+
cat > "$LOCAL_SETTINGS" << 'LOCALSETTINGS'
272+
# Auto-generated by devcontainer setup.sh — do not edit manually.
273+
274+
# Silence django-debug-toolbar's test-mode system check (E001).
275+
# SILENCED_SYSTEM_CHECKS in /etc/netbox/config/extra.py has no effect because
276+
# settings.py never reads that key from netbox.configuration.
277+
SILENCED_SYSTEM_CHECKS = ["debug_toolbar.E001"]
278+
LOCALSETTINGS
279+
echo "✅ local_settings.py written (SILENCED_SYSTEM_CHECKS)"
280+
231281
# Run migrations
232282
cd /opt/netbox/netbox
233283
export DEBUG="${DEBUG:-True}"

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ db.sqlite3
5151
.devcontainer/config/plugin-config.py
5252
.devcontainer/config/extra-configuration.py
5353
.devcontainer/config/extra-plugins.py
54+
.devcontainer/docker-compose.override.yml
5455

5556
# Proxy CA certificates (keep local)
5657
ca-bundle.crt

netbox_interface_name_rules/engine.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,46 @@ def apply_interface_name_rules(module, module_bay, force_reapply=False):
108108
return renamed
109109

110110

111+
def predict_rule_output(module, module_bay, raw_names):
112+
"""Predict the names apply_interface_name_rules would produce for raw_names.
113+
114+
Pure function — does not save, mutate, or query the Interface table. Used
115+
by external integrations (e.g., netbox-librenms-plugin) that need to know
116+
the post-rename names without actually applying any rule.
117+
118+
For breakout rules (channel_count > 0), each raw name expands to
119+
channel_count predicted names. For simple renames, one name in → one name
120+
out. Returns raw_names unchanged when no rule matches or evaluation fails.
121+
"""
122+
device_type = module.device.device_type if module.device else None
123+
platform = module.device.platform if module.device else None
124+
rule = find_matching_rule(module.module_type, _get_parent_module_type(module_bay), device_type, platform)
125+
if not rule:
126+
return list(raw_names)
127+
128+
variables = build_variables(module_bay, device=module.device)
129+
130+
output = []
131+
for raw_name in raw_names:
132+
vars_copy = dict(variables)
133+
vars_copy["base"] = raw_name
134+
try:
135+
if rule.channel_count > 0:
136+
temp = []
137+
for ch in range(rule.channel_count):
138+
vars_copy["channel"] = str(rule.channel_start + ch)
139+
temp.append(evaluate_name_template(rule.name_template, vars_copy))
140+
output.extend(temp)
141+
else:
142+
output.append(evaluate_name_template(rule.name_template, vars_copy))
143+
except (ValueError, TypeError, re.error):
144+
# Template eval failed; apply path would also fail and leave the
145+
# interface alone, so the predicted name is the raw name.
146+
output.append(raw_name)
147+
148+
return output
149+
150+
111151
def _try_rename_device_interface(rule, iface, vc_position, device, renamed_pks):
112152
"""Attempt to rename a single device-level interface using *rule*.
113153

netbox_interface_name_rules/signals.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,50 @@ def _apply_rules_for_device_deferred(device_pk):
241241
total,
242242
device,
243243
)
244+
245+
246+
# ---------------------------------------------------------------------------
247+
# Cross-plugin integration: netbox-librenms-plugin name prediction
248+
# ---------------------------------------------------------------------------
249+
# When netbox-librenms-plugin is installed it exposes a Signal that asks
250+
# subscribers to rewrite the list of interface names it predicts a module's
251+
# templates will produce. Without this rewrite, its module-adoption lookup
252+
# would only see NetBox's raw template output and miss interfaces we renamed
253+
# after install. The import is guarded so INR works fine when librenms-plugin
254+
# is absent.
255+
256+
try:
257+
from netbox_librenms_plugin.signals import (
258+
predict_module_interface_names as _librenms_predict_signal,
259+
)
260+
except ImportError:
261+
_librenms_predict_signal = None
262+
263+
264+
def on_librenms_predict_module_interface_names(sender, device, module, names, **kwargs):
265+
"""Rewrite librenms-plugin's predicted template names through our rule engine.
266+
267+
Always defined so it can be imported and tested in isolation even when
268+
netbox-librenms-plugin is not installed. Signal registration is guarded
269+
below so it only connects when the signal actually exists.
270+
"""
271+
module_bay = getattr(module, "module_bay", None)
272+
if module_bay is None:
273+
return None
274+
try:
275+
from .engine import predict_rule_output
276+
277+
return predict_rule_output(module, module_bay, names)
278+
except Exception:
279+
logger.exception(
280+
"predict_rule_output failed for module pk=%s; leaving names unchanged",
281+
getattr(module, "pk", "?"),
282+
)
283+
return None
284+
285+
286+
if _librenms_predict_signal is not None:
287+
receiver(
288+
_librenms_predict_signal,
289+
dispatch_uid="interface_name_rules_predict_module_interface_names",
290+
)(on_librenms_predict_module_interface_names)

0 commit comments

Comments
 (0)