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:
@@ -31,7 +31,8 @@ from ..models import (
|
||||
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
|
||||
EndpointHealthState, OperatorReport, EndpointLogCollection,
|
||||
)
|
||||
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
|
||||
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, minimum_agent_version_for, action_minimum_versions, parse_agent_version, agent_meets_minimum, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
|
||||
from . import agentstore
|
||||
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
||||
from shopdb.api import send_alert
|
||||
from markupsafe import escape
|
||||
@@ -432,6 +433,14 @@ def get_endpoint(asset_id: int):
|
||||
data = state.to_dict()
|
||||
data['status'] = status
|
||||
data['uptimedisplay'] = _format_uptime(state.uptimeseconds)
|
||||
# The currently published agent build, so the page can say "behind" without
|
||||
# hardcoding a version that goes stale the next time one is published.
|
||||
# None when nothing has been staged yet, which reads as "cannot tell".
|
||||
data['agentcurrent'] = agentstore.current_version('agent')
|
||||
# Per-action agent floors. Served rather than duplicated in the browser:
|
||||
# the enqueue guard refuses against these same numbers, so a copy in the
|
||||
# frontend would silently disagree the moment a floor was raised here.
|
||||
data['actionminimums'] = action_minimum_versions()
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@@ -553,6 +562,19 @@ def panel_status(asset_id: int):
|
||||
entry['format'] = fmt
|
||||
return entry
|
||||
|
||||
# Annotate the agent version when the endpoint is behind the published
|
||||
# build. Done here rather than as a separate row so the fact sits on the
|
||||
# version it describes. Silent when nothing is published, when the endpoint
|
||||
# has not reported, or when either version is unparseable - claiming
|
||||
# "behind" from a placeholder like '0.0.0-sample' would be a false alarm.
|
||||
agentvalue = state.agentversion
|
||||
published = agentstore.current_version('agent')
|
||||
if (agentvalue and published
|
||||
and parse_agent_version(agentvalue)
|
||||
and parse_agent_version(published)
|
||||
and not agent_meets_minimum(agentvalue, published)):
|
||||
agentvalue = '{} (behind {})'.format(agentvalue, published)
|
||||
|
||||
fields = [
|
||||
field('Logged in user', state.loggedinuser),
|
||||
field('Uptime', _format_uptime(state.uptimeseconds)),
|
||||
@@ -562,7 +584,7 @@ def panel_status(asset_id: int):
|
||||
fmt='datetime'),
|
||||
field('CPU %', state.cpupercent),
|
||||
field('Memory %', state.memorypercent),
|
||||
field('Agent', state.agentversion, mono=True),
|
||||
field('Agent', agentvalue, mono=True),
|
||||
field('Tray', state.trayversion, mono=True),
|
||||
field('Last reported',
|
||||
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None,
|
||||
@@ -2852,6 +2874,29 @@ def enqueue_endpoint_command(asset_id: int):
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
f'Action {action!r} does not accept a target')
|
||||
|
||||
# Capability, not authorization: does the agent on THIS endpoint actually
|
||||
# implement the action? An agent older than the action falls through to its
|
||||
# unknown-action default and answers "Unknown or disallowed action", which
|
||||
# reads as a permission refusal and sends the operator to check a grant they
|
||||
# already hold. Checked after the permission and policy gates so an
|
||||
# unauthorized caller still gets the authorization answer rather than a
|
||||
# version hint about a machine they may not act on.
|
||||
#
|
||||
# Deliberately a hard refusal, not a warning: the command is guaranteed to
|
||||
# fail, and queueing it anyway spends the one in-flight command slot and
|
||||
# writes a failed row that looks like the endpoint misbehaved.
|
||||
minagentversion = minimum_agent_version_for(action)
|
||||
if minagentversion:
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
reportedversion = getattr(state, 'agentversion', None)
|
||||
if not agent_meets_minimum(reportedversion, minagentversion):
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
f'Action {action!r} requires agent {minagentversion} or newer; '
|
||||
f'this endpoint reports '
|
||||
f'{reportedversion or "no version"}. Update the agent on this '
|
||||
f'endpoint to use it.')
|
||||
|
||||
# update_app pins a specific application version; verify it resolves to a
|
||||
# real installer now so a broken push is rejected at enqueue rather than
|
||||
# failing opaquely on the endpoint minutes later.
|
||||
|
||||
Reference in New Issue
Block a user