Generic build for other GE sites: no WJ data, one shopfloor type, Windows USB builder
Everything West Jefferson specific moved out to a separate repo (pxe-site-wj): NTLARS/UDC/heat-treat backups keyed by WJ machine numbers, CMM and Wax/Trace bay lists, WJ OpenText profiles, site-config.json, the WJ preinstall list, and the WJ shopfloor PC types (Collections, NoCollections, Common, Keyence, CMM, Genspect, HeatTreat, Wax/Trace, Display, PartMarker) with GE-Enforce, machine numbers and the desktop layout. The part-marker bench tools moved to their own repo. Dead scripts removed: build-proxmox-iso, deploy-bios, sync_hardware_models, mirror-from-gold, pull-bios, startnet-template.cmd. Shopfloor is one generic type, gea-shopfloor-standard, with today's timing: - PPKG at first logon (unattend Order 10, unchanged). - Install-AppList.ps1 runs C:\PreInstall\preinstall.json during setup. - A new SYSTEM task, GE Shopfloor Post-PPKG, runs postppkg.json once after the post-PPKG reboot. It also clears AutoLogonCount after the ShopFloor handover (the Users-level monitor can be denied that HKLM write) and removes the Intune Sync task when done. - Both lists ship empty. A site adds entries and installers. - WinPE choice 3 goes straight to the PPKG menu. Staging, fetch and heal carry only the generic payload. The webapp's shopfloor sub-menu editor is gone, and the imaging tiles name the new stages. - The playbook deploys the empty lists only if missing. It now deploys the PowerShell 7 MSI the shopfloor unattend installs, which it never did before. Blancco site settings move into the webapp (Updates > Blancco releases): business location, licensing Wi-Fi SSID and password. They are rendered into the served preferences.xml and applied to every staged release, and are seeded from site-vars (site_name, blancco_wifi_ssid, blancco_wifi_password) only while none exist. The repo XML carries no site values; the plaintext Wi-Fi password is gone from it. New site_name and site_timezone settings: report and evidence-pack site name, server clock and webapp time zone. No more West Jefferson defaults in webapp/config.py or the dashboard title. Sync-BPRT.ps1 gets the server address filled in at download instead of WJ's GE address. USB on Windows: - scripts/Build-PxeUsb.ps1 runs the same checks as build-usb.sh. It writes one FAT32 CIDATA partition (ISO files plus patched grub.cfg plus payload) with diskpart and reads every file back by SHA-256. - Tested with Windows PowerShell 5.1 in the win11 VM, and the layout installs and first-boots in the pxetest VM. - scripts/stage-usb-share.sh stages the build folder on a share. - site-content/ is optional for both builders. KB docs/kb/How_To_Deploy_PXE_Server_New_Site.html: new-site deployment, Windows only, with a site-vars.yml guide. Checks: webapp tests pass (5 suites); the unattend lint and playbook syntax checks pass; every .ps1 parses; there are no WJ hosts, IPs, names or PC types in playbook/, webapp/, scripts/ or autoinstall/.
This commit is contained in:
@@ -1648,8 +1648,6 @@ def startnet_editor():
|
||||
boot_menu=boot_menu,
|
||||
lint=lint,
|
||||
snapshots=snapshots,
|
||||
shopfloor_items=read_shopfloor_menu(),
|
||||
shopfloor_available=available_pctypes(),
|
||||
image_types=config.IMAGE_TYPES,
|
||||
friendly_names=config.FRIENDLY_NAMES,
|
||||
)
|
||||
@@ -1813,77 +1811,6 @@ def startnet_diff():
|
||||
return jsonify({"error": "provide snapshot_id or content"}), 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes - Shopfloor boot-menu (data-driven menu.json)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SHOPFLOOR_SETUP_DIR = os.path.join(config.ENROLLMENT_SHARE, "shopfloor-setup")
|
||||
SHOPFLOOR_MENU_JSON = os.path.join(SHOPFLOOR_SETUP_DIR, "menu.json")
|
||||
|
||||
|
||||
def read_shopfloor_menu():
|
||||
"""The shopfloor PC-type menu items from menu.json (list; [] if absent)."""
|
||||
try:
|
||||
with open(SHOPFLOOR_MENU_JSON) as fh:
|
||||
return json.load(fh).get("shopfloor", [])
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
|
||||
|
||||
def available_pctypes():
|
||||
"""gea-shopfloor-* handler dirs on the share - the only valid menu keys."""
|
||||
try:
|
||||
return sorted(
|
||||
d for d in os.listdir(SHOPFLOOR_SETUP_DIR)
|
||||
if d.startswith("gea-shopfloor-")
|
||||
and os.path.isdir(os.path.join(SHOPFLOOR_SETUP_DIR, d))
|
||||
)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
@app.route("/shopfloor-menu", methods=["GET", "POST"])
|
||||
def shopfloor_menu():
|
||||
if request.method == "POST":
|
||||
try:
|
||||
items = json.loads(request.form.get("items", "[]"))
|
||||
except json.JSONDecodeError:
|
||||
flash("Invalid menu payload.", "danger")
|
||||
return redirect(url_for("startnet_editor") + "#tab-shopfloor")
|
||||
valid = set(available_pctypes())
|
||||
clean = []
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
key = (it.get("key") or "").strip()
|
||||
label = (it.get("label") or "").strip()
|
||||
if not key or not label:
|
||||
continue
|
||||
if key not in valid:
|
||||
flash(f"Skipped '{label}': {key} has no shopfloor-setup handler.", "warning")
|
||||
continue
|
||||
clean.append({
|
||||
"key": key,
|
||||
"label": label,
|
||||
"hint": (it.get("hint") or "").strip(),
|
||||
"enabled": bool(it.get("enabled", True)),
|
||||
})
|
||||
if not clean:
|
||||
flash("Refused to save an empty menu.", "danger")
|
||||
return redirect(url_for("startnet_editor") + "#tab-shopfloor")
|
||||
try:
|
||||
os.makedirs(SHOPFLOOR_SETUP_DIR, exist_ok=True)
|
||||
with open(SHOPFLOOR_MENU_JSON, "w") as fh:
|
||||
json.dump({"shopfloor": clean}, fh, indent=2)
|
||||
audit("SHOPFLOOR_MENU_SAVE", f"{len(clean)} items")
|
||||
flash("Shopfloor boot menu saved - WinPE reads it at next boot.", "success")
|
||||
except OSError as exc:
|
||||
flash(f"Could not write menu.json: {exc}", "danger")
|
||||
return redirect(url_for("startnet_editor") + "#tab-shopfloor")
|
||||
|
||||
return redirect(url_for("startnet_editor") + "#tab-shopfloor")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes - Audit Log
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2064,6 +1991,7 @@ app.jinja_env.globals["blancco_failure_reason"] = blancco_report.failure_reason
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
return {
|
||||
"site_name": config.BLANCCO_SITE,
|
||||
"all_image_types": config.IMAGE_TYPES,
|
||||
"all_friendly_names": config.FRIENDLY_NAMES,
|
||||
# image pages: tab table, header data, tab URLs (see _image_header.html)
|
||||
|
||||
@@ -29,7 +29,7 @@ IMAGING_DIR = os.environ.get("IMAGING_DIR", "/var/log/pxe-imaging")
|
||||
# Blancco evidence: site name stamped on reports and evidence packs, and a
|
||||
# small JSON state file (cloud-upload confirmations, ServiceNow ticks) kept
|
||||
# off the report share so it never shows up as a report.
|
||||
BLANCCO_SITE = os.environ.get("BLANCCO_SITE", "West Jefferson")
|
||||
BLANCCO_SITE = os.environ.get("BLANCCO_SITE", "Unnamed site")
|
||||
# Every time the webapp shows (and evidence-pack month boundaries) is in
|
||||
# this zone, DST included. Stored timestamps keep their own UTC offsets.
|
||||
DISPLAY_TZ = os.environ.get("DISPLAY_TZ", "America/New_York")
|
||||
|
||||
165
webapp/services/blancco_prefs.py
Normal file
165
webapp/services/blancco_prefs.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Site settings in the Blancco preferences: set in the webapp, not in XML.
|
||||
|
||||
Three values differ per site and are filled into our preferences.xml:
|
||||
business_location printed on every erasure report (the site)
|
||||
wifi_ssid the Wi-Fi Blancco joins to license erasures in the GE
|
||||
wifi_password Blancco cloud (the imaging LAN has no internet)
|
||||
|
||||
They live in UPDATES_DIR/blancco/site-settings.json (0600, root). The repo's
|
||||
playbook/blancco-preferences.xml carries none of them; the playbook installs
|
||||
it as WEB_ROOT/blancco/preferences.repo.xml and this module renders the
|
||||
served WEB_ROOT/blancco/preferences.xml from it. The same values are applied
|
||||
whenever a new Blancco release is staged (blancco_update.apply_prefs), so an
|
||||
update keeps them.
|
||||
|
||||
The playbook seeds the settings from site-vars.yml (site_name,
|
||||
blancco_wifi_ssid, blancco_wifi_password) only while no settings file exists:
|
||||
after install the webapp page (Updates > Blancco releases) is the place to
|
||||
change them, and a playbook re-run never overwrites them.
|
||||
|
||||
python3 -m services.blancco_prefs render [--seed]
|
||||
--seed reads BLANCCO_SEED_LOCATION / _SSID / _PASSWORD from the
|
||||
environment (not argv: the password stays out of ps).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
import config
|
||||
from services import updates_common as uc
|
||||
|
||||
FIELDS = ("business_location", "wifi_ssid", "wifi_password")
|
||||
DEFAULTS = {"business_location": "", "wifi_ssid": "INTERNETACCESS", "wifi_password": ""}
|
||||
|
||||
_LOCATION = re.compile(rb"(<business_location>)[^<]*(</business_location>)")
|
||||
_WIRELESS = re.compile(
|
||||
rb'(<wireless>\s*<enabled>[^<]*</enabled>\s*<ssid encrypted="false">)[^<]*'
|
||||
rb'(</ssid>\s*<password encrypted="false">)[^<]*(</password>)')
|
||||
|
||||
|
||||
def settings_path():
|
||||
return os.path.join(uc.state_dir("blancco"), "site-settings.json")
|
||||
|
||||
|
||||
def repo_prefs_path():
|
||||
return os.path.join(config.WEB_ROOT, "blancco", "preferences.repo.xml")
|
||||
|
||||
|
||||
def served_prefs_path():
|
||||
return os.path.join(config.WEB_ROOT, "blancco", "preferences.xml")
|
||||
|
||||
|
||||
def load():
|
||||
data = uc.read_json(settings_path(), None)
|
||||
out = dict(DEFAULTS)
|
||||
if isinstance(data, dict):
|
||||
for k in FIELDS:
|
||||
if isinstance(data.get(k), str):
|
||||
out[k] = data[k]
|
||||
return out
|
||||
|
||||
|
||||
def exists():
|
||||
return os.path.isfile(settings_path())
|
||||
|
||||
|
||||
def _clean(value, field):
|
||||
value = (value or "").strip()
|
||||
if len(value) > 128:
|
||||
raise ValueError(f"{field} is too long (128 characters at most)")
|
||||
if any(ord(c) < 32 for c in value):
|
||||
raise ValueError(f"{field} has control characters")
|
||||
return value
|
||||
|
||||
|
||||
def save(business_location, wifi_ssid, wifi_password=None):
|
||||
"""Store the settings. wifi_password None (or '') keeps the stored one."""
|
||||
cur = load()
|
||||
new = {
|
||||
"business_location": _clean(business_location, "Business location"),
|
||||
"wifi_ssid": _clean(wifi_ssid, "Wi-Fi network"),
|
||||
"wifi_password": cur["wifi_password"] if not wifi_password else _clean(wifi_password, "Wi-Fi password"),
|
||||
}
|
||||
if not new["wifi_ssid"]:
|
||||
raise ValueError("Wi-Fi network (SSID) cannot be empty")
|
||||
path = settings_path()
|
||||
tmp = path + ".tmp"
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(new, f, indent=2)
|
||||
os.replace(tmp, path)
|
||||
return new
|
||||
|
||||
|
||||
def apply(xml, settings=None):
|
||||
"""Our preferences (bytes) with the site settings filled in. Values are
|
||||
XML-escaped; the replacement is a function, so nothing in them is read
|
||||
as a regex group reference."""
|
||||
s = settings or load()
|
||||
loc = escape(s["business_location"] or "Unnamed site").encode("utf-8")
|
||||
ssid = escape(s["wifi_ssid"]).encode("utf-8")
|
||||
pw = escape(s["wifi_password"]).encode("utf-8")
|
||||
out, n = _LOCATION.subn(lambda m: m.group(1) + loc + m.group(2), xml)
|
||||
if n != 1:
|
||||
raise ValueError("preferences have no single <business_location> element")
|
||||
out, n = _WIRELESS.subn(lambda m: m.group(1) + ssid + m.group(2) + pw + m.group(3), out)
|
||||
if n != 1:
|
||||
raise ValueError("preferences have no single <wireless> ssid/password block")
|
||||
return out
|
||||
|
||||
|
||||
def render():
|
||||
"""Write the served preferences.xml from the repo copy + settings, when it
|
||||
changes. Returns True if the file was written."""
|
||||
with open(repo_prefs_path(), "rb") as f:
|
||||
base = f.read()
|
||||
data = apply(base)
|
||||
# Same guard the playbook and the Blancco page apply: well-formed XML.
|
||||
import xml.etree.ElementTree as ET
|
||||
ET.fromstring(data)
|
||||
path = served_prefs_path()
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
if f.read() == data:
|
||||
return False
|
||||
except OSError:
|
||||
pass
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(data)
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, path)
|
||||
return True
|
||||
|
||||
|
||||
def public():
|
||||
"""For the page: the settings without the password."""
|
||||
s = load()
|
||||
return {"business_location": s["business_location"], "wifi_ssid": s["wifi_ssid"],
|
||||
"wifi_password_set": bool(s["wifi_password"]), "configured": exists()}
|
||||
|
||||
|
||||
def main(argv):
|
||||
if not argv or argv[0] != "render":
|
||||
print(__doc__)
|
||||
return 2
|
||||
if "--seed" in argv[1:] and not exists():
|
||||
save(os.environ.get("BLANCCO_SEED_LOCATION", ""),
|
||||
os.environ.get("BLANCCO_SEED_SSID", "") or DEFAULTS["wifi_ssid"],
|
||||
os.environ.get("BLANCCO_SEED_PASSWORD", ""))
|
||||
print("seeded site settings from site-vars")
|
||||
s = load()
|
||||
changed = render()
|
||||
print("CHANGED" if changed else "UNCHANGED")
|
||||
if not s["business_location"]:
|
||||
print("WARNING: business_location is empty: reports say 'Unnamed site'")
|
||||
if not s["wifi_password"]:
|
||||
print(f"WARNING: no Wi-Fi password for '{s['wifi_ssid']}': Blancco cannot license erasures")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -955,6 +955,15 @@ def apply_prefs(version):
|
||||
ours = _read(src)
|
||||
if not ours:
|
||||
raise ValueError(f"no preferences at {src}")
|
||||
# The site's business location and Wi-Fi (Updates > Blancco, site
|
||||
# settings) go into every release, so an update keeps them.
|
||||
from services import blancco_prefs
|
||||
try:
|
||||
ours = blancco_prefs.apply(ours)
|
||||
except ValueError as e:
|
||||
meta.update(status="failed", error=f"our preferences ({src}): {e}", prefs_source=src)
|
||||
uc.write_json(meta_path, meta)
|
||||
raise
|
||||
try:
|
||||
cfg = patch_config_img(vendor_cfg, ours)
|
||||
except StageBlocked as e:
|
||||
|
||||
@@ -79,8 +79,10 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Source = 'C:\ProgramData\GEAerospace\MediaCenter\Cache\Deploy\Applications\BPRT',
|
||||
[string]$WebUrl = 'http://10.134.48.170:9009',
|
||||
[string]$SshHost = '10.134.48.170',
|
||||
# Filled in with the address the page was opened from when the script is
|
||||
# downloaded from the PXE webapp (Updates > BPRT sync); else pass them.
|
||||
[string]$WebUrl = '',
|
||||
[string]$SshHost = '',
|
||||
[string]$SshUser = 'pxe',
|
||||
[switch]$DryRun,
|
||||
[string]$ZipOut,
|
||||
@@ -91,6 +93,9 @@ param(
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not $WebUrl -or -not $SshHost) {
|
||||
throw 'Give -WebUrl http://<PXE server>:9009 and -SshHost <PXE server> (or download this script from the PXE webapp, which fills them in).'
|
||||
}
|
||||
|
||||
# The cache folder has also been seen under MediaCreator rather than
|
||||
# MediaCenter; try that one when the default is missing.
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
{% import '_ui.html' as ui %}
|
||||
{% set stage_labels = {
|
||||
1: ('Booting from PXE', 'WinPE loaded - applying Windows image to disk.'),
|
||||
2: ('Configuring Windows', 'First boot. Running shopfloor setup baseline scripts.'),
|
||||
3: ('Installing apps', 'Type-specific app installs (eDNC, UDC, NTLARS, etc).'),
|
||||
4: ('Apps installed', 'Type-specific scripts complete. Preparing for Intune enrollment.'),
|
||||
2: ('Configuring Windows', 'First boot. Running shopfloor setup.'),
|
||||
3: ('Installing site apps', 'The site app list (pre-install). Empty unless the site added apps.'),
|
||||
4: ('Site apps done', 'Pre-install list complete. Preparing for Intune enrollment.'),
|
||||
5: ('Enrolling in Intune', 'PPKG installing - device joining Azure AD + Intune. ~5-10 min, reboot to follow.'),
|
||||
6: ('Waiting on first Intune sync','Post-PPKG settle (~120s). Triggering Schedule #3 sync repeatedly.'),
|
||||
7: ('Registered - assign category',
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
</div>
|
||||
<div class="ui-group">
|
||||
<div class="ui-navhead">Tools</div>
|
||||
{{ navlink('Boot menu', url_for('startnet_editor'), ep in ('startnet_editor', 'shopfloor_menu')) }}
|
||||
{{ navlink('Boot menu', url_for('startnet_editor'), ep == 'startnet_editor') }}
|
||||
{{ navlink('Enrollment packages', url_for('enrollment'), ep == 'enrollment') }}
|
||||
{{ navlink('Clonezilla backups', url_for('clonezilla_backups'), ep == 'clonezilla_backups') }}
|
||||
{{ navlink('Audit log', url_for('audit_log'), ep == 'audit_log') }}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{% block content %}
|
||||
{% set services_up = services | selectattr('active') | list | length %}
|
||||
{% call ui.head('Overview', 'West Jefferson') %}
|
||||
{% call ui.head('Overview', site_name) %}
|
||||
{{ nic_pill }}
|
||||
<details class="more">
|
||||
<summary class="fbtn" style="background: var(--surface); min-height: 36px">
|
||||
|
||||
@@ -69,11 +69,10 @@ Content-Type: application/json
|
||||
"serial": "4HBLF33",
|
||||
"mac": "e4:54:e8:dc:b1:f0",
|
||||
"hostname_target": "EDNMG3D4",
|
||||
"pctype": "gea-shopfloor-keyence",
|
||||
"machinenumber": "9999",
|
||||
"current_stage": "Run-ShopfloorSetup: 09-Setup-Keyence",
|
||||
"stage_index": 7,
|
||||
"stage_total": 9,
|
||||
"pctype": "gea-shopfloor-standard",
|
||||
"current_stage": "Run-ShopfloorSetup: installing the site app list",
|
||||
"stage_index": 3,
|
||||
"stage_total": 8,
|
||||
"status": "in_progress",
|
||||
"log_lines": ["last few log lines from the stage"]
|
||||
}</pre>
|
||||
|
||||
@@ -179,8 +179,6 @@
|
||||
<li role="presentation"><button class="stn-tab active" id="tab-settings-btn" data-bs-toggle="tab" data-bs-target="#tab-settings" type="button" role="tab" aria-controls="tab-settings" aria-selected="true">Settings</button></li>
|
||||
<li role="presentation"><button class="stn-tab" id="tab-menu-btn" data-bs-toggle="tab" data-bs-target="#tab-menu" type="button" role="tab" aria-controls="tab-menu" aria-selected="false">Boot menu
|
||||
{% if boot_menu %}<span class="tab-count">{{ boot_menu | length }}</span>{% endif %}</button></li>
|
||||
<li role="presentation"><button class="stn-tab" id="tab-shopfloor-btn" data-bs-toggle="tab" data-bs-target="#tab-shopfloor" type="button" role="tab" aria-controls="tab-shopfloor" aria-selected="false">Shopfloor sub-menu
|
||||
{% if shopfloor_items %}<span class="tab-count">{{ shopfloor_items | length }}</span>{% endif %}</button></li>
|
||||
<li role="presentation"><button class="stn-tab" id="tab-raw-btn" data-bs-toggle="tab" data-bs-target="#tab-raw" type="button" role="tab" aria-controls="tab-raw" aria-selected="false">Raw
|
||||
{% if lint.errors %}{{ ui.chip(lint.errors | length ~ (' error' if lint.errors | length == 1 else ' errors'), 'err') }}
|
||||
{% elif lint.warnings %}{{ ui.chip(lint.warnings | length ~ (' warning' if lint.warnings | length == 1 else ' warnings'), 'warn') }}{% endif %}</button></li>
|
||||
@@ -265,31 +263,6 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ============================ SHOPFLOOR SUB-MENU ======================== -->
|
||||
<div class="tab-pane fade" id="tab-shopfloor" role="tabpanel" aria-labelledby="tab-shopfloor-btn">
|
||||
<section class="panel stn-pad" aria-labelledby="sfHead">
|
||||
<div class="stn-bar">
|
||||
<div class="grow"><h2 id="sfHead">Shopfloor sub-menu</h2>
|
||||
<div class="sub">menu.json on the enrollment share. No boot.wim edit.</div></div>
|
||||
<button type="button" class="ui-btn" id="sfAddBtn">Add item</button>
|
||||
<button type="submit" form="sfForm" class="ui-btn pri">Save shopfloor menu</button>
|
||||
</div>
|
||||
<div class="notice info" style="margin-top: 12px"><span>
|
||||
The GEA Shopfloor choice drills into this sub-menu. Each PC type must be an existing
|
||||
<span class="mono">shopfloor-setup/gea-shopfloor-*</span> handler. WinPE falls back to the
|
||||
built-in menu if the share or picker is unavailable. Rows without a label are dropped on save.</span>
|
||||
</div>
|
||||
<form action="{{ url_for('shopfloor_menu') }}" method="post" id="sfForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="items" id="sfItemsData" value="[]">
|
||||
<div class="ed-list" id="sfTable">
|
||||
<div class="ed-hrow sf-grid" aria-hidden="true"><span>#</span><span class="ed-lab">Label</span><span class="ed-key">PC type (key)</span><span class="ed-hint">Hint</span><span class="ed-en">Shown</span><span></span></div>
|
||||
<div id="sfBody"><!-- rows injected by JS --></div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ================================= RAW ================================= -->
|
||||
<div class="tab-pane fade" id="tab-raw" role="tabpanel" aria-labelledby="tab-raw-btn">
|
||||
<div class="stn-cols">
|
||||
@@ -733,59 +706,4 @@
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
<script>
|
||||
(function(){
|
||||
const SF_AVAIL = {{ shopfloor_available | tojson }};
|
||||
const SF_ITEMS = {{ shopfloor_items | tojson }};
|
||||
const body = document.getElementById('sfBody');
|
||||
if (!body) return;
|
||||
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
const opts = sel => SF_AVAIL.map(k=>`<option value="${esc(k)}" ${k===sel?'selected':''}>${esc(k)}</option>`).join('');
|
||||
let uid = 0;
|
||||
// One line per item; Move up / Move down / Remove live in the row's "..." menu.
|
||||
function row(it){
|
||||
const r=document.createElement('div');
|
||||
r.className='ed-row sf-grid sf-row';
|
||||
const key=it.key||(SF_AVAIL[0]||'');
|
||||
const u=++uid;
|
||||
r.innerHTML =
|
||||
`<span class="ed-n sf-n"></span>`+
|
||||
`<div class="ed-lab"><label class="ed-cap" for="sfL${u}">Label</label><input class="field sf-label" id="sfL${u}" value="${esc(it.label||'')}" placeholder="Menu label"></div>`+
|
||||
`<div class="ed-key"><label class="ed-cap" for="sfK${u}">PC type (key)</label><select class="field sf-key" id="sfK${u}">${opts(key)}</select></div>`+
|
||||
`<div class="ed-hint"><label class="ed-cap" for="sfH${u}">Hint</label><input class="field sf-hint" id="sfH${u}" value="${esc(it.hint||'')}" placeholder="(optional)"></div>`+
|
||||
`<label class="ed-en"><input type="checkbox" class="sf-en" ${it.enabled===false?'':'checked'}><span class="ed-cap sf-en-txt" style="margin:0">Shown</span></label>`+
|
||||
`<div class="ed-act"><details class="more"><summary class="ui-btn ghost sf-more">⋯</summary><div class="menu">`+
|
||||
`<button type="button" class="sf-up">Move up</button><button type="button" class="sf-down">Move down</button>`+
|
||||
`<div class="sep"></div><button type="button" class="sf-rm del">Remove item</button></div></details></div>`;
|
||||
return r;
|
||||
}
|
||||
const rows=()=>[...body.querySelectorAll('.sf-row')];
|
||||
const renum=()=>rows().forEach((r,i,all)=>{
|
||||
const n=i+1;
|
||||
r.querySelector('.sf-n').textContent=n;
|
||||
r.querySelector('.sf-label').setAttribute('aria-label','Item '+n+' label');
|
||||
r.querySelector('.sf-key').setAttribute('aria-label','Item '+n+' PC type');
|
||||
r.querySelector('.sf-hint').setAttribute('aria-label','Item '+n+' hint');
|
||||
r.querySelector('.sf-en').setAttribute('aria-label','Item '+n+' shown in menu');
|
||||
r.querySelector('.sf-more').setAttribute('aria-label','More actions for item '+n);
|
||||
r.querySelector('.sf-up').disabled = i===0;
|
||||
r.querySelector('.sf-down').disabled = i===all.length-1;
|
||||
});
|
||||
const add=it=>{body.appendChild(row(it||{}));renum();};
|
||||
body.addEventListener('click',e=>{
|
||||
const r=e.target.closest('.sf-row'); if(!r)return;
|
||||
const menu=r.querySelector('details.more');
|
||||
if(e.target.closest('.sf-rm')){r.remove();renum();}
|
||||
else if(e.target.closest('.sf-up')&&r.previousElementSibling){r.parentNode.insertBefore(r,r.previousElementSibling);renum();menu.removeAttribute('open');menu.querySelector('summary').focus();}
|
||||
else if(e.target.closest('.sf-down')&&r.nextElementSibling){r.parentNode.insertBefore(r.nextElementSibling,r);renum();menu.removeAttribute('open');menu.querySelector('summary').focus();}
|
||||
});
|
||||
document.getElementById('sfAddBtn').addEventListener('click',()=>{add({});const l=body.lastElementChild.querySelector('.sf-label');if(l)l.focus();});
|
||||
document.getElementById('sfForm').addEventListener('submit',()=>{
|
||||
const items=rows().map(r=>({label:r.querySelector('.sf-label').value.trim(),key:r.querySelector('.sf-key').value,hint:r.querySelector('.sf-hint').value.trim(),enabled:r.querySelector('.sf-en').checked})).filter(x=>x.label&&x.key);
|
||||
document.getElementById('sfItemsData').value=JSON.stringify(items);
|
||||
});
|
||||
(SF_ITEMS.length?SF_ITEMS:[]).forEach(add);
|
||||
if(!rows().length) add({});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -66,6 +66,11 @@
|
||||
.sec-h, .upl, .prog { padding-left: 16px; padding-right: 16px; }
|
||||
.cur { padding: 18px 16px; }
|
||||
}
|
||||
.site-form { margin-top: 12px; }
|
||||
.site-grid { display: grid; grid-template-columns: max-content minmax(0, 28rem); gap: 8px 14px; align-items: center; }
|
||||
.site-grid .form-label { margin: 0; }
|
||||
.site-act { margin-top: 12px; }
|
||||
@media (max-width: 640px) { .site-grid { grid-template-columns: minmax(0, 1fr); } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -144,6 +149,33 @@
|
||||
<div class="sub" style="overflow-wrap: anywhere">Served from <span class="mono">{{ current.path }}</span>.</div>
|
||||
</section>
|
||||
|
||||
{# ---- Site settings ------------------------------------------------------- #}
|
||||
<section class="panel cur" aria-labelledby="site-h">
|
||||
<div class="cur-top">
|
||||
<div>
|
||||
<h2 id="site-h" class="sec" style="font-size: 15px; color: var(--muted)">Site settings</h2>
|
||||
<div class="sub">On every erasure report, and the Wi-Fi Blancco joins to license erasures in the GE Blancco cloud. Saved into every release, including ones staged later.</div>
|
||||
</div>
|
||||
<span style="margin-left: auto">{% if not site.configured %}{{ ui.chip('Not set', 'warn') }}{% elif not site.business_location or not site.wifi_password_set %}{{ ui.chip('Incomplete', 'warn') }}{% else %}{{ ui.chip('Set', 'ok') }}{% endif %}</span>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('blancco_update.site_settings') }}" class="site-form" autocomplete="off">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="site-grid">
|
||||
<label class="form-label" for="bpLoc">Business location</label>
|
||||
<input type="text" class="form-control" id="bpLoc" name="business_location" maxlength="128"
|
||||
value="{{ site.business_location }}" placeholder="Site name, e.g. Lynn">
|
||||
<label class="form-label" for="bpSsid">Wi-Fi network (SSID)</label>
|
||||
<input type="text" class="form-control" id="bpSsid" name="wifi_ssid" maxlength="128" required
|
||||
value="{{ site.wifi_ssid }}">
|
||||
<label class="form-label" for="bpPass">Wi-Fi password</label>
|
||||
<input type="password" class="form-control" id="bpPass" name="wifi_password" maxlength="128"
|
||||
autocomplete="new-password"
|
||||
placeholder="{{ 'Set (leave empty to keep it)' if site.wifi_password_set else 'Not set' }}">
|
||||
</div>
|
||||
<div class="site-act"><button type="submit" class="ui-btn pri sm">Save</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{# ---- Staged -------------------------------------------------------------- #}
|
||||
<section class="panel ui-list" aria-labelledby="stg-h">
|
||||
<div class="sec-h"><div><h2 id="stg-h" class="sec">Staged releases</h2>
|
||||
|
||||
120
webapp/tests/test_blancco_prefs.py
Normal file
120
webapp/tests/test_blancco_prefs.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Blancco site settings (business location, licensing Wi-Fi) rendered into
|
||||
the served preferences.xml.
|
||||
|
||||
Run from the webapp folder:
|
||||
PYTHONPATH=.:<deps> python -m unittest tests.test_blancco_prefs -v
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
from unittest import mock
|
||||
|
||||
_T = tempfile.mkdtemp(prefix="pxe-blancco-prefs-test-")
|
||||
for _k, _sub in {"WEB_ROOT": "www", "UPDATES_DIR": "upd"}.items():
|
||||
os.environ.setdefault(_k, os.path.join(_T, _sub))
|
||||
os.makedirs(os.environ[_k], exist_ok=True)
|
||||
|
||||
WEBAPP = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
REPO_PREFS = os.path.join(WEBAPP, "..", "playbook", "blancco-preferences.xml")
|
||||
sys.path.insert(0, WEBAPP)
|
||||
|
||||
from services import blancco_prefs as BP # noqa: E402
|
||||
|
||||
|
||||
def served():
|
||||
root = ET.parse(BP.served_prefs_path()).getroot()
|
||||
return (root.find(".//customer_data/business_location").text,
|
||||
root.find(".//wireless/ssid").text,
|
||||
root.find(".//wireless/password").text)
|
||||
|
||||
|
||||
class BlanccoPrefs(unittest.TestCase):
|
||||
def setUp(self):
|
||||
shutil.rmtree(os.environ["UPDATES_DIR"], ignore_errors=True)
|
||||
os.makedirs(os.environ["UPDATES_DIR"])
|
||||
os.makedirs(os.path.dirname(BP.repo_prefs_path()), exist_ok=True)
|
||||
shutil.copy(REPO_PREFS, BP.repo_prefs_path())
|
||||
for p in (BP.served_prefs_path(),):
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
|
||||
def test_repo_file_carries_no_site_values(self):
|
||||
root = ET.parse(REPO_PREFS).getroot()
|
||||
self.assertFalse(root.find(".//customer_data/business_location").text)
|
||||
self.assertFalse(root.find(".//wireless/password").text)
|
||||
self.assertFalse(root.find(".//wireless/ssid").text)
|
||||
|
||||
def test_render_with_special_characters(self):
|
||||
BP.save("Smith & Sons <Plant 2>", "Example-WiFi", r'Fake-Pa55!@# \1 \g<0> "q" &')
|
||||
self.assertTrue(BP.render())
|
||||
self.assertEqual(served(), ("Smith & Sons <Plant 2>", "Example-WiFi", r'Fake-Pa55!@# \1 \g<0> "q" &'))
|
||||
|
||||
def test_rest_of_file_unchanged(self):
|
||||
BP.save("Site A", "NET", "pw")
|
||||
BP.render()
|
||||
def strip(t):
|
||||
import re
|
||||
t = re.sub(r"<business_location>[^<]*</business_location>", "", t)
|
||||
return re.sub(r'(<ssid encrypted="false">)[^<]*(</ssid>\s*<password encrypted="false">)[^<]*', r"\1\2", t)
|
||||
with open(REPO_PREFS) as a, open(BP.served_prefs_path()) as b:
|
||||
self.assertEqual(strip(a.read()), strip(b.read()))
|
||||
|
||||
def test_render_is_idempotent(self):
|
||||
BP.save("Site A", "NET", "pw")
|
||||
self.assertTrue(BP.render())
|
||||
self.assertFalse(BP.render())
|
||||
|
||||
def test_blank_password_keeps_the_stored_one(self):
|
||||
BP.save("Site A", "NET", "first")
|
||||
BP.save("Site B", "NET2", "")
|
||||
BP.save("Site C", "NET3", None)
|
||||
s = BP.load()
|
||||
self.assertEqual((s["business_location"], s["wifi_ssid"], s["wifi_password"]), ("Site C", "NET3", "first"))
|
||||
|
||||
def test_public_never_returns_the_password(self):
|
||||
BP.save("Site A", "NET", "secret-pw")
|
||||
pub = BP.public()
|
||||
self.assertNotIn("secret-pw", json.dumps(pub))
|
||||
self.assertTrue(pub["wifi_password_set"])
|
||||
|
||||
def test_settings_file_is_private(self):
|
||||
BP.save("Site A", "NET", "pw")
|
||||
self.assertEqual(stat.S_IMODE(os.stat(BP.settings_path()).st_mode), 0o600)
|
||||
|
||||
def test_empty_ssid_refused(self):
|
||||
with self.assertRaisesRegex(ValueError, "SSID"):
|
||||
BP.save("Site A", " ", "pw")
|
||||
|
||||
def test_control_characters_refused(self):
|
||||
with self.assertRaisesRegex(ValueError, "control"):
|
||||
BP.save("Site\nA", "NET", "pw")
|
||||
|
||||
def test_unset_location_renders_unnamed_site(self):
|
||||
BP.save("", "NET", "")
|
||||
BP.render()
|
||||
self.assertEqual(served()[0], "Unnamed site")
|
||||
|
||||
def test_seed_only_when_no_settings_exist(self):
|
||||
env = {"BLANCCO_SEED_LOCATION": "Seeded Site", "BLANCCO_SEED_SSID": "SEEDNET",
|
||||
"BLANCCO_SEED_PASSWORD": "seedpw"}
|
||||
with mock.patch.dict(os.environ, env):
|
||||
self.assertEqual(BP.main(["render", "--seed"]), 0)
|
||||
self.assertEqual(served(), ("Seeded Site", "SEEDNET", "seedpw"))
|
||||
# Changed in the webapp afterwards: a later playbook run keeps it.
|
||||
BP.save("Webapp Site", "WEBNET", "webpw")
|
||||
with mock.patch.dict(os.environ, env):
|
||||
BP.main(["render", "--seed"])
|
||||
self.assertEqual(served(), ("Webapp Site", "WEBNET", "webpw"))
|
||||
|
||||
def test_apply_refuses_unknown_layout(self):
|
||||
with self.assertRaises(ValueError):
|
||||
BP.apply(b"<preferences><x/></preferences>")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -11,6 +11,7 @@ services/blancco_update.py; this module is the route surface.
|
||||
from flask import (Blueprint, abort, flash, jsonify, redirect, render_template, request,
|
||||
send_file, url_for)
|
||||
|
||||
from services import blancco_prefs
|
||||
from services import blancco_update as bu
|
||||
from services.audit import audit
|
||||
|
||||
@@ -37,9 +38,31 @@ def page():
|
||||
upload_dir=bu.upload_dir(),
|
||||
source_dirs=bu.source_dirs(),
|
||||
server=bu.SERVER,
|
||||
site=blancco_prefs.public(),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/site-settings", methods=["POST"])
|
||||
def site_settings():
|
||||
"""Business location and licensing Wi-Fi: saved, then the served
|
||||
preferences.xml is rendered again. The password is never shown back."""
|
||||
try:
|
||||
with bu._lock:
|
||||
blancco_prefs.save(request.form.get("business_location", ""),
|
||||
request.form.get("wifi_ssid", ""),
|
||||
request.form.get("wifi_password", ""))
|
||||
changed = blancco_prefs.render()
|
||||
except (OSError, ValueError) as e:
|
||||
flash(f"Blancco site settings not saved: {e}", "danger")
|
||||
return _back()
|
||||
s = blancco_prefs.public()
|
||||
audit("BLANCCO_SITE_SETTINGS", f"location={s['business_location']!r} ssid={s['wifi_ssid']!r} "
|
||||
f"password={'set' if s['wifi_password_set'] else 'empty'}")
|
||||
flash("Blancco site settings saved" + (" and preferences.xml updated." if changed else " (no change)."),
|
||||
"success")
|
||||
return _back()
|
||||
|
||||
|
||||
@bp.route("/status.json")
|
||||
def status():
|
||||
job = bu.job_state()
|
||||
|
||||
@@ -9,8 +9,10 @@ activation run as one background job at a time; logic lives in
|
||||
services/bprt_sync.py, this module is the route surface.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from flask import (Blueprint, abort, flash, jsonify, redirect, render_template,
|
||||
request, send_file, url_for)
|
||||
Response, request, send_file, url_for)
|
||||
|
||||
from services import bprt_sync as bs
|
||||
from services import mcl_ppkg
|
||||
@@ -131,7 +133,17 @@ def script():
|
||||
path = bs.sync_script()
|
||||
if not path:
|
||||
abort(404)
|
||||
return send_file(path, mimetype="text/plain", as_attachment=True, download_name="Sync-BPRT.ps1")
|
||||
# The server address as the work PC reached it (its GE address, port
|
||||
# 9009) goes in as the default, so the script needs no edits.
|
||||
host = request.host.split(":")[0]
|
||||
if not re.fullmatch(r"[A-Za-z0-9.-]{1,253}", host):
|
||||
abort(400)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
text = text.replace("[string]$WebUrl = '',", f"[string]$WebUrl = 'http://{host}:9009',", 1)
|
||||
text = text.replace("[string]$SshHost = '',", f"[string]$SshHost = '{host}',", 1)
|
||||
return Response(text, mimetype="text/plain",
|
||||
headers={"Content-Disposition": "attachment; filename=Sync-BPRT.ps1"})
|
||||
|
||||
|
||||
def _start(kind, arg, ok_msg, audit_action):
|
||||
|
||||
Reference in New Issue
Block a user