diff --git a/linter_exclusions.yml b/linter_exclusions.yml index 379e956dfa5..73260e38a79 100644 --- a/linter_exclusions.yml +++ b/linter_exclusions.yml @@ -3525,3 +3525,9 @@ confcom fragment attach: signed_fragment: rule_exclusions: - no_positional_parameters + +confcom containers from_image: + parameters: + image: + rule_exclusions: + - no_positional_parameters diff --git a/src/confcom/azext_confcom/_help.py b/src/confcom/azext_confcom/_help.py index 9817bef723e..efd2594aa36 100644 --- a/src/confcom/azext_confcom/_help.py +++ b/src/confcom/azext_confcom/_help.py @@ -321,3 +321,28 @@ - name: Attach the output of acifragmentgen to a registry text: az confcom acifragmentgen --chain my.cert.pem --key my_key.pem --svn "1" --namespace contoso --feed "test-feed" --input ./fragment_spec.json | az confcom fragment attach --manifest-tag myregistry.azurecr.io/image:latest """ + +helps[ + "confcom containers" +] = """ + type: group + short-summary: Commands which generate Security Policy Container Definitions. +""" + + +helps[ + "confcom containers from_image" +] = """ + type: command + short-summary: Create a Security Policy Container Definition based on a Radius app template. + + parameters: + - name: --platform + type: str + short-summary: 'The name of the platform the container definition will run on' + + + examples: + - name: Input an image reference and generate container definitions + text: az confcom containers from_image my.azurecr.io/myimage:tag +""" diff --git a/src/confcom/azext_confcom/_params.py b/src/confcom/azext_confcom/_params.py index d75ce70abc1..a1a53c27402 100644 --- a/src/confcom/azext_confcom/_params.py +++ b/src/confcom/azext_confcom/_params.py @@ -469,3 +469,18 @@ def load_arguments(self, _): help="Path to containerd socket if not using the default", validator=validate_katapolicygen_input, ) + + with self.argument_context("confcom containers from_image") as c: + c.positional( + "image", + type=str, + help="Image to create container definition from", + ) + c.argument( + "platform", + options_list=("--platform",), + required=False, + default="aci", + type=str, + help="Platform to create container definition for", + ) diff --git a/src/confcom/azext_confcom/command/containers_from_image.py b/src/confcom/azext_confcom/command/containers_from_image.py new file mode 100644 index 00000000000..d43e0dd07e0 --- /dev/null +++ b/src/confcom/azext_confcom/command/containers_from_image.py @@ -0,0 +1,12 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import json + +from azext_confcom.lib.containers import from_image as lib_containers_from_image + + +def containers_from_image(image: str, platform: str) -> str: + return print(json.dumps(lib_containers_from_image(image, platform))) diff --git a/src/confcom/azext_confcom/commands.py b/src/confcom/azext_confcom/commands.py index 7e1e93eabca..86d699301b7 100644 --- a/src/confcom/azext_confcom/commands.py +++ b/src/confcom/azext_confcom/commands.py @@ -17,3 +17,6 @@ def load_command_table(self, _): with self.command_group("confcom"): pass + + with self.command_group("confcom containers") as g: + g.custom_command("from_image", "containers_from_image") diff --git a/src/confcom/azext_confcom/custom.py b/src/confcom/azext_confcom/custom.py index 1b243a31370..814f228b930 100644 --- a/src/confcom/azext_confcom/custom.py +++ b/src/confcom/azext_confcom/custom.py @@ -25,6 +25,7 @@ print_existing_policy_from_yaml, print_func, str_to_sha256) from azext_confcom.command.fragment_attach import fragment_attach as _fragment_attach from azext_confcom.command.fragment_push import fragment_push as _fragment_push +from azext_confcom.command.containers_from_image import containers_from_image as _containers_from_image from knack.log import get_logger from pkg_resources import parse_version @@ -552,3 +553,13 @@ def fragment_push( signed_fragment=signed_fragment, manifest_tag=manifest_tag ) + + +def containers_from_image( + image: str, + platform: str, +) -> None: + _containers_from_image( + image=image, + platform=platform, + ) diff --git a/src/confcom/azext_confcom/lib/containers.py b/src/confcom/azext_confcom/lib/containers.py new file mode 100644 index 00000000000..ffcbefc69ed --- /dev/null +++ b/src/confcom/azext_confcom/lib/containers.py @@ -0,0 +1,23 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from dataclasses import asdict +from azext_confcom.lib.images import get_image_layers, get_image_config +from azext_confcom.lib.platform import ACI_MOUNTS + + +def from_image(image: str, platform: str) -> str: + + mounts = { + "aci": [asdict(mount) for mount in ACI_MOUNTS], + }.get(platform, None) + + return { + "id": image, + "name": image, + "layers": get_image_layers(image), + **({"mounts": mounts} if mounts else {}), + **get_image_config(image), + } diff --git a/src/confcom/azext_confcom/lib/images.py b/src/confcom/azext_confcom/lib/images.py new file mode 100644 index 00000000000..7921f650b7c --- /dev/null +++ b/src/confcom/azext_confcom/lib/images.py @@ -0,0 +1,65 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import functools +import subprocess +import docker + +from pathlib import Path + + +@functools.lru_cache() +def get_image(image_ref: str) -> docker.models.images.Image: + + client = docker.from_env() + + try: + image = client.images.get(image_ref) + except docker.errors.ImageNotFound: + client.images.pull(image_ref) + + image = client.images.get(image_ref) + return image + + +def get_image_layers(image: str) -> list[str]: + + binary_path = Path(__file__).parent.parent / "bin" / "dmverity-vhd" + + get_image(image) + result = subprocess.run( + [binary_path.as_posix(), "-d", "roothash", "-i", image], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + text=True, + ) + + return [line.split("hash: ")[-1] for line in result.stdout.splitlines()] + + +def get_image_config(image: str) -> dict: + + image_config = get_image(image).attrs.get("Config") + + config = {} + + if image_config.get("Cmd") or image_config.get("Entrypoint"): + config["command"] = ( + image_config.get("Entrypoint") or [] + + image_config.get("Cmd") or [] + ) + + if image_config.get("Env"): + config["env_rules"] = [{ + "pattern": p, + "strategy": "string", + "required": False, + } for p in image_config.get("Env")] + + if image_config.get("WorkingDir"): + config["working_dir"] = image_config.get("WorkingDir") + + return config diff --git a/src/confcom/azext_confcom/lib/platform.py b/src/confcom/azext_confcom/lib/platform.py new file mode 100644 index 00000000000..b4ef1138545 --- /dev/null +++ b/src/confcom/azext_confcom/lib/platform.py @@ -0,0 +1,19 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azext_confcom.lib.policy import ContainerMount + +ACI_MOUNTS = [ + ContainerMount( + destination="/etc/resolv.conf", + options=[ + "rbind", + "rshared", + "rw" + ], + source="sandbox:///tmp/atlas/resolvconf/.+", + type="bind" + ) +] diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_containers_from_image.py b/src/confcom/azext_confcom/tests/latest/test_confcom_containers_from_image.py new file mode 100644 index 00000000000..ff94812c09f --- /dev/null +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_containers_from_image.py @@ -0,0 +1,72 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import json +import tempfile +import docker +import pytest +import portalocker + +from contextlib import redirect_stdout +from io import StringIO +from itertools import product +from pathlib import Path +from deepdiff import DeepDiff + +from azext_confcom.command.containers_from_image import containers_from_image + + +TEST_DIR = Path(__file__).parent +CONFCOM_DIR = TEST_DIR.parent.parent.parent +SAMPLES_ROOT = CONFCOM_DIR / "samples" / "images" +DOCKER_LOCK = Path(tempfile.gettempdir()) / "confcom-docker.lock" + + +@pytest.fixture(scope="session", autouse=True) +def build_test_containers(): + + docker_client = docker.from_env() + with portalocker.Lock(DOCKER_LOCK.as_posix(), timeout=20): + for image_sample in SAMPLES_ROOT.iterdir(): + docker_client.images.build( + path=str(image_sample), + tag=f"confcom_test_{image_sample.name}", + quiet=True, + rm=True, + ) + + yield + + +@pytest.mark.parametrize( + "sample_directory, platform", + product( + [p.name for p in SAMPLES_ROOT.iterdir()], + ["aci"], + ) +) +def test_containers_from_image(sample_directory: str, platform: str): + + sample_directory = Path(SAMPLES_ROOT) / sample_directory + + expected_container_def_path = sample_directory / f"{platform}_container.inc.rego" + with expected_container_def_path.open("r", encoding="utf-8") as handle: + expected_container_def = json.load(handle) + + buffer = StringIO() + with redirect_stdout(buffer): + containers_from_image( + image=f"confcom_test_{sample_directory.name}", + platform=platform, + ) + + actual_container_def = json.loads(buffer.getvalue()) + + diff = DeepDiff( + actual_container_def, + expected_container_def, + ignore_order=True, + ) + assert diff == {}, diff \ No newline at end of file diff --git a/src/confcom/samples/images/command/Dockerfile b/src/confcom/samples/images/command/Dockerfile new file mode 100644 index 00000000000..4665c5e95c7 --- /dev/null +++ b/src/confcom/samples/images/command/Dockerfile @@ -0,0 +1,3 @@ +FROM hello-world + +CMD ["/command"] \ No newline at end of file diff --git a/src/confcom/samples/images/command/aci_container.inc.rego b/src/confcom/samples/images/command/aci_container.inc.rego new file mode 100644 index 00000000000..562c8353679 --- /dev/null +++ b/src/confcom/samples/images/command/aci_container.inc.rego @@ -0,0 +1,30 @@ +{ + "id": "confcom_test_command", + "name": "confcom_test_command", + "layers": [ + "8b4664979ffe3c5188efbbbb30e31716c03bfe880f15f455be0fc3beb4741de9" + ], + "mounts": [ + { + "destination": "/etc/resolv.conf", + "options": [ + "rbind", + "rshared", + "rw" + ], + "source": "sandbox:///tmp/atlas/resolvconf/.+", + "type": "bind" + } + ], + "command": [ + "/command" + ], + "env_rules": [ + { + "pattern": "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "strategy": "string", + "required": false + } + ], + "working_dir": "/" +} diff --git a/src/confcom/samples/images/environment_variables/Dockerfile b/src/confcom/samples/images/environment_variables/Dockerfile new file mode 100644 index 00000000000..8bff09797ef --- /dev/null +++ b/src/confcom/samples/images/environment_variables/Dockerfile @@ -0,0 +1,3 @@ +FROM hello-world + +ENV TEST_ENV_VAR="Test Env Value" \ No newline at end of file diff --git a/src/confcom/samples/images/environment_variables/aci_container.inc.rego b/src/confcom/samples/images/environment_variables/aci_container.inc.rego new file mode 100644 index 00000000000..3cd755d8471 --- /dev/null +++ b/src/confcom/samples/images/environment_variables/aci_container.inc.rego @@ -0,0 +1,35 @@ +{ + "id": "confcom_test_environment_variables", + "name": "confcom_test_environment_variables", + "layers": [ + "8b4664979ffe3c5188efbbbb30e31716c03bfe880f15f455be0fc3beb4741de9" + ], + "mounts": [ + { + "destination": "/etc/resolv.conf", + "options": [ + "rbind", + "rshared", + "rw" + ], + "source": "sandbox:///tmp/atlas/resolvconf/.+", + "type": "bind" + } + ], + "command": [ + "/hello" + ], + "env_rules": [ + { + "pattern": "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "strategy": "string", + "required": false + }, + { + "pattern": "TEST_ENV_VAR=Test Env Value", + "strategy": "string", + "required": false + } + ], + "working_dir": "/" +} diff --git a/src/confcom/samples/images/minimal/Dockerfile b/src/confcom/samples/images/minimal/Dockerfile new file mode 100644 index 00000000000..181b789871a --- /dev/null +++ b/src/confcom/samples/images/minimal/Dockerfile @@ -0,0 +1 @@ +FROM hello-world \ No newline at end of file diff --git a/src/confcom/samples/images/minimal/aci_container.inc.rego b/src/confcom/samples/images/minimal/aci_container.inc.rego new file mode 100644 index 00000000000..a8ef1b8fa3e --- /dev/null +++ b/src/confcom/samples/images/minimal/aci_container.inc.rego @@ -0,0 +1,30 @@ +{ + "id": "confcom_test_minimal", + "name": "confcom_test_minimal", + "layers": [ + "8b4664979ffe3c5188efbbbb30e31716c03bfe880f15f455be0fc3beb4741de9" + ], + "mounts": [ + { + "destination": "/etc/resolv.conf", + "options": [ + "rbind", + "rshared", + "rw" + ], + "source": "sandbox:///tmp/atlas/resolvconf/.+", + "type": "bind" + } + ], + "command": [ + "/hello" + ], + "env_rules": [ + { + "pattern": "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "strategy": "string", + "required": false + } + ], + "working_dir": "/" +} diff --git a/src/confcom/setup.py b/src/confcom/setup.py index fe40522e879..2af3603ce4b 100644 --- a/src/confcom/setup.py +++ b/src/confcom/setup.py @@ -19,7 +19,7 @@ logger.warn("Wheel is not available, disabling bdist_wheel hook") -VERSION = "1.5.0" +VERSION = "1.6.0" # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers