Add ingestion test and fix OAuth2 scopes and bootstrap logic
- Add tests/test_ingestion.py for end-to-end Diode pipeline verification - Fix OAuth2 client scopes: reconciler uses diode:reconcile, netbox-to-diode needs diode:read diode:write netbox:read netbox:write - Rewrite bootstrap-clients.sh with upsert behavior (delete+recreate) so scope and secret changes are applied on restart - Rewrite nginx.conf in setup.sh to match upstream auth_request architecture - Update .claude/settings.json with expanded tool permissions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2f459e6f4a
commit
fbde598be3
@ -3,7 +3,26 @@
|
|||||||
"allow": [
|
"allow": [
|
||||||
"Bash(/home/user/netbox-diode-project/.venv/bin/python --version)",
|
"Bash(/home/user/netbox-diode-project/.venv/bin/python --version)",
|
||||||
"Bash(/home/user/netbox-diode-project/.venv/bin/pip --version)",
|
"Bash(/home/user/netbox-diode-project/.venv/bin/pip --version)",
|
||||||
"Bash(git add .gitignore .claude/settings.json docker-compose.yml init-hydra-db.sh nginx/nginx.conf oauth2/client/bootstrap-clients.sh setup.sh)"
|
"Bash(git add .gitignore .claude/settings.json docker-compose.yml init-hydra-db.sh nginx/nginx.conf oauth2/client/bootstrap-clients.sh setup.sh)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(git config user.name \"sam\")",
|
||||||
|
"Bash(git config user.email \"info@apodacalab.com\")",
|
||||||
|
"Bash(git push origin main)",
|
||||||
|
"Bash(docker compose down)",
|
||||||
|
"Bash(docker compose up -d)",
|
||||||
|
"Bash(docker ps --filter \"name=netbox-diode-project\" --format \"table {{.Names}}\\\\t{{.Status}}\")",
|
||||||
|
"WebFetch(domain:github.com)",
|
||||||
|
"WebFetch(domain:pypi.org)",
|
||||||
|
"WebFetch(domain:docs.netboxlabs.com)",
|
||||||
|
"WebFetch(domain:netboxlabs.com)",
|
||||||
|
"WebFetch(domain:gist.github.com)",
|
||||||
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
|
"WebFetch(domain:virtualwires.wordpress.com)",
|
||||||
|
"WebFetch(domain:python-ipmi.readthedocs.io)",
|
||||||
|
"WebFetch(domain:proxmoxer.github.io)",
|
||||||
|
"WebFetch(domain:docs.openstack.org)",
|
||||||
|
"WebFetch(domain:deepwiki.com)",
|
||||||
|
"WebFetch(domain:docs.ansible.com)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,25 +26,22 @@ create_client() {
|
|||||||
exists_in_hydra=true
|
exists_in_hydra=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Log client existence status
|
# Upsert behavior: remove stale client definition so scope/secret updates are applied.
|
||||||
if [ "$exists_in_hydra" = true ]; then
|
if [ "$exists_in_hydra" = true ]; then
|
||||||
echo "INFO: client $client_id exists in Hydra"
|
echo "INFO: client $client_id exists in Hydra, replacing to refresh scope/secret"
|
||||||
return 0
|
hydra delete oauth2-client "$client_id" --endpoint "$HYDRA_ADMIN_URL" >/dev/null
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create new client if it doesn't exist in Hydra
|
hydra create oauth2-client --endpoint "$HYDRA_ADMIN_URL" \
|
||||||
if [ "$exists_in_hydra" = false ]; then
|
--id "$client_id" \
|
||||||
client_output=$(hydra create oauth2-client --endpoint $HYDRA_ADMIN_URL \
|
--secret "$client_secret" \
|
||||||
--id $client_id \
|
|
||||||
--secret $client_secret \
|
|
||||||
--grant-type "client_credentials" \
|
--grant-type "client_credentials" \
|
||||||
--response-type "token" \
|
--response-type "token" \
|
||||||
--scope "$scope" \
|
--scope "$scope" \
|
||||||
--token-endpoint-auth-method "client_secret_post" \
|
--token-endpoint-auth-method "client_secret_post" \
|
||||||
--format json)
|
--format json >/dev/null
|
||||||
|
|
||||||
echo "INFO: client $client_id created"
|
echo "INFO: client $client_id created/updated"
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Load client credentials
|
# Load client credentials
|
||||||
|
|||||||
195
setup.sh
195
setup.sh
@ -85,7 +85,7 @@ cat > "${SCRIPT_DIR}/oauth2/client/client-credentials.json" <<EOF
|
|||||||
"client_name": "Diode Reconciler",
|
"client_name": "Diode Reconciler",
|
||||||
"grant_types": ["client_credentials"],
|
"grant_types": ["client_credentials"],
|
||||||
"token_endpoint_auth_method": "client_secret_post",
|
"token_endpoint_auth_method": "client_secret_post",
|
||||||
"scope": "diode:reconciler",
|
"scope": "diode:reconcile",
|
||||||
"audience": ["diode-reconciler"]
|
"audience": ["diode-reconciler"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -94,7 +94,7 @@ cat > "${SCRIPT_DIR}/oauth2/client/client-credentials.json" <<EOF
|
|||||||
"client_name": "NetBox to Diode",
|
"client_name": "NetBox to Diode",
|
||||||
"grant_types": ["client_credentials"],
|
"grant_types": ["client_credentials"],
|
||||||
"token_endpoint_auth_method": "client_secret_post",
|
"token_endpoint_auth_method": "client_secret_post",
|
||||||
"scope": "diode:netbox-to-diode",
|
"scope": "diode:read diode:write netbox:read netbox:write",
|
||||||
"audience": ["netbox-to-diode"]
|
"audience": ["netbox-to-diode"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -102,46 +102,59 @@ EOF
|
|||||||
|
|
||||||
echo "==> Writing OAuth2 bootstrap script..."
|
echo "==> Writing OAuth2 bootstrap script..."
|
||||||
cat > "${SCRIPT_DIR}/oauth2/client/bootstrap-clients.sh" <<'BOOTSTRAP_EOF'
|
cat > "${SCRIPT_DIR}/oauth2/client/bootstrap-clients.sh" <<'BOOTSTRAP_EOF'
|
||||||
#!/usr/bin/env sh
|
#!/usr/bin/env bash
|
||||||
set -e
|
|
||||||
|
|
||||||
HYDRA_ADMIN_URL="${HYDRA_ADMIN_URL:-http://hydra:4445}"
|
set -euo pipefail
|
||||||
CREDENTIALS_FILE="/client-credentials/client-credentials.json"
|
|
||||||
|
|
||||||
echo "Waiting for Hydra to be ready..."
|
# Constants
|
||||||
until wget -qO- "${HYDRA_ADMIN_URL}/health/ready" 2>/dev/null | grep -q '"status":"ok"'; do
|
CREDENTIALS_FILE="/etc/config/oauth2/client/client-credentials.json"
|
||||||
echo " Hydra not ready yet, retrying in 3s..."
|
|
||||||
sleep 3
|
|
||||||
done
|
|
||||||
echo "Hydra is ready."
|
|
||||||
|
|
||||||
CLIENT_COUNT=$(cat "${CREDENTIALS_FILE}" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))")
|
# Create the credentials file if it doesn't exist
|
||||||
|
if [ ! -f "$CREDENTIALS_FILE" ]; then
|
||||||
|
echo "ERROR: credentials file $CREDENTIALS_FILE not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
for i in $(seq 0 $((CLIENT_COUNT - 1))); do
|
# Wait for Hydra to be ready
|
||||||
CLIENT_JSON=$(cat "${CREDENTIALS_FILE}" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin)[$i]))")
|
sleep 3
|
||||||
CLIENT_ID=$(echo "${CLIENT_JSON}" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_id'])")
|
|
||||||
|
|
||||||
echo "Checking client: ${CLIENT_ID}"
|
# Function to create client
|
||||||
|
create_client() {
|
||||||
|
local client_id=$1
|
||||||
|
local client_secret=$2
|
||||||
|
local scope=$3
|
||||||
|
local exists_in_hydra=false
|
||||||
|
|
||||||
# Check if client already exists
|
# Check if client exists in Hydra
|
||||||
HTTP_CODE=$(wget -qO/dev/null -S "${HYDRA_ADMIN_URL}/admin/clients/${CLIENT_ID}" 2>&1 | grep "HTTP/" | tail -1 | awk '{print $2}')
|
if hydra get oauth2-client $client_id --endpoint $HYDRA_ADMIN_URL >/dev/null 2>&1; then
|
||||||
if [ "${HTTP_CODE}" = "200" ]; then
|
exists_in_hydra=true
|
||||||
echo " Client '${CLIENT_ID}' already exists, skipping."
|
|
||||||
continue
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo " Registering client '${CLIENT_ID}'..."
|
# Upsert behavior: remove stale client definition so scope/secret updates are applied.
|
||||||
wget -qO- --header="Content-Type: application/json" \
|
if [ "$exists_in_hydra" = true ]; then
|
||||||
--post-data="${CLIENT_JSON}" \
|
echo "INFO: client $client_id exists in Hydra, replacing to refresh scope/secret"
|
||||||
"${HYDRA_ADMIN_URL}/admin/clients" || {
|
hydra delete oauth2-client "$client_id" --endpoint "$HYDRA_ADMIN_URL" >/dev/null
|
||||||
echo " ERROR: Failed to register client '${CLIENT_ID}'"
|
fi
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
echo ""
|
|
||||||
echo " Client '${CLIENT_ID}' registered successfully."
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "All OAuth2 clients registered."
|
hydra create oauth2-client --endpoint "$HYDRA_ADMIN_URL" \
|
||||||
|
--id "$client_id" \
|
||||||
|
--secret "$client_secret" \
|
||||||
|
--grant-type "client_credentials" \
|
||||||
|
--response-type "token" \
|
||||||
|
--scope "$scope" \
|
||||||
|
--token-endpoint-auth-method "client_secret_post" \
|
||||||
|
--format json >/dev/null
|
||||||
|
|
||||||
|
echo "INFO: client $client_id created/updated"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load client credentials
|
||||||
|
jq -c '.[]' "$CREDENTIALS_FILE" | while read -r client; do
|
||||||
|
client_id=$(echo "$client" | jq -r '.client_id')
|
||||||
|
client_secret=$(echo "$client" | jq -r '.client_secret')
|
||||||
|
scope=$(echo "$client" | jq -r '.scope')
|
||||||
|
create_client "$client_id" "$client_secret" "$scope"
|
||||||
|
done
|
||||||
BOOTSTRAP_EOF
|
BOOTSTRAP_EOF
|
||||||
chmod +x "${SCRIPT_DIR}/oauth2/client/bootstrap-clients.sh"
|
chmod +x "${SCRIPT_DIR}/oauth2/client/bootstrap-clients.sh"
|
||||||
|
|
||||||
@ -173,15 +186,27 @@ orb:
|
|||||||
snmp_discovery:
|
snmp_discovery:
|
||||||
snmp_scan:
|
snmp_scan:
|
||||||
config:
|
config:
|
||||||
schedule: "0 */6 * * *"
|
# Staged SNMP rollout: crawl known hosts with more tolerant timing to avoid partial walks.
|
||||||
timeout: 300
|
schedule: "15 */6 * * *"
|
||||||
snmp_timeout: 10
|
timeout: 180
|
||||||
retries: 3
|
snmp_timeout: 15
|
||||||
|
retries: 2
|
||||||
defaults:
|
defaults:
|
||||||
site: "main"
|
site: "main"
|
||||||
|
role: "Network Device"
|
||||||
|
if_type: "other"
|
||||||
|
device:
|
||||||
|
manufacturer: "Unknown SNMP Manufacturer"
|
||||||
|
model: "Unknown SNMP Model"
|
||||||
|
interface_patterns:
|
||||||
|
- match: ".*"
|
||||||
|
type: "other"
|
||||||
|
interface:
|
||||||
|
description: "Discovered by orb snmp_discovery"
|
||||||
scope:
|
scope:
|
||||||
targets:
|
targets:
|
||||||
- host: "10.0.0.0/24"
|
- host: "10.10.20.182"
|
||||||
|
- host: "10.10.20.55"
|
||||||
authentication:
|
authentication:
|
||||||
protocol_version: "SNMPv2c"
|
protocol_version: "SNMPv2c"
|
||||||
community: "public"
|
community: "public"
|
||||||
@ -201,55 +226,83 @@ EOF
|
|||||||
echo "==> Writing nginx.conf..."
|
echo "==> Writing nginx.conf..."
|
||||||
mkdir -p "${SCRIPT_DIR}/nginx"
|
mkdir -p "${SCRIPT_DIR}/nginx"
|
||||||
cat > "${SCRIPT_DIR}/nginx/nginx.conf" <<'NGINX_EOF'
|
cat > "${SCRIPT_DIR}/nginx/nginx.conf" <<'NGINX_EOF'
|
||||||
worker_processes 1;
|
upstream diode-ingester {
|
||||||
|
server diode-ingester:8081;
|
||||||
events {
|
|
||||||
worker_connections 1024;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
http {
|
upstream diode-reconciler {
|
||||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
|
||||||
'$status $body_bytes_sent "$http_referer" '
|
|
||||||
'"$http_user_agent"';
|
|
||||||
|
|
||||||
access_log /var/log/nginx/access.log main;
|
|
||||||
error_log /var/log/nginx/error.log warn;
|
|
||||||
|
|
||||||
upstream ingester_grpc {
|
|
||||||
server diode-ingester:8081;
|
|
||||||
}
|
|
||||||
|
|
||||||
upstream reconciler_grpc {
|
|
||||||
server diode-reconciler:8081;
|
server diode-reconciler:8081;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream diode-auth {
|
||||||
|
server diode-auth:8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
listen [::]:8080;
|
||||||
|
http2 on;
|
||||||
|
server_name localhost;
|
||||||
|
client_max_body_size 25m;
|
||||||
|
|
||||||
|
location /auth/introspect {
|
||||||
|
internal;
|
||||||
|
proxy_method POST;
|
||||||
|
proxy_pass http://diode-auth/introspect;
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
proxy_set_header X-Original-URI $request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
upstream auth_grpc {
|
location /diode/auth {
|
||||||
server diode-auth:8081;
|
rewrite /diode/auth/(.*) /$1 break;
|
||||||
|
proxy_pass http://diode-auth;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
location /diode/diode.v1.IngesterService {
|
||||||
listen 8080 http2;
|
auth_request /auth/introspect;
|
||||||
|
auth_request_set $auth_status $upstream_status;
|
||||||
|
error_page 401 = @error401;
|
||||||
|
error_page 403 = @error403;
|
||||||
|
|
||||||
# Diode Ingester gRPC
|
rewrite /diode/(.*) /$1 break;
|
||||||
location /diode.v1.IngesterService/ {
|
grpc_pass grpc://diode-ingester;
|
||||||
grpc_pass grpc://ingester_grpc;
|
grpc_set_header Host $host;
|
||||||
|
grpc_set_header X-Real-IP $remote_addr;
|
||||||
|
grpc_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
grpc_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Diode Reconciler gRPC
|
location /diode/diode.v1.ReconcilerService {
|
||||||
location /diode.v1.ReconcilerService/ {
|
auth_request /auth/introspect;
|
||||||
grpc_pass grpc://reconciler_grpc;
|
auth_request_set $auth_status $upstream_status;
|
||||||
|
error_page 401 = @error401;
|
||||||
|
error_page 403 = @error403;
|
||||||
|
|
||||||
|
rewrite /diode/(.*) /$1 break;
|
||||||
|
grpc_pass grpc://diode-reconciler;
|
||||||
|
grpc_set_header Host $host;
|
||||||
|
grpc_set_header X-Real-IP $remote_addr;
|
||||||
|
grpc_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
grpc_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Diode Auth gRPC
|
|
||||||
location /diode.v1.AuthService/ {
|
|
||||||
grpc_pass grpc://auth_grpc;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
location /health {
|
location /health {
|
||||||
return 200 'OK';
|
return 200 'OK';
|
||||||
add_header Content-Type text/plain;
|
add_header Content-Type text/plain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location @error401 {
|
||||||
|
return 401 '{"error":"unauthorized","error_description":"Authentication required"}';
|
||||||
|
}
|
||||||
|
|
||||||
|
location @error403 {
|
||||||
|
return 403 '{"error":"forbidden","error_description":"Access denied"}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
NGINX_EOF
|
NGINX_EOF
|
||||||
|
|||||||
79
tests/test_ingestion.py
Normal file
79
tests/test_ingestion.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Minimal Diode ingestion test.
|
||||||
|
|
||||||
|
Pushes a test Site and Device through the Diode pipeline to verify
|
||||||
|
the full flow: SDK -> nginx -> ingester -> Redis -> reconciler -> NetBox
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from netboxlabs.diode.sdk import DiodeClient
|
||||||
|
from netboxlabs.diode.sdk.ingester import (
|
||||||
|
Device,
|
||||||
|
DeviceRole,
|
||||||
|
DeviceType,
|
||||||
|
Entity,
|
||||||
|
Manufacturer,
|
||||||
|
Platform,
|
||||||
|
Site,
|
||||||
|
)
|
||||||
|
|
||||||
|
DIODE_TARGET = os.getenv("DIODE_TARGET", "grpc://localhost:8080/diode")
|
||||||
|
DIODE_CLIENT_ID = os.getenv("DIODE_CLIENT_ID", os.getenv("INGESTER_CLIENT_ID", "diode-ingester"))
|
||||||
|
DIODE_CLIENT_SECRET = os.getenv("DIODE_CLIENT_SECRET", os.getenv("INGESTER_CLIENT_SECRET"))
|
||||||
|
|
||||||
|
if not DIODE_CLIENT_SECRET:
|
||||||
|
print("ERROR: DIODE_CLIENT_SECRET must be set")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"Connecting to Diode at {DIODE_TARGET}...")
|
||||||
|
|
||||||
|
with DiodeClient(
|
||||||
|
target=DIODE_TARGET,
|
||||||
|
client_id=DIODE_CLIENT_ID,
|
||||||
|
client_secret=DIODE_CLIENT_SECRET,
|
||||||
|
app_name="diode-test",
|
||||||
|
app_version="0.0.1",
|
||||||
|
) as client:
|
||||||
|
entities = [
|
||||||
|
# Test Site
|
||||||
|
Entity(site=Site(
|
||||||
|
name="Test Site - Diode",
|
||||||
|
status="active",
|
||||||
|
description="Created by Diode ingestion test",
|
||||||
|
)),
|
||||||
|
|
||||||
|
# Test Device
|
||||||
|
Entity(device=Device(
|
||||||
|
name="test-device-diode-01",
|
||||||
|
device_type=DeviceType(
|
||||||
|
model="Test Model",
|
||||||
|
manufacturer=Manufacturer(name="Test Manufacturer"),
|
||||||
|
),
|
||||||
|
platform=Platform(name="Linux"),
|
||||||
|
site=Site(name="Test Site - Diode"),
|
||||||
|
role=DeviceRole(name="Test Role"),
|
||||||
|
status="active",
|
||||||
|
serial="DIODE-TEST-001",
|
||||||
|
comments="Created by Diode ingestion test",
|
||||||
|
)),
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"Ingesting {len(entities)} entities...")
|
||||||
|
response = client.ingest(entities=entities)
|
||||||
|
|
||||||
|
if response.errors:
|
||||||
|
print(f"ERRORS: {response.errors}")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print("Ingestion successful!")
|
||||||
|
print("Check NetBox for 'Test Site - Diode' and 'test-device-diode-01'")
|
||||||
|
print(f" -> The reconciler will process these shortly.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
x
Reference in New Issue
Block a user