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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user