Generate the collector script per site, and bring EventSaver into the repo
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:
@@ -1,13 +1,13 @@
|
||||
"""Computers plugin API endpoints."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask import Blueprint, request, Response, current_app
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AuditLog, Communication, CommunicationType, Setting, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||
|
||||
from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
|
||||
|
||||
from shopdb.api import require_permission, apply_import_timestamps
|
||||
from shopdb.api import require_permission, require_role, apply_import_timestamps
|
||||
|
||||
computers_bp = Blueprint('computers', __name__)
|
||||
|
||||
@@ -1064,3 +1064,112 @@ def dashboard_sharedmachines():
|
||||
|
||||
out.sort(key=lambda r: -r['pccount'])
|
||||
return success_response(out)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Collector client script
|
||||
# =============================================================================
|
||||
|
||||
CLIENT_SCRIPT_NAME = 'Report-AssetToShopDB.ps1'
|
||||
|
||||
|
||||
def _client_script_path():
|
||||
"""The reporter shipped with this plugin, which is the single source."""
|
||||
import os
|
||||
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
'client', CLIENT_SCRIPT_NAME)
|
||||
|
||||
|
||||
def _setting_value(key):
|
||||
row = Setting.query.filter_by(key=key).first()
|
||||
return ((row.value if row else '') or '').strip()
|
||||
|
||||
|
||||
def _generate_client_script(source: str, baseurl: str, ranges: str,
|
||||
version: str, generatedon: str) -> str:
|
||||
"""Stamp a site's own values into the reporter's parameter defaults.
|
||||
|
||||
ONLY the defaults are substituted, never the body: the file in the repo
|
||||
stays runnable as-is, so there is no second copy to drift. Everything
|
||||
stamped here is overridable at runtime - the parameter still wins, then the
|
||||
registry - because a bay may need to differ from its site.
|
||||
|
||||
The collector key is NOT stamped in. This file lands on every shop-floor PC,
|
||||
and a token in a file on hundreds of bays cannot be rotated quietly; it is
|
||||
read from HKLM:\\SOFTWARE\\GE\\ShopDB CollectorKey, provisioned per
|
||||
ADOPTING-AT-ANOTHER-SITE.md.
|
||||
"""
|
||||
apiurl = baseurl.rstrip('/') + '/api/collector/computers' if baseurl else ''
|
||||
header = (
|
||||
'# GENERATED by ShopDB {version} on {generatedon}\n'
|
||||
'# for {baseurl}\n'
|
||||
'#\n'
|
||||
'# Re-download after upgrading ShopDB: this copy matches that server\'s\n'
|
||||
'# collector contract. Edits here are lost on the next download - change\n'
|
||||
'# the site settings instead, or pass -ApiUrl / -AllowedRanges.\n'
|
||||
'#\n'
|
||||
'# The collector key is deliberately NOT in this file. Provision it as\n'
|
||||
'# HKLM:\\SOFTWARE\\GE\\ShopDB CollectorKey - see the adoption guide.\n'
|
||||
'\n'
|
||||
).format(version=version, generatedon=generatedon,
|
||||
baseurl=baseurl or 'an unconfigured site (set site_base_url)')
|
||||
|
||||
out = source
|
||||
if apiurl:
|
||||
old = "[string]$ApiUrl = ''"
|
||||
assert old in out, 'the reporter no longer declares $ApiUrl as expected'
|
||||
out = out.replace(old, "[string]$ApiUrl = '{0}'".format(apiurl), 1)
|
||||
if ranges:
|
||||
old = "[string]$AllowedRanges = ''"
|
||||
assert old in out, 'the reporter no longer declares $AllowedRanges as expected'
|
||||
out = out.replace(old, "[string]$AllowedRanges = '{0}'".format(ranges), 1)
|
||||
return header + out
|
||||
|
||||
|
||||
@computers_bp.route('/client-script', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def download_client_script():
|
||||
"""The collector reporter, stamped with THIS site's values.
|
||||
|
||||
Admin-only. It carries no secret, but it does state a site's URL and its
|
||||
internal ranges, which is configuration rather than something to hand out.
|
||||
"""
|
||||
import datetime
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
path = _client_script_path()
|
||||
if not os.path.isfile(path):
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'The collector script is not present in this install',
|
||||
http_code=404)
|
||||
|
||||
with open(path, 'r', encoding='utf-8') as handle:
|
||||
source = handle.read()
|
||||
|
||||
# A site that has not set its public URL still gets a usable script: the
|
||||
# browsing origin is the server the admin is talking to right now.
|
||||
baseurl = _setting_value('site_base_url') or request.url_root
|
||||
# From config, not an import: a plugin reaching into core is an ADR-002
|
||||
# violation and the contract test fails the build for it.
|
||||
version = current_app.config.get('VERSION') or 'unknown'
|
||||
generatedon = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d')
|
||||
|
||||
try:
|
||||
body = _generate_client_script(
|
||||
source, baseurl.strip(), _setting_value('computers_routableranges'),
|
||||
version, generatedon)
|
||||
except AssertionError as exc:
|
||||
return error_response(ErrorCodes.INTERNAL_ERROR, str(exc), http_code=500)
|
||||
|
||||
digest = hashlib.sha256(body.encode('utf-8')).hexdigest()
|
||||
return Response(
|
||||
body,
|
||||
mimetype='text/plain; charset=utf-8',
|
||||
headers={
|
||||
'Content-Disposition': 'attachment; filename={0}'.format(CLIENT_SCRIPT_NAME),
|
||||
# Published so a deployment can verify what it fetched, the same way
|
||||
# the installer publishes one.
|
||||
'X-Script-Sha256': digest,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user