Rework Build Site Agent binary UI with version auto-detect and rollback

Restructure the agent and tray binary blocks around an auto-detected current-release upload plus a separate legacy/rollback upload that stages a version without seizing current. Order staged versions by version number for the active-version dropdown, add a PE version detect route, and drop stale dev-status tags and manual-staging copy.
This commit is contained in:
benjamin.baker
2026-09-02 20:27:55 -04:00
parent 7a11851fbe
commit 729e488903
5 changed files with 541 additions and 35 deletions

View File

@@ -33,6 +33,7 @@ import io
import json import json
import os import os
import re import re
import struct
from datetime import datetime from datetime import datetime
from flask import current_app from flask import current_app
@@ -75,6 +76,155 @@ def looks_like_pe(data):
return len(data) >= 2 and data[:2] == _PE_MAGIC return len(data) >= 2 and data[:2] == _PE_MAGIC
# RT_VERSION resource type id (the version resource Windows shows in file
# properties). We must read the version from THIS resource on the outer
# executable - not the first VS_FIXEDFILEINFO signature we can find in the bytes.
# A self-contained single-file .NET exe bundles dozens of framework assemblies,
# each carrying its own version block, so a naive byte scan returns a framework
# version (for example the .NET runtime's) instead of the app's. The only
# reliable value is the RT_VERSION resource reached by walking the PE resource
# directory, which belongs to the outer image alone.
_RT_VERSION = 16
# 0xFEEF04BD little-endian: the dwSignature that opens VS_FIXEDFILEINFO inside
# the RT_VERSION resource data.
_VS_FIXEDFILEINFO_SIGNATURE = b'\xbd\x04\xef\xfe'
def _rva_to_offset(data, rva, sections):
"""Map a resource RVA to a file offset using the section table, or None."""
for virtual_address, virtual_size, raw_offset, raw_size in sections:
if virtual_address <= rva < virtual_address + max(virtual_size, raw_size):
delta = rva - virtual_address
if delta < raw_size:
return raw_offset + delta
return None
def _first_resource_entry_offset(data, table_offset, resource_base, sections,
want_id):
"""Walk one IMAGE_RESOURCE_DIRECTORY level and return a child offset.
When want_id is not None, follow the entry whose id matches (used to select
RT_VERSION at the type level); otherwise follow the first entry (used to
descend name/language levels). Returns the absolute file offset of the child
directory or data entry, with the high bit stripped, or None.
"""
# IMAGE_RESOURCE_DIRECTORY: 12 fixed bytes, then NumberOfNamedEntries (2)
# and NumberOfIdEntries (2), then the entries.
named = struct.unpack_from('<H', data, table_offset + 12)[0]
id_count = struct.unpack_from('<H', data, table_offset + 14)[0]
entries_offset = table_offset + 16
for index in range(named + id_count):
entry_offset = entries_offset + index * 8
name_or_id, offset_to_data = struct.unpack_from('<II', data, entry_offset)
# Named entries carry the high bit in name_or_id; we only match numeric
# ids, so skip named entries when selecting by id.
is_named = bool(name_or_id & 0x80000000)
if want_id is not None:
if is_named or name_or_id != want_id:
continue
# Directory children have the high bit set on offset_to_data.
child = (offset_to_data & 0x7FFFFFFF) + resource_base
return child
return None
def read_pe_file_version(data):
"""Dotted four-part file version from the PE's RT_VERSION resource, or None.
Walks the PE resource directory to the RT_VERSION resource of the outer
executable and decodes its VS_FIXEDFILEINFO file version. Returns None when
the file has no version resource or the structures are malformed. Never
raises - callers treat None as "unknown, ask the operator".
"""
try:
if len(data) < 0x40 or data[:2] != _PE_MAGIC:
return None
pe_offset = struct.unpack_from('<I', data, 0x3C)[0]
if data[pe_offset:pe_offset + 4] != b'PE\x00\x00':
return None
coff = pe_offset + 4
number_of_sections = struct.unpack_from('<H', data, coff + 2)[0]
size_of_optional = struct.unpack_from('<H', data, coff + 16)[0]
optional = coff + 20
# Magic distinguishes PE32 (0x10b) from PE32+ (0x20b); the data-directory
# array sits at a different offset in each.
magic = struct.unpack_from('<H', data, optional)[0]
if magic == 0x20B:
data_dir = optional + 112
else:
data_dir = optional + 96
# Resource table is data directory index 2: its RVA then size.
resource_rva = struct.unpack_from('<I', data, data_dir + 2 * 8)[0]
if not resource_rva:
return None
section_table = optional + size_of_optional
sections = []
for index in range(number_of_sections):
base = section_table + index * 40
virtual_size = struct.unpack_from('<I', data, base + 8)[0]
virtual_address = struct.unpack_from('<I', data, base + 12)[0]
raw_size = struct.unpack_from('<I', data, base + 16)[0]
raw_offset = struct.unpack_from('<I', data, base + 20)[0]
sections.append((virtual_address, virtual_size, raw_offset, raw_size))
resource_base = _rva_to_offset(data, resource_rva, sections)
if resource_base is None:
return None
# Level 1: type -> pick RT_VERSION. Level 2: name/id -> first.
# Level 3: language -> first. Then a data entry pointing at the resource.
type_dir = _first_resource_entry_offset(
data, resource_base, resource_base, sections, _RT_VERSION)
if type_dir is None:
return None
name_dir = _first_resource_entry_offset(
data, type_dir, resource_base, sections, None)
if name_dir is None:
return None
lang_entry = _first_resource_entry_offset(
data, name_dir, resource_base, sections, None)
if lang_entry is None:
return None
# lang_entry is an IMAGE_RESOURCE_DATA_ENTRY: RVA (4), size (4), ...
version_rva = struct.unpack_from('<I', data, lang_entry)[0]
version_offset = _rva_to_offset(data, version_rva, sections)
if version_offset is None:
return None
# Within the version resource, find VS_FIXEDFILEINFO by its signature.
signature = data.find(_VS_FIXEDFILEINFO_SIGNATURE, version_offset,
version_offset + 512)
if signature == -1:
return None
base = signature + 4 + 4 # skip signature and dwStrucVersion
version_ms, version_ls = struct.unpack_from('<II', data, base)
parts = (
(version_ms >> 16) & 0xFFFF,
version_ms & 0xFFFF,
(version_ls >> 16) & 0xFFFF,
version_ls & 0xFFFF,
)
if not any(parts):
return None
return '.'.join(str(part) for part in parts)
except (struct.error, TypeError, IndexError):
return None
def read_pe_file_version_from_path(path):
"""Read a PE file's version from disk, or None on any failure."""
try:
with open(path, 'rb') as handle:
return read_pe_file_version(handle.read())
except OSError:
return None
def _siteagent_dir(): def _siteagent_dir():
return os.path.join(current_app.instance_path, 'siteagent') return os.path.join(current_app.instance_path, 'siteagent')
@@ -117,12 +267,32 @@ def _write_manifest(binkey, manifest):
handle.write('\n') handle.write('\n')
def _version_sort_key(version):
"""Parse a dotted-numeric version into a tuple of ints for ordering.
Pads to four parts so 1.6 and 1.6.0.0 compare equal-ish and shorter
versions do not sort above longer ones with the same prefix. A part that
is not numeric (should not happen after validation) sorts as 0.
"""
parts = []
for chunk in str(version).split('.'):
try:
parts.append(int(chunk))
except ValueError:
parts.append(0)
while len(parts) < 4:
parts.append(0)
return tuple(parts[:4])
def list_versions(binkey): def list_versions(binkey):
"""Staged versions for a binary, newest upload first. """Staged versions for a binary, highest version number first.
Each item is {version, uploadedat, size, iscurrent}. Reads the manifest as Each item is {version, uploadedat, size, iscurrent}. Reads the manifest as
the source of truth for order and currency; a version whose exe is missing the source of truth for currency; a version whose exe is missing on disk is
on disk is dropped so the caller never offers a broken push. dropped so the caller never offers a broken push. Ordered by version number
(descending) so a rollback dropdown reads newest-build-first regardless of
the order versions were uploaded.
""" """
manifest = _read_manifest(binkey) manifest = _read_manifest(binkey)
current = manifest.get('current') current = manifest.get('current')
@@ -137,6 +307,7 @@ def list_versions(binkey):
'size': entry.get('size'), 'size': entry.get('size'),
'iscurrent': version == current, 'iscurrent': version == current,
}) })
out.sort(key=lambda item: _version_sort_key(item['version']), reverse=True)
return out return out
@@ -164,6 +335,51 @@ def current_path(binkey):
return None return None
def legacy_status(binkey):
"""Describe an unmanaged legacy flat file, or None when there is none.
Returned only when a flat file exists and no managed version is current, so
the UI can show its detected version and offer one-click adoption without
the operator re-uploading. {version} is the PE file version if readable,
else None.
"""
if current_version(binkey):
return None
legacy = _legacy_path(binkey)
if not os.path.isfile(legacy):
return None
try:
size = os.path.getsize(legacy)
except OSError:
size = None
return {
'version': read_pe_file_version_from_path(legacy),
'size': size,
}
def adopt_legacy(binkey, version):
"""Copy the legacy flat file into the version store as a staged version.
Reads the flat file, validates it as a PE, and stores it under the given
version (making it current). Returns the fresh manifest view, or None when
there is no legacy file or it is not a PE. Caller supplies the version -
typically the detected PE version, or an operator override when the binary
carries no version resource.
"""
legacy = _legacy_path(binkey)
if not os.path.isfile(legacy):
return None
try:
with open(legacy, 'rb') as handle:
data = handle.read()
except OSError:
return None
if not looks_like_pe(data):
return None
return store_version(binkey, version, data)
def _prune(binkey, manifest): def _prune(binkey, manifest):
"""Drop versions past the retention window, never removing the current one.""" """Drop versions past the retention window, never removing the current one."""
current = manifest.get('current') current = manifest.get('current')
@@ -197,14 +413,16 @@ def _remove_version_dir(binkey, version):
pass pass
def store_version(binkey, version, data): def store_version(binkey, version, data, make_current=True):
"""Stage an uploaded binary as a new version and make it current. """Stage an uploaded binary as a new version.
Writes data to siteagent/<binkey>/<version>/<exe>, records it newest-first in Writes data to siteagent/<binkey>/<version>/<exe>, records it newest-first in
the manifest (de-duping a re-upload of the same version in place), marks it the manifest (de-duping a re-upload of the same version in place), and prunes
current, and prunes older versions past RETAIN. Returns the fresh manifest older versions past RETAIN. When make_current is True the new version also
view {current, versions:[...]}. Caller must have validated binkey, version, becomes the current staged build; pass False to stage a rollback/legacy build
and that data is a PE binary. that should appear in the version list without displacing the active current.
Returns the fresh manifest view {current, versions:[...]}. Caller must have
validated binkey, version, and that data is a PE binary.
""" """
os.makedirs(os.path.join(_binary_dir(binkey), version), exist_ok=True) os.makedirs(os.path.join(_binary_dir(binkey), version), exist_ok=True)
with open(_version_exe_path(binkey, version), 'wb') as handle: with open(_version_exe_path(binkey, version), 'wb') as handle:
@@ -219,6 +437,7 @@ def store_version(binkey, version, data):
'size': len(data), 'size': len(data),
}) })
manifest['versions'] = versions manifest['versions'] = versions
if make_current or not manifest.get('current'):
manifest['current'] = version manifest['current'] = version
_prune(binkey, manifest) _prune(binkey, manifest)
_write_manifest(binkey, manifest) _write_manifest(binkey, manifest)

View File

@@ -51,15 +51,19 @@ def list_agent_binaries(binkey):
@endpoints_bp.route('/siteagent/binaries/<binkey>', methods=['POST']) @endpoints_bp.route('/siteagent/binaries/<binkey>', methods=['POST'])
@require_permission('endpoints.agentpackage') @require_permission('endpoints.agentpackage')
def upload_agent_binary(binkey): def upload_agent_binary(binkey):
"""Stage an uploaded binary as a new version and make it current. """Stage an uploaded binary as a new version.
Expects multipart/form-data with a 'file' upload and a 'version' field. The 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 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 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 executable. Older versions past the retention window are pruned, but never
the current one. 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() 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): if not agentstore.is_valid_binary_key(binkey):
return error_response( return error_response(
ErrorCodes.VALIDATION_ERROR, ErrorCodes.VALIDATION_ERROR,
@@ -90,7 +94,94 @@ def upload_agent_binary(binkey):
'uploaded file is not a Windows executable', 'uploaded file is not a Windows executable',
http_code=400) http_code=400)
result = agentstore.store_version(binkey, version, data) result = agentstore.store_version(binkey, version, data,
make_current=makecurrent)
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({ return success_response({
'binary': binkey, 'binary': binkey,
'current': result['current'], 'current': result['current'],

View File

@@ -462,6 +462,8 @@ def _siteagent_status_payload():
'traycurrent': agentstore.current_version('tray'), 'traycurrent': agentstore.current_version('tray'),
'agentversions': agentstore.list_versions('agent'), 'agentversions': agentstore.list_versions('agent'),
'trayversions': agentstore.list_versions('tray'), 'trayversions': agentstore.list_versions('tray'),
'agentlegacy': agentstore.legacy_status('agent'),
'traylegacy': agentstore.legacy_status('tray'),
} }

View File

@@ -160,7 +160,7 @@
</section> </section>
<!-- ==================================================================== <!-- ====================================================================
Adoption tool (spike) Adoption tool
================================================================= --> ================================================================= -->
<section class="settings-section"> <section class="settings-section">
<h3>Adoption tool</h3> <h3>Adoption tool</h3>
@@ -211,7 +211,7 @@
</section> </section>
<!-- ==================================================================== <!-- ====================================================================
Build site agent (placeholder) Build site agent
================================================================= --> ================================================================= -->
<section class="settings-section"> <section class="settings-section">
<h3>Build site agent</h3> <h3>Build site agent</h3>
@@ -227,10 +227,9 @@
</p> </p>
<p v-if="!agentstaged" class="field-hint"> <p v-if="!agentstaged" class="field-hint">
<strong>No agent binary is staged on this server.</strong> The package <strong>No agent binary is staged on this server.</strong> The package
will still build with config and installer, but you must drop will still build with config and installer, but no agent will ship
<code>shopdbagent.exe</code> beside them before deploying. Stage a inside it. Upload a current release in the binary section below to
prebuilt agent under <code>instance/siteagent/</code> to have it ship have it included in every download.
in the download.
</p> </p>
<label class="field-label" for="agent-baseurl">Server base URL</label> <label class="field-label" for="agent-baseurl">Server base URL</label>
@@ -317,43 +316,112 @@
class="binary-block" class="binary-block"
> >
<h4>{{ binary.label }}</h4> <h4>{{ binary.label }}</h4>
<p class="field-hint">
Upload {{ binary.name }} updated EXE. Uploading here will update the
binaries available for your "Build Site Agent" install package.
</p>
<p class="field-hint"> <p class="field-hint">
Current staged version: Current staged version:
<strong>{{ binary.current || 'none (legacy or unstaged)' }}</strong> <strong>{{ binary.current || 'none staged yet - upload a build below' }}</strong>
</p> </p>
<!-- Current release upload. The version is auto-detected from the
EXE's PE version resource on select; the operator can override.
This upload becomes the current staged build. -->
<label class="field-label">
Upload {{ binary.name }} binary current release
</label>
<div class="binary-upload"> <div class="binary-upload">
<input
v-model="binary.version"
class="form-control"
type="text"
placeholder="Version, e.g. 1.6.3"
/>
<input <input
class="form-control" class="form-control"
type="file" type="file"
accept=".exe" accept=".exe"
@change="onBinaryFileChange(binary.key, $event)" @change="onBinaryFileChange(binary.key, $event)"
/> />
<input
v-model="binary.version"
class="form-control"
type="text"
:placeholder="binary.detecting
? 'Reading version...'
: 'Version, auto-detected from EXE'"
/>
<button <button
class="btn btn-secondary" class="btn btn-secondary"
type="button" type="button"
:disabled="binary.busy || !binary.version || !binary.file" :disabled="binary.busy || !binary.version || !binary.file"
@click="uploadBinary(binary.key)" @click="uploadBinary(binary.key)"
> >
{{ binary.busy ? 'Uploading...' : 'Upload' }} {{ binary.busy ? 'Uploading...' : 'Upload current release' }}
</button>
</div>
<!-- Rollback / legacy path. Stages a build into the version list
without displacing the current release so it is available to
roll back to. WARNING copy per intent: the version must be
confirmed from the EXE properties before uploading. -->
<label class="field-label">
Upload legacy build for rollback, please confirm agent version
</label>
<p class="field-warning">
WARNING: confirm agent version before uploading from the EXE
properties and enter the version number.
</p>
<div class="binary-upload">
<input
class="form-control"
type="file"
accept=".exe"
@change="onLegacyFileChange(binary.key, $event)"
/>
<input
v-model="binary.legacyversion"
class="form-control"
type="text"
placeholder="Version, e.g. 1.5.0"
/>
<button
class="btn btn-secondary"
type="button"
:disabled="binary.busy || !binary.legacyversion || !binary.legacyfile"
@click="uploadLegacy(binary.key)"
>
{{ binary.busy ? 'Uploading...' : 'Upload legacy build' }}
</button> </button>
</div> </div>
<p v-if="binary.message" class="field-hint">{{ binary.message }}</p> <p v-if="binary.message" class="field-hint">{{ binary.message }}</p>
<p v-if="binary.error" class="field-error">{{ binary.error }}</p> <p v-if="binary.error" class="field-error">{{ binary.error }}</p>
<!-- Version selector: pick the staged build shipped by the next
download. Ordered by version number (highest first). -->
<div v-if="binary.versions.length" class="binary-upload">
<label class="field-label" :for="`select-${binary.key}`">
Active version
</label>
<select
:id="`select-${binary.key}`"
class="form-control"
:value="binary.current || ''"
:disabled="binary.busy"
@change="setCurrentBinary(binary.key, $event.target.value)"
>
<option
v-for="entry in binary.versions"
:key="entry.version"
:value="entry.version"
>
{{ entry.version }}{{ entry.iscurrent ? ' (current)' : '' }}
</option>
</select>
</div>
<div v-if="binary.versions.length" class="previous-versions"> <div v-if="binary.versions.length" class="previous-versions">
<button <button
class="btn-link" class="btn-link"
type="button" type="button"
@click="binary.showprevious = !binary.showprevious" @click="binary.showprevious = !binary.showprevious"
> >
{{ binary.showprevious ? 'Hide' : 'Show' }} previous versions {{ binary.showprevious ? 'Hide' : 'Show' }} staged versions
({{ binary.versions.length }}) ({{ binary.versions.length }})
</button> </button>
<table v-if="binary.showprevious" class="versions-table"> <table v-if="binary.showprevious" class="versions-table">
@@ -434,7 +502,7 @@ const copied = ref(false)
const message = ref('') const message = ref('')
const error = ref('') const error = ref('')
// Adoption tool (spike): build a one-shot, agentless enrollment download. // Adoption tool: build a one-shot, agentless enrollment download.
const adoptbaseurl = ref('') const adoptbaseurl = ref('')
const adoptcollectorkey = ref('') const adoptcollectorkey = ref('')
const adoptbusy = ref(false) const adoptbusy = ref(false)
@@ -458,11 +526,15 @@ const agenterror = ref('')
// version list, and busy/message state so the two rows act independently. The // version list, and busy/message state so the two rows act independently. The
// 'key' matches the backend binkey (agent | tray). // 'key' matches the backend binkey (agent | tray).
const binaries = ref([ const binaries = ref([
{ key: 'agent', label: 'Endpoint agent (shopdbagent.exe)', { key: 'agent', label: 'ShopDB Agent (shopdbagent.exe)',
current: null, versions: [], version: '', file: null, name: 'ShopDB Agent',
current: null, versions: [], version: '', file: '', detecting: false,
legacyversion: '', legacyfile: '',
busy: false, message: '', error: '', showprevious: false }, busy: false, message: '', error: '', showprevious: false },
{ key: 'tray', label: 'Tray agent (shopdb-endpoint-tray.exe)', { key: 'tray', label: 'Tray Agent (shopdb-endpoint-tray.exe)',
current: null, versions: [], version: '', file: null, name: 'Tray Agent',
current: null, versions: [], version: '', file: '', detecting: false,
legacyversion: '', legacyfile: '',
busy: false, message: '', error: '', showprevious: false }, busy: false, message: '', error: '', showprevious: false },
]) ])
@@ -664,11 +736,39 @@ async function buildSiteAgent() {
} }
} }
async function detectVersion(binkey, file) {
const form = new FormData()
form.append('file', file)
const response = await api.post(
`/endpoints/siteagent/binaries/${binkey}/detect`, form)
return response.data.data?.version || ''
}
function onBinaryFileChange(binkey, event) { function onBinaryFileChange(binkey, event) {
const binary = binaryFor(binkey) const binary = binaryFor(binkey)
if (!binary) return if (!binary) return
const files = event.target.files const files = event.target.files
binary.file = files && files.length ? files[0] : null binary.file = files && files.length ? files[0] : ''
binary.error = ''
if (!binary.file) return
binary.detecting = true
detectVersion(binkey, binary.file)
.then((version) => {
if (version) binary.version = version
})
.catch((detectError) => {
console.error(detectError)
})
.finally(() => {
binary.detecting = false
})
}
function onLegacyFileChange(binkey, event) {
const binary = binaryFor(binkey)
if (!binary) return
const files = event.target.files
binary.legacyfile = files && files.length ? files[0] : ''
} }
async function uploadBinary(binkey) { async function uploadBinary(binkey) {
@@ -686,7 +786,7 @@ async function uploadBinary(binkey) {
const data = response.data.data || {} const data = response.data.data || {}
applyBinaryStatus(binkey, data.current, data.versions) applyBinaryStatus(binkey, data.current, data.versions)
binary.version = '' binary.version = ''
binary.file = null binary.file = ''
binary.message = 'Uploaded and set as current.' binary.message = 'Uploaded and set as current.'
} catch (uploadError) { } catch (uploadError) {
binary.error = uploadError.response?.data?.data?.error?.message binary.error = uploadError.response?.data?.data?.error?.message
@@ -699,6 +799,35 @@ async function uploadBinary(binkey) {
} }
} }
async function uploadLegacy(binkey) {
const binary = binaryFor(binkey)
if (!binary || !binary.legacyfile || !binary.legacyversion) return
binary.busy = true
binary.message = ''
binary.error = ''
try {
const form = new FormData()
form.append('version', binary.legacyversion)
form.append('file', binary.legacyfile)
form.append('makecurrent', 'false')
const response = await api.post(
`/endpoints/siteagent/binaries/${binkey}`, form)
const data = response.data.data || {}
applyBinaryStatus(binkey, data.current, data.versions)
binary.legacyversion = ''
binary.legacyfile = ''
binary.message = 'Legacy build staged for rollback.'
} catch (uploadError) {
binary.error = uploadError.response?.data?.data?.error?.message
|| (uploadError.response?.status === 403
? 'You do not have permission to upload agent binaries'
: 'Could not upload the legacy build')
console.error(uploadError)
} finally {
binary.busy = false
}
}
async function setCurrentBinary(binkey, version) { async function setCurrentBinary(binkey, version) {
const binary = binaryFor(binkey) const binary = binaryFor(binkey)
if (!binary) return if (!binary) return
@@ -775,6 +904,14 @@ function editConfiguration() {
} }
.binary-upload .form-control[type="text"] { max-width: 12rem; } .binary-upload .form-control[type="text"] { max-width: 12rem; }
.previous-versions { margin-top: 0.5rem; } .previous-versions { margin-top: 0.5rem; }
.field-warning {
margin: 0.25rem 0 0.5rem;
padding: 0.5rem 0.75rem;
border-left: 3px solid var(--color-warning, #b8860b);
background: rgba(184, 134, 11, 0.08);
font-size: 0.85rem;
font-weight: 600;
}
.btn-link { .btn-link {
background: none; background: none;
border: none; border: none;

View File

@@ -0,0 +1,57 @@
"""Unit tests for the versioned agent binary store helpers.
Covers the pure, context-free helpers: version validation, PE magic and PE
file-version parsing. The staging/retention paths that touch the Flask instance
directory are exercised through the API tests; here we keep to the logic that
needs no app context.
The PE version parser walks the resource directory to the outer image's
RT_VERSION resource (not the first VS_FIXEDFILEINFO signature in the bytes),
because a self-contained single-file .NET exe bundles many framework assemblies
that each carry their own version block. Building a full PE with a resource
directory inline is impractical here, so the parser's correctness is verified
against real agent binaries in the API/integration layer; these unit tests pin
the surrounding validators and the malformed-input contract (never raises,
returns None).
"""
from plugins.endpoints.api import agentstore
def test_is_valid_version_accepts_dotted_numeric():
assert agentstore.is_valid_version('1')
assert agentstore.is_valid_version('1.6.3')
assert agentstore.is_valid_version('1.6.3.0')
def test_is_valid_version_rejects_bad_input():
assert not agentstore.is_valid_version('')
assert not agentstore.is_valid_version('v1.6')
assert not agentstore.is_valid_version('1.6.3.0.1')
assert not agentstore.is_valid_version('../etc')
assert not agentstore.is_valid_version('latest')
def test_is_valid_binary_key():
assert agentstore.is_valid_binary_key('agent')
assert agentstore.is_valid_binary_key('tray')
assert not agentstore.is_valid_binary_key('other')
def test_looks_like_pe():
assert agentstore.looks_like_pe(b'MZ\x90\x00')
assert not agentstore.looks_like_pe(b'PK\x03\x04')
assert not agentstore.looks_like_pe(b'')
def test_read_pe_file_version_none_on_non_pe():
assert agentstore.read_pe_file_version(b'not a pe file') is None
def test_read_pe_file_version_none_on_truncated_pe():
# MZ header but nothing else valid: must return None, never raise.
assert agentstore.read_pe_file_version(b'MZ' + b'\x00' * 200) is None
def test_read_pe_file_version_none_on_empty():
assert agentstore.read_pe_file_version(b'') is None