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:
@@ -33,6 +33,7 @@ import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
from datetime import datetime
|
||||
|
||||
from flask import current_app
|
||||
@@ -75,6 +76,155 @@ def looks_like_pe(data):
|
||||
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():
|
||||
return os.path.join(current_app.instance_path, 'siteagent')
|
||||
|
||||
@@ -117,12 +267,32 @@ def _write_manifest(binkey, manifest):
|
||||
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):
|
||||
"""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
|
||||
the source of truth for order and currency; a version whose exe is missing
|
||||
on disk is dropped so the caller never offers a broken push.
|
||||
the source of truth for currency; a version whose exe is missing on disk is
|
||||
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)
|
||||
current = manifest.get('current')
|
||||
@@ -137,6 +307,7 @@ def list_versions(binkey):
|
||||
'size': entry.get('size'),
|
||||
'iscurrent': version == current,
|
||||
})
|
||||
out.sort(key=lambda item: _version_sort_key(item['version']), reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
@@ -164,6 +335,51 @@ def current_path(binkey):
|
||||
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):
|
||||
"""Drop versions past the retention window, never removing the current one."""
|
||||
current = manifest.get('current')
|
||||
@@ -197,14 +413,16 @@ def _remove_version_dir(binkey, version):
|
||||
pass
|
||||
|
||||
|
||||
def store_version(binkey, version, data):
|
||||
"""Stage an uploaded binary as a new version and make it current.
|
||||
def store_version(binkey, version, data, make_current=True):
|
||||
"""Stage an uploaded binary as a new version.
|
||||
|
||||
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
|
||||
current, and prunes older versions past RETAIN. Returns the fresh manifest
|
||||
view {current, versions:[...]}. Caller must have validated binkey, version,
|
||||
and that data is a PE binary.
|
||||
the manifest (de-duping a re-upload of the same version in place), and prunes
|
||||
older versions past RETAIN. When make_current is True the new version also
|
||||
becomes the current staged build; pass False to stage a rollback/legacy build
|
||||
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)
|
||||
with open(_version_exe_path(binkey, version), 'wb') as handle:
|
||||
@@ -219,7 +437,8 @@ def store_version(binkey, version, data):
|
||||
'size': len(data),
|
||||
})
|
||||
manifest['versions'] = versions
|
||||
manifest['current'] = version
|
||||
if make_current or not manifest.get('current'):
|
||||
manifest['current'] = version
|
||||
_prune(binkey, manifest)
|
||||
_write_manifest(binkey, manifest)
|
||||
return {
|
||||
|
||||
@@ -51,15 +51,19 @@ def list_agent_binaries(binkey):
|
||||
@endpoints_bp.route('/siteagent/binaries/<binkey>', methods=['POST'])
|
||||
@require_permission('endpoints.agentpackage')
|
||||
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
|
||||
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
|
||||
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()
|
||||
makecurrent = (request.form.get('makecurrent') or 'true').strip().lower()
|
||||
makecurrent = makecurrent not in ('false', '0', 'no')
|
||||
if not agentstore.is_valid_binary_key(binkey):
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
@@ -90,7 +94,94 @@ def upload_agent_binary(binkey):
|
||||
'uploaded file is not a Windows executable',
|
||||
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({
|
||||
'binary': binkey,
|
||||
'current': result['current'],
|
||||
|
||||
@@ -462,6 +462,8 @@ def _siteagent_status_payload():
|
||||
'traycurrent': agentstore.current_version('tray'),
|
||||
'agentversions': agentstore.list_versions('agent'),
|
||||
'trayversions': agentstore.list_versions('tray'),
|
||||
'agentlegacy': agentstore.legacy_status('agent'),
|
||||
'traylegacy': agentstore.legacy_status('tray'),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user