Import the wilm agent store and WINC adoption work, with four fixes

Eleven commits from the sister site: a reversible Maximo/WINC machine-adoption
import, a versioned agent binary store with upload and rollback, remote-BuildKit
CI, tray version reporting, and UI fixes. Applied as a patch again - the
published mirror rewrites hashes, so the branches have unrelated histories and
git refuses to merge them.

Reviewed by three agents before merging. Four things fixed here.

THE PUBLISH WORKFLOW WAS DEAD. The new remote-BuildKit step in
publish-images.yml sits at column 0 instead of indented under steps:, so the
whole file is invalid YAML and GitHub would refuse it. Their entire GHCR
distribution channel - which Start-ShopDBDev.ps1 and the compose files consume -
was broken at the source and clearly never run. Indented; all five workflows now
parse.

THE IMPORT'S APPROVAL CHECK COULD NOT FAIL. commit() built the plan and the
approval packet from the same upload and then asked the writer whether they
matched, so packet_matches_plan compared a hash against itself. The documented
contract - "a human approves a specific, frozen plan, and the writer can prove
nothing drifted" - did not exist in the web path: an admin could preview file A
and commit file B, gated only by the needs-create group NAMES they ticked. The
preview's planhash is now required at commit and checked against a freshly
rebuilt plan. Their tests committed without previewing, which is what let this
through; they go through the real flow now, and three new tests pin it - all
confirmed to fail with the check stubbed out.

endpoints.agentpackage QUIETLY GREW. It used to mean "wrap the staged exe in a
ZIP"; with the upload routes it also means "choose the executable every future
package installs and runs as SYSTEM". Before this, staging needed filesystem
access to the server. The permission description still said only "generate and
download a package", so an admin granting it could not see what they were
granting. Reworded.

NOTHING RECORDED WHAT WAS STAGED, OR BY WHOM. The manifest held version, size
and timestamp; re-uploading a version replaces the bytes in place under the same
label. A sha256 of the stored file is now recorded, and the upload is
audit-logged with it - after an incident there was previously no answer to who
put that binary on the fleet.

Also documented the nine new routes in docs/api-inventory.json (that gate cannot
run on the mirror, so it falls to us at promotion time again), regenerated the
spec and the project map, and kept our own docs where they conflicted: docs/ is
scrubbed from the mirror, so their DEV-BUILD-TEST.md still had the dev-be
channel mapping we corrected, and their PLUGIN-HOOKS.md was a new 45-line file
rather than our 737-line canonical one. Their journal section is appended to
ours.

Contract version comes across as 0.30.0 for a genuinely additive surface.

Suite 2369, frontend 324, gate green.

Scope: product
This commit is contained in:
cproudlock
2026-09-03 07:54:14 -04:00
parent 7f6aaf2e64
commit 13be0ef90f
53 changed files with 5064 additions and 95 deletions

View File

@@ -106,8 +106,9 @@
</div>
<div class="card form-card">
<h4>How it works</h4>
<ul class="field-hint info-list">
<details class="collapsible">
<summary><h4>How it works</h4></summary>
<ul class="field-hint info-list">
<li>
<strong>Generate</strong> mints a fresh key and, on the first
generation, turns off the bootstrap <code>.env</code> fallback so
@@ -127,9 +128,11 @@
the kill switch.
</li>
</ul>
</details>
<h4>When to rotate</h4>
<ul class="field-hint info-list">
<details class="collapsible">
<summary><h4>When to rotate</h4></summary>
<ul class="field-hint info-list">
<li>
<strong>An installer or agent left the secure environment.</strong>
If a build carrying the key is copied off the shop floor - onto an
@@ -156,11 +159,12 @@
regenerate and redeploy to known-good endpoints.
</li>
</ul>
</details>
</div>
</section>
<!-- ====================================================================
Adoption tool (spike)
Adoption tool
================================================================= -->
<section class="settings-section">
<h3>Adoption tool</h3>
@@ -211,7 +215,7 @@
</section>
<!-- ====================================================================
Build site agent (placeholder)
Build site agent
================================================================= -->
<section class="settings-section">
<h3>Build site agent</h3>
@@ -227,10 +231,9 @@
</p>
<p v-if="!agentstaged" class="field-hint">
<strong>No agent binary is staged on this server.</strong> The package
will still build with config and installer, but you must drop
<code>shopdbagent.exe</code> beside them before deploying. Stage a
prebuilt agent under <code>instance/siteagent/</code> to have it ship
in the download.
will still build with config and installer, but no agent will ship
inside it. Upload a current release in the binary section below to
have it included in every download.
</p>
<label class="field-label" for="agent-baseurl">Server base URL</label>
@@ -303,6 +306,162 @@
</div>
<p v-if="agentmessage" class="field-hint">{{ agentmessage }}</p>
<p v-if="agenterror" class="field-error">{{ agenterror }}</p>
<!-- ================================================================
Agent binary versions: upload a new build, keep the last few,
and roll the current pointer back to a retained version. Staging
only changes what a FUTURE download contains; it does not touch
any running endpoint. Pushing to live endpoints is a separate,
heavier grant handled elsewhere. Collapsed by default - binary
management is infrequent next to building a package. -->
<details class="collapsible binary-manager">
<summary><h4>Upload New/Legacy Agents</h4></summary>
<div
v-for="binary in binaries"
:key="binary.key"
class="binary-block"
>
<h4>{{ binary.label }}</h4>
<p class="field-hint">
Upload {{ binary.name }} updated EXE. Uploading here will update the
binaries available for your "Build Site Agent" install package.
</p>
<p class="field-hint">
Current staged version:
<strong>{{ binary.current || 'none staged yet - upload a build below' }}</strong>
</p>
<!-- Current release upload. The version is auto-detected from the
EXE's PE version resource on select; the operator can override.
This upload becomes the current staged build. -->
<label class="field-label">
Upload {{ binary.name }} binary current release
</label>
<div class="binary-upload">
<input
class="form-control"
type="file"
accept=".exe"
@change="onBinaryFileChange(binary.key, $event)"
/>
<input
v-model="binary.version"
class="form-control"
type="text"
:placeholder="binary.detecting
? 'Reading version...'
: 'Version, auto-detected from EXE'"
/>
<button
class="btn btn-secondary"
type="button"
:disabled="binary.busy || !binary.version || !binary.file"
@click="uploadBinary(binary.key)"
>
{{ binary.busy ? 'Uploading...' : 'Upload current release' }}
</button>
</div>
<!-- Rollback / legacy path. Stages a build into the version list
without displacing the current release so it is available to
roll back to. WARNING copy per intent: the version must be
confirmed from the EXE properties before uploading. -->
<label class="field-label">
Upload legacy build for rollback
</label>
<p class="field-warning">
WARNING: confirm agent version before uploading from the EXE
properties and enter the version number.
</p>
<div class="binary-upload">
<input
class="form-control"
type="file"
accept=".exe"
@change="onLegacyFileChange(binary.key, $event)"
/>
<input
v-model="binary.legacyversion"
class="form-control"
type="text"
placeholder="Version, e.g. 1.5.0"
/>
<button
class="btn btn-secondary"
type="button"
:disabled="binary.busy || !binary.legacyversion || !binary.legacyfile"
@click="uploadLegacy(binary.key)"
>
{{ binary.busy ? 'Uploading...' : 'Upload legacy build' }}
</button>
</div>
<p v-if="binary.message" class="field-hint">{{ binary.message }}</p>
<p v-if="binary.error" class="field-error">{{ binary.error }}</p>
<!-- Version selector: pick the staged build shipped by the next
download. Ordered by version number (highest first). -->
<div v-if="binary.versions.length" class="binary-upload">
<label class="field-label" :for="`select-${binary.key}`">
Active version
</label>
<select
:id="`select-${binary.key}`"
class="form-control"
:value="binary.current || ''"
:disabled="binary.busy"
@change="setCurrentBinary(binary.key, $event.target.value)"
>
<option
v-for="entry in binary.versions"
:key="entry.version"
:value="entry.version"
>
{{ entry.version }}{{ entry.iscurrent ? ' (current)' : '' }}
</option>
</select>
</div>
<div v-if="binary.versions.length" class="previous-versions">
<button
class="btn-link"
type="button"
@click="binary.showprevious = !binary.showprevious"
>
{{ binary.showprevious ? 'Hide' : 'Show' }} staged versions
({{ binary.versions.length }})
</button>
<table v-if="binary.showprevious" class="versions-table">
<thead>
<tr>
<th>Version</th>
<th>Uploaded</th>
<th>Size</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="entry in binary.versions" :key="entry.version">
<td class="mono">{{ entry.version }}</td>
<td>{{ formatDate(entry.uploadedat) }}</td>
<td>{{ formatBytes(entry.size) }}</td>
<td>
<span v-if="entry.iscurrent" class="current-tag">current</span>
<button
v-else
class="btn-link"
type="button"
:disabled="binary.busy"
@click="setCurrentBinary(binary.key, entry.version)"
>
Make current
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</details>
</div>
</section>
@@ -350,7 +509,7 @@ const copied = ref(false)
const message = ref('')
const error = ref('')
// Adoption tool (spike): build a one-shot, agentless enrollment download.
// Adoption tool: build a one-shot, agentless enrollment download.
const adoptbaseurl = ref('')
const adoptcollectorkey = ref('')
const adoptbusy = ref(false)
@@ -370,12 +529,45 @@ const agentbusy = ref(false)
const agentmessage = ref('')
const agenterror = ref('')
// Versioned agent/tray binaries. Each block owns its own upload form, retained
// version list, and busy/message state so the two rows act independently. The
// 'key' matches the backend binkey (agent | tray).
const binaries = ref([
{ key: 'agent', label: 'ShopDB Agent (shopdbagent.exe)',
name: 'ShopDB Agent',
current: null, versions: [], version: '', file: '', detecting: false,
legacyversion: '', legacyfile: '',
busy: false, message: '', error: '', showprevious: false },
{ key: 'tray', label: 'Tray Agent (shopdb-endpoint-tray.exe)',
name: 'Tray Agent',
current: null, versions: [], version: '', file: '', detecting: false,
legacyversion: '', legacyfile: '',
busy: false, message: '', error: '', showprevious: false },
])
function binaryFor(binkey) {
return binaries.value.find((entry) => entry.key === binkey)
}
function formatBytes(value) {
if (!value && value !== 0) return ''
if (value < 1024) return value + ' B'
if (value < 1024 * 1024) return (value / 1024).toFixed(1) + ' KB'
return (value / (1024 * 1024)).toFixed(1) + ' MB'
}
function formatDate(value) {
if (!value) return ''
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString()
}
function filenameFromResponse(response, fallback) {
const disposition = response.headers?.['content-disposition'] || ''
const match = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition)
return match ? decodeURIComponent(match[1]) : fallback
}
function applyStatus(data) {
key.value = data.key || null
envkeyfallback.value = !!data.envkeyfallback
@@ -401,11 +593,20 @@ onMounted(async () => {
agentstaged.value = !!status.agentstaged
certavailable.value = !!status.certavailable
agentbundlecert.value = !!status.certavailable
applyBinaryStatus('agent', status.agentcurrent, status.agentversions)
applyBinaryStatus('tray', status.traycurrent, status.trayversions)
} catch (statusError) {
console.error(statusError)
}
})
function applyBinaryStatus(binkey, current, versions) {
const binary = binaryFor(binkey)
if (!binary) return
binary.current = current || null
binary.versions = Array.isArray(versions) ? versions : []
}
async function regenerate() {
if (key.value &&
!window.confirm('Regenerate the key? The current key stops working '
@@ -528,7 +729,10 @@ async function buildSiteAgent() {
const url = window.URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = url
link.download = 'shopdb-endpoint-agent.zip'
// Honor the server's versioned download name (shopdb-endpoint-agent-<ver>.zip)
// from Content-Disposition so operators see the agent version in the file
// name; fall back to the fixed name when the header is absent.
link.download = filenameFromResponse(response, 'shopdb-endpoint-agent.zip')
document.body.appendChild(link)
link.click()
link.remove()
@@ -548,6 +752,123 @@ async function buildSiteAgent() {
}
}
async function detectVersion(binkey, file) {
const form = new FormData()
form.append('file', file)
const response = await api.post(
`/endpoints/siteagent/binaries/${binkey}/detect`, form)
return response.data.data?.version || ''
}
function onBinaryFileChange(binkey, event) {
const binary = binaryFor(binkey)
if (!binary) return
const files = event.target.files
binary.file = files && files.length ? files[0] : ''
binary.error = ''
if (!binary.file) return
binary.detecting = true
detectVersion(binkey, binary.file)
.then((version) => {
if (version) binary.version = version
})
.catch((detectError) => {
console.error(detectError)
})
.finally(() => {
binary.detecting = false
})
}
function onLegacyFileChange(binkey, event) {
const binary = binaryFor(binkey)
if (!binary) return
const files = event.target.files
binary.legacyfile = files && files.length ? files[0] : ''
}
async function uploadBinary(binkey) {
const binary = binaryFor(binkey)
if (!binary || !binary.file || !binary.version) return
binary.busy = true
binary.message = ''
binary.error = ''
try {
const form = new FormData()
form.append('version', binary.version)
form.append('file', binary.file)
const response = await api.post(
`/endpoints/siteagent/binaries/${binkey}`, form)
const data = response.data.data || {}
applyBinaryStatus(binkey, data.current, data.versions)
binary.version = ''
binary.file = ''
binary.message = 'Uploaded and set as current.'
} catch (uploadError) {
binary.error = uploadError.response?.data?.data?.error?.message
|| (uploadError.response?.status === 403
? 'You do not have permission to upload agent binaries'
: 'Could not upload the binary')
console.error(uploadError)
} finally {
binary.busy = false
}
}
async function uploadLegacy(binkey) {
const binary = binaryFor(binkey)
if (!binary || !binary.legacyfile || !binary.legacyversion) return
binary.busy = true
binary.message = ''
binary.error = ''
try {
const form = new FormData()
form.append('version', binary.legacyversion)
form.append('file', binary.legacyfile)
form.append('makecurrent', 'false')
const response = await api.post(
`/endpoints/siteagent/binaries/${binkey}`, form)
const data = response.data.data || {}
applyBinaryStatus(binkey, data.current, data.versions)
binary.legacyversion = ''
binary.legacyfile = ''
binary.message = 'Legacy build staged for rollback.'
} catch (uploadError) {
binary.error = uploadError.response?.data?.data?.error?.message
|| (uploadError.response?.status === 403
? 'You do not have permission to upload agent binaries'
: 'Could not upload the legacy build')
console.error(uploadError)
} finally {
binary.busy = false
}
}
async function setCurrentBinary(binkey, version) {
const binary = binaryFor(binkey)
if (!binary) return
if (!window.confirm(`Make version ${version} the current staged build? `
+ 'New downloads will ship this version.')) {
return
}
binary.busy = true
binary.message = ''
binary.error = ''
try {
const response = await api.put(
`/endpoints/siteagent/binaries/${binkey}/current`, { version })
const data = response.data.data || {}
applyBinaryStatus(binkey, data.current, data.versions)
binary.message = `Version ${version} is now current.`
} catch (setError) {
binary.error = setError.response?.data?.data?.error?.message
|| 'Could not change the current version'
console.error(setError)
} finally {
binary.busy = false
}
}
function editConfiguration() {
router.push({ name: 'endpoints-monitors-settings' })
}
@@ -585,4 +906,72 @@ function editConfiguration() {
.settings-section > h3 { margin-bottom: 0.25rem; }
.info-list { margin: 0.25rem 0 0; padding-left: 1.25rem; }
.info-list li { margin-bottom: 0.5rem; }
.binary-block {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #333);
}
.binary-upload {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
margin: 0.5rem 0;
}
.binary-upload .form-control[type="text"] { max-width: 12rem; }
.previous-versions { margin-top: 0.5rem; }
.collapsible { margin: 0.5rem 0; }
.collapsible > summary {
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
gap: 0.4rem;
user-select: none;
}
.collapsible > summary::-webkit-details-marker { display: none; }
.collapsible > summary::before {
content: "\25be";
display: inline-block;
transform: rotate(-90deg);
transition: transform 0.15s ease;
color: var(--color-text-muted, #888);
}
.collapsible[open] > summary::before { transform: rotate(0deg); }
.collapsible > summary h4 { margin: 0; }
.field-warning {
margin: 0.25rem 0 0.5rem;
padding: 0.5rem 0.75rem;
border-left: 3px solid var(--color-warning, #b8860b);
background: rgba(184, 134, 11, 0.08);
font-size: 0.85rem;
font-weight: 600;
}
.btn-link {
background: none;
border: none;
color: var(--color-link, #4ea1ff);
cursor: pointer;
padding: 0;
font-size: 0.9rem;
text-decoration: underline;
}
.btn-link:disabled { color: var(--color-text-muted, #888); cursor: default; }
.versions-table {
width: 100%;
margin-top: 0.5rem;
border-collapse: collapse;
font-size: 0.85rem;
}
.versions-table th,
.versions-table td {
text-align: left;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid var(--color-border, #333);
}
.current-tag {
font-size: 0.8rem;
color: var(--color-success, #4caf50);
font-weight: 600;
}
</style>

View File

@@ -342,7 +342,9 @@ async function loadEndpoints() {
// navigates when it resolved to a computerid.
function openPc(item) {
if (item.computerid) {
router.push(`/pcs/${item.computerid}`)
// Tag the origin so PC Details and the edit form return here (Fleet)
// instead of the PCs list. Absent for the normal PCs-list entry path.
router.push(`/pcs/${item.computerid}?from=fleet`)
}
}