diff --git a/.github/workflows/build-offline-package.yml b/.github/workflows/build-offline-package.yml index 456a01c530..6998b5a976 100644 --- a/.github/workflows/build-offline-package.yml +++ b/.github/workflows/build-offline-package.yml @@ -49,7 +49,7 @@ on: jobs: build-offline-package: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: diff --git a/deploy/offline/build_offline_package.sh b/deploy/offline/build_offline_package.sh index e135455b36..382223b793 100755 --- a/deploy/offline/build_offline_package.sh +++ b/deploy/offline/build_offline_package.sh @@ -1,5 +1,4 @@ #!/bin/bash -# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. set -e @@ -471,35 +470,13 @@ 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 "${save_args[@]}" "$image"; then + if docker save -o "$output_file" "$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 deleted file mode 100644 index 13f21b1059..0000000000 --- a/test-e2e/changes/bugs/OFFLINE-EXPORT-PLATFORM-20261102.yaml +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index 1abe6b4f4f..0000000000 --- a/test/backend/deploy/test_offline_image_export.py +++ /dev/null @@ -1,143 +0,0 @@ -# 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