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
232 lines
9.3 KiB
Python
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),
|
|
})
|