Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion deploy/offline/build_offline_package.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/bin/bash
# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.

set -e

Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions test-e2e/changes/bugs/OFFLINE-EXPORT-PLATFORM-20261102.yaml
Original file line number Diff line number Diff line change
@@ -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/<requested platform> 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.
143 changes: 143 additions & 0 deletions test/backend/deploy/test_offline_image_export.py
Original file line number Diff line number Diff line change
@@ -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
Loading