diff --git a/README.md b/README.md index 63eab7be2..7c33926f9 100644 --- a/README.md +++ b/README.md @@ -512,21 +512,26 @@ PXE_CONFIG_SERVER = 'pxe' The `installer` parameter is optional. If you leave it empty it will be automatically defined as `http:///installers/xcp-ng//`. -## Bash scripts +## xva_bridge.py - * get_xva_bridge.sh: a script to get the XAPI bridge value from inside a xva file and the compression method used for this xva file. +This script gets and sets the XAPI bridge and compression method of an XVA file. It requires libarchive-c==5.3. + +To print an XVA file's bridge value and compression method: ``` -$ /path/to/get_xva_bridge.sh alpine-minimal-3.12.0.xva -ova.xml -alpine-minimal-3.12.0.xva's bridge network is: xapi1 and its compression method is: tar. +$ xva_bridge.py alpine-minimal-3.12.0.xva -v +DEBUG:root:Compression: zstd +DEBUG:root:Header is 23889 bytes +INFO:root:Found bridge xenbr0 ``` - * set_xva_bridge.sh: a script to modify the XAPI bridge value inside a xva file and the compression method used for this xva file if wanted. The original xva file is saved before modification. +To set an XVA file's bridge value and compression method. By default, the script will save the resulting archive to `xva_path.new`: ``` -- Usage: /path/to/set_xva_bridge.sh [XVA_filename] compression[zstd|gzip] bridge_value[xenbr0|xapi[:9]|...] -- All options are mandatory. - -$ /path/to/set_xva_bridge.sh alpine-minimal-3.12.0.xva zstd xenbr0 +$ xva_bridge.py alpine-minimal-3.12.0.xva --set-bridge xenbr0 +INFO:root:Found bridge xapi1 +INFO:root:Output path: alpine-minimal-3.12.0.xva.new +INFO:root:Setting bridge to xenbr0 ``` + +For more details, see `xva_bridge.py --help`. diff --git a/pyproject.toml b/pyproject.toml index 7602554fa..d8dfd96e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "cryptography>=3.3.1", "gitpython", "legacycrypt", + "libarchive-c==5.3", "packaging>=20.7", "pluggy>=1.1.0", "pytest>=8.0.0", diff --git a/requirements/base.txt b/requirements/base.txt index 38b1c68c8..cd94853ba 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -2,6 +2,7 @@ cryptography>=3.3.1 gitpython legacycrypt +libarchive-c==5.3 packaging>=20.7 pluggy>=1.1.0 pytest>=8.0.0 diff --git a/scripts/get_xva_bridge.sh b/scripts/get_xva_bridge.sh deleted file mode 100755 index fab3f9300..000000000 --- a/scripts/get_xva_bridge.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash - -set -u - -# extract and read ova.xml -get_bridge() -{ - TMPFOLD=$(mktemp -d /tmp/xvaXXXX) - tar -xf "${XVA_PATH}/${XVA_NAME}" -C "${TMPFOLD}" ova.xml - chmod +r "${TMPFOLD}/ova.xml" - XML_VALUE=$(grep -oE "bridge[^<]*" "${TMPFOLD}/ova.xml") - LENGTH=${#XML_VALUE} - PREFIX_LENGTH=$((${LENGTH}-17)) - NETWORK_VALUE=$(cut -c 1-${PREFIX_LENGTH} <<< ${XML_VALUE}) - echo $(cut -c 35-${#NETWORK_VALUE} <<< ${NETWORK_VALUE}) - - if [ -d "${TMPFOLD}" ]; then - rm -Rf "${TMPFOLD}" - fi -} - -if [ ! -z "${1+set}" ]; then - XVA_NAME=$(basename "$1") - XVA_PATH=$(dirname "$1") - if [ ! -e "$1" ]; then - echo "File $1 doesn't exist. Check the name or the path." - exit 1 - fi -else - echo "Error: you forgot to specify the name of the file to scan" - echo "Usage: $0 XVA_FILE" - exit 1 -fi - -BRIDGE=$(get_bridge) -echo "${XVA_NAME}'s XAPI bridge network is: ${BRIDGE} and its compression method is: $(file ${XVA_PATH}/${XVA_NAME} | cut -f 2 -d : | cut -f 2 -d " ")." diff --git a/scripts/set_xva_bridge.sh b/scripts/set_xva_bridge.sh deleted file mode 100755 index 6aebe33e2..000000000 --- a/scripts/set_xva_bridge.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/bin/bash - -set -u - -# functions -# usage -usage() -{ - echo "----------------------------------------------------------------------------------------" - echo "- Usage: $0 [XVA_filename] compression[zstd|gzip] bridge_value[xenbr0|xapi[:9]|...]" - echo "- All options are mandatory." - echo "----------------------------------------------------------------------------------------" -} - -# check parameters and prompt the usage if needed -if [ -z "${1+set}" ] -then - echo "Error: XVA name missing." - usage - exit 1 -else - if [ -e "${1}" ]; then - XVA_NAME=$(realpath "$1") - - if [ ${XVA_NAME##*.} != xva ] - then - echo "Error: ${XVA_NAME} doesn't seem to be a xva file" - usage - exit 1 - fi - else - echo "Error: file $1 not found." - usage - exit 1 - fi -fi - -if ([ "$2" = "gzip" ] || [ "$2" = "zstd" ]) -then - COMPRESS_METHOD=$2 -else - echo "Error: unsupported compression value." - usage - exit 1 -fi - -if [ -z "${3+set}" ] -then - echo "Error: please specify the new bridge value." - usage - exit 1 -else - BRIDGE_VALUE=$3 -fi - -# we detect the compression method of the xva to uncompress it right -OLD_COMPRESSION=$(file -b "${XVA_NAME}" | cut -f 1 -d " ") -if [ "${OLD_COMPRESSION}" != "Zstandard" ] && [ "${OLD_COMPRESSION}" != "gzip" ] && [ "${OLD_COMPRESSION}" != "tar" ]; then - echo "Error: unknown compression type detected for ${XVA_NAME}: ${OLD_COMPRESSION}" - exit 1 -fi - -PATHFOLDER=$(dirname "${XVA_NAME}") -TMPFOLDER=$(mktemp -d "${PATHFOLDER}"/xvaXXXX) - -# extract and create the file list at the same time -TMP_LIST=$(mktemp "${PATHFOLDER}"/SortedListXXXX.txt) -if [ -f "${XVA_NAME}" ]; then - tar -xvf "${XVA_NAME}" -C "${TMPFOLDER}" > "${TMP_LIST}" -else - echo "Error: ${XVA_NAME} not found." - exit 1 -fi - -chmod -R u+rX "${TMPFOLDER}" - -if [ -e "${TMPFOLDER}/ova.xml" ]; then - sed -i "s/bridge<\/name>[^<]*<\/value><\/member>/bridge<\/name>${BRIDGE_VALUE}<\/value><\/member>/g" "${TMPFOLDER}/ova.xml" -else - echo "Error: File ova.xml not found during the sed." - exit 1 -fi - - -# save first file -mv "${XVA_NAME}" "${XVA_NAME}.save" - -# Create the new XVA -tar -C "${TMPFOLDER}" --${COMPRESS_METHOD} -cf "${XVA_NAME}" --no-recursion -T "${TMP_LIST}" --numeric-owner --owner=:0 --group=:0 --mode=ugo= --mtime=@0 -rm -f "${TMP_LIST}" - -# clean TMPFOLDER -if [ -d "${TMPFOLDER}" ]; then - rm -Rf "${TMPFOLDER}" -else - echo "Warning: No tmp folder to delete." -fi diff --git a/scripts/xva_bridge.py b/scripts/xva_bridge.py new file mode 100755 index 000000000..b4138d916 --- /dev/null +++ b/scripts/xva_bridge.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 + +# Tested on libarchive-c==5.3. Due to our use of library internals, may not work on other versions of libarchive-c. + +import argparse +import io +import logging +from xml.dom import minidom + +import libarchive +import libarchive.ffi + +class XvaHeaderMember: + def __init__(self, member: minidom.Element): + self.member = member + + def get_name(self): + for child in self.member.childNodes: + if child.nodeType == minidom.Node.ELEMENT_NODE and child.tagName == "name" and child.firstChild: + return child.firstChild.nodeValue + return None + + def get_value(self): + for child in self.member.childNodes: + if child.nodeType == minidom.Node.ELEMENT_NODE and child.tagName == "value" and child.firstChild: + return child.firstChild.nodeValue + return None + + def set_value(self, value: str): + for child in self.member.childNodes: + if child.nodeType == minidom.Node.ELEMENT_NODE and child.tagName == "value" and child.firstChild: + child.firstChild.nodeValue = value # type: ignore + return None + + +class XvaHeader: + def __init__(self, header_bytes: bytes): + self.xml = minidom.parseString(header_bytes.decode()) + + def members(self): + for member in self.xml.getElementsByTagName("member"): + if member.nodeType == minidom.Node.ELEMENT_NODE: + yield XvaHeaderMember(member) + + def get_bridge(self): + for member in self.members(): + if member.get_name() == "bridge": + return member.get_value() + raise ValueError("Could not find bridge value in XVA header") + + def set_bridge(self, bridge: str): + for member in self.members(): + if member.get_name() == "bridge": + member.set_value(bridge) + return + raise ValueError("Could not find bridge value in XVA header") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("xva", help="input file path") + parser.add_argument( + "--set-bridge", help="new bridge value of format `xenbr0|xapi[:9]|...`; omit this option to show current bridge" + ) + parser.add_argument( + "--compression", + choices=["zstd", "gzip"], + default="zstd", + help="compression mode of new XVA when setting bridge value (default: zstd)", + ) + parser.add_argument("-o", "--output", help="output file path (must not be the same as input)") + parser.add_argument("-v", "--verbose", action="store_true", help="verbose logging") + args = parser.parse_args() + + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + else: + logging.getLogger().setLevel(logging.INFO) + + with libarchive.file_reader(args.xva, "tar") as input_file: + logging.debug(f"Compression: {', '.join(filter.decode() for filter in input_file.filter_names)}") + + entry_iter = iter(input_file) + + header_entry = next(entry_iter) + if header_entry.pathname != "ova.xml": + raise ValueError("Unexpected header entry name") + with io.BytesIO() as header_writer: + for block in header_entry.get_blocks(): + header_writer.write(block) + header_bytes = header_writer.getvalue() + + logging.debug(f"Header is {len(header_bytes)} bytes") + + header = XvaHeader(header_bytes) + bridge = header.get_bridge() + logging.info(f"Found bridge {bridge}") + + if args.set_bridge: + output_path = args.output + if not output_path: + output_path = args.xva + ".new" + logging.info(f"Output path: {output_path}") + + logging.info(f"Setting bridge to {args.set_bridge}") + header.set_bridge(args.set_bridge) + + logging.debug(f"Using compression {args.compression}") + with libarchive.file_writer(output_path, "pax_restricted", args.compression) as output_file: + new_header_bytes = header.xml.toxml().encode() + output_file.add_file_from_memory( + "ova.xml", len(new_header_bytes), new_header_bytes, permission=0o400, uid=0, gid=0 + ) + + for entry in entry_iter: + logging.debug(f"Copying {entry.pathname}: {entry.size} bytes") + new_entry = libarchive.ArchiveEntry(entry.header_codec, perm=0o400, uid=0, gid=0) + for attr in ["filetype", "pathname", "size"]: + setattr(new_entry, attr, getattr(entry, attr)) + + # ArchiveEntry doesn't expose block copying, so write the entry manually via the FFI interface + libarchive.ffi.write_header(output_file._pointer, new_entry._entry_p) + for block in entry.get_blocks(): + libarchive.ffi.write_data(output_file._pointer, block, len(block)) + libarchive.ffi.write_finish_entry(output_file._pointer) diff --git a/uv.lock b/uv.lock index d7bfc6808..d79da102c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" [[package]] @@ -295,6 +295,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/05/3ba583d3551c562e11377558f7fd92dbb7a2abcff85ccf2d8aa2b908726a/legacycrypt-0.3-py3-none-any.whl", hash = "sha256:b5e373506ccb442f8d715e29fa75f53a11bbec3ca0d7b63445f4dbb656555218", size = 22281, upload-time = "2019-05-28T09:07:04.992Z" }, ] +[[package]] +name = "libarchive-c" +version = "5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/23/e72434d5457c24113e0c22605cbf7dd806a2561294a335047f5aa8ddc1ca/libarchive_c-5.3.tar.gz", hash = "sha256:5ddb42f1a245c927e7686545da77159859d5d4c6d00163c59daff4df314dae82", size = 54349, upload-time = "2025-05-22T08:08:04.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/3f/ff00c588ebd7eae46a9d6223389f5ae28a3af4b6d975c0f2a6d86b1342b9/libarchive_c-5.3-py3-none-any.whl", hash = "sha256:651550a6ec39266b78f81414140a1e04776c935e72dfc70f1d7c8e0a3672ffba", size = 17035, upload-time = "2025-05-22T08:08:03.045Z" }, +] + [[package]] name = "markupsafe" version = "3.0.2" @@ -674,6 +683,7 @@ dependencies = [ { name = "cryptography" }, { name = "gitpython" }, { name = "legacycrypt" }, + { name = "libarchive-c" }, { name = "packaging" }, { name = "pluggy" }, { name = "pytest" }, @@ -701,6 +711,7 @@ requires-dist = [ { name = "cryptography", specifier = ">=3.3.1" }, { name = "gitpython" }, { name = "legacycrypt" }, + { name = "libarchive-c", specifier = "==5.3" }, { name = "packaging", specifier = ">=20.7" }, { name = "pluggy", specifier = ">=1.1.0" }, { name = "pytest", specifier = ">=8.0.0" },