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:
@@ -127,6 +127,14 @@ COMMAND_ALLOWLIST = {
|
||||
'still reads a caller-supplied path as SYSTEM: it is '
|
||||
'bounded to text files, but it is not a sandbox.',
|
||||
'targetkind': 'logpath',
|
||||
# Introduced in agent 1.16.0. An older agent has no case for this action
|
||||
# and falls through to its unknown-action default, which answers
|
||||
# "Unknown or disallowed action" - a message that reads as an
|
||||
# authorization refusal and sends the operator looking at permissions
|
||||
# they already hold. Declaring the floor here lets the enqueue route
|
||||
# refuse first, naming both versions, so the answer is the actionable
|
||||
# one: this endpoint needs a newer agent.
|
||||
'minagentversion': '1.16.0',
|
||||
},
|
||||
'update_app': {
|
||||
'label': 'Install/update application',
|
||||
@@ -230,6 +238,70 @@ def elevated_permission_for(action):
|
||||
return ELEVATED_ACTION_PERMISSIONS.get(action)
|
||||
|
||||
|
||||
def minimum_agent_version_for(action):
|
||||
"""The lowest agent version that implements an action, or None if any will do."""
|
||||
return COMMAND_ALLOWLIST.get(action, {}).get('minagentversion')
|
||||
|
||||
|
||||
def action_minimum_versions():
|
||||
"""Every action that has an agent version floor, as {action: version}.
|
||||
|
||||
Actions without a floor are omitted rather than mapped to None: a caller
|
||||
asking "is there a minimum for this?" gets a plain absent key, and the
|
||||
payload stays small as the allowlist grows.
|
||||
"""
|
||||
return {
|
||||
action: spec['minagentversion']
|
||||
for action, spec in COMMAND_ALLOWLIST.items()
|
||||
if spec.get('minagentversion')
|
||||
}
|
||||
|
||||
|
||||
def parse_agent_version(value):
|
||||
"""A reported agent version as a comparable tuple, or None if unusable.
|
||||
|
||||
Agents report four parts ('1.15.0.0') while the csproj and the binary store
|
||||
carry three ('1.16.0'), so a plain string compare puts a NEWER agent below
|
||||
the floor it satisfies. Both are normalised to a tuple and compared
|
||||
numerically, with missing trailing parts treated as zero, so '1.16.0' and
|
||||
'1.16.0.0' are the same version.
|
||||
|
||||
Anything non-numeric returns None rather than a guess. The fleet carries
|
||||
'0.0.0-sample' on seeded rows, and there is no honest ordering between a
|
||||
placeholder and a real build - the caller decides what to do about an
|
||||
unknown version, which is a different question from being out of date.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
parts = []
|
||||
for chunk in str(value).strip().split('.'):
|
||||
if not chunk.isdigit():
|
||||
return None
|
||||
parts.append(int(chunk))
|
||||
if not parts:
|
||||
return None
|
||||
while len(parts) < 4:
|
||||
parts.append(0)
|
||||
return tuple(parts[:4])
|
||||
|
||||
|
||||
def agent_meets_minimum(reported, minimum):
|
||||
"""Whether a reported agent version satisfies a minimum.
|
||||
|
||||
Returns False when either side is unparseable. That is the fail-closed
|
||||
direction: an endpoint whose version we cannot read is one we cannot claim
|
||||
supports the action, and letting it through only moves the failure to the
|
||||
agent where the error message is worse.
|
||||
"""
|
||||
if not minimum:
|
||||
return True
|
||||
reportedparts = parse_agent_version(reported)
|
||||
minimumparts = parse_agent_version(minimum)
|
||||
if reportedparts is None or minimumparts is None:
|
||||
return False
|
||||
return reportedparts >= minimumparts
|
||||
|
||||
|
||||
# Maps a monitored services/processes row to the start/stop/status actions that
|
||||
# control it, so the services panel can render an inline toggle on exactly the
|
||||
# rows that have a registered command action. The key is the agent-reported
|
||||
|
||||
Reference in New Issue
Block a user