Skip to content

Commit

Permalink
Add proper platform handling.
Browse files Browse the repository at this point in the history
  • Loading branch information
felixfontein committed Dec 3, 2023
1 parent 036d166 commit 604fd6f
Show file tree
Hide file tree
Showing 4 changed files with 268 additions and 6 deletions.
173 changes: 173 additions & 0 deletions plugins/module_utils/_platform.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# Copyright (c) 2023 Felix Fontein <[email protected]>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import (absolute_import, division, print_function)
__metaclass__ = type

import re


_VALID_STR = re.compile('^[A-Za-z0-9_-]+$')


def _validate_part(string, part, part_name):
if not part:
raise ValueError('Invalid platform string "{string}": {part} is empty'.format(string=string, part=part_name))
if not _VALID_STR.match(part):
raise ValueError('Invalid platform string "{string}": {part} has invalid characters'.format(string=string, part=part_name))
return part


# See https://github.com/containerd/containerd/blob/main/platforms/database.go#L32-L38
_KNOWN_OS = (
"aix", "android", "darwin", "dragonfly", "freebsd", "hurd", "illumos", "ios", "js",
"linux", "nacl", "netbsd", "openbsd", "plan9", "solaris", "windows", "zos",
)

# See https://github.com/containerd/containerd/blob/main/platforms/database.go#L54-L60
_KNOWN_ARCH = (
"386", "amd64", "amd64p32", "arm", "armbe", "arm64", "arm64be", "ppc64", "ppc64le",
"loong64", "mips", "mipsle", "mips64", "mips64le", "mips64p32", "mips64p32le",
"ppc", "riscv", "riscv64", "s390", "s390x", "sparc", "sparc64", "wasm",
)


def _normalize_os(os_str):
# See normalizeOS() in https://github.com/containerd/containerd/blob/main/platforms/database.go
os_str = os_str.lower()
if os_str == 'macos':
os_str = 'darwin'
return os_str


_NORMALIZE_ARCH = {
("i386", None): ("386", ""),
("x86_64", "v1"): ("amd64", ""),
("x86-64", "v1"): ("amd64", ""),
("amd64", "v1"): ("amd64", ""),
("x86_64", None): ("amd64", None),
("x86-64", None): ("amd64", None),
("amd64", None): ("amd64", None),
("aarch64", "8"): ("arm64", ""),
("arm64", "8"): ("arm64", ""),
("aarch64", "v8"): ("arm64", ""),
("arm64", "v8"): ("arm64", ""),
("aarch64", None): ("arm64", None),
("arm64", None): ("arm64", None),
("armhf", None): ("arm", "v7"),
("armel", None): ("arm", "v6"),
("arm", ""): ("arm", "v7"),
("arm", "5"): ("arm", "v5"),
("arm", "6"): ("arm", "v6"),
("arm", "7"): ("arm", "v7"),
("arm", "8"): ("arm", "v8"),
("arm", None): ("arm", None),
}


def _normalize_arch(arch_str, variant_str):
# See normalizeArch() in https://github.com/containerd/containerd/blob/main/platforms/database.go
arch_str = arch_str.lower()
variant_str = variant_str.lower()
res = _NORMALIZE_ARCH.get((arch_str, variant_str))
if res is None:
res = _NORMALIZE_ARCH.get((arch_str, None))
if res is None:
return arch_str, variant_str
if res is not None:
arch_str = res[0]
if res[1] is not None:
variant_str = res[1]
return arch_str, variant_str


class _Platform(object):
def __init__(self, os=None, arch=None, variant=None):
self.os = os
self.arch = arch
self.variant = variant
if variant is not None:
if arch is None:
raise ValueError('If variant is given, architecture must be given too')
if os is None:
raise ValueError('If variant is given, os must be given too')

@classmethod
def parse_platform_string(cls, string, daemon_os=None, daemon_arch=None):
# See Parse() in https://github.com/containerd/containerd/blob/main/platforms/platforms.go
if string is None:
return cls()
if not string:
raise ValueError('Platform string must be non-empty')
parts = string.split('/', 2)
arch = None
variant = None
if len(parts) == 1:
_validate_part(string, string, 'OS/architecture')
# The part is either OS or architecture
os = _normalize_os(string)
if os in _KNOWN_OS:
if daemon_arch is not None:
arch, variant = _normalize_arch(daemon_arch, '')
return cls(os=os, arch=arch, variant=variant)
arch, variant = _normalize_arch(os, '')
if arch in _KNOWN_ARCH:
return cls(
os=_normalize_os(daemon_os) if daemon_os else None,
arch=arch or None,
variant=variant or None,
)
raise ValueError('Invalid platform string "{0}": unknown OS or architecture'.format(string))
os = _validate_part(string, parts[0], 'OS')
if not os:
raise ValueError('Invalid platform string "{0}": OS is empty'.format(string))
arch = _validate_part(string, parts[1], 'architecture') if len(parts) > 1 else None
if arch is not None and not arch:
raise ValueError('Invalid platform string "{0}": architecture is empty'.format(string))
variant = _validate_part(string, parts[2], 'variant') if len(parts) > 2 else None
if variant is not None and not variant:
raise ValueError('Invalid platform string "{0}": variant is empty'.format(string))
arch, variant = _normalize_arch(arch, variant or '')
if len(parts) == 2 and arch == 'arm' and variant == 'v7':
variant = None
if len(parts) == 3 and arch == 'arm64' and variant == '':
variant = 'v8'
return cls(os=_normalize_os(os), arch=arch, variant=variant or None)

def __str__(self):
if self.variant:
parts = [self.os, self.arch, self.variant]
elif self.os:
if self.arch:
parts = [self.os, self.arch]
else:
parts = [self.os]
elif self.arch is not None:
parts = [self.arch]
else:
parts = []
return '/'.join(parts)

def __repr__(self):
return '_Platform(os={os!r}, arch={arch!r}, variant={variant!r})'.format(os=self.os, arch=self.arch, variant=self.variant)

def __eq__(self, other):
return self.os == other.os and self.arch == other.arch and self.variant == other.variant


def normalize_platform_string(string, daemon_os=None, daemon_arch=None):
return str(_Platform.parse_platform_string(string, daemon_os=daemon_os, daemon_arch=daemon_arch))


def compose_platform_string(os=None, arch=None, variant=None, daemon_os=None, daemon_arch=None):
if os is None and daemon_os is not None:
os = _normalize_os(daemon_os)
if arch is None and daemon_arch is not None:
arch, variant = _normalize_arch(daemon_arch, variant or '')
variant = variant or None
return str(_Platform(os=os, arch=arch, variant=variant or None))


def compare_platform_strings(string1, string2):
return _Platform.parse_platform_string(string1) == _Platform.parse_platform_string(string2)
15 changes: 14 additions & 1 deletion plugins/module_utils/module_container/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
omit_none_from_dict,
)

from ansible_collections.community.docker.plugins.module_utils._platform import (
compare_platform_strings,
)

from ansible_collections.community.docker.plugins.module_utils._api.utils.utils import (
parse_env_file,
)
Expand Down Expand Up @@ -755,6 +759,15 @@ def _preprocess_ports(module, values):
return values


def _compare_platform(option, param_value, container_value):
if option.comparison == 'ignore':
return True
try:
return compare_platform_strings(param_value, container_value)
except ValueError:
return param_value == container_value


OPTION_AUTO_REMOVE = (
OptionGroup()
.add_option('auto_remove', type='bool')
Expand Down Expand Up @@ -1031,7 +1044,7 @@ def _preprocess_ports(module, values):

OPTION_PLATFORM = (
OptionGroup()
.add_option('platform', type='str')
.add_option('platform', type='str', compare=_compare_platform)
)

OPTION_PRIVILEGED = (
Expand Down
40 changes: 40 additions & 0 deletions plugins/module_utils/module_container/docker_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
RequestException,
)

from ansible_collections.community.docker.plugins.module_utils._platform import (
compose_platform_string,
normalize_platform_string,
)

from ansible_collections.community.docker.plugins.module_utils.module_container.base import (
OPTION_AUTO_REMOVE,
OPTION_BLKIO_WEIGHT,
Expand Down Expand Up @@ -1048,16 +1053,48 @@ def _set_values_log(module, data, api_version, options, values):


def _get_values_platform(module, container, api_version, options, image, host_info):
if image and (image.get('Os') or image.get('Architecture') or image.get('Variant')):
return {
'platform': compose_platform_string(
os=image.get('Os'),
arch=image.get('Architecture'),
variant=image.get('Variant'),
daemon_os=host_info.get('OSType') if host_info else None,
daemon_arch=host_info.get('Architecture') if host_info else None,
)
}
return {
'platform': container.get('Platform'),
}


def _get_expected_values_platform(module, client, api_version, options, image, values, host_info):
expected_values = {}
if 'platform' in values:
try:
expected_values['platform'] = normalize_platform_string(
values['platform'],
daemon_os=host_info.get('OSType') if host_info else None,
daemon_arch=host_info.get('Architecture') if host_info else None,
)
except ValueError as exc:
module.fail_json(msg='Error while parsing platform parameer: %s' % (to_native(exc), ))
return expected_values


def _set_values_platform(module, data, api_version, options, values):
if 'platform' in values:
data['platform'] = values['platform']


def _needs_container_image_platform(values):
return 'platform' in values


def _needs_host_info_platform(values):
return 'platform' in values


def _get_values_restart(module, container, api_version, options, image, host_info):
restart_policy = container['HostConfig'].get('RestartPolicy') or {}
return {
Expand Down Expand Up @@ -1306,6 +1343,9 @@ def _preprocess_container_names(module, client, api_version, value):
OPTION_PLATFORM.add_engine('docker_api', DockerAPIEngine(
get_value=_get_values_platform,
set_value=_set_values_platform,
get_expected_values=_get_expected_values_platform,
needs_container_image=_needs_container_image_platform,
needs_host_info=_needs_host_info_platform,
min_api_version='1.41',
))

Expand Down
46 changes: 41 additions & 5 deletions tests/integration/targets/docker_container/tasks/tests/options.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3564,17 +3564,38 @@ avoid such warnings, please quote the value.' in (log_options_2.warnings | defau
register: platform_1
ignore_errors: true

- name: platform (idempotency)
- name: platform (idempotency with full name)
# Docker daemon only returns 'linux' as the platform for the container,
# so this has to be handled correctly by our additional code
docker_container:
image: hello-world:latest
name: "{{ cname }}"
state: present
# The container always reports 'linux' as platform instead of 'linux/amd64'...
platform: linux
platform: linux/amd64
debug: true
register: platform_2
ignore_errors: true

- name: platform (idempotency with shorter name)
docker_container:
image: hello-world:latest
name: "{{ cname }}"
state: present
platform: linux
debug: true
register: platform_3
ignore_errors: true

- name: platform (idempotency with shorter name)
docker_container:
image: hello-world:latest
name: "{{ cname }}"
state: present
platform: amd64
debug: true
register: platform_4
ignore_errors: true

- name: platform (changed)
docker_container:
image: hello-world:latest
Expand All @@ -3587,7 +3608,19 @@ avoid such warnings, please quote the value.' in (log_options_2.warnings | defau
comparisons:
# Do not restart because of the changed image ID
image: ignore
register: platform_3
register: platform_5
ignore_errors: true

- name: platform (idempotency)
docker_container:
image: hello-world:latest
name: "{{ cname }}"
state: present
pull: true
platform: 386
force_kill: true
debug: true
register: platform_6
ignore_errors: true

- name: cleanup
Expand All @@ -3601,7 +3634,10 @@ avoid such warnings, please quote the value.' in (log_options_2.warnings | defau
that:
- platform_1 is changed
- platform_2 is not changed and platform_2 is not failed
- platform_3 is changed
- platform_3 is not changed and platform_3 is not failed
- platform_4 is not changed and platform_4 is not failed
- platform_5 is changed
- platform_6 is not changed and platform_6 is not failed
when: docker_api_version is version('1.41', '>=')
- assert:
that:
Expand Down

0 comments on commit 604fd6f

Please sign in to comment.