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:
@@ -11,7 +11,8 @@ from plugins.endpoints.plugin import EndpointsPlugin, CURATED_EVENT_IDS
|
||||
from plugins.endpoints.eventcatalog import EVENT_CATALOG, CRITICAL_EVENT_IDS
|
||||
from plugins.endpoints.commands import (
|
||||
COMMAND_ALLOWLIST, is_allowed_action, elevated_permission_for,
|
||||
requires_target,
|
||||
requires_target, minimum_agent_version_for, agent_meets_minimum,
|
||||
parse_agent_version,
|
||||
)
|
||||
|
||||
|
||||
@@ -216,6 +217,61 @@ def test_collect_log_survives_a_policy_that_never_mentions_it():
|
||||
assert elevated_permission_for('collect_log') == 'endpoints.updateapp'
|
||||
|
||||
|
||||
def test_collect_log_declares_the_agent_version_that_implements_it():
|
||||
"""collect_log names its minimum agent version.
|
||||
|
||||
An agent older than the action has no case for it and falls through to its
|
||||
unknown-action default, answering "Unknown or disallowed action" - which
|
||||
reads as an authorization refusal and sends the operator to check a grant
|
||||
they already hold. The floor is what lets the server refuse first with the
|
||||
actionable answer instead.
|
||||
"""
|
||||
assert minimum_agent_version_for('collect_log') == '1.16.0'
|
||||
# Actions that every shipped agent understands must NOT declare a floor,
|
||||
# or the guard starts refusing endpoints that are perfectly capable.
|
||||
assert minimum_agent_version_for('collect_now') is None
|
||||
assert minimum_agent_version_for('system_restart') is None
|
||||
|
||||
|
||||
def test_agent_version_comparison_survives_real_fleet_version_strings():
|
||||
"""Version comparison is numeric per part, not lexical.
|
||||
|
||||
Two traps, both real in this fleet. Agents report four parts ('1.15.0.0')
|
||||
while the csproj and binary store carry three ('1.16.0'), so the same build
|
||||
must compare equal across both spellings. And a lexical compare ranks
|
||||
'1.9.0.0' ABOVE '1.16.0' because '9' > '1' - which would silently let an
|
||||
old agent past the guard, the exact failure the guard exists to prevent.
|
||||
"""
|
||||
assert parse_agent_version('1.16.0') == parse_agent_version('1.16.0.0')
|
||||
assert agent_meets_minimum('1.16.0', '1.16.0')
|
||||
assert agent_meets_minimum('1.16.0.0', '1.16.0')
|
||||
assert agent_meets_minimum('1.17.0.0', '1.16.0')
|
||||
assert agent_meets_minimum('2.0.0.0', '1.16.0')
|
||||
assert not agent_meets_minimum('1.15.0.0', '1.16.0')
|
||||
assert not agent_meets_minimum('1.11.0.0', '1.16.0')
|
||||
# The lexical trap.
|
||||
assert not agent_meets_minimum('1.9.0.0', '1.16.0')
|
||||
|
||||
|
||||
def test_an_unreadable_agent_version_fails_the_guard_closed():
|
||||
"""An unparseable or absent version does not satisfy a minimum.
|
||||
|
||||
Eight endpoints in this fleet report '0.0.0-sample', and a never-checked-in
|
||||
endpoint reports nothing at all. There is no honest ordering between a
|
||||
placeholder and a real build, so the guard refuses rather than guessing.
|
||||
Letting it through would only move the failure to the agent, where the
|
||||
error message is the misleading one.
|
||||
"""
|
||||
assert parse_agent_version('0.0.0-sample') is None
|
||||
assert not agent_meets_minimum('0.0.0-sample', '1.16.0')
|
||||
assert not agent_meets_minimum(None, '1.16.0')
|
||||
assert not agent_meets_minimum('', '1.16.0')
|
||||
# No declared floor means no opinion: an action every agent implements must
|
||||
# stay available even to an endpoint whose version we cannot read.
|
||||
assert agent_meets_minimum('0.0.0-sample', None)
|
||||
assert agent_meets_minimum(None, None)
|
||||
|
||||
|
||||
def test_run_script_and_update_app_are_allowlisted_delivery_actions():
|
||||
"""The two software-delivery actions are allowlisted with a targetkind so
|
||||
the enqueue route accepts them and carries their subject on the wire:
|
||||
|
||||
Reference in New Issue
Block a user