Add one-click agent self-upgrade from the endpoint detail page
An operator could see that an endpoint was behind the published build but had no way to act on it. update_app resolves against Application rows, and the agent lives in the version store, so there was nothing to push. Bridge the two: the staged build is packaged and registered as a reserved Application row, and a managed PowerShell script is seeded to install it. The endpoint downloads that package over the existing collector-authenticated app-package route, so no parallel delivery path is introduced. The new route prepares the delivery and then DELEGATES to enqueue_endpoint_command as a digest-pinned run_script. It deliberately does not implement its own enqueue: a second path would duplicate, and eventually drift from, the endpoint lock, the Configuration Policy gate, stale-command reaping and the at-most-one-in-flight rule. A fleet-wide agent swap is the last action that should skip a quarantine check. Tests pin that delegation rather than only the happy path. Because it lands as run_script, the caller needs endpoints.runscript on top of endpoints.agentupdate. That is intended: this delivers arbitrary SYSTEM PowerShell by the same mechanism as any other script push. The audit event is written only on an actual 201, so a refused push does not record an upgrade that never happened. Replacing the agent is self-modifying the control plane - the process receiving the command is the one being swapped. The script therefore re-launches itself detached and exits immediately, because the agent runs run_script as a direct child and holds its stdout/stderr pipes while waiting on it. Expect the command to report queued rather than success: the process that would report completion is stopped mid-run. Judge the result by the version the endpoint reports afterward. Tray relaunch does not use the installer's `explorer.exe <path>` trick. That works in install-shopdbagent.ps1 only because the installer runs as the user - note its own `-ne "SYSTEM"` guard. This script always runs as SYSTEM in session 0, where the hand-off reaches no shell and exits 0, so Start-Process reported success for a relaunch that never happened. It also cannot descend into the session with a token-duplicating launch: UserContextSpool documents that WTSQueryUserToken is blocked by endpoint protection on these hosts. Instead a one-shot scheduled task targets the pinned ShopFloor account, matching how the user-context spool already resolves that user, and the tray must run as that account or the mapped-drive probe reports the wrong (empty) drive table. The relaunch is verified by polling for the process rather than assumed, and stays best-effort: the Run key still starts the tray at next logon, and a late tray is not worth rolling back a good agent upgrade. panel_status now carries agentbehind/agentversion/agentcurrent so the UI gates the action on a flag rather than parsing "(behind X)" out of a label. The button is confirmed by typing UPDATE, naming both versions and the service interruption, matching how run_script and system_restart are gated. Verified on live endpoints: upgraded 1.13.0.0 and 1.15.0.0 machines to 1.16.0.0. run_script delivery, digest fetch and system context all predate 1.13.0.0, so no minimum agent version is declared for this path.
This commit is contained in:
@@ -569,10 +569,11 @@ def panel_status(asset_id: int):
|
||||
# "behind" from a placeholder like '0.0.0-sample' would be a false alarm.
|
||||
agentvalue = state.agentversion
|
||||
published = agentstore.current_version('agent')
|
||||
if (agentvalue and published
|
||||
and parse_agent_version(agentvalue)
|
||||
and parse_agent_version(published)
|
||||
and not agent_meets_minimum(agentvalue, published)):
|
||||
agentbehind = (agentvalue and published
|
||||
and parse_agent_version(agentvalue)
|
||||
and parse_agent_version(published)
|
||||
and not agent_meets_minimum(agentvalue, published))
|
||||
if agentbehind:
|
||||
agentvalue = '{} (behind {})'.format(agentvalue, published)
|
||||
|
||||
fields = [
|
||||
@@ -609,7 +610,17 @@ def panel_status(asset_id: int):
|
||||
'{} monitor(s) expected running but not reported: {}'.format(
|
||||
len(drift), names)))
|
||||
|
||||
return success_response({'fields': fields})
|
||||
# Machine-readable companion to the "(behind X)" text above. The UI needs to
|
||||
# decide whether to offer the upgrade action, and parsing that decision back
|
||||
# out of a display string would tie the button to the wording of a label.
|
||||
# Emitted as a sibling of fields so nothing about the existing rendering
|
||||
# changes; an older UI simply ignores it.
|
||||
return success_response({
|
||||
'fields': fields,
|
||||
'agentbehind': bool(agentbehind),
|
||||
'agentversion': state.agentversion,
|
||||
'agentcurrent': published,
|
||||
})
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/hardware', methods=['GET'])
|
||||
@@ -2975,6 +2986,90 @@ def enqueue_endpoint_command(asset_id: int):
|
||||
message='Command queued', http_code=201)
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>/agentupgrade', methods=['POST'])
|
||||
@require_permission('endpoints.agentupdate')
|
||||
def enqueue_agent_upgrade(asset_id: int):
|
||||
"""Push the published agent build to one endpoint.
|
||||
|
||||
Replacing the agent is self-modifying the control plane: the process that
|
||||
receives this command is the one being swapped. It therefore sits behind
|
||||
endpoints.agentupdate rather than endpoints.command or endpoints.deploy -
|
||||
see the permission's rationale in plugin.py.
|
||||
|
||||
This does NOT enqueue directly. It prepares the delivery (packages the
|
||||
current build, seeds/refreshes the upgrade script) and then delegates to
|
||||
enqueue_endpoint_command as a run_script push. Writing a second enqueue
|
||||
path here would duplicate - and eventually drift from - the endpoint lock
|
||||
check, the Configuration Policy gate, stale-command reaping and the
|
||||
at-most-one-in-flight rule. Those are exactly the protections a
|
||||
fleet-wide agent swap must not be the one action to skip.
|
||||
|
||||
Because it lands as run_script, the caller needs endpoints.runscript too
|
||||
(enforced by the delegate as the elevated permission for that action).
|
||||
That is deliberate: this delivers arbitrary SYSTEM PowerShell by the same
|
||||
mechanism as any other script push.
|
||||
"""
|
||||
from . import agentregistry, agentupgradescript
|
||||
|
||||
# Refresh the package and the script body from the CURRENT staged build
|
||||
# before queueing, so an upgrade cannot ship a stale payload if the row was
|
||||
# written before the latest upload. Both operations are idempotent.
|
||||
try:
|
||||
registration = agentregistry.register_current_agent_package()
|
||||
except Exception as exc: # noqa: BLE001 - surfaced, not swallowed
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Could not package the agent build: {exc}')
|
||||
if registration is None:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'No agent build is published. Upload an agent binary before '
|
||||
'pushing an upgrade.')
|
||||
|
||||
try:
|
||||
seeded = agentupgradescript.seed_upgrade_script()
|
||||
except Exception as exc: # noqa: BLE001 - surfaced, not swallowed
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Could not prepare the upgrade script: {exc}')
|
||||
if seeded is None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'The agent upgrade script could not be prepared.')
|
||||
|
||||
# Rewrite the request body to the equivalent run_script push and hand it to
|
||||
# the real enqueue route. The policyoverride/overridereason fields are
|
||||
# forwarded untouched so break-glass keeps behaving exactly as it does for
|
||||
# a normal script push, including its admin-role and reason requirements.
|
||||
payload = request.get_json(silent=True) or {}
|
||||
forwarded = {
|
||||
'action': 'run_script',
|
||||
'target': 'script:{}'.format(seeded['scriptid']),
|
||||
}
|
||||
if payload.get('policyoverride'):
|
||||
forwarded['policyoverride'] = True
|
||||
forwarded['overridereason'] = payload.get('overridereason') or ''
|
||||
|
||||
# Swap the parsed JSON for the delegate. Flask caches the parsed body on
|
||||
# the request, so setting the cache is what makes get_json() return this.
|
||||
request._cached_json = (forwarded, forwarded)
|
||||
|
||||
response = enqueue_endpoint_command(asset_id)
|
||||
|
||||
# Audit only a push that actually queued. The delegate returns a non-201
|
||||
# for a lock, a policy denial or an in-flight command, and recording those
|
||||
# as upgrades would put events in the log for pushes that never happened.
|
||||
status = getattr(response, 'status_code', None)
|
||||
if status == 201:
|
||||
AuditLog.log(
|
||||
'created', 'AgentUpgrade', entityid=asset_id,
|
||||
entityname='agent {}'.format(registration['agentversion']),
|
||||
details='agent upgrade queued for asset {} (version {}, script {})'
|
||||
.format(asset_id, registration['agentversion'],
|
||||
seeded['scriptid']))
|
||||
db.session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@endpoints_bp.route('/monitors/resolved', methods=['GET'])
|
||||
def resolved_monitors_for_agent():
|
||||
"""Agent watch list: the monitors this endpoint should report on.
|
||||
|
||||
Reference in New Issue
Block a user