Generate the collector script per site, and bring EventSaver into the repo
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s

A site adopting ShopDB had to be handed two files and told what to edit in them.
Both are now the product's, and one of them the server writes for you.

GET /api/computers/client-script (admin) returns Report-AssetToShopDB.ps1 with
this site's values already in it: site_base_url becomes the -ApiUrl default and
the new computers_routableranges setting becomes -AllowedRanges. Only the
PARAMETER DEFAULTS are substituted - the copy in plugins/computers/client/ stays
runnable, so there is no second version to drift from the first - and everything
stamped stays overridable by argument or registry, because a bay may need to
differ from its site. Settings > Computers > Asset reporter edits the ranges,
downloads the script and shows its SHA-256.

The collector key is deliberately not stamped in, and a test fails if it ever
is. That file lands on every shop-floor PC, and a token spread across hundreds
of bays cannot be rotated quietly; it stays in the registry, provisioned per
ADOPTING-AT-ANOTHER-SITE.md.

The routable ranges are the last thing that was hardcoded in that script. They
are now a setting, so West Jefferson's two CIDRs move out of source code and
into that site's own configuration - which is what ADR-015 asks for - and a site
that sets nothing still works, because the script falls back to the NIC carrying
the default route.

EventSaver joins it in plugins/slides/client/, source only: EventSaver.cs and
EventSaver.ini, no compiled .scr - a binary is a release asset, like the
installer exe. The share path that was compiled into Config.Folder is gone. It
used to be the fallback when the ini was missing, which silently pointed a new
site at the reference site's file server; it is now empty, and failing visibly
beats displaying another site's slides. Verified by compiling the edited source
in the Windows VM with the in-box csc.exe: 15,872 bytes, exit 0.

Also: the DSC example in the adoption guide gains a CollectorRanges resource and
stops passing -ApiUrl to a script that already reads BaseUrl from the registry
the same example writes, and the guide points at the generated download instead
of hand-editing a URL.

The contract test caught the endpoint importing shopdb directly for the version
string, which ADR-002 forbids a plugin from doing. The product and contract
versions are in app.config now, which a plugin reads through current_app.

Adds docs/proposals/printer-assignment.md: assign printers to a PC in ShopDB and
let the bay install them, with what the fleet data says about drivers - HP and
Xerox cover 41 of 44 printers with universal drivers, there are no Brother
printers at all despite 208 files of Brother inkjet drivers in the installer,
and printerdrivers holds one row pointing at a per-model folder instead of a
universal driver.
This commit is contained in:
cproudlock
2026-08-18 15:51:14 -04:00
parent 96f127f8c8
commit 2083029ff2
13 changed files with 1169 additions and 16 deletions

View File

@@ -0,0 +1,122 @@
"""The collector reporter, generated with a site's own values.
A site used to receive a script with the reference site's server and VLANs in
it, which it had to find and edit. The server now stamps its own values into the
parameter defaults, so the file downloads ready to deploy.
What must stay true:
- ONLY the defaults are substituted. The copy in the repo stays runnable, so
there is never a second version to drift from the first.
- The collector key is NEVER in the file. It lands on every shop-floor PC, and a
token spread across hundreds of bays cannot be rotated quietly.
- Everything stamped is still overridable, because a bay may need to differ from
its site.
"""
import pytest
from shopdb.core.models import Setting
SCRIPT = '/api/computers/client-script'
def _set(db, key, value):
row = Setting.query.filter_by(key=key).first()
if row is None:
row = Setting(key=key, value=value, valuetype='string', category='computers')
db.session.add(row)
else:
row.value = value
db.session.commit()
def test_an_anonymous_caller_gets_nothing(client):
"""It states a site's URL and internal ranges - configuration, not a
handout."""
assert client.get(SCRIPT).status_code in (401, 422)
def test_it_serves_the_reporter_as_a_download(client, auth_headers):
resp = client.get(SCRIPT, headers=auth_headers)
assert resp.status_code == 200, resp.get_data(as_text=True)[:200]
assert 'attachment' in resp.headers['Content-Disposition']
assert 'Report-AssetToShopDB.ps1' in resp.headers['Content-Disposition']
body = resp.get_data(as_text=True)
assert 'param(' in body
assert 'api/collector/computers' in body
def test_it_publishes_a_hash_of_what_it_served(client, auth_headers):
"""So a deployment can verify what it fetched, like the installer does."""
import hashlib
resp = client.get(SCRIPT, headers=auth_headers)
digest = hashlib.sha256(resp.get_data()).hexdigest()
assert resp.headers['X-Script-Sha256'] == digest
def test_it_stamps_the_sites_own_url(client, db, auth_headers):
_set(db, 'site_base_url', 'https://shopdb.example.net')
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
assert "[string]$ApiUrl = 'https://shopdb.example.net/api/collector/computers'" in body
assert 'for https://shopdb.example.net' in body
def test_a_site_with_no_url_configured_still_gets_a_usable_script(client, db,
auth_headers):
"""Blank site_base_url falls back to the origin the admin is talking to,
which is by definition a reachable address for this server."""
_set(db, 'site_base_url', '')
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
assert "[string]$ApiUrl = ''" not in body
assert 'api/collector/computers' in body
def test_it_stamps_the_sites_ranges(client, db, auth_headers):
"""The replacement for the two VLANs that used to be source code."""
_set(db, 'computers_routableranges', '10.20.0.0/23,10.21.4.0/26')
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
assert "[string]$AllowedRanges = '10.20.0.0/23,10.21.4.0/26'" in body
def test_no_ranges_configured_leaves_the_default_route_fallback(client, db,
auth_headers):
"""An unconfigured site must not be given someone else's addressing: the
script falls back to the NIC carrying the default route."""
_set(db, 'computers_routableranges', '')
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
assert "[string]$AllowedRanges = ''" in body
assert 'default route' in body
def test_the_collector_key_is_never_in_the_file(client, db, auth_headers):
"""The one thing that must not be stamped in. A token in a file on every bay
is a token nobody can rotate quietly."""
_set(db, 'site_base_url', 'https://shopdb.example.net')
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
assert 'shopdb_pat_' not in body
assert "$ApiKey = ''" in body or "[string]$ApiKey = ''" in body
# It says where the key comes from instead.
assert 'CollectorKey' in body
def test_the_body_is_not_rewritten_only_the_defaults(client, auth_headers):
"""The repo copy stays runnable. If generation started editing the body,
the file on disk and the file a site runs would diverge."""
from plugins.computers.api.routes import _client_script_path
with open(_client_script_path(), 'r', encoding='utf-8') as handle:
source = handle.read()
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
# Every line of the source survives except the two parameter defaults.
changed = [line for line in source.splitlines()
if line.strip() and line not in body.splitlines()]
assert all('$ApiUrl' in line or '$AllowedRanges' in line for line in changed), changed
def test_it_names_the_version_that_generated_it(client, auth_headers):
"""So a script found on a bay can be traced to the server that made it."""
from shopdb import __version__
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
assert __version__ in body