"""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'], }