Files
shopdb-flask/plugins/endpoints/api/agentupload.py
cproudlock 13be0ef90f Import the wilm agent store and WINC adoption work, with four fixes
Eleven commits from the sister site: a reversible Maximo/WINC machine-adoption
import, a versioned agent binary store with upload and rollback, remote-BuildKit
CI, tray version reporting, and UI fixes. Applied as a patch again - the
published mirror rewrites hashes, so the branches have unrelated histories and
git refuses to merge them.

Reviewed by three agents before merging. Four things fixed here.

THE PUBLISH WORKFLOW WAS DEAD. The new remote-BuildKit step in
publish-images.yml sits at column 0 instead of indented under steps:, so the
whole file is invalid YAML and GitHub would refuse it. Their entire GHCR
distribution channel - which Start-ShopDBDev.ps1 and the compose files consume -
was broken at the source and clearly never run. Indented; all five workflows now
parse.

THE IMPORT'S APPROVAL CHECK COULD NOT FAIL. commit() built the plan and the
approval packet from the same upload and then asked the writer whether they
matched, so packet_matches_plan compared a hash against itself. The documented
contract - "a human approves a specific, frozen plan, and the writer can prove
nothing drifted" - did not exist in the web path: an admin could preview file A
and commit file B, gated only by the needs-create group NAMES they ticked. The
preview's planhash is now required at commit and checked against a freshly
rebuilt plan. Their tests committed without previewing, which is what let this
through; they go through the real flow now, and three new tests pin it - all
confirmed to fail with the check stubbed out.

endpoints.agentpackage QUIETLY GREW. It used to mean "wrap the staged exe in a
ZIP"; with the upload routes it also means "choose the executable every future
package installs and runs as SYSTEM". Before this, staging needed filesystem
access to the server. The permission description still said only "generate and
download a package", so an admin granting it could not see what they were
granting. Reworded.

NOTHING RECORDED WHAT WAS STAGED, OR BY WHOM. The manifest held version, size
and timestamp; re-uploading a version replaces the bytes in place under the same
label. A sha256 of the stored file is now recorded, and the upload is
audit-logged with it - after an incident there was previously no answer to who
put that binary on the fleet.

Also documented the nine new routes in docs/api-inventory.json (that gate cannot
run on the mirror, so it falls to us at promotion time again), regenerated the
spec and the project map, and kept our own docs where they conflicted: docs/ is
scrubbed from the mirror, so their DEV-BUILD-TEST.md still had the dev-be
channel mapping we corrected, and their PLUGIN-HOOKS.md was a new 45-line file
rather than our 737-line canonical one. Their journal section is appended to
ours.

Contract version comes across as 0.30.0 for a genuinely additive surface.

Suite 2369, frontend 324, gate green.

Scope: product
2026-09-03 07:54:14 -04:00

232 lines
9.3 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.
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
# Hard cap on an uploaded agent binary. The real agent is a few MB; 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.
_MAX_UPLOAD_BYTES = 64 * 1024 * 1024
@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)
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)
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)
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()
return success_response({
'binary': binkey,
'current': result['current'],
'versions': result['versions'],
})
@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)
return success_response({
'binary': binkey,
'current': result['current'],
'versions': result['versions'],
})
@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)
return success_response({
'binary': binkey,
'current': agentstore.current_version(binkey),
'versions': agentstore.list_versions(binkey),
})