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.
297 lines
13 KiB
Python
297 lines
13 KiB
Python
"""Agent binary upload and retention routes.
|
|
|
|
Lets an operator drop in a new shopdbagent.exe or shopdb-endpoint-tray.exe from
|
|
the Endpoints page, keep the last few versions, and roll the current pointer
|
|
back to a retained build. The actual staging/retention logic lives in
|
|
agentstore.py; this module is only the HTTP surface and authorization.
|
|
|
|
Authorization boundary (deliberate):
|
|
- Uploading, listing, and selecting the current staged version is
|
|
endpoints.agentpackage - the same grant that already lets someone build and
|
|
download the site-agent ZIP. Staging a binary only changes what a FUTURE
|
|
download contains; it does not touch any running endpoint.
|
|
- Pushing that binary to live endpoints (agent replacement) is a separate,
|
|
heavier grant, endpoints.agentupdate, and is NOT wired here. It is a distinct
|
|
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.
|
|
"""
|
|
|
|
from flask import request
|
|
|
|
from shopdb.api import (success_response, error_response, ErrorCodes, AuditLog, db,
|
|
require_permission)
|
|
|
|
from .routes import endpoints_bp
|
|
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
|
|
# fully into memory-staged storage.
|
|
#
|
|
# 128 MB, not 64: the self-contained single-file tray publishes at ~69 MB
|
|
# (it carries the whole .NET runtime plus WinForms), which the old 64 MB cap
|
|
# rejected outright. The upload failed on SIZE, but the operator saw only a
|
|
# version-detection failure, because the size check returns before the PE is
|
|
# ever parsed. Config.MAX_CONTENT_LENGTH must stay above this - it bounds the
|
|
# whole multipart envelope, so a cap equal to it would reject a legal file on
|
|
# the boundary bytes alone.
|
|
_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):
|
|
"""List staged versions for a binary (agent or tray), newest first."""
|
|
if not agentstore.is_valid_binary_key(binkey):
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'binkey must be one of: agent, tray',
|
|
http_code=400)
|
|
return success_response({
|
|
'binary': binkey,
|
|
'current': agentstore.current_version(binkey),
|
|
'versions': agentstore.list_versions(binkey),
|
|
})
|
|
|
|
|
|
@endpoints_bp.route('/siteagent/binaries/<binkey>', methods=['POST'])
|
|
@require_permission('endpoints.agentpackage')
|
|
def upload_agent_binary(binkey):
|
|
"""Stage an uploaded binary as a new version.
|
|
|
|
Expects multipart/form-data with a 'file' upload and a 'version' field. The
|
|
version must be a strict dotted-numeric string (for example 1.6.3) so it is
|
|
safe as a directory name and orderable. The upload must be a Windows PE
|
|
executable. Older versions past the retention window are pruned, but never
|
|
the current one. By default the upload becomes the current staged build; send
|
|
makecurrent=false to stage a rollback/legacy build that joins the version
|
|
list without displacing the active current.
|
|
"""
|
|
version = (request.form.get('version') or '').strip()
|
|
makecurrent = (request.form.get('makecurrent') or 'true').strip().lower()
|
|
makecurrent = makecurrent not in ('false', '0', 'no')
|
|
if not agentstore.is_valid_binary_key(binkey):
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'binkey must be one of: agent, tray',
|
|
http_code=400)
|
|
|
|
upload = request.files.get('file')
|
|
if upload is None or not upload.filename:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'a file upload is required',
|
|
http_code=400)
|
|
|
|
data = upload.read(_MAX_UPLOAD_BYTES + 1)
|
|
if len(data) > _MAX_UPLOAD_BYTES:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'uploaded binary exceeds the size limit',
|
|
http_code=400)
|
|
if not agentstore.looks_like_pe(data):
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'uploaded file is not a Windows executable',
|
|
http_code=400)
|
|
|
|
# Gate the version at the PE, not at the raw input. Product versions carry
|
|
# semver build metadata (1.5.0+abcdef...) that is not a legal directory
|
|
# name, so the operator's typed string is only a hint. The dotted-numeric
|
|
# FILE version stamped in the binary's PE resource is the authority; fall
|
|
# back to it whenever the typed value is missing or not dotted-numeric, and
|
|
# only reject when neither yields a usable version.
|
|
if not agentstore.is_valid_version(version):
|
|
detected = agentstore.read_pe_file_version(data)
|
|
if agentstore.is_valid_version(detected):
|
|
version = detected
|
|
else:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'version must be a dotted-numeric string such as 1.6.3',
|
|
http_code=400)
|
|
|
|
result = agentstore.store_version(binkey, version, data,
|
|
make_current=makecurrent)
|
|
|
|
# This is the binary every future package installs and runs as SYSTEM on
|
|
# the fleet. Without a record, an incident has no answer to "who staged
|
|
# this, and when" - the manifest holds the file, not the person. The digest
|
|
# identifies the BYTES, because a version string is chosen by whoever
|
|
# uploads and re-uploading one replaces the file in place.
|
|
entry = next((v for v in result['versions']
|
|
if v.get('version') == version), {})
|
|
AuditLog.log(
|
|
'uploaded', 'AgentBinary', entityname='%s %s' % (binkey, version),
|
|
changes={'sha256': entry.get('sha256'), 'size': entry.get('size'),
|
|
'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,
|
|
})
|
|
|
|
|
|
@endpoints_bp.route('/siteagent/binaries/<binkey>/detect', methods=['POST'])
|
|
@require_permission('endpoints.agentpackage')
|
|
def detect_agent_binary_version(binkey):
|
|
"""Read the PE file version of an uploaded EXE without staging it.
|
|
|
|
The Build Site Agent page calls this on file-select so the version field is
|
|
prefilled from the binary's own version resource - the operator does not
|
|
have to read the EXE properties by hand for a current-release upload.
|
|
Returns {version} which is null when the file carries no readable version.
|
|
"""
|
|
if not agentstore.is_valid_binary_key(binkey):
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'binkey must be one of: agent, tray',
|
|
http_code=400)
|
|
|
|
upload = request.files.get('file')
|
|
if upload is None or not upload.filename:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'a file upload is required',
|
|
http_code=400)
|
|
|
|
data = upload.read(_MAX_UPLOAD_BYTES + 1)
|
|
if len(data) > _MAX_UPLOAD_BYTES:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'uploaded binary exceeds the size limit',
|
|
http_code=400)
|
|
if not agentstore.looks_like_pe(data):
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'uploaded file is not a Windows executable',
|
|
http_code=400)
|
|
|
|
return success_response({
|
|
'binary': binkey,
|
|
'version': agentstore.read_pe_file_version(data),
|
|
})
|
|
|
|
|
|
@endpoints_bp.route('/siteagent/binaries/<binkey>/adopt', methods=['POST'])
|
|
@require_permission('endpoints.agentpackage')
|
|
def adopt_legacy_agent_binary(binkey):
|
|
"""Adopt the unmanaged legacy flat file into the version store.
|
|
|
|
The pre-versioning install dropped a single shopdbagent.exe (or tray exe)
|
|
straight under siteagent/. This copies that file in as a real staged version
|
|
so it shows a version and joins retention, without the operator having to
|
|
re-upload it. The version comes from the request when the binary carries no
|
|
readable PE version resource; otherwise the detected version is used.
|
|
"""
|
|
if not agentstore.is_valid_binary_key(binkey):
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'binkey must be one of: agent, tray',
|
|
http_code=400)
|
|
|
|
status = agentstore.legacy_status(binkey)
|
|
if status is None:
|
|
return error_response(
|
|
ErrorCodes.NOT_FOUND,
|
|
'there is no unmanaged legacy binary to adopt',
|
|
http_code=404)
|
|
|
|
source = request.get_json(silent=True) or {}
|
|
version = (source.get('version') or status.get('version') or '').strip()
|
|
if not agentstore.is_valid_version(version):
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'version must be a dotted-numeric string such as 1.6.3',
|
|
http_code=400)
|
|
|
|
result = agentstore.adopt_legacy(binkey, version)
|
|
if result is None:
|
|
return error_response(
|
|
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,
|
|
})
|
|
|
|
|
|
@endpoints_bp.route('/siteagent/binaries/<binkey>/current', methods=['PUT'])
|
|
@require_permission('endpoints.agentpackage')
|
|
def set_current_agent_binary(binkey):
|
|
"""Point 'current' at an already-staged version (roll back or forward)."""
|
|
if not agentstore.is_valid_binary_key(binkey):
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'binkey must be one of: agent, tray',
|
|
http_code=400)
|
|
source = request.get_json(silent=True) or {}
|
|
version = (source.get('version') or '').strip()
|
|
if not agentstore.is_valid_version(version):
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'version must be a dotted-numeric string such as 1.6.3',
|
|
http_code=400)
|
|
if not agentstore.set_current(binkey, version):
|
|
return error_response(
|
|
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,
|
|
})
|