A multi-agent review of the merged branch produced 29 findings that survived
adversarial verification. This clears the blockers and the four worth fixing
before it reaches main. The design held up well: path-traversal defence on all
four serving routes is clean, the agent's SHA-256 check fails closed, enqueue
authority is proportionate and audited, and no site literals or credential leaks
in the new C#.
THE TESTS NEVER RAN
pytest.ini sets testpaths = tests, so plugins/endpoints/tests/ was collected ZERO
times. Fifty-five lines of delivery assertions were dead code, and run directly
five of them failed. plugins/printedparts/tests/ had been invisible for longer.
That is the worst shape a gap can take: the suite is green, the coverage is
imaginary, and the collector auth on a download route could be deleted without
anything going red.
Both files moved under tests/. tests/test_no_hidden_tests.py now fails if a test
file appears anywhere pytest will not look; it caught a staged copy inside the
installer bundle on its first run, and was verified by planting a violation.
The five failures were stale expectations, and they split by cause. His: the
applications panel made six panels where his test said five, and 'applications'
belongs in KNOWN_PAYLOAD_KEYS now that it is persisted - his own test contradicted
his own feature. Ours: we renamed the events card to activity, moved the
fleet-wide cards to endpoints.fleet, and made the list route a bare route('')
which Flask rejects unless the blueprint is mounted under a prefix.
FOUR FIXES
The snapshot table's key was a 32-bit autoincrement on a table rewritten
wholesale every check-in, so it climbs at (endpoints x apps) per report and
exhausts on MySQL. Widened while the table is unreleased. Widening it broke every
insert on SQLite, which only autoincrements a column typed exactly INTEGER
PRIMARY KEY, so it carries a dialect variant - and nothing caught either fault,
because no test inserted into that table at all. One does now.
Ingest truncated displayname but not displayversion or publisher. On strict MySQL
an over-long registry value fails the whole check-in transaction, so one machine
with a malformed Uninstall entry goes silent entirely. SQLite does not enforce
lengths, so the suite would never have shown it.
update_app's version: branch pinned AppVersion.sha256, but that route resolves the
version to its APPLICATION and serves application.installpath - so the digest
described an artifact the route never sends, and where it was null the agent had
nothing to verify and failed open. It now pins the file actually served.
The same branch ended the delivery URL at the id, so an MSI arrived with no
extension and could not be routed through msiexec. It carries the filename now.
Agent packaging moved off require_role('admin') to a new endpoints.agentpackage
permission. The generated ZIP carries a live collector key, so this is a heavier
grant than deploying to one endpoint - and a role cannot be given to one person
for one job the way a permission can.
Full suite 2231 passed (2198 before, plus the 22 that never ran and 11 new),
frontend 314.
441 lines
18 KiB
Python
441 lines
18 KiB
Python
"""Site agent generator packaging.
|
|
|
|
Builds a deployable, instance-locked endpoint-agent package for this site. An
|
|
admin supplies the server base URL, the managed endpoint agent key (minted in
|
|
the key generator above), and the reporting cadence; this route returns a ZIP
|
|
carrying the prebuilt shopdbagent.exe, a non-secret appsettings.json, an
|
|
install.ps1 that provisions the credentials, and a README.
|
|
|
|
This module is deliberately self-contained so it can be maintained alongside
|
|
the endpoint agent without editing routes.py. It attaches two admin-gated
|
|
routes to the existing endpoints blueprint.
|
|
|
|
The container has no .NET SDK, so - exactly like the adoption tool - the agent
|
|
exe is built out of band and staged under instance/siteagent/. When a prebuilt
|
|
binary is present it ships in the ZIP; otherwise the ZIP still carries the
|
|
config and installer so an operator can drop the exe in beside them.
|
|
|
|
Per repo policy the live CollectorKey and BaseUrl are provisioned into the
|
|
Windows registry (HKLM\\SOFTWARE\\GE\\ShopDB), never baked into appsettings.json.
|
|
The generated install.ps1 writes those two registry values from the operator's
|
|
inputs; appsettings.json ships with an empty baseurl and a placeholder apikey so
|
|
the agent fails closed until provisioned. The collector key is embedded only in
|
|
the install script's default parameter and is never stored or logged here.
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import ssl
|
|
import zipfile
|
|
|
|
from flask import request, current_app, send_file
|
|
|
|
from shopdb.api import (success_response, error_response, ErrorCodes,
|
|
require_permission)
|
|
|
|
from .routes import endpoints_bp
|
|
|
|
# Prebuilt, staged agent binaries. Built out of band and dropped under
|
|
# instance/siteagent/ (mirrors the adoption tool's STAGED_EXE_RELPATH model).
|
|
STAGED_AGENT_RELPATH = os.path.join('siteagent', 'shopdbagent.exe')
|
|
STAGED_TRAY_RELPATH = os.path.join('siteagent', 'shopdb-endpoint-tray.exe')
|
|
|
|
# Public TLS certificate the agent must trust when this deployment terminates
|
|
# TLS itself with a self-signed or otherwise untrusted certificate. We can only
|
|
# auto-bundle when THIS app owns the cert file (the dev topology, where gunicorn
|
|
# terminates TLS with certs/server.crt). When a site fronts the app with its own
|
|
# reverse proxy the app cannot see the proxy's cert, so bundling is opt-in and
|
|
# only offered when a readable cert file is actually found here. Resolution order:
|
|
# the SHOPDB_TLS_CERT env var, then the mounted container path, then the repo
|
|
# path. Only a PEM public certificate is ever read - never a private key.
|
|
_CERT_CANDIDATES = (
|
|
os.environ.get('SHOPDB_TLS_CERT') or '',
|
|
'/app/certs/server.crt',
|
|
os.path.join(os.getcwd(), 'certs', 'server.crt'),
|
|
)
|
|
|
|
|
|
def _server_cert_path():
|
|
"""Path to a readable public TLS cert to bundle, or None if none exists."""
|
|
for candidate in _CERT_CANDIDATES:
|
|
if candidate and os.path.isfile(candidate):
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _validate_pem_cert(raw):
|
|
"""Return normalized PEM text for a valid X.509 certificate, else None.
|
|
|
|
Accepts operator-uploaded .crt/.pem bytes. Parses every PEM certificate
|
|
block through the stdlib ssl module (which round-trips it to DER, so junk
|
|
or a non-certificate PEM such as a private key is rejected). The returned
|
|
text is the concatenation of the re-encoded blocks, so a leaf-plus-chain
|
|
upload is preserved while any trailing garbage is dropped.
|
|
"""
|
|
try:
|
|
text = raw.decode('ascii')
|
|
except (UnicodeDecodeError, AttributeError):
|
|
return None
|
|
|
|
marker = '-----BEGIN CERTIFICATE-----'
|
|
if marker not in text:
|
|
return None
|
|
|
|
blocks = []
|
|
start = text.find(marker)
|
|
while start != -1:
|
|
end = text.find('-----END CERTIFICATE-----', start)
|
|
if end == -1:
|
|
return None
|
|
end += len('-----END CERTIFICATE-----')
|
|
block = text[start:end] + '\n'
|
|
try:
|
|
ssl.PEM_cert_to_DER_cert(block)
|
|
except (ssl.SSLError, ValueError):
|
|
return None
|
|
blocks.append(block)
|
|
start = text.find(marker, end)
|
|
|
|
if not blocks:
|
|
return None
|
|
return ''.join(blocks)
|
|
|
|
# The non-secret runtime config shipped in the package. baseurl and apikey stay
|
|
# empty/placeholder so the agent fails closed until the registry is provisioned
|
|
# by install.ps1. Cadence is stamped from the operator's input at build time.
|
|
_BASE_APPSETTINGS = {
|
|
'baseurl': '',
|
|
'apikey': 'REPLACE_WITH_COLLECTOR_KEY',
|
|
'cadenceseconds': 900,
|
|
'jitterseconds': 60,
|
|
'adoptonfirstrun': True,
|
|
'utilitiespath': '',
|
|
'logpath': '',
|
|
'monitors': {
|
|
'processes': [
|
|
{'name': 'dncMain.exe', 'app': 'eDNC'},
|
|
{'name': 'udc.exe', 'app': 'Universal Data Collector'},
|
|
{'name': 'agent.exe', 'app': 'MTConnect'},
|
|
],
|
|
'services': [
|
|
{'name': 'MTConnect UDC Agent', 'app': 'MTConnect'},
|
|
],
|
|
},
|
|
'launchpaths': {
|
|
'DncMain.exe': 'C:\\Program Files (x86)\\Dnc\\bin\\DncMain.exe',
|
|
'UDC.exe': 'C:\\Program Files\\UDC\\UDC.exe',
|
|
'agent.exe': 'C:\\ProgramData\\udc\\mtconnect_udc\\agent\\run.bat',
|
|
},
|
|
}
|
|
|
|
_README = """ShopDB endpoint agent (site build)
|
|
|
|
WHAT THIS IS
|
|
An instance-locked endpoint agent for this site. It installs as a Windows
|
|
service, reports identity, hardware, and installed-software inventory on a
|
|
fixed cadence, and executes delivery commands (scripts, app updates) issued
|
|
from this ShopDB server. Unlike the adoption tool it stays resident.
|
|
|
|
FILES
|
|
shopdbagent.exe The agent service host (present only if the server
|
|
had a prebuilt copy staged).
|
|
shopdb-endpoint-tray.exe Optional interactive tray app (present when staged).
|
|
appsettings.json Non-secret runtime config (cadence, monitors,
|
|
launch paths). No credentials.
|
|
install.ps1 Provisions the credentials and installs the service.
|
|
server.crt Server TLS certificate (present only for self-signed
|
|
or internal-CA deployments, either auto-detected or
|
|
uploaded by the operator). install.ps1 imports it
|
|
into the machine Trusted Root store so the agent can
|
|
validate its outbound TLS connection to the server.
|
|
|
|
USAGE
|
|
1. Copy this whole folder to the target endpoint.
|
|
2. Right-click install.ps1 and Run with PowerShell as administrator. It
|
|
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
|
|
copies the agent into Program Files, and starts the service.
|
|
|
|
SECURITY
|
|
install.ps1 carries a live collector key in its default -CollectorKey value.
|
|
Treat this package as a secret. The key is not recoverable from the ShopDB
|
|
server after it was generated; regenerate the key in the UI to revoke it.
|
|
"""
|
|
|
|
|
|
def _staged_path(relpath):
|
|
"""Absolute path to a staged binary under the instance directory."""
|
|
return os.path.join(current_app.instance_path, relpath)
|
|
|
|
|
|
def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None):
|
|
"""The install.ps1 that provisions credentials and installs the service.
|
|
|
|
The operator's server URL and collector key are stamped as the default
|
|
parameter values so a double-click install needs no arguments. The key is
|
|
written only to the registry (HKLM\\SOFTWARE\\GE\\ShopDB), never into
|
|
appsettings.json, honoring the registry-provisioning contract. When a server
|
|
certificate is bundled, certfilename names it and the script imports it into
|
|
the machine Trusted Root store so the agent's outbound TLS to a self-signed
|
|
or internal-CA server validates.
|
|
"""
|
|
baseurl_literal = json.dumps(baseurl)
|
|
collectorkey_literal = json.dumps(collectorkey)
|
|
certfile_literal = json.dumps(certfilename or '')
|
|
return """# install.ps1
|
|
# ShopDB endpoint agent installer. Run from an ELEVATED (admin) PowerShell on
|
|
# the endpoint. Provisions the registry credentials, copies the agent to
|
|
# Program Files, and installs and starts the Windows service. The server URL and
|
|
# collector key are stamped as defaults by the site agent generator.
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
[string] $BaseUrl = {baseurl},
|
|
[string] $CollectorKey = {collectorkey},
|
|
[string] $InstallDir = "C:\\Program Files\\ShopDB\\EndpointAgent",
|
|
[string] $ServiceName = "ShopDbEndpointAgent",
|
|
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
|
|
[string] $CertFileName = {certfile},
|
|
[switch] $NoTray
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
$sourceDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
|
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
|
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {{
|
|
throw "This script must be run from an elevated (Run as administrator) PowerShell."
|
|
}}
|
|
|
|
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {{
|
|
throw "BaseUrl is not set. Pass -BaseUrl 'http://<host>:<port>' when running the script."
|
|
}}
|
|
if ([string]::IsNullOrWhiteSpace($CollectorKey)) {{
|
|
throw "CollectorKey is not set. Regenerate the key in the endpoints UI and rebuild."
|
|
}}
|
|
|
|
# Write HKLM values to BOTH the 64-bit and 32-bit (WOW6432Node) registry views.
|
|
# The agent reads Registry64 first then Registry32 and uses the first view that
|
|
# yields a value, so a stale WOW6432Node copy from a prior install (for example
|
|
# an old http:// BaseUrl) would silently win if we only wrote the 64-bit view.
|
|
# Writing both keeps the two views in lockstep so re-provisioning cannot be
|
|
# shadowed by a leftover value.
|
|
function Set-HklmValueBothViews {{
|
|
param(
|
|
[Parameter(Mandatory)] [string] $SubKey,
|
|
[Parameter(Mandatory)] [string] $Name,
|
|
[Parameter(Mandatory)] [string] $Value
|
|
)
|
|
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64,
|
|
[Microsoft.Win32.RegistryView]::Registry32)) {{
|
|
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
|
|
[Microsoft.Win32.RegistryHive]::LocalMachine, $view)
|
|
try {{
|
|
$key = $base.CreateSubKey($SubKey)
|
|
try {{ $key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::String) }}
|
|
finally {{ $key.Dispose() }}
|
|
}}
|
|
finally {{ $base.Dispose() }}
|
|
}}
|
|
}}
|
|
|
|
Write-Host "== ShopDB endpoint agent install ==" -ForegroundColor Cyan
|
|
Write-Host "BaseUrl : $BaseUrl"
|
|
Write-Host "InstallDir : $InstallDir"
|
|
Write-Host "ServiceName : $ServiceName"
|
|
|
|
# 0. Stop and remove any previous install so the file copy is a clean overwrite.
|
|
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
|
if ($existing) {{
|
|
Write-Host "[0/4] Removing existing service $ServiceName ..."
|
|
if ($existing.Status -ne "Stopped") {{
|
|
try {{ Stop-Service -Name $ServiceName -Force -ErrorAction Stop }}
|
|
catch {{ Write-Warning "could not stop service: $($_.Exception.Message)" }}
|
|
}}
|
|
sc.exe delete $ServiceName | Out-Null
|
|
Start-Sleep -Seconds 2
|
|
}}
|
|
$trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
|
|
foreach ($name in @("shopdbagent", $trayBase)) {{
|
|
foreach ($p in (Get-Process -Name $name -ErrorAction SilentlyContinue)) {{
|
|
try {{ Stop-Process -Id $p.Id -Force -ErrorAction Stop }} catch {{ }}
|
|
}}
|
|
}}
|
|
|
|
# 1. Provision registry credentials (both 64-bit and 32-bit views).
|
|
Write-Host "[1/4] Provisioning registry HKLM:\\SOFTWARE\\GE\\ShopDB (Registry64 + Registry32) ..."
|
|
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "BaseUrl" -Value $BaseUrl
|
|
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "CollectorKey" -Value $CollectorKey
|
|
|
|
# 1a. Trust the bundled server certificate, if one shipped. The agent's outbound
|
|
# TLS to a self-signed or internal-CA server fails closed until the machine
|
|
# trusts the cert. Importing into LocalMachine\\Root covers the SYSTEM service.
|
|
# No-op when the deployment uses a publicly trusted (or already-trusted internal)
|
|
# certificate, in which case no cert is bundled.
|
|
if (-not [string]::IsNullOrWhiteSpace($CertFileName)) {{
|
|
$certPath = Join-Path $sourceDir $CertFileName
|
|
if (Test-Path $certPath) {{
|
|
Write-Host " trusting bundled server certificate $CertFileName ..."
|
|
try {{
|
|
Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root | Out-Null
|
|
}}
|
|
catch {{ Write-Warning "could not import server certificate: $($_.Exception.Message)" }}
|
|
}}
|
|
else {{
|
|
Write-Warning "server certificate $CertFileName named but not found beside install.ps1."
|
|
}}
|
|
}}
|
|
|
|
# 2. Copy agent files to the install directory.
|
|
Write-Host "[2/4] Copying agent files to $InstallDir ..."
|
|
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
|
Get-ChildItem -Path $sourceDir -File | ForEach-Object {{
|
|
try {{ Unblock-File -Path $_.FullName -ErrorAction Stop }} catch {{ }}
|
|
}}
|
|
$agentSource = Join-Path $sourceDir "shopdbagent.exe"
|
|
if (-not (Test-Path $agentSource)) {{
|
|
throw "shopdbagent.exe not found in $sourceDir. This package did not ship a prebuilt agent; stage the exe beside install.ps1 and rerun."
|
|
}}
|
|
Copy-Item -Path $agentSource -Destination $InstallDir -Force
|
|
$appsettings = Join-Path $sourceDir "appsettings.json"
|
|
if (Test-Path $appsettings) {{
|
|
Copy-Item -Path $appsettings -Destination $InstallDir -Force
|
|
}}
|
|
if (-not $NoTray) {{
|
|
$traySource = Join-Path $sourceDir $TrayExeName
|
|
if (Test-Path $traySource) {{
|
|
Copy-Item -Path $traySource -Destination $InstallDir -Force
|
|
}}
|
|
}}
|
|
|
|
# 3. Install and start the service. The agent supports its own service install.
|
|
Write-Host "[3/4] Installing and starting the service ..."
|
|
$installedAgent = Join-Path $InstallDir "shopdbagent.exe"
|
|
$binPath = '"' + $installedAgent + '"'
|
|
sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null
|
|
sc.exe start $ServiceName | Out-Null
|
|
|
|
Write-Host "[4/4] Done." -ForegroundColor Green
|
|
""".format(
|
|
baseurl=baseurl_literal,
|
|
collectorkey=collectorkey_literal,
|
|
certfile=certfile_literal,
|
|
)
|
|
|
|
|
|
def _siteagent_status_payload():
|
|
"""Whether a prebuilt agent binary is staged for packaging."""
|
|
agent_staged = os.path.isfile(_staged_path(STAGED_AGENT_RELPATH))
|
|
tray_staged = os.path.isfile(_staged_path(STAGED_TRAY_RELPATH))
|
|
return {
|
|
'agentstaged': agent_staged,
|
|
'traystaged': tray_staged,
|
|
'certavailable': _server_cert_path() is not None,
|
|
}
|
|
|
|
|
|
@endpoints_bp.route('/siteagent', methods=['GET'])
|
|
@require_permission('endpoints.agentpackage')
|
|
def get_siteagent():
|
|
"""Report whether the site agent build has a prebuilt binary staged."""
|
|
return success_response(_siteagent_status_payload())
|
|
|
|
|
|
@endpoints_bp.route('/siteagent/package', methods=['POST'])
|
|
@require_permission('endpoints.agentpackage')
|
|
def package_siteagent():
|
|
"""Bundle the site agent as a downloadable ZIP.
|
|
|
|
Accepts either JSON or multipart/form-data with fields baseurl,
|
|
collectorkey, cadenceseconds, and bundlecert. A multipart request may also
|
|
carry a certfile upload: an operator-supplied .crt/.pem that is validated as
|
|
X.509 and bundled so the installer trusts it as a root on the endpoint. This
|
|
is the reverse-proxy path where the app cannot see its own TLS cert. When no
|
|
cert is uploaded but bundlecert is set and this app terminates TLS with a
|
|
readable certificate, that auto-detected cert ships instead.
|
|
|
|
Returns an in-memory ZIP with the staged shopdbagent.exe (and tray when
|
|
present), a non-secret appsettings.json, install.ps1, and README.txt. The
|
|
collector key is embedded only in install.ps1 as its default parameter and
|
|
is never stored or logged.
|
|
"""
|
|
if request.form or request.files:
|
|
source = request.form
|
|
certupload = request.files.get('certfile')
|
|
else:
|
|
source = request.get_json(silent=True) or {}
|
|
certupload = None
|
|
|
|
baseurl = (source.get('baseurl') or '').strip()
|
|
collectorkey = (source.get('collectorkey') or '').strip()
|
|
cadenceseconds = source.get('cadenceseconds', 900)
|
|
bundlecert = str(source.get('bundlecert', '')).lower() in ('1', 'true', 'yes', 'on')
|
|
|
|
if not baseurl or not collectorkey:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'baseurl and collectorkey are required',
|
|
http_code=400)
|
|
|
|
try:
|
|
cadenceseconds = int(cadenceseconds)
|
|
except (TypeError, ValueError):
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'cadenceseconds must be an integer',
|
|
http_code=400)
|
|
if cadenceseconds < 60:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'cadenceseconds must be at least 60',
|
|
http_code=400)
|
|
|
|
appsettings = dict(_BASE_APPSETTINGS)
|
|
appsettings['cadenceseconds'] = cadenceseconds
|
|
|
|
# An operator-uploaded cert wins over the auto-detected server cert: it is
|
|
# the explicit reverse-proxy answer where the app cannot see its own TLS
|
|
# cert. Validate it as X.509 and bundle the normalized PEM. Fall back to the
|
|
# app-owned cert only when bundlecert is set and no upload was provided.
|
|
cert_pem = None
|
|
certpath = None
|
|
if certupload is not None and certupload.filename:
|
|
cert_pem = _validate_pem_cert(certupload.read())
|
|
if cert_pem is None:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'certfile is not a valid PEM X.509 certificate',
|
|
http_code=400)
|
|
elif bundlecert:
|
|
certpath = _server_cert_path()
|
|
|
|
certfilename = 'server.crt' if (cert_pem or certpath) else None
|
|
|
|
buffer = io.BytesIO()
|
|
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
|
|
archive.writestr('appsettings.json',
|
|
json.dumps(appsettings, indent=2) + '\n')
|
|
archive.writestr('install.ps1',
|
|
_install_script(baseurl.rstrip('/'), collectorkey,
|
|
cadenceseconds, certfilename))
|
|
archive.writestr('README.txt', _README)
|
|
|
|
agent = _staged_path(STAGED_AGENT_RELPATH)
|
|
if os.path.isfile(agent):
|
|
archive.write(agent, 'shopdbagent.exe')
|
|
tray = _staged_path(STAGED_TRAY_RELPATH)
|
|
if os.path.isfile(tray):
|
|
archive.write(tray, 'shopdb-endpoint-tray.exe')
|
|
if cert_pem:
|
|
archive.writestr('server.crt', cert_pem)
|
|
elif certpath:
|
|
archive.write(certpath, 'server.crt')
|
|
|
|
buffer.seek(0)
|
|
return send_file(
|
|
buffer,
|
|
mimetype='application/zip',
|
|
as_attachment=True,
|
|
download_name='shopdb-endpoint-agent.zip')
|