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.
110 lines
4.1 KiB
Python
110 lines
4.1 KiB
Python
"""Register staged endpoint agent builds as installable Applications.
|
|
|
|
The endpoint update_app push resolves an install source from the applications
|
|
table alone - it has no knowledge of the agent version store. Without a row
|
|
there, a build can be staged and current in agentstore and still be impossible
|
|
to push, which is exactly the gap found in the fleet: no application row existed
|
|
for the agent at all.
|
|
|
|
This module bridges the two by making the UPLOAD the single source of truth.
|
|
Staging a build repackages the current agent (plus tray) and rewrites the
|
|
application row's installpath and installersha256 in the same request, so the
|
|
row cannot describe a build that is no longer current. Nothing here is
|
|
hand-maintained; an operator never edits these rows.
|
|
"""
|
|
|
|
import os
|
|
|
|
from shopdb.api import Application, application_package_location, db
|
|
|
|
from . import agentstore
|
|
|
|
# Reserved appname for the managed agent bundle. appname is unique, which is
|
|
# what makes the upsert idempotent: repeated uploads update one row rather than
|
|
# accumulating a row per build. Changing this string strands the existing row,
|
|
# so it is a constant rather than anything configurable.
|
|
AGENT_APPNAME = 'ShopDB Endpoint Agent'
|
|
|
|
AGENT_PACKAGE_FILENAME = 'shopdb-endpoint-agent.zip'
|
|
|
|
_AGENT_DESCRIPTION = (
|
|
'Managed ShopDB endpoint agent bundle. Created and kept current '
|
|
'automatically when an agent build is staged - do not edit by hand.'
|
|
)
|
|
|
|
|
|
def _package_path(appid):
|
|
directory, _prefix = application_package_location()
|
|
return os.path.join(directory, str(appid), AGENT_PACKAGE_FILENAME)
|
|
|
|
|
|
def _get_or_create_app():
|
|
"""Return the reserved agent Application row, creating it when absent.
|
|
|
|
Does not commit: the caller commits once, after the package is written, so
|
|
a failed write cannot leave a row advertising an installer that is not
|
|
there.
|
|
"""
|
|
app = Application.query.filter_by(appname=AGENT_APPNAME).first()
|
|
if app:
|
|
return app, False
|
|
|
|
app = Application(
|
|
appname=AGENT_APPNAME,
|
|
appdescription=_AGENT_DESCRIPTION,
|
|
# Installable so it can be pushed; hidden so a machine-managed row does
|
|
# not surface in the software catalogue alongside real licensed apps.
|
|
isinstallable=True,
|
|
ishidden=True,
|
|
)
|
|
db.session.add(app)
|
|
# Flush rather than commit: the row needs its appid to build the package
|
|
# path, but the transaction stays open so the whole registration is atomic.
|
|
db.session.flush()
|
|
return app, True
|
|
|
|
|
|
def register_current_agent_package():
|
|
"""Repackage the current agent build and point the agent app row at it.
|
|
|
|
Returns a dict describing what was registered, or None when there is no
|
|
current agent build to package. Safe to call on every upload: it is an
|
|
upsert keyed on the reserved appname, and it rewrites the package file in
|
|
place.
|
|
"""
|
|
payload, manifest = agentstore.build_current_package()
|
|
if payload is None:
|
|
return None
|
|
|
|
app, created = _get_or_create_app()
|
|
|
|
path = _package_path(app.appid)
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
# Write to a temp name and replace, so an interrupted write cannot leave a
|
|
# truncated archive at the path the row already advertises. os.replace is
|
|
# atomic within a filesystem.
|
|
temppath = path + '.tmp'
|
|
with open(temppath, 'wb') as handle:
|
|
handle.write(payload)
|
|
os.replace(temppath, path)
|
|
|
|
_directory, urlprefix = application_package_location()
|
|
app.installpath = '{}{}/{}'.format(
|
|
urlprefix, app.appid, AGENT_PACKAGE_FILENAME)
|
|
# The digest of the ARCHIVE, which is what the agent downloads and verifies.
|
|
# The per-binary digests inside the manifest are for the install script to
|
|
# check its unpacked contents, and are not interchangeable with this one.
|
|
app.installersha256 = manifest['sha256']
|
|
db.session.commit()
|
|
|
|
return {
|
|
'appid': app.appid,
|
|
'appname': app.appname,
|
|
'created': created,
|
|
'installpath': app.installpath,
|
|
'sha256': manifest['sha256'],
|
|
'size': manifest['size'],
|
|
'agentversion': manifest['agentversion'],
|
|
'contents': manifest['contents'],
|
|
}
|