Files
pxe-server/webapp/templates/updates_blancco.html
cproudlock 397063f987 Webapp: Updates pages, image page tabs with review-before-save, BPRT sync
Updates group:
- Dell drivers and BIOS: check Dell's catalogs against all three driver maps
  and models.txt, download with a shared speed cap, repack, preview, apply
  with backups and rollback.
- iPXE boot files: check GitHub for the signed Secure Boot set, verify the
  sha256 and each file's signer, install atomically, roll back. Includes the
  by-hand procedure.
- Blancco: upload a release, stage it, test-boot it from the menu, activate,
  roll back.
- BPRT sync: validate a batch uploaded from the MediaCreator Lite cache
  (scripts/windows/Sync-BPRT.ps1 or a zip), show the diff, swap with the site
  overlay files kept.

Images: one sidebar entry per image with Overview, Drivers, Unattend and OS and
Packages tabs. Every save shows a diff first, keeps the file's format, makes a
backup and can be undone. Driver lint runs inline.

Enrollment: upload standard/engineer MCL packages, one per tenant/purpose,
with archive and restore. Hardware coverage counts engineer as Win11.
2026-10-13 16:58:55 -04:00

399 lines
24 KiB
HTML

{% extends "base.html" %}
{% block title %}Blancco Updates - PXE Server Manager{% endblock %}
{% macro mb(n) -%}{{ "%.1f"|format((n or 0) / 1048576) }} MB{%- endmacro %}
{% macro release_cells(r) -%}
<td class="mono">{{ r.kernel or '-' }}</td>
<td class="mono">{{ r.prefs_version or '-' }}{% if r.vendor_prefs_version and r.vendor_prefs_version != r.prefs_version %}<div class="small text-muted">vendor {{ r.vendor_prefs_version }}</div>{% endif %}</td>
<td class="text-nowrap">{{ mb(r.size) }}</td>
{%- endmacro %}
{% block content %}
<div class="page-header">
<h1><i class="bi bi-eraser"></i> Blancco Updates</h1>
<div class="header-actions">
<span class="badge badge-info badge-lg">Live: {{ current.version }}{% if current.inferred %} (installed by hand){% endif %}</span>
{% if test %}<span class="badge badge-warning badge-lg">Test entry: {{ test.version or '?' }}</span>{% endif %}
</div>
</div>
<p class="text-muted">
Installs a new Blancco Drive Eraser release from the vendor ISO (or the vendor's USB image, <span class="mono">.img</span>
or <span class="mono">.img.gz</span>). Staging extracts the release next to the live one, applies the same changes as
<span class="mono">scripts/prepare-boot-tools.sh</span> (our preferences written into <span class="mono">config.img</span>,
the initramfs network patch), and a test entry in the PXE boot menu boots the staged copy. Only <strong>Activate</strong>
changes what the normal Blancco entry boots, and the files it replaces are kept as a backup you can roll back to.
The Ubuntu kernel, its initrd and the kmod tarball the UEFI path uses are not part of a release and are never touched.
</p>
{# ---- Live release -------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-hdd-network"></i> Live release</div>
<p class="small text-muted mb-2">Served from <span class="mono">{{ current.path }}</span>.</p>
<div class="table-container">
<table class="data-table">
<thead><tr><th>Version</th><th>Blancco kernel</th><th>Preferences</th><th>Size</th><th>Rootfs date</th><th>Installed</th></tr></thead>
<tbody>
<tr>
<td class="mono">{{ current.version }}
{% if current.inferred %}<div class="small text-muted">from the preferences in config.img</div>{% endif %}
{% if current.changed_by_hand %}<div class="small text-warning">files changed outside this page since the last activate or roll back</div>{% endif %}</td>
{{ release_cells(current) }}
<td class="mono text-nowrap">{{ current.rootfs_mtime | timestamp_fmt if current.rootfs_mtime else '-' }}</td>
<td class="small">
{% if current.activated %}{{ current.activated | local_dt }}
{% if current.restored_from %}<div class="text-muted">rolled back to {{ current.restored_from }}</div>
{% elif current.source %}<div class="text-muted">from {{ current.source }}</div>{% endif %}
{% else %}<span class="text-muted">by hand (before this page)</span>{% endif %}
</td>
</tr>
</tbody>
</table>
</div>
{% if current.missing %}
<div class="alert alert-warning mt-2 mb-0">Missing from the live folder: <span class="mono">{{ current.missing | join(', ') }}</span></div>
{% endif %}
</div>
{# ---- Job ----------------------------------------------------------------- #}
{% if job.state %}
<div class="section-card" id="blanccoJob" data-state="{{ job.state }}">
<div class="section-title"><i class="bi bi-gear"></i> Staging {{ job.source or '' }}</div>
{% if job_running %}
<div class="d-flex align-items-center gap-2 mb-1">
<span class="spinner-border spinner-border-sm" role="status"></span>
<span id="blanccoJobStep">{{ job.step or 'Starting' }}</span>
<span id="blanccoJobMsg" class="small text-muted">{{ job.message or '' }}</span>
</div>
<div class="progress mb-2" style="height: 0.5rem;">
<div id="blanccoJobBar" class="progress-bar" style="width: {{ job.percent or 0 }}%;"></div>
</div>
<form method="post" action="{{ url_for('blancco_update.cancel') }}" class="mb-2"
onsubmit="return confirm('Stop staging and remove the partial files?');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm"><i class="bi bi-x-circle"></i> Cancel</button>
</form>
{% elif job.state == 'done' %}
<div class="alert alert-success mb-2">{{ job.message }} <span class="small text-muted">({{ job.finished | local_dt }})</span></div>
{% elif job.state == 'blocked' %}
<div class="alert alert-warning mb-2"><strong>Staging stopped: preferences need merging.</strong> {{ job.message }}</div>
{% elif job.state == 'cancelled' %}
<div class="alert alert-warning mb-2">Staging was cancelled.</div>
{% else %}
<div class="alert alert-danger mb-2">{{ job.message or 'Staging failed.' }}</div>
{% endif %}
{% if job.log %}
<details {% if job_running or job.state in ('failed', 'blocked') %}open{% endif %}>
<summary class="small text-muted" style="cursor:pointer;">Job log</summary>
<pre id="blanccoJobLog" class="mono small mb-0" style="white-space: pre-wrap; max-height: 16rem; overflow:auto;">{{ job.log | join('\n') }}</pre>
</details>
{% endif %}
</div>
{% endif %}
{# ---- Get a release ------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-upload"></i> Release files</div>
<form id="blanccoUpload" method="post" action="{{ url_for('blancco_update.upload') }}" enctype="multipart/form-data"
class="d-flex flex-wrap gap-2 align-items-end mb-2">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field mb-0" style="min-width: min(22rem, 100%);">
<label for="blanccoFile" class="form-label">Vendor ISO or USB image</label>
<input type="file" class="form-control" id="blanccoFile" name="release_file" accept=".iso,.img,.gz" required>
<div class="form-text">About 1 GB; streamed to <span class="mono">{{ upload_dir }}</span>.</div>
</div>
<button type="submit" class="pxe-btn pxe-btn-primary"><i class="bi bi-upload"></i> Upload</button>
</form>
<div id="blanccoUploadProgress" class="mb-3" style="display:none;">
<div class="small mb-1" id="blanccoUploadText">Uploading...</div>
<div class="progress" style="height: 0.5rem;"><div id="blanccoUploadBar" class="progress-bar" style="width: 0%;"></div></div>
</div>
<p class="small text-muted">
Files copied in by hand are listed too, from
{% for key, folder in source_dirs %}<span class="mono">{{ folder }}</span>{{ ', ' if not loop.last }}{% endfor %}
(<span class="mono">.iso</span>, <span class="mono">.img</span>, <span class="mono">.img.gz</span>, <span class="mono">.iso.gz</span>).
</p>
{% if sources %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>File</th><th>Folder</th><th>Size</th><th>Modified</th><th class="text-end"></th></tr></thead>
<tbody>
{% for s in sources %}
<tr>
<td class="mono">{{ s.name }}</td>
<td class="small text-muted">{{ s.key }}</td>
<td class="text-nowrap">{{ mb(s.size) }}</td>
<td class="mono text-nowrap">{{ s.modified | timestamp_fmt }}</td>
<td class="text-end text-nowrap">
<form method="post" action="{{ url_for('blancco_update.stage') }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="source" value="{{ s.key }}:{{ s.name }}">
<button type="submit" class="pxe-btn pxe-btn-primary btn-row-action" {% if job_running %}disabled{% endif %}>
<i class="bi bi-box-seam"></i> Stage</button>
</form>
{% if s.deletable %}
<form method="post" action="{{ url_for('blancco_update.delete_upload', name=s.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Delete ' ~ s.name ~ '?') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action"><i class="bi bi-trash"></i></button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">No release files yet. Upload the vendor ISO or USB image above.</p>
{% endif %}
</div>
{# ---- Staged -------------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-box-seam"></i> Staged releases</div>
{% if staged %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Version</th><th>Blancco kernel</th><th>Preferences</th><th>Size</th><th>Status</th><th>Staged</th><th class="text-end"></th></tr></thead>
<tbody>
{% for s in staged %}
<tr>
<td class="mono">{{ s.name }}{% if s.in_menu %} <span class="badge badge-warning">in boot menu</span>{% endif %}</td>
{{ release_cells(s) }}
<td>
{% if s.status == 'ready' %}<span class="badge badge-success">Ready</span>
{% if s.merged_prefs %}<div class="small text-muted">merged preferences</div>{% endif %}
{% elif s.status == 'blocked' %}<span class="badge badge-danger">Needs preferences merge</span>
{% elif s.status == 'failed' %}<span class="badge badge-danger">Failed</span>
{% if s.error %}<div class="small text-danger wrap">{{ s.error }}</div>{% endif %}
{% else %}<span class="badge badge-secondary">{{ s.status }}</span>{% endif %}
</td>
<td class="small text-nowrap">{{ s.staged | local_dt }}<div class="text-muted">{{ s.source or '' }}</div></td>
<td class="text-end text-nowrap">
{% if s.status == 'ready' %}
{% if not s.in_menu %}
<form method="post" action="{{ url_for('blancco_update.test_add', version=s.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Add a test entry for ' ~ s.name ~ ' to the PXE boot menu' ~ (' (replacing the one for ' ~ (test.version or '?') ~ ')' if test else '') ~ '? The menu is backed up first.') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-list-check"></i> Add test entry</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('blancco_update.activate', version=s.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Make Blancco ' ~ s.name ~ ' the live release? Every Blancco boot from now on gets it. The current files (' ~ current.version ~ ') are backed up first and can be rolled back.') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-primary btn-row-action"><i class="bi bi-check2-circle"></i> Activate</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('blancco_update.delete_staged', version=s.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Delete staged ' ~ s.name ~ '?') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action" title="Delete"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{% if s.status == 'blocked' %}
<tr>
<td colspan="7" class="wrap">
<div class="alert alert-warning mb-2">{{ s.blocked }}</div>
<ol class="small mb-2">
<li><a href="{{ url_for('blancco_update.vendor_prefs', version=s.name) }}">Download the vendor preferences.xml of {{ s.name }}</a>.</li>
<li>Carry our settings across from <span class="mono">playbook/blancco-preferences.xml</span> (report share
<span class="mono">{{ server }}</span>/<span class="mono">blancco-reports</span>, <span class="mono">auto_backup</span>, WiFi,
screensaver, business location and the rest), keeping the new file's <span class="mono">&lt;version&gt;</span> block and any new settings.</li>
<li>Upload the merged file here; staging then finishes. Commit it to the repo as <span class="mono">playbook/blancco-preferences.xml</span>.</li>
</ol>
<form method="post" action="{{ url_for('blancco_update.merged_prefs', version=s.name) }}" enctype="multipart/form-data"
class="d-flex flex-wrap gap-2 align-items-end">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field mb-0" style="min-width: min(20rem, 100%);">
<label class="form-label" for="prefs-{{ loop.index }}">Merged preferences.xml</label>
<input type="file" class="form-control" id="prefs-{{ loop.index }}" name="prefs_file" accept=".xml" required>
</div>
<button type="submit" class="pxe-btn pxe-btn-primary"><i class="bi bi-upload"></i> Upload and finish staging</button>
</form>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">Nothing staged.</p>
{% endif %}
</div>
{# ---- Test entry ---------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-list-check"></i> Test entry in the PXE boot menu</div>
{% if test %}
<p class="mb-2">
The menu has <span class="mono">{{ test.item or 'a blancco_staged entry' }}</span>, booting the staged
<strong class="mono">{{ test.version or '?' }}</strong> from <span class="mono">/blancco/staged/{{ test.version }}/</span>.
</p>
{% if test.efi is false %}
<div class="alert alert-warning">The entry's UEFI branch is off: when it was added, the UEFI initrd did not read
<span class="mono">blancco.base=</span>, so a UEFI test boot would have run the LIVE release. A UEFI boot of the entry
says so and returns to the menu. After rebuilding the initrd, add the test entry again.</div>
{% endif %}
<form method="post" action="{{ url_for('blancco_update.test_remove') }}"
onsubmit="return confirm('Remove the test entry from the PXE boot menu? The menu is backed up first.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-secondary"><i class="bi bi-x-circle"></i> Remove test entry</button>
</form>
{% else %}
<p class="text-muted">No test entry. <strong>Add test entry</strong> on a staged release puts one under the normal Blancco entry.</p>
{% endif %}
<ul class="small mt-3 mb-0">
<li><strong>Legacy BIOS:</strong> the entry boots Blancco's own kernel from the staged folder
(<span class="mono">archiso_http_srv</span> pointed at it). Works with any initrd.</li>
<li><strong>UEFI:</strong> the entry copies the live <span class="mono">:blancco_efi</span> kernel line (Ubuntu kernel,
Secure Boot as the normal entry) and adds <span class="mono">blancco.base=/blancco/staged/&lt;version&gt;</span>, which
the init in <span class="mono">{{ initrd.file or 'kexec-initrd' }}</span> must understand:
{% if initrd.state == 'yes' %}<span class="badge badge-success">supported</span>
{% elif initrd.state == 'no' %}<span class="badge badge-danger">not supported</span>
<div class="text-danger">This initrd predates <span class="mono">blancco.base=</span>: booted, it would run the LIVE release, so a test
entry added now has its UEFI branch off (legacy BIOS still works). Rebuild the initrd with the current
<span class="mono">playbook/blancco-init.sh</span>, then add the entry again (the boot screen shows "release under test"
when it works).</div>
{% else %}<span class="badge badge-secondary">unknown</span> <span class="text-muted">{{ initrd.detail }}</span>{% endif %}</li>
<li>Add, replace and remove edit only the test entry's lines; each edit saves
<span class="mono">GetPxeScript.aspx.bak-&lt;time&gt;-pre-blancco-staged*</span> next to the menu first. Activating the
version under test removes its entry.</li>
</ul>
</div>
{# ---- Backups ------------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-clock-history"></i> Backups</div>
<p class="small text-muted mb-2">Folders <span class="mono">{{ current.path }}/backup-*</span>. A rollback first saves what is live, so it can be undone the same way;
the restored backup's folder is then released, because its files are the live files.</p>
{% if backups %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Backup</th><th>Version</th><th>Blancco kernel</th><th>Preferences</th><th>Size</th><th>Saved</th><th class="text-end"></th></tr></thead>
<tbody>
{% for b in backups %}
<tr>
<td class="mono">{{ b.name }}{% if b.reason %}<div class="small text-muted">{{ b.reason }}</div>{% endif %}</td>
<td class="mono">{{ b.version }}</td>
{{ release_cells(b) }}
<td class="mono text-nowrap">{{ b.created | local_dt if b.created is string else b.created | timestamp_fmt }}</td>
<td class="text-end text-nowrap">
{% if b.complete %}
<form method="post" action="{{ url_for('blancco_update.rollback', name=b.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Put ' ~ b.name ~ ' back live? What is live now (' ~ current.version ~ ') is backed up first.') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-arrow-counterclockwise"></i> Roll back</button>
</form>
{% else %}
<span class="badge badge-secondary" title="Missing {{ b.missing | join(', ') }}">Incomplete</span>
{% endif %}
<form method="post" action="{{ url_for('blancco_update.delete_backup', name=b.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Delete ' ~ b.name ~ '? It cannot be rolled back to afterwards.') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action" title="Delete"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="form-text">Each backup holds one release, about 1 GB. Taking one is instant (hard links of the live files before they are replaced).</div>
{% else %}
<p class="text-muted mb-0">No backups yet. Activating a release saves the one it replaces here.</p>
{% endif %}
</div>
{# ---- How it works -------------------------------------------------------- #}
<div class="pxe-card">
<h3 class="pxe-card-title"><i class="bi bi-info-circle"></i> What a release changes</h3>
<p class="small mb-2">
Replaced in <span class="mono">{{ current.path }}</span>: <span class="mono">arch/x86_64/airootfs.sfs</span>,
<span class="mono">vmlinuz-bde-linux</span>, <span class="mono">initramfs-bde-linux.img</span> (copytoram network patch),
<span class="mono">intel-ucode.img</span>, <span class="mono">amd-ucode.img</span>, <span class="mono">config.img</span>
(vendor cpio with our preferences written in at the same size), <span class="mono">preferences.xml</span>,
<span class="mono">config-clean.xml</span> (the licence container from <span class="mono">config.img</span>) and, when the
ISO has one, <span class="mono">blancco-shim.efi</span>. Never touched: <span class="mono">vmlinuz-ubuntu*</span>,
<span class="mono">kexec-initrd*</span>, <span class="mono">kmod*.tar.gz</span>, <span class="mono">grubx64.efi</span>,
<span class="mono">shimx64.efi</span>, <span class="mono">blancco-progress.py</span>.
</p>
<p class="small mb-0">
Our preferences come from the live <span class="mono">preferences.xml</span> (the playbook deploys
<span class="mono">playbook/blancco-preferences.xml</span> there). They are a merge onto one vendor release's file,
so when a release's <span class="mono">&lt;version&gt;</span> differs, staging stops until a merged file is uploaded.
After activating such a release, commit the merged file to the repo, or the next playbook run puts the old one back.
</p>
<p class="small mb-0 mt-2">
A playbook run also copies <span class="mono">boot-tools/blancco/*</span> from the USB over the live files. After activating
a release here, refresh that copy (<span class="mono">scripts/prepare-boot-tools.sh</span> with the new ISO), or a playbook run
puts the old release back.
</p>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
(function () {
var csrf = document.querySelector('meta[name="csrf-token"]').content;
// Upload: stream the raw file (no multipart parse, no copy in /tmp) with a progress bar.
var form = document.getElementById('blanccoUpload');
form.addEventListener('submit', function (ev) {
var input = document.getElementById('blanccoFile');
if (!input.files.length || !window.XMLHttpRequest) { return; }
ev.preventDefault();
var file = input.files[0];
var box = document.getElementById('blanccoUploadProgress');
var bar = document.getElementById('blanccoUploadBar');
var text = document.getElementById('blanccoUploadText');
var xhr = new XMLHttpRequest();
xhr.open('POST', form.action + '?name=' + encodeURIComponent(file.name));
xhr.setRequestHeader('X-CSRF-Token', csrf);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.upload.onprogress = function (e) {
if (!e.lengthComputable) { return; }
var pct = 100 * e.loaded / e.total;
bar.style.width = pct.toFixed(1) + '%';
text.textContent = 'Uploading ' + file.name + ': ' + Math.round(e.loaded / 1048576) + ' of ' +
Math.round(e.total / 1048576) + ' MB';
};
xhr.onload = function () {
var r = {};
try { r = JSON.parse(xhr.responseText); } catch (e) {}
if (xhr.status === 200 && r.ok) { location.reload(); return; }
text.textContent = 'Upload failed: ' + (r.error || ('HTTP ' + xhr.status));
bar.classList.add('bg-danger');
};
xhr.onerror = function () { text.textContent = 'Upload failed: connection lost.'; bar.classList.add('bg-danger'); };
box.style.display = '';
form.querySelector('button[type=submit]').disabled = true;
xhr.send(file);
});
// Follow a running staging job; reload when it ends.
var job = document.getElementById('blanccoJob');
if (!job || job.dataset.state !== 'running') { return; }
var URL = '{{ url_for("blancco_update.status") }}';
function tick() {
fetch(URL, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.state !== 'running') { location.reload(); return; }
document.getElementById('blanccoJobStep').textContent = d.step || '';
document.getElementById('blanccoJobMsg').textContent = d.message || '';
document.getElementById('blanccoJobBar').style.width = (d.percent || 0) + '%';
var log = document.getElementById('blanccoJobLog');
if (log && d.log) { log.textContent = d.log.join('\n'); }
setTimeout(tick, 2000);
})
.catch(function () { setTimeout(tick, 5000); });
}
setTimeout(tick, 1500);
})();
</script>
{% endblock %}