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

@@ -15,6 +15,12 @@ Authorization boundary (deliberate):
command family from application deploys (endpoints.deploy) so app-push rights
never imply SYSTEM-level agent replacement. That push pipe is a later slice.
Staging also keeps the pushable application row current (see agentregistry):
whenever the current build changes, the bundle is repackaged and the row's
installpath/installersha256 are rewritten. Registration is deliberately NOT
allowed to fail the upload - the binary is already staged and audited by then,
so a packaging problem is reported in the response payload rather than raised.
Uploads are capped and PE-checked so a wrong or hostile file cannot be staged.
"""
@@ -24,7 +30,7 @@ from shopdb.api import (success_response, error_response, ErrorCodes, AuditLog,
require_permission)
from .routes import endpoints_bp
from . import agentstore
from . import agentstore, agentregistry
# Hard cap on an uploaded agent binary. This is a generous ceiling that still
# refuses an operator who points at a giant wrong file before it is buffered
@@ -40,6 +46,33 @@ from . import agentstore
_MAX_UPLOAD_BYTES = 128 * 1024 * 1024
def _sync_agent_registration():
"""Repackage and re-register the current build. Returns a status dict.
Called from every route that can change which build is current, so the
pushable application row can never describe a build that is no longer
staged. That drift is the whole failure this is meant to prevent, and it
would be silent - the row would keep pointing at bytes nobody selected.
Never raises. Staging has already succeeded on disk and been audited by the
time this runs; a packaging or database problem must not turn a successful
upload into an error response and leave the operator thinking the binary
was rejected. The failure is reported in the response payload instead, so
the UI can surface it without losing the upload.
"""
try:
registration = agentregistry.register_current_agent_package()
except Exception as exc: # noqa: BLE001 - reported, not swallowed
db.session.rollback()
return {'ok': False, 'error': str(exc)}
if registration is None:
# No current agent build to package. Normal when only a tray has been
# staged: the bundle is defined by the agent, so there is nothing to
# register yet.
return {'ok': False, 'error': 'no current agent build to package'}
return {'ok': True, **registration}
@endpoints_bp.route('/siteagent/binaries/<binkey>', methods=['GET'])
@require_permission('endpoints.agentpackage')
def list_agent_binaries(binkey):
@@ -129,10 +162,16 @@ def upload_agent_binary(binkey):
'madecurrent': makecurrent})
db.session.commit()
# Keep the pushable application row in step with what is now current, in
# the same request. Doing it here rather than at push time means the row
# and the staged build cannot disagree in between.
registration = _sync_agent_registration()
return success_response({
'binary': binkey,
'current': result['current'],
'versions': result['versions'],
'registration': registration,
})
@@ -215,10 +254,12 @@ def adopt_legacy_agent_binary(binkey):
ErrorCodes.VALIDATION_ERROR,
'the legacy binary could not be adopted',
http_code=400)
registration = _sync_agent_registration()
return success_response({
'binary': binkey,
'current': result['current'],
'versions': result['versions'],
'registration': registration,
})
@@ -243,8 +284,13 @@ def set_current_agent_binary(binkey):
ErrorCodes.NOT_FOUND,
'that version is not staged',
http_code=404)
# A rollback changes the bytes the fleet should receive; repackage so the
# application row follows the pointer instead of pinning the build the
# operator just rolled away from.
registration = _sync_agent_registration()
return success_response({
'binary': binkey,
'current': agentstore.current_version(binkey),
'versions': agentstore.list_versions(binkey),
'registration': registration,
})