Auto-register staged agent builds as a pushable application

Gate collect_log on a minimum agent version and bridge the agent version
store to the applications table so a staged build can actually be pushed.

update_app resolves an install source from the applications table alone; it
cannot see agentstore. A build could therefore be staged and current while no
application row existed at all, which is what the fleet was in: the agent was
unpushable. Staging is now the single source of truth - it packages the
current build and upserts the row in the same request, so the row cannot
describe a build that is no longer staged.

PARTIAL BUILD ADVISORY
This is steps 1-4 of a longer rollout and is deliberately incomplete:
  - There is no push trigger yet. The registered package is resolvable and
	verifiable, but nothing enqueues an upgrade; the "behind current build"
	indicator is still display-only.
  - The upgrade template script, its script row, and the enqueue route
	(endpoints.agentupdate) are not written.
  - Blocking constraint for the next slice: run_script executes
	powershell.exe -File <script> with NO argument channel, so a delivered
	upgrade script cannot be passed BaseUrl/CollectorKey and must either
	source them itself or run_script must be extended first.
Nothing here touches a live endpoint. Staging only changes what a future
push would contain.

Details:
  - Package members carry a fixed timestamp. zipfile stamps the current mtime
	by default, so identical input produced a different archive digest on
	every call. update_app pins a download by SHA-256, so a drifting hash
	would rewrite installersha256 out from under an endpoint mid-download and
	fail its verification.
  - All three routes that can change 'current' re-register (upload,
	adopt-legacy, rollback). Covering only upload would leave the row pinned
	to the build an operator just rolled away from, and the push would ship
	it anyway - silent drift.
  - Registration cannot fail an upload. The binary is staged and audited by
	the time it runs, so a packaging problem is reported in the response
	payload rather than raised; returning an error would tell the operator a
	successful upload was rejected.
  - The package is written to a temp name and os.replace'd, so an interrupted
	write cannot leave a truncated archive at the path the row advertises.
  - The row is isinstallable but hidden: machine-managed, and it must not
	appear in the software catalogue beside real licensed applications.
  - Added application_package_location() to the shopdb.api contract surface
	(lazy, matching collector_authorized) returning the package directory and
	URL prefix together. agentregistry reached into shopdb.core internals,
	violating the plugin import contract; sourcing both from one place also
	stops the write location and installpath from drifting apart.

collect_log is refused at enqueue below its minimum agent version, with
numeric comparison - string ordering misplaces 1.9.0.0 against 1.16.0. The
refusal is mirrored in the UI so a command slot is not spent on a call that
is guaranteed to fail.

Test isolation: create_app('testing') resolves instance_path to the real repo
instance/ directory, so agent upload tests write into the developer's actual
store. The new tests redirect both the store and package output into tmp_path
and include a guard test that fails if that isolation regresses. NOTE: the
pre-existing test_endpoints_agentupload.py still has this leak and deposits
1.5.0.0 and 1.6.3 into instance/siteagent/ on a full run - not fixed here.

Suite: 2834 passed, 11 skipped. The two failures (test_docs_publishable, and
a symlink scan needing Windows privileges) were confirmed pre-existing by
stashing this work and reproducing them.
This commit is contained in:
benjamin.baker
2026-09-09 21:40:51 -04:00
parent eee06743a6
commit d32293ccb2
11 changed files with 983 additions and 6 deletions

View File

@@ -695,6 +695,15 @@
again. Previously collected logs are still readable below.
</div>
<!-- Capability, not permission. Shown even while the endpoint is
offline, because "too old" stays true when it comes back and an
operator planning work should see both facts at once. History
below still reads: logs collected before are unaffected. -->
<div
v-if="!agentSupportsLogCollection"
class="logcollect-note logcollect-note--warn"
>{{ logCollectorVersionNotice }}</div>
<div class="logcollect-form">
<label class="logcollect-label" for="logcollect-path">
Full path to the log file
@@ -705,7 +714,7 @@
class="logcollect-input mono"
type="text"
placeholder="C:\ProgramData\ShopDB\EndpointAgent\agent.log"
:disabled="collectingLog || !liveness"
:disabled="collectingLog || !liveness || !agentSupportsLogCollection"
@keyup.enter="collectLog"
/>
<p v-if="logpatherror" class="logcollect-error">{{ logpatherror }}</p>
@@ -719,7 +728,7 @@
<div class="logcollect-actions">
<button
class="btn btn-warning"
:disabled="!logpath.trim() || collectingLog || !liveness"
:disabled="!logpath.trim() || collectingLog || !liveness || !agentSupportsLogCollection"
@click="collectLog"
>{{ collectingLog ? 'Collecting...' : 'Collect Log' }}</button>
</div>
@@ -1031,6 +1040,15 @@ const computer = ref(null)
// when the viewer lacks endpoints.view - in either case the chip is simply not
// shown, which is better than rendering "unknown" and implying a fault.
const liveness = ref(null)
// Agent version this endpoint last reported, for capability gating. Null when
// the endpoints plugin is absent, the viewer cannot read telemetry, or the
// endpoint has never checked in.
const agentversion = ref(null)
// The currently published agent build, and the per-action version floors, both
// served by the endpoint state route. Held rather than hardcoded so raising a
// floor on the server cannot leave this page disagreeing with the API.
const agentcurrent = ref(null)
const actionminimums = ref({})
const installedApps = ref([])
const assetPanels = ref(null)
@@ -1459,10 +1477,19 @@ async function loadLiveness(assetid) {
if (state.status) {
liveness.value = { level: levels[state.status], label: labels[state.status] }
}
// Already in this payload, so no second request. Used to tell an operator
// that an action needs a newer agent BEFORE they queue a command that the
// agent will refuse with a message that reads like a permissions problem.
agentversion.value = state.agentversion || null
agentcurrent.value = state.agentcurrent || null
actionminimums.value = state.actionminimums || {}
} catch (error) {
// 404 = no agent on this PC, 403 = the viewer cannot read telemetry.
// Neither is a page error; the chip just does not appear.
liveness.value = null
agentversion.value = null
agentcurrent.value = null
actionminimums.value = {}
}
}
@@ -1798,6 +1825,62 @@ const logcollectionsloading = ref(false)
const activelog = ref(null)
const logcontentloading = ref(false)
// Log collection needs an agent new enough to have the action. An older agent
// has no case for it and answers "Unknown or disallowed action", which reads as
// a permissions refusal - so an operator checks a grant they already hold
// rather than the agent version that is actually the problem. The server
// refuses these too; this only stops the pointless round trip and says why.
const collectLogMinAgent = computed(() => actionminimums.value.collect_log || null)
// Numeric per part, never lexical. Agents report four parts ('1.15.0.0') while
// the build carries three ('1.16.0'), and a string compare would also rank
// '1.9.0.0' above '1.16.0' because '9' > '1'. Mirrors agent_meets_minimum on
// the server; both must agree or the button and the API disagree about the
// same endpoint.
function parseAgentVersion(value) {
if (!value) return null
const parts = String(value).trim().split('.')
const out = []
for (const chunk of parts) {
if (!/^\d+$/.test(chunk)) return null
out.push(Number(chunk))
}
if (!out.length) return null
while (out.length < 4) out.push(0)
return out.slice(0, 4)
}
function agentMeetsMinimum(reported, minimum) {
const reportedparts = parseAgentVersion(reported)
const minimumparts = parseAgentVersion(minimum)
// Fail closed on an unreadable version. Seeded rows carry '0.0.0-sample',
// and there is no honest ordering between a placeholder and a real build.
if (!reportedparts || !minimumparts) return false
for (let i = 0; i < 4; i += 1) {
if (reportedparts[i] > minimumparts[i]) return true
if (reportedparts[i] < minimumparts[i]) return false
}
return true
}
const agentSupportsLogCollection = computed(() => {
// No floor served means the action carries no version requirement, so this
// must not block. Only a floor that the endpoint fails should disable it.
if (!collectLogMinAgent.value) return true
return agentMeetsMinimum(agentversion.value, collectLogMinAgent.value)
})
// Names both versions. "Requires a newer agent" alone leaves the operator
// without the one fact they need to act: which version this machine is on.
const logCollectorVersionNotice = computed(() => {
if (agentSupportsLogCollection.value) return ''
const reported = agentversion.value
? `this endpoint reports ${agentversion.value}`
: 'this endpoint has not reported an agent version'
return `Log collection requires agent ${collectLogMinAgent.value} or newer; `
+ `${reported}. Update the agent on this endpoint to use it.`
})
// History for this endpoint. Metadata only - bodies are fetched one at a time,
// because 20 rows of multi-hundred-KB logs would make opening the tab far more
// expensive than reading the one log that is actually wanted.
@@ -1838,6 +1921,12 @@ function validateLogPath(raw) {
}
async function collectLog() {
// Belt and braces with the disabled button: keyboard Enter reaches here too,
// and a stale agentversion could leave the button live for a moment.
if (!agentSupportsLogCollection.value) {
logpatherror.value = logCollectorVersionNotice.value
return
}
const path = logpath.value.trim()
logpatherror.value = validateLogPath(path)
if (logpatherror.value) return