|
5 | 5 |
|
6 | 6 | import copy |
7 | 7 | import json |
| 8 | +import subprocess |
8 | 9 | import warnings |
9 | 10 | from enum import Enum, auto |
10 | 11 | from typing import Any, Dict, List, Optional, Tuple, Union |
|
41 | 42 | process_fragment_imports, |
42 | 43 | process_mounts, |
43 | 44 | process_mounts_from_config, |
44 | | - readable_diff) |
45 | | -from azext_confcom.lib.images import get_image_platform # pylint: disable=unused-import |
| 45 | + readable_diff, |
| 46 | + find_value_in_params_and_vars) |
| 47 | +from azext_confcom.lib.images import get_image_platform |
46 | 48 | from azext_confcom.lib.defaults import get_debug_mode_exec_procs |
47 | 49 | from knack.log import get_logger |
48 | 50 | from tqdm import tqdm |
@@ -669,6 +671,76 @@ def set_images(self, images: List[ContainerImage]) -> None: |
669 | 671 | self._images = images |
670 | 672 |
|
671 | 673 |
|
| 674 | +def _get_image_platforms_from_docker(image_name: str) -> List[str]: |
| 675 | + """Detect all platforms an image supports using docker manifest inspect. |
| 676 | +
|
| 677 | + If the image reference is a digest (@sha256:...), strip it back to the |
| 678 | + tag so that multi-platform manifest list lookups work correctly. |
| 679 | + """ |
| 680 | + # Strip digest references — manifest inspect needs a tag, not a digest |
| 681 | + if "@sha256:" in image_name: |
| 682 | + image_name = image_name.split("@sha256:")[0] |
| 683 | + if ":" not in image_name.split("/")[-1]: |
| 684 | + image_name += ":latest" |
| 685 | + |
| 686 | + try: |
| 687 | + result = subprocess.run( |
| 688 | + ["docker", "manifest", "inspect", image_name], |
| 689 | + capture_output=True, text=True, timeout=30, check=True, |
| 690 | + ) |
| 691 | + data = json.loads(result.stdout) |
| 692 | + platforms = [] |
| 693 | + for manifest in data.get("manifests", []): |
| 694 | + plat = manifest.get("platform", {}) |
| 695 | + os_name = plat.get("os", "unknown") |
| 696 | + arch = plat.get("architecture", "unknown") |
| 697 | + if os_name != "unknown" and arch != "unknown": |
| 698 | + platforms.append(f"{os_name}/{arch}") |
| 699 | + if platforms: |
| 700 | + return platforms |
| 701 | + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, |
| 702 | + json.JSONDecodeError, FileNotFoundError): |
| 703 | + pass |
| 704 | + return [] |
| 705 | + |
| 706 | + |
| 707 | +def validate_image_platform(image_name: str, platform: str) -> None: |
| 708 | + """Validate that the image supports the specified platform. |
| 709 | +
|
| 710 | + Uses docker manifest inspect for multi-platform detection, |
| 711 | + then falls back to get_image_platform (docker pull) for single-platform. |
| 712 | + """ |
| 713 | + supported = _get_image_platforms_from_docker(image_name) |
| 714 | + |
| 715 | + # Fall back to single-platform detection via docker pull |
| 716 | + if not supported: |
| 717 | + try: |
| 718 | + detected = get_image_platform(image_name) |
| 719 | + if detected: |
| 720 | + supported = [detected] |
| 721 | + except (ValueError, KeyError, AttributeError): |
| 722 | + pass |
| 723 | + |
| 724 | + if not supported: |
| 725 | + logger.warning( |
| 726 | + "Could not detect supported platforms for image '%s'. " |
| 727 | + "Skipping platform validation.", image_name, |
| 728 | + ) |
| 729 | + return |
| 730 | + |
| 731 | + if len(supported) == 1 and supported[0] != platform: |
| 732 | + eprint( |
| 733 | + f'Image "{image_name}" only supports platform "{supported[0]}", ' |
| 734 | + f'which does not match the specified platform "{platform}".' |
| 735 | + ) |
| 736 | + |
| 737 | + if len(supported) > 1 and platform not in supported: |
| 738 | + eprint( |
| 739 | + f'Image "{image_name}" supports platforms {supported}, ' |
| 740 | + f'which does not include the specified platform "{platform}".' |
| 741 | + ) |
| 742 | + |
| 743 | + |
672 | 744 | # pylint: disable=R0914, |
673 | 745 | def load_policy_from_arm_template_str( |
674 | 746 | template_data: str, |
@@ -830,6 +902,10 @@ def load_policy_from_arm_template_str( |
830 | 902 | f'Field ["{config.ACI_FIELD_TEMPLATE_IMAGE}"] is empty or cannot be found' |
831 | 903 | ) |
832 | 904 |
|
| 905 | + # Resolve ARM parameters/variables to get the real image name for validation |
| 906 | + resolved_image = find_value_in_params_and_vars(all_params, all_vars, image_name) |
| 907 | + validate_image_platform(resolved_image, platform) |
| 908 | + |
833 | 909 | exec_processes = [] |
834 | 910 | extract_probe(exec_processes, image_properties, config.ACI_FIELD_CONTAINERS_READINESS_PROBE) |
835 | 911 | extract_probe(exec_processes, image_properties, config.ACI_FIELD_CONTAINERS_LIVENESS_PROBE) |
@@ -920,6 +996,8 @@ def load_policy_from_image_name( |
920 | 996 |
|
921 | 997 | containers = [] |
922 | 998 | for image_name in image_names: |
| 999 | + validate_image_platform(image_name, platform) |
| 1000 | + |
923 | 1001 | container = {} |
924 | 1002 | # assign just the fields that are expected |
925 | 1003 | # the values will come when calling |
@@ -1037,6 +1115,8 @@ def load_policy_from_json( |
1037 | 1115 | f'Field ["{config.ACI_FIELD_TEMPLATE_IMAGE}"] is empty or cannot be found' |
1038 | 1116 | ) |
1039 | 1117 |
|
| 1118 | + validate_image_platform(image_name, platform) |
| 1119 | + |
1040 | 1120 | container_name = case_insensitive_dict_get( |
1041 | 1121 | container, config.ACI_FIELD_CONTAINERS_NAME |
1042 | 1122 | ) or image_name |
@@ -1250,6 +1330,8 @@ def load_policy_from_virtual_node_yaml_str( |
1250 | 1330 | if not image: |
1251 | 1331 | eprint("Container does not have an image field") |
1252 | 1332 |
|
| 1333 | + validate_image_platform(image, platform) |
| 1334 | + |
1253 | 1335 | # env vars |
1254 | 1336 | envs = process_env_vars_from_yaml( |
1255 | 1337 | container, |
|
0 commit comments