Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: test

on:
push:
pull_request:

jobs:
# The core tiers install nothing, so this job is fast and cannot be broken
# by a registry outage. It is also the job that proves a bare checkout works:
# there is no ipplan database here, so the real-data tests skip.
core:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# ipplan2dhmap.py runs on whatever python the server ships, so cover
# both the oldest still-supported version and the newest.
python-version: ['3.12', '3.14']
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
node-version: '24'

- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}

- name: JS logic
run: make test-unit

- name: Python, servers and fake backend
run: make test-py

# jsdom, Playwright and a browser. Uses the same image as test/podman.sh so
# local and CI runs are the same environment.
browser:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.62.0-noble
steps:
- uses: actions/checkout@v7

- name: Install dependencies
run: npm ci --no-audit --no-fund

- name: Menu under jsdom
run: node --test "test/dom/*.test.js"

- name: Full app in chromium
run: npx playwright test

- if: failure()
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: |
playwright-report/
test-results/
retention-days: 7
18 changes: 17 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,17 @@
/data.json
/data.json

# Test outputs - never commit anything a test run produces
node_modules/
test-results/
playwright-report/
.playwright/
__pycache__/
*.pyc

# Real ipplan databases must never be committed (they contain host
# addressing and network plans). Drop them in local/ for local testing.
*.db
*.db.xz
/local/*
!/local/.gitkeep
!/local/README.md
76 changes: 76 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# dhmap - development and test entry points.
#
# The core tiers need nothing but python3 and node, which is why they are kept
# dependency-free. The browser tiers need jsdom, Playwright and a browser, so
# they run in a container and install nothing on the host.

PYTHON ?= python3
NODE ?= node
PORT ?= 8000
BACKEND_PORT ?= 5000
SCENARIO ?= degraded

PODMAN_RUN := ./test/podman.sh

.PHONY: test test-unit test-py test-dom test-e2e serve data clean help

help:
@echo 'make test run every tier whose dependencies are available'
@echo 'make test-unit JS logic (no install)'
@echo 'make test-py python + servers (no install)'
@echo 'make test-dom menu under jsdom (podman)'
@echo 'make test-e2e full app in chromium (podman)'
@echo 'make serve run the app locally with a fake backend'
@echo 'make clean remove generated outputs'
@echo
@echo 'Drop a real ipplan database in local/ to work against real data;'
@echo 'see local/README.md.'

# Tier 1 - no dependencies beyond node and python3.
test-unit:
$(NODE) --test "test/unit/*.test.js"

test-py:
$(PYTHON) -m unittest discover -s test/python -t test/python

# Tiers 2 and 3 - containerised, so nothing is installed on the host.
test-dom:
$(PODMAN_RUN) '$(NODE) --test "test/dom/*.test.js"'

test-e2e:
$(PODMAN_RUN) 'playwright test'

# Runs what it can: the core tiers always, the browser tiers when podman is
# available. Keeps a bare checkout useful without failing on missing tools.
test: test-unit test-py
@if command -v podman >/dev/null 2>&1; then \
$(MAKE) test-dom test-e2e; \
else \
echo; \
echo 'podman not found - skipping the browser tiers (test-dom, test-e2e).'; \
fi

# data.json is a build output: generated from a real ipplan database when one
# is present, otherwise from the example.
data:
@$(PYTHON) test/data_source.py

serve: data
@echo "map http://localhost:$(PORT)/dhmon.html"
@echo "example http://localhost:$(PORT)/src/examples/"
@echo "scenario $(SCENARIO)"
@echo
@$(PYTHON) test/fake/backend.py --port $(BACKEND_PORT) \
--scenario $(SCENARIO) \
--devices `$(PYTHON) test/data_source.py --devices` & \
trap 'kill %1 2>/dev/null' EXIT INT TERM; \
$(PYTHON) localserver.py --port $(PORT) \
--analytics-port $(BACKEND_PORT)

clean:
rm -f data.json
rm -rf test-results playwright-report
find . -name __pycache__ -type d -not -path './src/vendor/*' \
-exec rm -rf {} + 2>/dev/null || true
@echo 'Removed generated outputs. The container dependency volume is'
@echo 'kept; remove it with: podman volume rm dhmap-deps'
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,28 @@ HTML5/JS library for drawing and updating network layouts

# Try it

cd src/
python -m SimpleHTTPServer
make serve

Go to `http://localhost:8000/examples/`
Go to `http://localhost:8000/dhmon.html` for the map, or
`http://localhost:8000/src/examples/` for a minimal usage example.

`make serve` runs the local web server together with a fake analytics backend,
so the map is populated without needing a real dhmon behind it. To render a
real event instead, drop an ipplan database into `local/` first — see
[local/README.md](local/README.md).

# Tests

make test

Runs everything that has its dependencies available. The core suites need only
`python3` and `node`:

make test-unit # JS logic
make test-py # ipplan2dhmap.py, localserver, fake backend

The browser suites additionally need `npm ci` and
`npx playwright install chromium`:

make test-dom # menu rendering under jsdom
make test-e2e # full app in headless chromium
Empty file added local/.gitkeep
Empty file.
33 changes: 33 additions & 0 deletions local/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# local/

Scratch directory for local development. **Everything here is gitignored.**

## Running against real event data

Drop a real ipplan database here and every command picks it up automatically:

cp ~/Downloads/ipplan.db.xz local/

`.xz` files are decompressed to a temp directory transparently, so you can drop
the file in exactly as it arrives. Then:

make serve # serves the real event map on localhost:8000
make test-py # additionally runs the assertions against the real database

Resolution order, used consistently everywhere:

1. `$IPPLAN_DB`
2. `local/ipplan.db`
3. `local/ipplan.db.xz`
4. a generated fixture topology (the default, and what CI uses)

With no database present everything still works — the suite falls back to a
fixture topology and the real-data tests report as skipped.

## Do not commit real data

A real `ipplan.db` contains host records with IPv4/IPv6 addressing and network
definitions with VLANs, netmasks and gateways. This repository is public *and*
is deployed verbatim to `/var/www/html/dhmap` by puppet, so anything committed
here becomes web-served. `.gitignore` covers `local/`, `*.db` and `*.db.xz` to
make that mistake hard to make.
128 changes: 113 additions & 15 deletions localserver.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,122 @@
#!/usr/bin/env python3
# Used to run dhmap + analytics on the same machine for dev purposes
"""Serve dhmap locally, proxying /analytics/ to the analytics backend.

import SimpleHTTPServer
import SocketServer
Used to run dhmap + analytics on the same machine for dev purposes. The
analytics backend is normally dhmon's; for local work without one, see
test/fake/backend.py, which serves the same endpoints from a scenario.

PORT = 8000
The proxy mirrors production, where apache is configured with
`ProxyPass /analytics http://localhost:5000` (see the dhmon::analytics puppet
class), so /analytics/ping.status is served from the backend's /ping.status
and the browser sees a single origin.
"""
import argparse
import functools
import http.server
import os
import socketserver
import urllib.error
import urllib.request

DEFAULT_PORT = 8000
DEFAULT_ANALYTICS_PORT = 5000
# Matches the ProxyPass url in the dhmon::analytics puppet class.
ANALYTICS_PREFIX = '/analytics'


class RevHandler(http.server.SimpleHTTPRequestHandler):
"""Static file handler that reverse-proxies /analytics/ to the backend."""

analytics_port = DEFAULT_ANALYTICS_PORT
# Long enough to survive a slow backend, short enough not to hang a reload.
proxy_timeout = 10

class RevHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
def do_GET(self):
if self.path.startswith('/analytics/'):
path = self.path[len('/analytics/'):]
self.send_response(302)
self.send_header("Location", "http://localhost:5000/" + path)
if self.path.startswith(ANALYTICS_PREFIX):
# ProxyPass strips the prefix, so /analytics/ping.status is the
# backend's /ping.status.
return self._proxy(self.path[len(ANALYTICS_PREFIX):])
return http.server.SimpleHTTPRequestHandler.do_GET(self)

def _proxy(self, path):
url = 'http://localhost:%d%s' % (self.analytics_port, path)

# Fetch first, then relay. Writing to the client inside this try would
# let a disconnected browser - BrokenPipeError is an OSError - be
# misreported as the backend being unreachable.
try:
with urllib.request.urlopen(url, timeout=self.proxy_timeout) as upstream:
status, headers, body = (
upstream.status, upstream.headers, upstream.read())
except urllib.error.HTTPError as error:
# Pass the backend's own error through rather than masking it.
with error:
status, headers, body = error.code, error.headers, error.read()
except (urllib.error.URLError, OSError) as error:
return self._safely(self.send_error, 502,
'analytics backend unreachable at %s: %s'
% (url, error))

self._relay(status, headers, body)

def _relay(self, status, headers, body):
def write():
self.send_response(status)
content_type = headers.get('Content-Type')
if content_type:
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
return None
else:
SimpleHTTPServer.SimpleHTTPRequestHandler.do_GET(self)
self.wfile.write(body)

self._safely(write)

def _safely(self, action, *args):
"""Run `action`, tolerating the client having gone away.

The map polls every 10 seconds, so a reload or a closed tab routinely
drops a connection mid-response. That is normal, not an error worth a
traceback.
"""
try:
action(*args)
except (BrokenPipeError, ConnectionResetError):
self.close_connection = True


def serve(port=DEFAULT_PORT, analytics_port=DEFAULT_ANALYTICS_PORT,
directory=None, quiet=False):
"""Create a server. Returns it without serving, so tests can drive it.

Pass port=0 to bind an arbitrary free port; read it back from
httpd.server_address[1]. Pass quiet=True to suppress request logging.
"""
# Subclass per server so concurrent servers can target different backends;
# setting the attribute on a functools.partial would not reach the class.
overrides = {'analytics_port': analytics_port}
if quiet:
overrides['log_message'] = lambda self, *args: None
handler = type('BoundRevHandler', (RevHandler,), overrides)
handler = functools.partial(
handler,
directory=directory or os.path.dirname(os.path.abspath(__file__)))
# Without this a restart within TIME_WAIT fails to bind.
socketserver.TCPServer.allow_reuse_address = True
return socketserver.TCPServer(('', port), handler)

httpd = SocketServer.TCPServer(("", PORT), RevHandler)

print("serving at port {}".format(PORT))
httpd.serve_forever()
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--port', type=int, default=DEFAULT_PORT,
help='port to serve on (default: %(default)s)')
parser.add_argument('--analytics-port', type=int,
default=DEFAULT_ANALYTICS_PORT,
help='analytics backend port (default: %(default)s)')
args = parser.parse_args()

httpd = serve(args.port, args.analytics_port)
print('serving at port {}'.format(args.port))
try:
httpd.serve_forever()
except KeyboardInterrupt:
httpd.shutdown()
Loading