From 1572b98acc7e79c937e4852c0e46752f70c7eb3d Mon Sep 17 00:00:00 2001 From: hhhhsc701 <56435672+hhhhsc701@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:37:42 +0800 Subject: [PATCH] fix(deploy): export offline images for the selected platform --- deploy/offline/build_offline_package.sh | 25 ++- .../OFFLINE-EXPORT-PLATFORM-20261102.yaml | 66 ++++++++ .../deploy/test_offline_image_export.py | 143 ++++++++++++++++++ 3 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 test-e2e/changes/bugs/OFFLINE-EXPORT-PLATFORM-20261102.yaml create mode 100644 test/backend/deploy/test_offline_image_export.py diff --git a/deploy/offline/build_offline_package.sh b/deploy/offline/build_offline_package.sh index 382223b793..e135455b36 100755 --- a/deploy/offline/build_offline_package.sh +++ b/deploy/offline/build_offline_package.sh @@ -1,4 +1,5 @@ #!/bin/bash +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. set -e @@ -470,13 +471,35 @@ pull_all_images() { echo "✅ All images pulled successfully" } +docker_supports_save_platform() { + local api_versions client_api server_api api + + # Platform-specific image export requires API 1.48 on both ends. The client + # API also reflects an explicit DOCKER_API_VERSION compatibility override. + api_versions="$(docker version --format '{{.Client.APIVersion}} {{.Server.APIVersion}}' 2>/dev/null)" || return 1 + read -r client_api server_api <<< "$api_versions" + + for api in "$client_api" "$server_api"; do + [[ "$api" =~ ^([0-9]+)\.([0-9]+)$ ]] || return 1 + if (( 10#${BASH_REMATCH[1]} < 1 || (10#${BASH_REMATCH[1]} == 1 && 10#${BASH_REMATCH[2]} < 48) )); then + return 1 + fi + done + return 0 +} + save_image_to_tar() { local image="$1" local output_file="$2" + local save_args=(-o "$output_file") + + if docker_supports_save_platform; then + save_args=(--platform "linux/$PLATFORM" "${save_args[@]}") + fi echo "Saving image to tar: $output_file" - if docker save -o "$output_file" "$image"; then + if docker save "${save_args[@]}" "$image"; then echo "✅ Saved: $output_file" return 0 else diff --git a/test-e2e/changes/bugs/OFFLINE-EXPORT-PLATFORM-20261102.yaml b/test-e2e/changes/bugs/OFFLINE-EXPORT-PLATFORM-20261102.yaml new file mode 100644 index 0000000000..13f21b1059 --- /dev/null +++ b/test-e2e/changes/bugs/OFFLINE-EXPORT-PLATFORM-20261102.yaml @@ -0,0 +1,66 @@ +schema_version: '1.0' +change: + change_id: OFFLINE-EXPORT-PLATFORM-20261102 + change_type: bugfix + bug_id: OFFLINE-EXPORT-PLATFORM-20261102 + title: Export offline images for the requested platform on modern Docker + description: >- + Match docker save to the platform already selected by docker pull when both + client and server support API 1.48, retaining the original export command + for Docker 18.09 and other older or unrecognized API versions. + observed_behavior: >- + Actions job 124467441278 successfully pulls all arm64 images on an amd64 + Docker 29.4.2 containerd runner, then docker save fails while reading the + absent amd64 manifest b6a8a0906cb82407c0144e57fcab51fe1f8f431c2eccbd4c577ece0b0430ac14. + expected_behavior: >- + Export only linux/ when both Docker APIs support the + platform option. Preserve legacy export compatibility and propagate export + errors without retrying an unrestricted export. + escaped_stage: D5 + coverage_gap: + type: missing_assertion + description: >- + deploy/tests/test_build_offline_package.sh stubs every Docker save as + successful without checking its platform. Existing infrastructure tests + exercise the formal scenario dispatcher, not the real export function. + Add isolated regression assertions for API negotiation and export errors. + feature_contract_changed: false + affected_features: + existing: [F-174] + added: [] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + no_case_change_reason: >- + The affected logic is a local command-selection contract. Traditional UT + supplies the additional proof; the existing deployment shell suite covers + adjacent packaging behavior. DEP-003 and DEP-05 were removed by user request + in D5-OFFLINE-CASES-REMOVAL-20261010 and are not recreated. No formal D1-D5 + execution or successful real containerd export is claimed. + traditional_ut: + decision: add + behaviors: + - Select the requested arm64 or amd64 platform at API 1.48 and newer. + - Keep the legacy export command when either client or server API is older. + - Handle malformed API output and failed version probes conservatively. + - Preserve output paths and propagate export errors without an unrestricted retry. + reason: >- + Test the actual Bash functions with a controlled Docker executable. Existing + tests do not detect the absent platform option. Runtime deployment and + browser/API boundaries are not changed by command selection. + test_selectors: + - test/backend/deploy/test_offline_image_export.py + notes: >- + Evidence: https://github.com/ModelEngine-Group/nexent/actions/runs/41175375169/job/124467441278, + commit 046d77c8c479d30ccd1ed44cd37f727547798d0f. DockerHub's exact logged + index maps the missing digest to amd64; the companion amd64 package succeeds. + Scope: pull_with_retry, save_image_to_tar, both save_all_images loops and + build-offline-package.yml's platform matrix. F-174 already owns offline + image completeness. No Feature contract, workflow, deployment, registry or + existing formal Case changes are required. The sibling nexent-doc directory + and its previously referenced offline-package-platform-safe-export SPEC are + unavailable in this checkout; external SPEC maintenance remains blocked. diff --git a/test/backend/deploy/test_offline_image_export.py b/test/backend/deploy/test_offline_image_export.py new file mode 100644 index 0000000000..1abe6b4f4f --- /dev/null +++ b/test/backend/deploy/test_offline_image_export.py @@ -0,0 +1,143 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Regression contracts for platform selection when exporting offline images.""" + +import json +import os +from pathlib import Path +import re +import subprocess +import sys + +import pytest + + +PACKAGE_SCRIPT = Path(__file__).resolve().parents[3] / "deploy/offline/build_offline_package.sh" + + +@pytest.fixture +def export_image(tmp_path): + """Run the real export functions with Docker isolated at the executable boundary.""" + functions = "\n".join(re.findall( + r"^(?:docker_supports_save_platform|save_image_to_tar)\(\) \{.*?^\}", + PACKAGE_SCRIPT.read_text(encoding="utf-8"), + flags=re.MULTILINE | re.DOTALL, + )) + docker = tmp_path / "docker" + docker.write_text(f"#!{sys.executable}\n" + '''\ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +import json +import os +from pathlib import Path +import sys + +arguments = sys.argv[1:] +with open(os.environ["FAKE_DOCKER_LOG"], "a", encoding="utf-8") as log: + log.write(json.dumps(arguments) + "\\n") +if arguments[0] == "version": + print(os.environ["FAKE_CLIENT_API"], os.environ["FAKE_SERVER_API"]) + sys.exit(int(os.environ["FAKE_VERSION_STATUS"])) +if arguments[0] == "save": + if os.environ["FAKE_REQUIRE_PLATFORM"] == "true" and "--platform" not in arguments: + print("unable to create manifests file: amd64 content digest not found", file=sys.stderr) + sys.exit(1) + if int(os.environ["FAKE_SAVE_STATUS"]): + print("synthetic image export failure", file=sys.stderr) + sys.exit(int(os.environ["FAKE_SAVE_STATUS"])) + Path(arguments[arguments.index("-o") + 1]).write_bytes(b"synthetic image tar") + sys.exit(0) +sys.exit(2) +''', encoding="utf-8") + docker.chmod(0o755) + + def run(platform="arm64", client_api="1.54", server_api="1.54", *, + version_status=0, save_status=0, require_platform=False): + log_path = tmp_path / "docker.jsonl" + output = tmp_path / "offline images.tar" + environment = os.environ.copy() + environment.update( + PATH=f"{tmp_path}{os.pathsep}{environment['PATH']}", + FAKE_DOCKER_LOG=str(log_path), + FAKE_CLIENT_API=client_api, + FAKE_SERVER_API=server_api, + FAKE_VERSION_STATUS=str(version_status), + FAKE_SAVE_STATUS=str(save_status), + FAKE_REQUIRE_PLATFORM=str(require_platform).lower(), + ) + result = subprocess.run( + ["bash", "-c", functions + '\nPLATFORM="$1"\nsave_image_to_tar "$2" "$3"', + "offline-export-test", platform, "nexent/nexent:v2.8.3", str(output)], + env=environment, capture_output=True, text=True, check=False, + ) + calls = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines()] + return result, calls, output + + return run + + +@pytest.mark.parametrize("platform", ["arm64", "amd64"]) +@pytest.mark.parametrize("api_version", ["1.48", "1.54", "1.100", "2.0"]) +def test_exports_only_requested_platform_on_supported_docker(export_image, platform, api_version): + result, calls, output = export_image( + platform, api_version, api_version, require_platform=True, + ) + + assert result.returncode == 0, result.stdout + result.stderr + saves = [call for call in calls if call[0] == "save"] + assert len(saves) == 1 + assert saves[0][saves[0].index("--platform") + 1] == f"linux/{platform}" + assert saves[0][saves[0].index("-o") + 1] == str(output) + assert saves[0][-1] == "nexent/nexent:v2.8.3" + assert output.read_bytes() == b"synthetic image tar" + + +@pytest.mark.parametrize("client_api,server_api", [ + ("1.39", "1.39"), + ("1.47", "1.47"), + ("1.54", "1.39"), + ("1.39", "1.54"), + ("1.47", "1.48"), + ("1.48", "1.47"), +]) +def test_preserves_legacy_export_when_either_api_is_older(export_image, client_api, server_api): + result, calls, output = export_image(client_api=client_api, server_api=server_api) + + assert result.returncode == 0, result.stdout + result.stderr + assert [call for call in calls if call[0] == "save"] == [ + ["save", "-o", str(output), "nexent/nexent:v2.8.3"], + ] + assert output.is_file() + + +@pytest.mark.parametrize("client_api,server_api,version_status", [ + ("", "", 0), + ("1.54", "unknown", 0), + ("unknown", "1.54", 0), + ("1", "1.54", 0), + ("1.54", "1.54", 1), +]) +def test_uses_legacy_export_when_api_support_cannot_be_determined( + export_image, client_api, server_api, version_status, +): + result, calls, output = export_image( + client_api=client_api, server_api=server_api, version_status=version_status, + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert [call for call in calls if call[0] == "save"] == [ + ["save", "-o", str(output), "nexent/nexent:v2.8.3"], + ] + + +@pytest.mark.parametrize("api_version,has_platform", [("1.54", True), ("1.39", False)]) +def test_export_failure_is_reported_without_an_unrestricted_retry(export_image, api_version, has_platform): + result, calls, output = export_image(client_api=api_version, server_api=api_version, save_status=1) + + assert result.returncode != 0 + assert "synthetic image export failure" in result.stderr + assert "Failed to save image: nexent/nexent:v2.8.3" in result.stdout + assert "Saved:" not in result.stdout + assert not output.exists() + saves = [call for call in calls if call[0] == "save"] + assert len(saves) == 1 + assert ("--platform" in saves[0]) is has_platform