From ab1c94e87d6d77fe3b3c14080169812dabce6382 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Tue, 16 Dec 2025 09:15:54 +0000 Subject: [PATCH 01/14] Restore the behaviour of `--upload-fragment` for acifragmentgen (#13) ### Why Addresses - https://github.com/Azure/azure-cli-extensions/issues/9222 ### How - [x] Update the code to restore the "attach to first image in input" behaviour - [x] Add two new commands: `fragment push` and `fragment attach` to allow the user to explicitly do one or the other (or both!) - [x] Add new tests which run a local docker registry, and test that the fragments are generated, signed, pushed and attached as expected (as well as the default behaviour) --- This checklist is used to make sure that common guidelines for a pull request are followed. ### Related command ### General Guidelines - [x] Have you run `azdev style ` locally? (`pip install azdev` required) - [x] Have you run `python scripts/ci/test_index.py -q` locally? (`pip install wheel==0.30.0` required) - [x] My extension version conforms to the [Extension version schema](https://github.com/Azure/azure-cli/blob/release/doc/extensions/versioning_guidelines.md) --- linter_exclusions.yml | 12 + src/confcom/HISTORY.rst | 6 + src/confcom/azext_confcom/_help.py | 43 +++ src/confcom/azext_confcom/_params.py | 35 ++ .../azext_confcom/command/fragment_attach.py | 46 +++ .../azext_confcom/command/fragment_push.py | 46 +++ src/confcom/azext_confcom/commands.py | 4 + src/confcom/azext_confcom/custom.py | 50 ++- .../data/genpolicy-settings.json | 338 ++++++++++++++++++ src/confcom/azext_confcom/data/rules.rego | 6 +- .../latest/test_confcom_acifragmentgen.py | 252 +++++++++++++ .../tests/latest/test_confcom_arm.py | 17 +- .../tests/latest/test_confcom_fragment.py | 12 +- .../tests/latest/test_confcom_tar.py | 2 +- .../tests/latest/test_confcom_virtual_node.py | 16 +- src/confcom/samples/certs/create_certchain.sh | 72 ++-- src/confcom/setup.py | 2 +- 17 files changed, 904 insertions(+), 55 deletions(-) create mode 100644 src/confcom/azext_confcom/command/fragment_attach.py create mode 100644 src/confcom/azext_confcom/command/fragment_push.py create mode 100644 src/confcom/azext_confcom/data/genpolicy-settings.json create mode 100644 src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py diff --git a/linter_exclusions.yml b/linter_exclusions.yml index ce4aaa82bca..5105cb7f34b 100644 --- a/linter_exclusions.yml +++ b/linter_exclusions.yml @@ -3504,3 +3504,15 @@ neon postgres organization: neon postgres project: rule_exclusions: - require_wait_command_if_no_wait + +confcom fragment push: + parameters: + signed_fragment: + rule_exclusions: + - no_positional_parameters + +confcom fragment attach: + parameters: + signed_fragment: + rule_exclusions: + - no_positional_parameters diff --git a/src/confcom/HISTORY.rst b/src/confcom/HISTORY.rst index 8ffb3997568..80a04fc5975 100644 --- a/src/confcom/HISTORY.rst +++ b/src/confcom/HISTORY.rst @@ -3,6 +3,12 @@ Release History =============== +1.5.0 +++++++ +* restored the behaviour of --upload-fragment in acifragmentgen to attach to first image in input +* added --push-fragment-to flag to acifragmentgen to allow explicit uploading of standalone fragments +* added --attach-fragment-to flag to acifragmentgen to allow explicit uploading of image attached fragments + 1.4.5 ++++++ * Drop the dependency on OPA diff --git a/src/confcom/azext_confcom/_help.py b/src/confcom/azext_confcom/_help.py index 15368cc61db..9817bef723e 100644 --- a/src/confcom/azext_confcom/_help.py +++ b/src/confcom/azext_confcom/_help.py @@ -278,3 +278,46 @@ - name: Input a Kubernetes YAML file with a custom containerd socket path text: az confcom katapolicygen --yaml "./pod.json" --containerd-pull --containerd-socket-path "/my/custom/containerd.sock" """ + +helps[ + "confcom fragment" +] = """ + type: group + short-summary: Commands to handle Confidential Container Policy Fragments. +""" + +helps[ + "confcom fragment push" +] = """ + type: command + short-summary: Push a Confidential Container Policy Fragment to an ORAS registry + + parameters: + - name: --manifest-tag + type: string + short-summary: 'The reference to push the signed fragment to' + + examples: + - name: Push a signed fragment to a registry + text: az confcom fragment push ./fragment.reg.cose --manifest-tag myregistry.azurecr.io/fragment:latest + - name: Push 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 push --manifest-tag myregistry.azurecr.io/fragment:latest +""" + +helps[ + "confcom fragment attach" +] = """ + type: command + short-summary: Attach a Confidential Container Policy Fragment to an image in an ORAS registry. + + parameters: + - name: --manifest-tag + type: string + short-summary: 'The reference to attach the signed fragment to' + + examples: + - name: Attach a signed fragment to a registry + text: az confcom fragment attach ./fragment.reg.cose --manifest-tag myregistry.azurecr.io/image:latest + - 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 +""" diff --git a/src/confcom/azext_confcom/_params.py b/src/confcom/azext_confcom/_params.py index ccbea8d0091..d75ce70abc1 100644 --- a/src/confcom/azext_confcom/_params.py +++ b/src/confcom/azext_confcom/_params.py @@ -5,6 +5,8 @@ # pylint: disable=line-too-long import json +import argparse +import sys from knack.arguments import CLIArgumentType from azext_confcom._validators import ( validate_params_file, @@ -44,6 +46,32 @@ def load_arguments(self, _): c.argument("tags", tags_type) c.argument("confcom_name", confcom_name_type, options_list=["--name", "-n"]) + with self.argument_context("confcom fragment attach") as c: + c.positional( + "signed_fragment", + nargs='?', + type=argparse.FileType('rb'), + default=sys.stdin.buffer, + help="Signed fragment to attach", + ) + c.argument( + "manifest_tag", + help="Manifest tag for the fragment", + ) + + with self.argument_context("confcom fragment push") as c: + c.positional( + "signed_fragment", + nargs='?', + type=argparse.FileType('rb'), + default=sys.stdin.buffer, + help="Signed fragment to push", + ) + c.argument( + "manifest_tag", + help="Manifest tag for the fragment", + ) + with self.argument_context("confcom acipolicygen") as c: c.argument( "input_path", @@ -362,6 +390,13 @@ def load_arguments(self, _): type=json.loads, help='Container definitions to include in the policy' ) + c.argument( + "out_signed_fragment", + action="store_true", + default=False, + required=False, + help="Emit only the signed fragment bytes", + ) with self.argument_context("confcom katapolicygen") as c: c.argument( diff --git a/src/confcom/azext_confcom/command/fragment_attach.py b/src/confcom/azext_confcom/command/fragment_attach.py new file mode 100644 index 00000000000..39f29ae48da --- /dev/null +++ b/src/confcom/azext_confcom/command/fragment_attach.py @@ -0,0 +1,46 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import subprocess +import tempfile +from typing import BinaryIO + + +def oras_attach( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + subprocess.run( + [ + "oras", + "attach", + "--artifact-type", "application/x-ms-ccepolicy-frag", + manifest_tag, + os.path.relpath(signed_fragment.name, start=os.getcwd()), + ], + check=True, + timeout=120, + ) + + +def fragment_attach( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + + if signed_fragment.name == "": + with tempfile.NamedTemporaryFile(delete=True) as temp_signed_fragment: + temp_signed_fragment.write(signed_fragment.read()) + temp_signed_fragment.flush() + oras_attach( + signed_fragment=temp_signed_fragment, + manifest_tag=manifest_tag, + ) + else: + oras_attach( + signed_fragment=signed_fragment, + manifest_tag=manifest_tag, + ) diff --git a/src/confcom/azext_confcom/command/fragment_push.py b/src/confcom/azext_confcom/command/fragment_push.py new file mode 100644 index 00000000000..89912c87637 --- /dev/null +++ b/src/confcom/azext_confcom/command/fragment_push.py @@ -0,0 +1,46 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import subprocess +import tempfile +from typing import BinaryIO + + +def oras_push( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + subprocess.run( + [ + "oras", + "push", + "--artifact-type", "application/x-ms-ccepolicy-frag", + manifest_tag, + os.path.relpath(signed_fragment.name, start=os.getcwd()), + ], + check=True, + timeout=120, + ) + + +def fragment_push( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + + if signed_fragment.name == "": + with tempfile.NamedTemporaryFile(delete=True) as temp_signed_fragment: + temp_signed_fragment.write(signed_fragment.read()) + temp_signed_fragment.flush() + oras_push( + signed_fragment=temp_signed_fragment, + manifest_tag=manifest_tag, + ) + else: + oras_push( + signed_fragment=signed_fragment, + manifest_tag=manifest_tag, + ) diff --git a/src/confcom/azext_confcom/commands.py b/src/confcom/azext_confcom/commands.py index 1d2bb45f724..7e1e93eabca 100644 --- a/src/confcom/azext_confcom/commands.py +++ b/src/confcom/azext_confcom/commands.py @@ -11,5 +11,9 @@ def load_command_table(self, _): g.custom_command("acifragmentgen", "acifragmentgen_confcom") g.custom_command("katapolicygen", "katapolicygen_confcom") + with self.command_group("confcom fragment") as g: + g.custom_command("attach", "fragment_attach", is_preview=True) + g.custom_command("push", "fragment_push", is_preview=True) + with self.command_group("confcom"): pass diff --git a/src/confcom/azext_confcom/custom.py b/src/confcom/azext_confcom/custom.py index 2f90c796bbd..045e1743dd6 100644 --- a/src/confcom/azext_confcom/custom.py +++ b/src/confcom/azext_confcom/custom.py @@ -5,7 +5,7 @@ import os import sys -from typing import Optional +from typing import Optional, BinaryIO from azext_confcom import oras_proxy, os_util, security_policy from azext_confcom._validators import resolve_stdio @@ -22,6 +22,8 @@ get_image_name, inject_policy_into_template, inject_policy_into_yaml, pretty_print_func, print_existing_policy_from_arm_template, 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 knack.log import get_logger from pkg_resources import parse_version @@ -255,6 +257,7 @@ def acifragmentgen_confcom( upload_fragment: bool = False, no_print: bool = False, fragments_json: str = "", + out_signed_fragment: bool = False, ): if container_definitions is None: container_definitions = [] @@ -361,12 +364,16 @@ def acifragmentgen_confcom( fragment_text = policy.generate_fragment(namespace, svn, output_type, omit_id=omit_id) - if output_type != security_policy.OutputType.DEFAULT and not no_print: + if output_type != security_policy.OutputType.DEFAULT and not no_print and not out_signed_fragment: print(fragment_text) # take ".rego" off the end of the filename if it's there, it'll get added back later output_filename = output_filename.replace(".rego", "") filename = f"{output_filename or namespace}.rego" + + if out_signed_fragment: + filename = os.path.join("/tmp", filename) + os_util.write_str_to_file(filename, fragment_text) if key: @@ -374,11 +381,22 @@ def acifragmentgen_confcom( iss = cose_proxy.create_issuer(chain) out_path = filename + ".cose" + if out_signed_fragment: + out_path = os.path.join("/tmp", os.path.basename(out_path)) + cose_proxy.cose_sign(filename, key, chain, feed, iss, algo, out_path) - if upload_fragment and image_target: - oras_proxy.attach_fragment_to_image(image_target, out_path) - elif upload_fragment: - oras_proxy.push_fragment_to_registry(feed, out_path) + + # Preserve default behaviour established since version 1.1.0 of attaching + # the fragment to the first image specified in input + # (or --image-target if specified) + if upload_fragment: + oras_proxy.attach_fragment_to_image( + image_name=image_target or policy_images[0].containerImage, + filename=out_path, + ) + + if out_signed_fragment: + sys.stdout.buffer.write(open(out_path, "rb").read()) def katapolicygen_confcom( @@ -512,3 +530,23 @@ def get_fragment_output_type(outraw): if outraw: output_type = security_policy.OutputType.RAW return output_type + + +def fragment_attach( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + _fragment_attach( + signed_fragment=signed_fragment, + manifest_tag=manifest_tag + ) + + +def fragment_push( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + _fragment_push( + signed_fragment=signed_fragment, + manifest_tag=manifest_tag + ) diff --git a/src/confcom/azext_confcom/data/genpolicy-settings.json b/src/confcom/azext_confcom/data/genpolicy-settings.json new file mode 100644 index 00000000000..73d9c1125bb --- /dev/null +++ b/src/confcom/azext_confcom/data/genpolicy-settings.json @@ -0,0 +1,338 @@ +{ + "pause_container": { + "Root": { + "Path": "$(cpath)/$(bundle-id)", + "Readonly": true + }, + "Mounts": [ + { + "destination": "/dev/shm", + "type_": "bind", + "source": "/run/kata-containers/sandbox/shm", + "options": [ + "rbind" + ] + }, + { + "destination": "/etc/resolv.conf", + "type_": "bind", + "options": [ + "rbind", + "ro", + "nosuid", + "nodev", + "noexec" + ] + } + ], + "Annotations": { + "io.kubernetes.cri.container-type": "sandbox", + "io.kubernetes.cri.sandbox-id": "^[a-z0-9]{64}$", + "io.kubernetes.cri.sandbox-log-directory": "^/var/log/pods/$(sandbox-namespace)_$(sandbox-name)_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + "io.katacontainers.pkg.oci.container_type": "pod_sandbox", + "io.kubernetes.cri.sandbox-namespace": "default", + "io.katacontainers.pkg.oci.bundle_path": "/run/containerd/io.containerd.runtime.v2.task/k8s.io/$(bundle-id)" + }, + "Process": { + "Args": [ + "/pause" + ] + }, + "Linux": { + "MaskedPaths": [ + "/proc/acpi", + "/proc/asound", + "/proc/kcore", + "/proc/keys", + "/proc/latency_stats", + "/proc/timer_list", + "/proc/timer_stats", + "/proc/sched_debug", + "/sys/firmware", + "/proc/scsi" + ], + "ReadonlyPaths": [ + "/proc/bus", + "/proc/fs", + "/proc/irq", + "/proc/sys", + "/proc/sysrq-trigger" + ] + } + }, + "other_container": { + "Root": { + "Path": "$(cpath)/$(bundle-id)" + }, + "Mounts": [ + { + "destination": "/etc/hosts", + "type_": "bind", + "options": [ + "rbind", + "rprivate", + "rw" + ] + }, + { + "destination": "/dev/termination-log", + "type_": "bind", + "options": [ + "rbind", + "rprivate", + "rw" + ] + }, + { + "destination": "/etc/hostname", + "type_": "bind", + "options": [ + "rbind", + "rprivate" + ] + }, + { + "destination": "/etc/resolv.conf", + "type_": "bind", + "options": [ + "rbind", + "rprivate" + ] + }, + { + "destination": "/dev/shm", + "type_": "bind", + "source": "/run/kata-containers/sandbox/shm", + "options": [ + "rbind" + ] + }, + { + "destination": "/var/run/secrets/kubernetes.io/serviceaccount", + "type_": "bind", + "options": [ + "rbind", + "rprivate", + "ro" + ] + }, + { + "destination": "/var/run/secrets/azure/tokens", + "source": "$(sfprefix)tokens$", + "type_": "bind", + "options": [ + "rbind", + "rprivate", + "ro" + ] + } + ], + "Annotations": { + "io.katacontainers.pkg.oci.bundle_path": "/run/containerd/io.containerd.runtime.v2.task/k8s.io/$(bundle-id)", + "io.kubernetes.cri.sandbox-id": "^[a-z0-9]{64}$", + "io.katacontainers.pkg.oci.container_type": "pod_container", + "io.kubernetes.cri.container-type": "container" + } + }, + "volumes": { + "emptyDir": { + "mount_type": "local", + "mount_source": "^$(cpath)/$(sandbox-id)/local/", + "mount_point": "^$(cpath)/$(sandbox-id)/local/", + "driver": "local", + "source": "local", + "fstype": "local", + "options": [ + "mode=0777" + ] + }, + "emptyDir_memory": { + "mount_type": "bind", + "mount_source": "^/run/kata-containers/sandbox/ephemeral/", + "mount_point": "^/run/kata-containers/sandbox/ephemeral/", + "driver": "ephemeral", + "source": "tmpfs", + "fstype": "tmpfs", + "options": [] + }, + "configMap": { + "mount_type": "bind", + "mount_source": "$(sfprefix)", + "mount_point": "^$(cpath)/watchable/$(bundle-id)-[a-z0-9]{16}-", + "driver": "watchable-bind", + "fstype": "bind", + "options": [ + "rbind", + "rprivate", + "ro" + ] + }, + "confidential_configMap": { + "mount_type": "bind", + "mount_source": "$(sfprefix)", + "mount_point": "$(sfprefix)", + "driver": "local", + "fstype": "bind", + "options": [ + "rbind", + "rprivate", + "ro" + ] + } + }, + "mount_destinations": [ + "/sys/fs/cgroup", + "/etc/hosts", + "/dev/termination-log", + "/etc/hostname", + "/etc/resolv.conf", + "/dev/shm", + "/var/run/secrets/kubernetes.io/serviceaccount", + "/var/run/secrets/azure/tokens" + ], + "sandbox": { + "storages": [ + { + "driver": "ephemeral", + "driver_options": [], + "source": "shm", + "fstype": "tmpfs", + "options": [ + "noexec", + "nosuid", + "nodev", + "mode=1777", + "size=67108864" + ], + "mount_point": "/run/kata-containers/sandbox/shm", + "fs_group": null + } + ] + }, + "common": { + "cpath": "/run/kata-containers/shared/containers", + "sfprefix": "^$(cpath)/$(bundle-id)-[a-z0-9]{16}-", + "spath": "/run/kata-containers/sandbox/storage", + "ip_p": "[0-9]{1,5}", + "ipv4_a": "((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}", + "svc_name": "[A-Z0-9_\\.\\-]+", + "dns_label": "[a-zA-Z0-9_\\.\\-]+", + "s_source1": "^..2[0-9]{3}_[0-1][0-9]_[0-3][0-9]_[0-2][0-9]_[0-5][0-9]_[0-5][0-9]\\.[0-9]{1,10}$", + "s_source2": "^..data/", + "default_caps": [ + "CAP_CHOWN", + "CAP_DAC_OVERRIDE", + "CAP_FSETID", + "CAP_FOWNER", + "CAP_MKNOD", + "CAP_NET_RAW", + "CAP_SETGID", + "CAP_SETUID", + "CAP_SETFCAP", + "CAP_SETPCAP", + "CAP_NET_BIND_SERVICE", + "CAP_SYS_CHROOT", + "CAP_KILL", + "CAP_AUDIT_WRITE" + ], + "privileged_caps": [ + "CAP_CHOWN", + "CAP_DAC_OVERRIDE", + "CAP_DAC_READ_SEARCH", + "CAP_FOWNER", + "CAP_FSETID", + "CAP_KILL", + "CAP_SETGID", + "CAP_SETUID", + "CAP_SETPCAP", + "CAP_LINUX_IMMUTABLE", + "CAP_NET_BIND_SERVICE", + "CAP_NET_BROADCAST", + "CAP_NET_ADMIN", + "CAP_NET_RAW", + "CAP_IPC_LOCK", + "CAP_IPC_OWNER", + "CAP_SYS_MODULE", + "CAP_SYS_RAWIO", + "CAP_SYS_CHROOT", + "CAP_SYS_PTRACE", + "CAP_SYS_PACCT", + "CAP_SYS_ADMIN", + "CAP_SYS_BOOT", + "CAP_SYS_NICE", + "CAP_SYS_RESOURCE", + "CAP_SYS_TIME", + "CAP_SYS_TTY_CONFIG", + "CAP_MKNOD", + "CAP_LEASE", + "CAP_AUDIT_WRITE", + "CAP_AUDIT_CONTROL", + "CAP_SETFCAP", + "CAP_MAC_OVERRIDE", + "CAP_MAC_ADMIN", + "CAP_SYSLOG", + "CAP_WAKE_ALARM", + "CAP_BLOCK_SUSPEND", + "CAP_AUDIT_READ", + "CAP_PERFMON", + "CAP_BPF", + "CAP_CHECKPOINT_RESTORE" + ], + "virtio_blk_storage_classes": [ + "cc-local-csi", + "cc-managed-csi", + "cc-managed-premium-csi" + ], + "smb_storage_classes": [ + { + "name": "azurefile-csi-kata-cc", + "mount_options": [ + "dir_mode=0777", + "file_mode=0777", + "mfsymlinks", + "cache=strict", + "nosharesock", + "actimeo=30", + "nobrl" + ] + } + ] + }, + "kata_config": { + "confidential_guest": true + }, + "cluster_config": { + "default_namespace": "default" + }, + "request_defaults": { + "CreateContainerRequest": { + "allow_env_regex": [ + "^HOSTNAME=$(dns_label)$", + "^$(svc_name)_PORT_$(ip_p)_TCP=tcp://$(ipv4_a):$(ip_p)$", + "^$(svc_name)_PORT_$(ip_p)_TCP_PROTO=tcp$", + "^$(svc_name)_PORT_$(ip_p)_TCP_PORT=$(ip_p)$", + "^$(svc_name)_PORT_$(ip_p)_TCP_ADDR=$(ipv4_a)$", + "^$(svc_name)_SERVICE_HOST=$(ipv4_a)$", + "^$(svc_name)_SERVICE_PORT=$(ip_p)$", + "^$(svc_name)_SERVICE_PORT_$(dns_label)=$(ip_p)$", + "^$(svc_name)_PORT=tcp://$(ipv4_a):$(ip_p)$", + "^AZURE_CLIENT_ID=[A-Fa-f0-9-]*$", + "^AZURE_TENANT_ID=[A-Fa-f0-9-]*$", + "^AZURE_FEDERATED_TOKEN_FILE=/var/run/secrets/azure/tokens/azure-identity-token$", + "^AZURE_AUTHORITY_HOST=https://login\\.microsoftonline\\.com/$", + "^TERM=xterm$" + ] + }, + "CopyFileRequest": [ + "$(sfprefix)" + ], + "ExecProcessRequest": { + "commands": [], + "regex": [] + }, + "CloseStdinRequest": false, + "ReadStreamRequest": true, + "UpdateEphemeralMountsRequest": false, + "WriteStreamRequest": false + } +} diff --git a/src/confcom/azext_confcom/data/rules.rego b/src/confcom/azext_confcom/data/rules.rego index a5208cf9d3b..4e4c3b3e03d 100644 --- a/src/confcom/azext_confcom/data/rules.rego +++ b/src/confcom/azext_confcom/data/rules.rego @@ -54,6 +54,7 @@ default AllowRequestsFailingPolicy := false # Constants S_NAME_KEY = "io.kubernetes.cri.sandbox-name" S_NAMESPACE_KEY = "io.kubernetes.cri.sandbox-namespace" +BUNDLE_ID = "[a-z0-9]{64}" CreateContainerRequest { # Check if the input request should be rejected even before checking the @@ -468,6 +469,9 @@ allow_by_bundle_or_sandbox_id(p_oci, i_oci, p_storages, i_storages) { bundle_path := i_oci.Annotations["io.katacontainers.pkg.oci.bundle_path"] bundle_id := replace(bundle_path, "/run/containerd/io.containerd.runtime.v2.task/k8s.io/", "") + bundle_id_format := concat("", ["^", BUNDLE_ID, "$"]) + regex.match(bundle_id_format, bundle_id) + key := "io.kubernetes.cri.sandbox-id" p_regex := p_oci.Annotations[key] @@ -1226,7 +1230,7 @@ CopyFileRequest { some regex1 in policy_data.request_defaults.CopyFileRequest regex2 := replace(regex1, "$(sfprefix)", policy_data.common.sfprefix) regex3 := replace(regex2, "$(cpath)", policy_data.common.cpath) - regex4 := replace(regex3, "$(bundle-id)", "[a-z0-9]{64}") + regex4 := replace(regex3, "$(bundle-id)", BUNDLE_ID) print("CopyFileRequest: regex4 =", regex4) regex.match(regex4, input.path) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py new file mode 100644 index 00000000000..5426e8ec707 --- /dev/null +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py @@ -0,0 +1,252 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import contextlib +import io +import json +import os +import subprocess +import tempfile +import pytest + +from azext_confcom.custom import acifragmentgen_confcom, fragment_push, fragment_attach + +TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), "..")) +SAMPLES_DIR = os.path.abspath(os.path.join(TEST_DIR, "..", "..", "..", "samples")) + + +@pytest.fixture() +def docker_image(): + + registry_id = subprocess.run( + ["docker", "run", "-d", "-p", "0:5000", "registry:2"], + stdout=subprocess.PIPE, + text=True, + ).stdout + + registry_port = subprocess.run( + ["docker", "port", registry_id], + stdout=subprocess.PIPE, + text=True, + ).stdout.split(":")[-1].strip() + + test_container_ref = f"localhost:{registry_port}/hello-world:latest" + subprocess.run(["docker", "pull", "hello-world"]) + subprocess.run(["docker", "tag", "hello-world", test_container_ref]) + subprocess.run(["docker", "push", test_container_ref]) + + with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", delete=True) as temp_file: + json.dump({ + "version": "1.0.0", + "containers": [ + { + "name": "hello-world", + "properties": { + "image": test_container_ref, + }, + } + ] + }, temp_file) + temp_file.flush() + + yield test_container_ref, temp_file.name + + subprocess.run(["docker", "stop", registry_id]) + + +@pytest.fixture(scope="session") +def cert_chain(): + with tempfile.TemporaryDirectory() as temp_dir: + subprocess.run( + [ + os.path.join(SAMPLES_DIR, "certs", "create_certchain.sh"), + temp_dir + ], + check=True, + ) + yield temp_dir + + +def test_acifragmentgen_fragment_gen(docker_image): + + image_ref, spec_file_path = docker_image + + with tempfile.TemporaryDirectory() as temp_dir: # Prevent test writing files to repo + acifragmentgen_confcom( + image_name=None, + tar_mapping_location=None, + key=None, + chain=None, + minimum_svn=None, + input_path=spec_file_path, + svn="1", + namespace="contoso", + feed="test-feed", + outraw=True, + output_filename=os.path.join(temp_dir, "fragment.rego"), + out_signed_fragment=False, + ) + + # TODO: Implement a proper validation for the fragment, this is hard + # because each test run will have a unique image to have unique local + # registries on different ports + + +def test_acifragmentgen_fragment_sign(docker_image, cert_chain): + + image_ref, spec_file_path = docker_image + + with tempfile.TemporaryDirectory() as temp_dir: # Prevent test writing files to repo + acifragmentgen_confcom( + image_name=None, + tar_mapping_location=None, + key=os.path.join(cert_chain, "intermediateCA", "private", "ec_p384_private.pem"), + chain=os.path.join(cert_chain, "intermediateCA", "certs", "www.contoso.com.chain.cert.pem"), + minimum_svn=None, + input_path=spec_file_path, + svn="1", + namespace="contoso", + feed="test-feed", + outraw=True, + output_filename=os.path.join(temp_dir, "fragment.rego"), + out_signed_fragment=False, + ) + + # TODO: Implement a proper validation for the cose document + + +def test_acifragmentgen_fragment_upload_fragment(docker_image, cert_chain): + + image_ref, spec_file_path = docker_image + + with tempfile.TemporaryDirectory() as temp_dir: # Prevent test writing files to repo + acifragmentgen_confcom( + image_name=None, + tar_mapping_location=None, + key=os.path.join(cert_chain, "intermediateCA", "private", "ec_p384_private.pem"), + chain=os.path.join(cert_chain, "intermediateCA", "certs", "www.contoso.com.chain.cert.pem"), + minimum_svn=None, + input_path=spec_file_path, + svn="1", + namespace="contoso", + feed="test-feed", + outraw=True, + upload_fragment=True, + output_filename=os.path.relpath(os.path.join(temp_dir, "fragment.rego"), os.getcwd()), # Must be relative for oras + out_signed_fragment=False, + ) + + # Confirm the fragment exists and is attached in the registry + oras_result = json.loads(subprocess.run( + ["oras", "discover", image_ref, "--format", "json"], + stdout=subprocess.PIPE, + check=True, + ).stdout) + + if "referrers" in oras_result: + fragment_ref = oras_result["referrers"][0]["reference"] + elif oras_result.get("manifests")[0].get("artifactType") == "application/x-ms-ccepolicy-frag": + fragment_ref = oras_result["manifests"][0]["reference"] + else: + raise AssertionError(f"{oras_result=}") + + fragment_path = json.loads(subprocess.run( + ["oras", "pull", fragment_ref, "--format", "json", "-o", "/tmp"], + check=True, + stdout=subprocess.PIPE, + ).stdout)["files"][0]["path"] + + + with open(fragment_path, "rb") as actual_fragment_file: + with open(os.path.join(temp_dir, "fragment.rego.cose"), "rb") as expected_fragment_file: + assert actual_fragment_file.read() == expected_fragment_file.read() + + +def test_acifragmentgen_fragment_push(docker_image, cert_chain, capsysbinary): + + image_ref, spec_file_path = docker_image + fragment_ref = image_ref.replace("hello-world", "fragment") + + acifragmentgen_confcom( + image_name=None, + tar_mapping_location=None, + key=os.path.join(cert_chain, "intermediateCA", "private", "ec_p384_private.pem"), + chain=os.path.join(cert_chain, "intermediateCA", "certs", "www.contoso.com.chain.cert.pem"), + minimum_svn=None, + input_path=spec_file_path, + svn="1", + namespace="contoso", + feed="test-feed", + out_signed_fragment=True, + ) + + signed_fragment = capsysbinary.readouterr()[0] + signed_fragment_io = io.BytesIO(signed_fragment) + signed_fragment_io.name = "" + + fragment_push( + signed_fragment=signed_fragment_io, + manifest_tag=fragment_ref, + ) + + # Confirm the fragment exists in the registry + fragment_path = json.loads(subprocess.run( + ["oras", "pull", fragment_ref, "--format", "json", "-o", "/tmp"], + check=True, + stdout=subprocess.PIPE, + ).stdout)["files"][0]["path"] + + with open(fragment_path, "rb") as f: + assert f.read() == signed_fragment + + +def test_acifragmentgen_fragment_attach(docker_image, cert_chain, capsysbinary): + + image_ref, spec_file_path = docker_image + + acifragmentgen_confcom( + image_name=None, + tar_mapping_location=None, + key=os.path.join(cert_chain, "intermediateCA", "private", "ec_p384_private.pem"), + chain=os.path.join(cert_chain, "intermediateCA", "certs", "www.contoso.com.chain.cert.pem"), + minimum_svn=None, + input_path=spec_file_path, + svn="1", + namespace="contoso", + feed="test-feed", + out_signed_fragment=True, + ) + + signed_fragment = capsysbinary.readouterr()[0] + signed_fragment_io = io.BytesIO(signed_fragment) + signed_fragment_io.name = "" + + fragment_attach( + signed_fragment=signed_fragment_io, + manifest_tag=image_ref, + ) + + # Confirm the fragment exists and is attached in the registry + oras_result = json.loads(subprocess.run( + ["oras", "discover", image_ref, "--format", "json"], + stdout=subprocess.PIPE, + check=True, + ).stdout) + + if "referrers" in oras_result: + fragment_ref = oras_result["referrers"][0]["reference"] + elif oras_result["manifests"][0].get("artifactType") == "application/x-ms-ccepolicy-frag": + fragment_ref = oras_result["manifests"][0]["reference"] + else: + raise AssertionError(f"{oras_result=}") + + fragment_path = json.loads(subprocess.run( + ["oras", "pull", fragment_ref, "--format", "json", "-o", "/tmp"], + check=True, + stdout=subprocess.PIPE, + ).stdout)["files"][0]["path"] + + with open(fragment_path, "rb") as f: + assert f.read() == signed_fragment diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_arm.py b/src/confcom/azext_confcom/tests/latest/test_confcom_arm.py index cd5f0be05f6..c75d1aa3e8a 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_arm.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_arm.py @@ -3,10 +3,13 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import fcntl import os import unittest import json import deepdiff +import docker +import requests from unittest.mock import patch from azext_confcom.security_policy import ( @@ -24,6 +27,7 @@ ) TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), "..")) +PRUNE_LOCK_PATH = "/tmp/confcom_docker_prune.lock" class PolicyGeneratingArm(unittest.TestCase): @@ -5007,7 +5011,18 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): - cls.client.containers.prune() + # Coordinate cleanup across xdist workers to avoid prune conflicts. + with open(PRUNE_LOCK_PATH, "w") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + try: + cls.client.containers.prune() + except (docker.errors.APIError, requests.exceptions.ReadTimeout) as exc: + # Ignore conflicts (another prune in flight) or slow daemon timeouts. + status = getattr(getattr(exc, "response", None), "status_code", None) + if status not in (409, None) or not isinstance(exc, requests.exceptions.ReadTimeout): + raise + finally: + fcntl.flock(lock_file, fcntl.LOCK_UN) cls.client.close() def test_arm_template_security_context_no_run_as_group(self): diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py b/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py index 2725ede31c0..66102f151da 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py @@ -5,6 +5,7 @@ import json import os +from pathlib import Path import subprocess import tempfile import time @@ -493,7 +494,7 @@ def test_tar_file_fragment(self): try: with tempfile.TemporaryDirectory() as folder: filename = os.path.join(folder, "oci.tar") - filename2 = os.path.join(self.path, "oci2.tar") + filename2 = os.path.join(folder, "oci2.tar") tar_mapping_file = {"mcr.microsoft.com/aks/e2e/library-busybox:master.220314.1-linux-amd64": filename2} create_tar_file(filename) @@ -762,14 +763,16 @@ class FragmentPolicySigning(unittest.TestCase): """ @classmethod def setUpClass(cls): - cls.key_dir_parent = os.path.join(SAMPLES_DIR, 'certs') + cls.key_dir_parent = Path(tempfile.gettempdir(), "certchain") + cls.key_dir_parent.mkdir(parents=True, exist_ok=True) cls.key = os.path.join(cls.key_dir_parent, 'intermediateCA', 'private', 'ec_p384_private.pem') cls.chain = os.path.join(cls.key_dir_parent, 'intermediateCA', 'certs', 'www.contoso.com.chain.cert.pem') if not os.path.exists(cls.key) or not os.path.exists(cls.chain): - script_path = os.path.join(cls.key_dir_parent, 'create_certchain.sh') + script_path = os.path.join(SAMPLES_DIR, "certs", 'create_certchain.sh') arg_list = [ script_path, + cls.key_dir_parent.as_posix(), ] os.chmod(script_path, 0o755) @@ -777,8 +780,7 @@ def setUpClass(cls): item = subprocess.run( arg_list, check=False, - shell=True, - cwd=cls.key_dir_parent, + shell=False, env=os.environ.copy(), ) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py b/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py index ab2733745f5..1da2de3e90a 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py @@ -175,7 +175,7 @@ def test_oci_tar_file(self): try: with tempfile.TemporaryDirectory() as folder: filename = os.path.join(folder, "oci.tar") - filename2 = os.path.join(self.path, "oci2.tar") + filename2 = os.path.join(folder, "oci2.tar") tar_mapping_file = {"mcr.microsoft.com/aks/e2e/library-busybox:master.220314.1-linux-amd64": filename2} create_tar_file(filename) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_virtual_node.py b/src/confcom/azext_confcom/tests/latest/test_confcom_virtual_node.py index c6e8ad4a23a..2c6a3ad8766 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_virtual_node.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_virtual_node.py @@ -4,6 +4,8 @@ # -------------------------------------------------------------------------------------------- import os +from pathlib import Path +import tempfile import unittest import json import subprocess @@ -22,6 +24,7 @@ ) TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), "..")) +SAMPLES_DIR = os.path.abspath(os.path.join(TEST_DIR, "..", "..", "..", "samples")) class PolicyGeneratingVirtualNode(unittest.TestCase): @@ -338,17 +341,19 @@ class PolicyGeneratingVirtualNode(unittest.TestCase): ports: - containerPort: 80 name: web -""" + """ @classmethod def setUpClass(cls): - cls.key_dir_parent = os.path.join(TEST_DIR, '..', '..', '..', 'samples', 'certs') + cls.key_dir_parent = Path(tempfile.gettempdir(), "certchain") + cls.key_dir_parent.mkdir(parents=True, exist_ok=True) cls.key = os.path.join(cls.key_dir_parent, 'intermediateCA', 'private', 'ec_p384_private.pem') cls.chain = os.path.join(cls.key_dir_parent, 'intermediateCA', 'certs', 'www.contoso.com.chain.cert.pem') if not os.path.exists(cls.key) or not os.path.exists(cls.chain): - script_path = os.path.join(cls.key_dir_parent, 'create_certchain.sh') + script_path = os.path.join(SAMPLES_DIR, "certs", 'create_certchain.sh') arg_list = [ script_path, + cls.key_dir_parent.as_posix(), ] os.chmod(script_path, 0o755) @@ -356,8 +361,7 @@ def setUpClass(cls): item = subprocess.run( arg_list, check=False, - shell=True, - cwd=cls.key_dir_parent, + shell=False, env=os.environ.copy(), ) @@ -534,4 +538,4 @@ def test_custom_args(self): containers = json.loads(extract_containers_from_text(virtual_node_policy.get_serialized_output(OutputType.PRETTY_PRINT), container_start)) command = containers[0].get("command") - self.assertEqual(command[-2:], ["test", "values"]) \ No newline at end of file + self.assertEqual(command[-2:], ["test", "values"]) diff --git a/src/confcom/samples/certs/create_certchain.sh b/src/confcom/samples/certs/create_certchain.sh index 5e94f4c6f4e..48575efef3f 100755 --- a/src/confcom/samples/certs/create_certchain.sh +++ b/src/confcom/samples/certs/create_certchain.sh @@ -3,87 +3,91 @@ OriginalPath=`pwd` RootPath=`realpath $(dirname $0)` -cd $RootPath +OutPath=${1:-$RootPath} + +mkdir -p $OutPath + +cd $OutPath # create dirs for root CA -mkdir -p $RootPath/rootCA/{certs,crl,newcerts,private,csr} -mkdir -p $RootPath/intermediateCA/{certs,crl,newcerts,private,csr} +mkdir -p $OutPath/rootCA/{certs,crl,newcerts,private,csr} +mkdir -p $OutPath/intermediateCA/{certs,crl,newcerts,private,csr} # create index files -echo 1000 > $RootPath/rootCA/serial -echo 1000 > $RootPath/intermediateCA/serial +echo 1000 > $OutPath/rootCA/serial +echo 1000 > $OutPath/intermediateCA/serial # create crlnumbers -echo 0100 > $RootPath/rootCA/crlnumber -echo 0100 > $RootPath/intermediateCA/crlnumber +echo 0100 > $OutPath/rootCA/crlnumber +echo 0100 > $OutPath/intermediateCA/crlnumber # create index files -touch $RootPath/rootCA/index.txt -touch $RootPath/intermediateCA/index.txt +touch $OutPath/rootCA/index.txt +touch $OutPath/intermediateCA/index.txt # NOTE: needed for testing -echo "unique_subject = no" >> $RootPath/rootCA/index.txt.attr -echo "unique_subject = no" >> $RootPath/intermediateCA/index.txt.attr +echo "unique_subject = no" >> $OutPath/rootCA/index.txt.attr +echo "unique_subject = no" >> $OutPath/intermediateCA/index.txt.attr # generate root key -openssl genrsa -out $RootPath/rootCA/private/ca.key.pem 4096 -chmod 400 $RootPath/rootCA/private/ca.key.pem +openssl genrsa -out $OutPath/rootCA/private/ca.key.pem 4096 +chmod 400 $OutPath/rootCA/private/ca.key.pem # view the key -# openssl rsa -noout -text -in $RootPath/rootCA/private/ca.key.pem +# openssl rsa -noout -text -in $OutPath/rootCA/private/ca.key.pem # generate root cert -openssl req -config openssl_root.cnf -key $RootPath/rootCA/private/ca.key.pem -new -x509 -days 7300 -sha256 -extensions v3_ca -out $RootPath/rootCA/certs/ca.cert.pem -subj "/C=US/ST=Georgia/L=Atlanta/O=Microsoft/OU=ACCCT/CN=Root CA" +openssl req -config $RootPath/openssl_root.cnf -key $OutPath/rootCA/private/ca.key.pem -new -x509 -days 7300 -sha256 -extensions v3_ca -out $OutPath/rootCA/certs/ca.cert.pem -subj "/C=US/ST=Georgia/L=Atlanta/O=Microsoft/OU=ACCCT/CN=Root CA" # change permissions on root key so it's not globally readable -chmod 644 $RootPath/rootCA/certs/ca.cert.pem +chmod 644 $OutPath/rootCA/certs/ca.cert.pem # verify root cert -openssl x509 -noout -text -in $RootPath/rootCA/certs/ca.cert.pem +openssl x509 -noout -text -in $OutPath/rootCA/certs/ca.cert.pem # generate intermediate key -openssl genrsa -out $RootPath/intermediateCA/private/intermediate.key.pem 4096 -chmod 600 $RootPath/intermediateCA/private/intermediate.key.pem +openssl genrsa -out $OutPath/intermediateCA/private/intermediate.key.pem 4096 +chmod 600 $OutPath/intermediateCA/private/intermediate.key.pem # make CSR for intermediate -openssl req -config openssl_intermediate.cnf -key $RootPath/intermediateCA/private/intermediate.key.pem -new -sha256 -out $RootPath/intermediateCA/certs/intermediate.csr.pem -subj "/C=US/ST=Georgia/L=Atlanta/O=Microsoft/OU=ACCCT/CN=Intermediate CA" +openssl req -config $RootPath/openssl_intermediate.cnf -key $OutPath/intermediateCA/private/intermediate.key.pem -new -sha256 -out $OutPath/intermediateCA/certs/intermediate.csr.pem -subj "/C=US/ST=Georgia/L=Atlanta/O=Microsoft/OU=ACCCT/CN=Intermediate CA" # sign intermediate cert with root -openssl ca -config openssl_root.cnf -extensions v3_intermediate_ca -days 3650 -notext -md sha256 -in $RootPath/intermediateCA/certs/intermediate.csr.pem -out $RootPath/intermediateCA/certs/intermediate.cert.pem -batch +openssl ca -config $RootPath/openssl_root.cnf -extensions v3_intermediate_ca -days 3650 -notext -md sha256 -in $OutPath/intermediateCA/certs/intermediate.csr.pem -out $OutPath/intermediateCA/certs/intermediate.cert.pem -batch # make it readable by everyone -chmod 644 $RootPath/intermediateCA/certs/intermediate.cert.pem +chmod 644 $OutPath/intermediateCA/certs/intermediate.cert.pem # print the cert -# openssl x509 -noout -text -in $RootPath/intermediateCA/certs/intermediate.cert.pem +# openssl x509 -noout -text -in $OutPath/intermediateCA/certs/intermediate.cert.pem # verify intermediate cert -openssl verify -CAfile $RootPath/rootCA/certs/ca.cert.pem $RootPath/intermediateCA/certs/intermediate.cert.pem +openssl verify -CAfile $OutPath/rootCA/certs/ca.cert.pem $OutPath/intermediateCA/certs/intermediate.cert.pem # create chain file -cat $RootPath/intermediateCA/certs/intermediate.cert.pem $RootPath/rootCA/certs/ca.cert.pem > $RootPath/intermediateCA/certs/ca-chain.cert.pem +cat $OutPath/intermediateCA/certs/intermediate.cert.pem $OutPath/rootCA/certs/ca.cert.pem > $OutPath/intermediateCA/certs/ca-chain.cert.pem # verify chain -openssl verify -CAfile $RootPath/intermediateCA/certs/ca-chain.cert.pem $RootPath/intermediateCA/certs/intermediate.cert.pem +openssl verify -CAfile $OutPath/intermediateCA/certs/ca-chain.cert.pem $OutPath/intermediateCA/certs/intermediate.cert.pem # create server key -openssl ecparam -out $RootPath/intermediateCA/private/www.contoso.com.key.pem -name secp384r1 -genkey -openssl pkcs8 -topk8 -nocrypt -in $RootPath/intermediateCA/private/www.contoso.com.key.pem -out $RootPath/intermediateCA/private/ec_p384_private.pem +openssl ecparam -out $OutPath/intermediateCA/private/www.contoso.com.key.pem -name secp384r1 -genkey +openssl pkcs8 -topk8 -nocrypt -in $OutPath/intermediateCA/private/www.contoso.com.key.pem -out $OutPath/intermediateCA/private/ec_p384_private.pem -chmod 600 $RootPath/intermediateCA/private/www.contoso.com.key.pem +chmod 600 $OutPath/intermediateCA/private/www.contoso.com.key.pem # create csr for server -openssl req -config openssl_intermediate.cnf -key $RootPath/intermediateCA/private/www.contoso.com.key.pem -new -sha384 -out $RootPath/intermediateCA/csr/www.contoso.com.csr.pem -batch +openssl req -config $RootPath/openssl_intermediate.cnf -key $OutPath/intermediateCA/private/www.contoso.com.key.pem -new -sha384 -out $OutPath/intermediateCA/csr/www.contoso.com.csr.pem -batch # sign server cert with intermediate key -openssl ca -config openssl_intermediate.cnf -extensions server_cert -days 375 -notext -md sha384 -in $RootPath/intermediateCA/csr/www.contoso.com.csr.pem -out $RootPath/intermediateCA/certs/www.contoso.com.cert.pem -batch +openssl ca -config $RootPath/openssl_intermediate.cnf -extensions server_cert -days 375 -notext -md sha384 -in $OutPath/intermediateCA/csr/www.contoso.com.csr.pem -out $OutPath/intermediateCA/certs/www.contoso.com.cert.pem -batch # print the cert -# openssl x509 -noout -text -in $RootPath/intermediateCA/certs/www.contoso.com.cert.pem +# openssl x509 -noout -text -in $OutPath/intermediateCA/certs/www.contoso.com.cert.pem # make a public key -# openssl x509 -pubkey -noout -in $RootPath/intermediateCA/certs/www.contoso.com.cert.pem -out $RootPath/intermediateCA/certs/pubkey.pem +# openssl x509 -pubkey -noout -in $OutPath/intermediateCA/certs/www.contoso.com.cert.pem -out $OutPath/intermediateCA/certs/pubkey.pem # create chain file -cat $RootPath/intermediateCA/certs/www.contoso.com.cert.pem $RootPath/intermediateCA/certs/intermediate.cert.pem $RootPath/rootCA/certs/ca.cert.pem > $RootPath/intermediateCA/certs/www.contoso.com.chain.cert.pem +cat $OutPath/intermediateCA/certs/www.contoso.com.cert.pem $OutPath/intermediateCA/certs/intermediate.cert.pem $OutPath/rootCA/certs/ca.cert.pem > $OutPath/intermediateCA/certs/www.contoso.com.chain.cert.pem cd $OriginalPath \ No newline at end of file diff --git a/src/confcom/setup.py b/src/confcom/setup.py index 7b8c1157a0d..fe40522e879 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.4.5" +VERSION = "1.5.0" # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers From 35e560c51459bf901743e8f81c5cca6a0232c996 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Mon, 17 Nov 2025 17:50:53 +0000 Subject: [PATCH 02/14] Add containers from_image command --- linter_exclusions.yml | 18 +++++ src/confcom/azext_confcom/_help.py | 68 +++++++++++++++++++ src/confcom/azext_confcom/_params.py | 15 ++++ .../command/containers_from_image.py | 20 ++++++ src/confcom/azext_confcom/commands.py | 3 + src/confcom/azext_confcom/custom.py | 35 +++++++++- src/confcom/azext_confcom/lib/images.py | 64 +++++++++++++++++ src/confcom/azext_confcom/lib/platform.py | 17 +++++ 8 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 src/confcom/azext_confcom/command/containers_from_image.py create mode 100644 src/confcom/azext_confcom/lib/images.py create mode 100644 src/confcom/azext_confcom/lib/platform.py diff --git a/linter_exclusions.yml b/linter_exclusions.yml index ce4aaa82bca..b102955d2f3 100644 --- a/linter_exclusions.yml +++ b/linter_exclusions.yml @@ -3504,3 +3504,21 @@ neon postgres organization: neon postgres project: rule_exclusions: - require_wait_command_if_no_wait + +confcom fragment push: + parameters: + signed_fragment: + rule_exclusions: + - no_positional_parameters + +confcom fragment attach: + parameters: + 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 15368cc61db..392e9e2e666 100644 --- a/src/confcom/azext_confcom/_help.py +++ b/src/confcom/azext_confcom/_help.py @@ -278,3 +278,71 @@ - name: Input a Kubernetes YAML file with a custom containerd socket path text: az confcom katapolicygen --yaml "./pod.json" --containerd-pull --containerd-socket-path "/my/custom/containerd.sock" """ + +helps[ + "confcom fragment" +] = """ + type: group + short-summary: Commands to handle Confidential Container Policy Fragments. +""" + +helps[ + "confcom fragment push" +] = """ + type: command + short-summary: Push a Confidential Container Policy Fragment to an ORAS registry + + parameters: + - name: --manifest-tag + type: string + short-summary: 'The reference to push the signed fragment to' + + examples: + - name: Push a signed fragment to a registry + text: az confcom fragment push ./fragment.reg.cose --manifest-tag myregistry.azurecr.io/fragment:latest + - name: Push 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 push --manifest-tag myregistry.azurecr.io/fragment:latest +""" + +helps[ + "confcom fragment attach" +] = """ + type: command + short-summary: Attach a Confidential Container Policy Fragment to an image in an ORAS registry. + + parameters: + - name: --manifest-tag + type: string + short-summary: 'The reference to attach the signed fragment to' + + examples: + - name: Attach a signed fragment to a registry + text: az confcom fragment attach ./fragment.reg.cose --manifest-tag myregistry.azurecr.io/image:latest + - 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 ccbea8d0091..c6df09293f5 100644 --- a/src/confcom/azext_confcom/_params.py +++ b/src/confcom/azext_confcom/_params.py @@ -434,3 +434,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..4d3046a3733 --- /dev/null +++ b/src/confcom/azext_confcom/command/containers_from_image.py @@ -0,0 +1,20 @@ + +import json + +from azext_confcom.lib.images import get_image_layers, get_image_config +from azext_confcom.lib.platform import ACI_MOUNTS + + +def containers_from_image(image: str, platform: str) -> str: + + mounts = { + "aci": ACI_MOUNTS, + }.get(platform, None) + + return json.dumps({ + "id": image, + "name": image, + "layers": get_image_layers(image), + **({"mounts": mounts} if mounts else {}), + **get_image_config(image), + }) \ No newline at end of file diff --git a/src/confcom/azext_confcom/commands.py b/src/confcom/azext_confcom/commands.py index 1d2bb45f724..b355a854fab 100644 --- a/src/confcom/azext_confcom/commands.py +++ b/src/confcom/azext_confcom/commands.py @@ -13,3 +13,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 2f90c796bbd..e949f1fcd64 100644 --- a/src/confcom/azext_confcom/custom.py +++ b/src/confcom/azext_confcom/custom.py @@ -5,7 +5,7 @@ import os import sys -from typing import Optional +from typing import Optional, BinaryIO from azext_confcom import oras_proxy, os_util, security_policy from azext_confcom._validators import resolve_stdio @@ -22,6 +22,9 @@ get_image_name, inject_policy_into_template, inject_policy_into_yaml, pretty_print_func, print_existing_policy_from_arm_template, 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 @@ -512,3 +515,33 @@ def get_fragment_output_type(outraw): if outraw: output_type = security_policy.OutputType.RAW return output_type + + +def fragment_attach( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + _fragment_attach( + signed_fragment=signed_fragment, + manifest_tag=manifest_tag + ) + + +def fragment_push( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + _fragment_push( + signed_fragment=signed_fragment, + manifest_tag=manifest_tag + ) + + +def containers_from_image( + image: str, + platform: str, +) -> None: + print(_containers_from_image( + image=image, + platform=platform, + )) diff --git a/src/confcom/azext_confcom/lib/images.py b/src/confcom/azext_confcom/lib/images.py new file mode 100644 index 00000000000..9f3924c53f0 --- /dev/null +++ b/src/confcom/azext_confcom/lib/images.py @@ -0,0 +1,64 @@ +# -------------------------------------------------------------------------------------------- +# 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 os +import subprocess +import docker + + +@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 = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "bin", "dmverity-vhd") + + get_image(image) + result = subprocess.run( + [binary_path, "-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..395d39a1309 --- /dev/null +++ b/src/confcom/azext_confcom/lib/platform.py @@ -0,0 +1,17 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +ACI_MOUNTS = [ + { + "destination": "/etc/resolv.conf", + "options": [ + "rbind", + "rshared", + "rw" + ], + "source": "sandbox:///tmp/atlas/resolvconf/.+", + "type": "bind" + } +] From 778f3412ecbeb088ba7386fba199c95d124af743 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Tue, 18 Nov 2025 14:01:00 +0000 Subject: [PATCH 03/14] Satisfy azdev style --- src/confcom/azext_confcom/command/containers_from_image.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/confcom/azext_confcom/command/containers_from_image.py b/src/confcom/azext_confcom/command/containers_from_image.py index 4d3046a3733..f92db7f451a 100644 --- a/src/confcom/azext_confcom/command/containers_from_image.py +++ b/src/confcom/azext_confcom/command/containers_from_image.py @@ -1,3 +1,7 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- import json @@ -17,4 +21,4 @@ def containers_from_image(image: str, platform: str) -> str: "layers": get_image_layers(image), **({"mounts": mounts} if mounts else {}), **get_image_config(image), - }) \ No newline at end of file + }) From 7a351b2624afc7a59069fdd34ecf619791dbca35 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Wed, 26 Nov 2025 17:19:25 +0000 Subject: [PATCH 04/14] Add tests --- .../test_confcom_containers_from_image.py | 72 +++++++++++++++++++ src/confcom/samples/images/command/Dockerfile | 3 + .../images/command/aci_container.inc.rego | 30 ++++++++ .../images/environment_variables/Dockerfile | 3 + .../aci_container.inc.rego | 35 +++++++++ src/confcom/samples/images/minimal/Dockerfile | 1 + .../images/minimal/aci_container.inc.rego | 30 ++++++++ .../samples/images/working_dir/Dockerfile | 3 + .../images/working_dir/aci_container.inc.rego | 31 ++++++++ 9 files changed, 208 insertions(+) create mode 100644 src/confcom/azext_confcom/tests/latest/test_confcom_containers_from_image.py create mode 100644 src/confcom/samples/images/command/Dockerfile create mode 100644 src/confcom/samples/images/command/aci_container.inc.rego create mode 100644 src/confcom/samples/images/environment_variables/Dockerfile create mode 100644 src/confcom/samples/images/environment_variables/aci_container.inc.rego create mode 100644 src/confcom/samples/images/minimal/Dockerfile create mode 100644 src/confcom/samples/images/minimal/aci_container.inc.rego create mode 100644 src/confcom/samples/images/working_dir/Dockerfile create mode 100644 src/confcom/samples/images/working_dir/aci_container.inc.rego 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..ec2579e5c82 --- /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 fcntl +import hashlib +from itertools import product +import json +import os +import tempfile +import time +import uuid +from pathlib import Path + +import docker +import pytest + +from azext_confcom.command.containers_from_image import containers_from_image +from azext_confcom.lib.platform import ACI_MOUNTS +from deepdiff import DeepDiff +import portalocker + + +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) + + actual_container_def = json.loads(containers_from_image( + image=f"confcom_test_{sample_directory.name}", + platform=platform, + )) + + 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/samples/images/working_dir/Dockerfile b/src/confcom/samples/images/working_dir/Dockerfile new file mode 100644 index 00000000000..d7bf7f4a5e1 --- /dev/null +++ b/src/confcom/samples/images/working_dir/Dockerfile @@ -0,0 +1,3 @@ +FROM hello-world + +WORKDIR /home \ No newline at end of file diff --git a/src/confcom/samples/images/working_dir/aci_container.inc.rego b/src/confcom/samples/images/working_dir/aci_container.inc.rego new file mode 100644 index 00000000000..df61f863bf2 --- /dev/null +++ b/src/confcom/samples/images/working_dir/aci_container.inc.rego @@ -0,0 +1,31 @@ +{ + "id": "confcom_test_working_dir", + "name": "confcom_test_working_dir", + "layers": [ + "8b4664979ffe3c5188efbbbb30e31716c03bfe880f15f455be0fc3beb4741de9", + "eff9550216100f194b7f04d12ae70e5511ffd7360eec6b947197176701397e60" + ], + "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": "/home" +} From cd15bf61a89bb991c97cb5206be800b6aedaa259 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Thu, 27 Nov 2025 15:33:18 +0000 Subject: [PATCH 05/14] Split command lib versions of command --- .../command/containers_from_image.py | 16 ++-------- src/confcom/azext_confcom/custom.py | 4 +-- .../lib/containers_from_image.py | 22 ++++++++++++++ src/confcom/azext_confcom/rootfs_proxy.py | 2 ++ .../test_confcom_containers_from_image.py | 30 +++++++++---------- 5 files changed, 43 insertions(+), 31 deletions(-) create mode 100644 src/confcom/azext_confcom/lib/containers_from_image.py diff --git a/src/confcom/azext_confcom/command/containers_from_image.py b/src/confcom/azext_confcom/command/containers_from_image.py index f92db7f451a..b7fd451a6d4 100644 --- a/src/confcom/azext_confcom/command/containers_from_image.py +++ b/src/confcom/azext_confcom/command/containers_from_image.py @@ -5,20 +5,8 @@ import json -from azext_confcom.lib.images import get_image_layers, get_image_config -from azext_confcom.lib.platform import ACI_MOUNTS +from azext_confcom.lib.containers_from_image import containers_from_image as lib_containers_from_image def containers_from_image(image: str, platform: str) -> str: - - mounts = { - "aci": ACI_MOUNTS, - }.get(platform, None) - - return json.dumps({ - "id": image, - "name": image, - "layers": get_image_layers(image), - **({"mounts": mounts} if mounts else {}), - **get_image_config(image), - }) + return print(json.dumps(lib_containers_from_image(image, platform))) diff --git a/src/confcom/azext_confcom/custom.py b/src/confcom/azext_confcom/custom.py index e949f1fcd64..de6d0b92178 100644 --- a/src/confcom/azext_confcom/custom.py +++ b/src/confcom/azext_confcom/custom.py @@ -541,7 +541,7 @@ def containers_from_image( image: str, platform: str, ) -> None: - print(_containers_from_image( + _containers_from_image( image=image, platform=platform, - )) + ) diff --git a/src/confcom/azext_confcom/lib/containers_from_image.py b/src/confcom/azext_confcom/lib/containers_from_image.py new file mode 100644 index 00000000000..e21684984df --- /dev/null +++ b/src/confcom/azext_confcom/lib/containers_from_image.py @@ -0,0 +1,22 @@ +# -------------------------------------------------------------------------------------------- +# 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.images import get_image_layers, get_image_config +from azext_confcom.lib.platform import ACI_MOUNTS + + +def containers_from_image(image: str, platform: str) -> str: + + mounts = { + "aci": 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/rootfs_proxy.py b/src/confcom/azext_confcom/rootfs_proxy.py index f41dbd8f008..10ffba2e596 100644 --- a/src/confcom/azext_confcom/rootfs_proxy.py +++ b/src/confcom/azext_confcom/rootfs_proxy.py @@ -54,6 +54,8 @@ def download_binaries(): with open(binary_info["path"], "wb") as f: f.write(dmverity_vhd_fetch_resp.content) + os.chmod(binary_info["path"], 0o755) + def __init__(self): script_directory = os.path.dirname(os.path.realpath(__file__)) DEFAULT_LIB = "./bin/dmverity-vhd" 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 index ec2579e5c82..ff94812c09f 100644 --- 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 @@ -3,23 +3,19 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import fcntl -import hashlib -from itertools import product import json -import os import tempfile -import time -import uuid -from pathlib import Path - import docker import pytest +import portalocker -from azext_confcom.command.containers_from_image import containers_from_image -from azext_confcom.lib.platform import ACI_MOUNTS +from contextlib import redirect_stdout +from io import StringIO +from itertools import product +from pathlib import Path from deepdiff import DeepDiff -import portalocker + +from azext_confcom.command.containers_from_image import containers_from_image TEST_DIR = Path(__file__).parent @@ -59,10 +55,14 @@ def test_containers_from_image(sample_directory: str, platform: str): with expected_container_def_path.open("r", encoding="utf-8") as handle: expected_container_def = json.load(handle) - actual_container_def = json.loads(containers_from_image( - image=f"confcom_test_{sample_directory.name}", - platform=platform, - )) + 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, From 3878dc0e3681966004c2fddce2882fdb35734bb0 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Mon, 8 Dec 2025 10:12:04 +0000 Subject: [PATCH 06/14] Rename the containers lib code file --- src/confcom/azext_confcom/command/containers_from_image.py | 2 +- .../lib/{containers_from_image.py => containers.py} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/confcom/azext_confcom/lib/{containers_from_image.py => containers.py} (92%) diff --git a/src/confcom/azext_confcom/command/containers_from_image.py b/src/confcom/azext_confcom/command/containers_from_image.py index b7fd451a6d4..d43e0dd07e0 100644 --- a/src/confcom/azext_confcom/command/containers_from_image.py +++ b/src/confcom/azext_confcom/command/containers_from_image.py @@ -5,7 +5,7 @@ import json -from azext_confcom.lib.containers_from_image import containers_from_image as lib_containers_from_image +from azext_confcom.lib.containers import from_image as lib_containers_from_image def containers_from_image(image: str, platform: str) -> str: diff --git a/src/confcom/azext_confcom/lib/containers_from_image.py b/src/confcom/azext_confcom/lib/containers.py similarity index 92% rename from src/confcom/azext_confcom/lib/containers_from_image.py rename to src/confcom/azext_confcom/lib/containers.py index e21684984df..54a2398aaa8 100644 --- a/src/confcom/azext_confcom/lib/containers_from_image.py +++ b/src/confcom/azext_confcom/lib/containers.py @@ -7,7 +7,7 @@ from azext_confcom.lib.platform import ACI_MOUNTS -def containers_from_image(image: str, platform: str) -> str: +def from_image(image: str, platform: str) -> str: mounts = { "aci": ACI_MOUNTS, From 5ed2d6db8c930fa59a3d50b492ca89515d5b890f Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Mon, 8 Dec 2025 15:11:05 +0000 Subject: [PATCH 07/14] Build images with buildkit for deterministic hashes --- .../test_confcom_containers_from_image.py | 28 +++++++++++++------ .../images/working_dir/aci_container.inc.rego | 2 +- 2 files changed, 20 insertions(+), 10 deletions(-) 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 index ff94812c09f..f2d880f69c0 100644 --- 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 @@ -4,8 +4,9 @@ # -------------------------------------------------------------------------------------------- import json +import os +import subprocess import tempfile -import docker import pytest import portalocker @@ -23,18 +24,27 @@ 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() + env = os.environ.copy() + env["DOCKER_BUILDKIT"] = "1" + 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, + subprocess.run( + [ + "docker", + "build", + str(image_sample), + "-t", + f"confcom_test_{image_sample.name}", + "--quiet", + ], + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, ) yield @@ -69,4 +79,4 @@ def test_containers_from_image(sample_directory: str, platform: str): expected_container_def, ignore_order=True, ) - assert diff == {}, diff \ No newline at end of file + assert diff == {}, diff diff --git a/src/confcom/samples/images/working_dir/aci_container.inc.rego b/src/confcom/samples/images/working_dir/aci_container.inc.rego index df61f863bf2..d1fc0770bae 100644 --- a/src/confcom/samples/images/working_dir/aci_container.inc.rego +++ b/src/confcom/samples/images/working_dir/aci_container.inc.rego @@ -3,7 +3,7 @@ "name": "confcom_test_working_dir", "layers": [ "8b4664979ffe3c5188efbbbb30e31716c03bfe880f15f455be0fc3beb4741de9", - "eff9550216100f194b7f04d12ae70e5511ffd7360eec6b947197176701397e60" + "1c4128b7270b18b052aff3f68a5611873057aa0b9ce3acfbf273494e67c63254" ], "mounts": [ { From e19667b2baedbe0f6db49638b47308da35909f7c Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Mon, 8 Dec 2025 15:50:44 +0000 Subject: [PATCH 08/14] Revert "Build images with buildkit for deterministic hashes" This reverts commit e8f7637d8d3e52a12f75359524f3682ac5df601e. --- .../test_confcom_containers_from_image.py | 28 ++++++------------- .../images/working_dir/aci_container.inc.rego | 2 +- 2 files changed, 10 insertions(+), 20 deletions(-) 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 index f2d880f69c0..ff94812c09f 100644 --- 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 @@ -4,9 +4,8 @@ # -------------------------------------------------------------------------------------------- import json -import os -import subprocess import tempfile +import docker import pytest import portalocker @@ -24,27 +23,18 @@ SAMPLES_ROOT = CONFCOM_DIR / "samples" / "images" DOCKER_LOCK = Path(tempfile.gettempdir()) / "confcom-docker.lock" + @pytest.fixture(scope="session", autouse=True) def build_test_containers(): - env = os.environ.copy() - env["DOCKER_BUILDKIT"] = "1" - + docker_client = docker.from_env() with portalocker.Lock(DOCKER_LOCK.as_posix(), timeout=20): for image_sample in SAMPLES_ROOT.iterdir(): - subprocess.run( - [ - "docker", - "build", - str(image_sample), - "-t", - f"confcom_test_{image_sample.name}", - "--quiet", - ], - env=env, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=True, + docker_client.images.build( + path=str(image_sample), + tag=f"confcom_test_{image_sample.name}", + quiet=True, + rm=True, ) yield @@ -79,4 +69,4 @@ def test_containers_from_image(sample_directory: str, platform: str): expected_container_def, ignore_order=True, ) - assert diff == {}, diff + assert diff == {}, diff \ No newline at end of file diff --git a/src/confcom/samples/images/working_dir/aci_container.inc.rego b/src/confcom/samples/images/working_dir/aci_container.inc.rego index d1fc0770bae..df61f863bf2 100644 --- a/src/confcom/samples/images/working_dir/aci_container.inc.rego +++ b/src/confcom/samples/images/working_dir/aci_container.inc.rego @@ -3,7 +3,7 @@ "name": "confcom_test_working_dir", "layers": [ "8b4664979ffe3c5188efbbbb30e31716c03bfe880f15f455be0fc3beb4741de9", - "1c4128b7270b18b052aff3f68a5611873057aa0b9ce3acfbf273494e67c63254" + "eff9550216100f194b7f04d12ae70e5511ffd7360eec6b947197176701397e60" ], "mounts": [ { From 07bc86c8b4fefe98352b4b275ce261c44cac2f61 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Mon, 8 Dec 2025 15:57:19 +0000 Subject: [PATCH 09/14] Change the working_dir sample for extra_layers --- .../images/{working_dir => extra_layers}/Dockerfile | 2 +- .../{working_dir => extra_layers}/aci_container.inc.rego | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) rename src/confcom/samples/images/{working_dir => extra_layers}/Dockerfile (51%) rename src/confcom/samples/images/{working_dir => extra_layers}/aci_container.inc.rego (75%) diff --git a/src/confcom/samples/images/working_dir/Dockerfile b/src/confcom/samples/images/extra_layers/Dockerfile similarity index 51% rename from src/confcom/samples/images/working_dir/Dockerfile rename to src/confcom/samples/images/extra_layers/Dockerfile index d7bf7f4a5e1..6c5531643b0 100644 --- a/src/confcom/samples/images/working_dir/Dockerfile +++ b/src/confcom/samples/images/extra_layers/Dockerfile @@ -1,3 +1,3 @@ FROM hello-world -WORKDIR /home \ No newline at end of file +COPY Dockerfile . \ No newline at end of file diff --git a/src/confcom/samples/images/working_dir/aci_container.inc.rego b/src/confcom/samples/images/extra_layers/aci_container.inc.rego similarity index 75% rename from src/confcom/samples/images/working_dir/aci_container.inc.rego rename to src/confcom/samples/images/extra_layers/aci_container.inc.rego index df61f863bf2..2a9379278a3 100644 --- a/src/confcom/samples/images/working_dir/aci_container.inc.rego +++ b/src/confcom/samples/images/extra_layers/aci_container.inc.rego @@ -1,9 +1,9 @@ { - "id": "confcom_test_working_dir", - "name": "confcom_test_working_dir", + "id": "confcom_test_extra_layers", + "name": "confcom_test_extra_layers", "layers": [ "8b4664979ffe3c5188efbbbb30e31716c03bfe880f15f455be0fc3beb4741de9", - "eff9550216100f194b7f04d12ae70e5511ffd7360eec6b947197176701397e60" + "19c61360dade7dce5999ec5641dd9ce2f7388f3382ce6bb47ddff72d0dc39b0d" ], "mounts": [ { @@ -27,5 +27,5 @@ "required": false } ], - "working_dir": "/home" + "working_dir": "/" } From 40dd106da5ab2d32905b3843b56d76e2f16d72ab Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Mon, 8 Dec 2025 17:24:00 +0000 Subject: [PATCH 10/14] . --- src/confcom/samples/images/extra_layers/Dockerfile | 4 ++-- .../samples/images/extra_layers/aci_container.inc.rego | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/confcom/samples/images/extra_layers/Dockerfile b/src/confcom/samples/images/extra_layers/Dockerfile index 6c5531643b0..764c3c49034 100644 --- a/src/confcom/samples/images/extra_layers/Dockerfile +++ b/src/confcom/samples/images/extra_layers/Dockerfile @@ -1,3 +1,3 @@ -FROM hello-world +FROM busybox -COPY Dockerfile . \ No newline at end of file +RUN echo "hi" > hi.txt \ No newline at end of file diff --git a/src/confcom/samples/images/extra_layers/aci_container.inc.rego b/src/confcom/samples/images/extra_layers/aci_container.inc.rego index 2a9379278a3..dcd9c5eadbc 100644 --- a/src/confcom/samples/images/extra_layers/aci_container.inc.rego +++ b/src/confcom/samples/images/extra_layers/aci_container.inc.rego @@ -2,8 +2,8 @@ "id": "confcom_test_extra_layers", "name": "confcom_test_extra_layers", "layers": [ - "8b4664979ffe3c5188efbbbb30e31716c03bfe880f15f455be0fc3beb4741de9", - "19c61360dade7dce5999ec5641dd9ce2f7388f3382ce6bb47ddff72d0dc39b0d" + "13f6c367267457d9516d57c493e76b0324979e94cee9de3b310f913708b3667a", + "ae7e9183858927a54e0ae33a479948abd16a6f38712b84324191b900270cde8c" ], "mounts": [ { @@ -18,7 +18,7 @@ } ], "command": [ - "/hello" + "sh" ], "env_rules": [ { @@ -26,6 +26,5 @@ "strategy": "string", "required": false } - ], - "working_dir": "/" + ] } From 02de77bbd2cdb68eb5757eee4e5ae13c973f4a85 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Mon, 8 Dec 2025 17:51:29 +0000 Subject: [PATCH 11/14] Remove extra layers test --- .../samples/images/extra_layers/Dockerfile | 3 -- .../extra_layers/aci_container.inc.rego | 30 ------------------- 2 files changed, 33 deletions(-) delete mode 100644 src/confcom/samples/images/extra_layers/Dockerfile delete mode 100644 src/confcom/samples/images/extra_layers/aci_container.inc.rego diff --git a/src/confcom/samples/images/extra_layers/Dockerfile b/src/confcom/samples/images/extra_layers/Dockerfile deleted file mode 100644 index 764c3c49034..00000000000 --- a/src/confcom/samples/images/extra_layers/Dockerfile +++ /dev/null @@ -1,3 +0,0 @@ -FROM busybox - -RUN echo "hi" > hi.txt \ No newline at end of file diff --git a/src/confcom/samples/images/extra_layers/aci_container.inc.rego b/src/confcom/samples/images/extra_layers/aci_container.inc.rego deleted file mode 100644 index dcd9c5eadbc..00000000000 --- a/src/confcom/samples/images/extra_layers/aci_container.inc.rego +++ /dev/null @@ -1,30 +0,0 @@ -{ - "id": "confcom_test_extra_layers", - "name": "confcom_test_extra_layers", - "layers": [ - "13f6c367267457d9516d57c493e76b0324979e94cee9de3b310f913708b3667a", - "ae7e9183858927a54e0ae33a479948abd16a6f38712b84324191b900270cde8c" - ], - "mounts": [ - { - "destination": "/etc/resolv.conf", - "options": [ - "rbind", - "rshared", - "rw" - ], - "source": "sandbox:///tmp/atlas/resolvconf/.+", - "type": "bind" - } - ], - "command": [ - "sh" - ], - "env_rules": [ - { - "pattern": "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", - "strategy": "string", - "required": false - } - ] -} From 7647188123f870c31c6d3ffe5bb1dcf8b79c13db Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Tue, 16 Dec 2025 14:48:34 +0000 Subject: [PATCH 12/14] Review tidy ups --- src/confcom/azext_confcom/lib/containers.py | 3 ++- src/confcom/azext_confcom/lib/images.py | 7 ++++--- src/confcom/azext_confcom/lib/platform.py | 14 ++++++++------ src/confcom/azext_confcom/rootfs_proxy.py | 2 -- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/confcom/azext_confcom/lib/containers.py b/src/confcom/azext_confcom/lib/containers.py index 54a2398aaa8..ffcbefc69ed 100644 --- a/src/confcom/azext_confcom/lib/containers.py +++ b/src/confcom/azext_confcom/lib/containers.py @@ -3,6 +3,7 @@ # 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 @@ -10,7 +11,7 @@ def from_image(image: str, platform: str) -> str: mounts = { - "aci": ACI_MOUNTS, + "aci": [asdict(mount) for mount in ACI_MOUNTS], }.get(platform, None) return { diff --git a/src/confcom/azext_confcom/lib/images.py b/src/confcom/azext_confcom/lib/images.py index 9f3924c53f0..7921f650b7c 100644 --- a/src/confcom/azext_confcom/lib/images.py +++ b/src/confcom/azext_confcom/lib/images.py @@ -4,10 +4,11 @@ # -------------------------------------------------------------------------------------------- import functools -import os import subprocess import docker +from pathlib import Path + @functools.lru_cache() def get_image(image_ref: str) -> docker.models.images.Image: @@ -25,11 +26,11 @@ def get_image(image_ref: str) -> docker.models.images.Image: def get_image_layers(image: str) -> list[str]: - binary_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "bin", "dmverity-vhd") + binary_path = Path(__file__).parent.parent / "bin" / "dmverity-vhd" get_image(image) result = subprocess.run( - [binary_path, "-d", "roothash", "-i", image], + [binary_path.as_posix(), "-d", "roothash", "-i", image], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, diff --git a/src/confcom/azext_confcom/lib/platform.py b/src/confcom/azext_confcom/lib/platform.py index 395d39a1309..b4ef1138545 100644 --- a/src/confcom/azext_confcom/lib/platform.py +++ b/src/confcom/azext_confcom/lib/platform.py @@ -3,15 +3,17 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +from azext_confcom.lib.policy import ContainerMount + ACI_MOUNTS = [ - { - "destination": "/etc/resolv.conf", - "options": [ + ContainerMount( + destination="/etc/resolv.conf", + options=[ "rbind", "rshared", "rw" ], - "source": "sandbox:///tmp/atlas/resolvconf/.+", - "type": "bind" - } + source="sandbox:///tmp/atlas/resolvconf/.+", + type="bind" + ) ] diff --git a/src/confcom/azext_confcom/rootfs_proxy.py b/src/confcom/azext_confcom/rootfs_proxy.py index 10ffba2e596..f41dbd8f008 100644 --- a/src/confcom/azext_confcom/rootfs_proxy.py +++ b/src/confcom/azext_confcom/rootfs_proxy.py @@ -54,8 +54,6 @@ def download_binaries(): with open(binary_info["path"], "wb") as f: f.write(dmverity_vhd_fetch_resp.content) - os.chmod(binary_info["path"], 0o755) - def __init__(self): script_directory = os.path.dirname(os.path.realpath(__file__)) DEFAULT_LIB = "./bin/dmverity-vhd" From 646e4a5e610acb175443fe88fd50551a89aecbd0 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Tue, 16 Dec 2025 09:15:54 +0000 Subject: [PATCH 13/14] Restore the behaviour of `--upload-fragment` for acifragmentgen (#13) Addresses - https://github.com/Azure/azure-cli-extensions/issues/9222 - [x] Update the code to restore the "attach to first image in input" behaviour - [x] Add two new commands: `fragment push` and `fragment attach` to allow the user to explicitly do one or the other (or both!) - [x] Add new tests which run a local docker registry, and test that the fragments are generated, signed, pushed and attached as expected (as well as the default behaviour) --- This checklist is used to make sure that common guidelines for a pull request are followed. - [x] Have you run `azdev style ` locally? (`pip install azdev` required) - [x] Have you run `python scripts/ci/test_index.py -q` locally? (`pip install wheel==0.30.0` required) - [x] My extension version conforms to the [Extension version schema](https://github.com/Azure/azure-cli/blob/release/doc/extensions/versioning_guidelines.md) --- linter_exclusions.yml | 12 + src/confcom/HISTORY.rst | 6 + src/confcom/azext_confcom/_help.py | 43 +++ src/confcom/azext_confcom/_params.py | 35 ++ .../azext_confcom/command/fragment_attach.py | 46 +++ .../azext_confcom/command/fragment_push.py | 46 +++ src/confcom/azext_confcom/commands.py | 4 + src/confcom/azext_confcom/custom.py | 52 ++- .../data/genpolicy-settings.json | 338 ++++++++++++++++++ src/confcom/azext_confcom/data/rules.rego | 6 +- .../latest/test_confcom_acifragmentgen.py | 252 +++++++++++++ .../tests/latest/test_confcom_arm.py | 18 +- .../tests/latest/test_confcom_fragment.py | 12 +- .../tests/latest/test_confcom_tar.py | 2 +- .../tests/latest/test_confcom_virtual_node.py | 16 +- src/confcom/samples/certs/create_certchain.sh | 72 ++-- src/confcom/setup.py | 2 +- 17 files changed, 907 insertions(+), 55 deletions(-) create mode 100644 src/confcom/azext_confcom/command/fragment_attach.py create mode 100644 src/confcom/azext_confcom/command/fragment_push.py create mode 100644 src/confcom/azext_confcom/data/genpolicy-settings.json create mode 100644 src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py diff --git a/linter_exclusions.yml b/linter_exclusions.yml index ce4aaa82bca..5105cb7f34b 100644 --- a/linter_exclusions.yml +++ b/linter_exclusions.yml @@ -3504,3 +3504,15 @@ neon postgres organization: neon postgres project: rule_exclusions: - require_wait_command_if_no_wait + +confcom fragment push: + parameters: + signed_fragment: + rule_exclusions: + - no_positional_parameters + +confcom fragment attach: + parameters: + signed_fragment: + rule_exclusions: + - no_positional_parameters diff --git a/src/confcom/HISTORY.rst b/src/confcom/HISTORY.rst index 8ffb3997568..70a2ef341f6 100644 --- a/src/confcom/HISTORY.rst +++ b/src/confcom/HISTORY.rst @@ -3,6 +3,12 @@ Release History =============== +1.5.0 +++++++ +* restored the behaviour of --upload-fragment in acifragmentgen to attach to first image in input +* added confcom fragment push command to allow explicit uploading of standalone fragments +* added confcom fragment attach command to allow explicit uploading of image attached fragments + 1.4.5 ++++++ * Drop the dependency on OPA diff --git a/src/confcom/azext_confcom/_help.py b/src/confcom/azext_confcom/_help.py index 15368cc61db..9817bef723e 100644 --- a/src/confcom/azext_confcom/_help.py +++ b/src/confcom/azext_confcom/_help.py @@ -278,3 +278,46 @@ - name: Input a Kubernetes YAML file with a custom containerd socket path text: az confcom katapolicygen --yaml "./pod.json" --containerd-pull --containerd-socket-path "/my/custom/containerd.sock" """ + +helps[ + "confcom fragment" +] = """ + type: group + short-summary: Commands to handle Confidential Container Policy Fragments. +""" + +helps[ + "confcom fragment push" +] = """ + type: command + short-summary: Push a Confidential Container Policy Fragment to an ORAS registry + + parameters: + - name: --manifest-tag + type: string + short-summary: 'The reference to push the signed fragment to' + + examples: + - name: Push a signed fragment to a registry + text: az confcom fragment push ./fragment.reg.cose --manifest-tag myregistry.azurecr.io/fragment:latest + - name: Push 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 push --manifest-tag myregistry.azurecr.io/fragment:latest +""" + +helps[ + "confcom fragment attach" +] = """ + type: command + short-summary: Attach a Confidential Container Policy Fragment to an image in an ORAS registry. + + parameters: + - name: --manifest-tag + type: string + short-summary: 'The reference to attach the signed fragment to' + + examples: + - name: Attach a signed fragment to a registry + text: az confcom fragment attach ./fragment.reg.cose --manifest-tag myregistry.azurecr.io/image:latest + - 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 +""" diff --git a/src/confcom/azext_confcom/_params.py b/src/confcom/azext_confcom/_params.py index ccbea8d0091..d75ce70abc1 100644 --- a/src/confcom/azext_confcom/_params.py +++ b/src/confcom/azext_confcom/_params.py @@ -5,6 +5,8 @@ # pylint: disable=line-too-long import json +import argparse +import sys from knack.arguments import CLIArgumentType from azext_confcom._validators import ( validate_params_file, @@ -44,6 +46,32 @@ def load_arguments(self, _): c.argument("tags", tags_type) c.argument("confcom_name", confcom_name_type, options_list=["--name", "-n"]) + with self.argument_context("confcom fragment attach") as c: + c.positional( + "signed_fragment", + nargs='?', + type=argparse.FileType('rb'), + default=sys.stdin.buffer, + help="Signed fragment to attach", + ) + c.argument( + "manifest_tag", + help="Manifest tag for the fragment", + ) + + with self.argument_context("confcom fragment push") as c: + c.positional( + "signed_fragment", + nargs='?', + type=argparse.FileType('rb'), + default=sys.stdin.buffer, + help="Signed fragment to push", + ) + c.argument( + "manifest_tag", + help="Manifest tag for the fragment", + ) + with self.argument_context("confcom acipolicygen") as c: c.argument( "input_path", @@ -362,6 +390,13 @@ def load_arguments(self, _): type=json.loads, help='Container definitions to include in the policy' ) + c.argument( + "out_signed_fragment", + action="store_true", + default=False, + required=False, + help="Emit only the signed fragment bytes", + ) with self.argument_context("confcom katapolicygen") as c: c.argument( diff --git a/src/confcom/azext_confcom/command/fragment_attach.py b/src/confcom/azext_confcom/command/fragment_attach.py new file mode 100644 index 00000000000..39f29ae48da --- /dev/null +++ b/src/confcom/azext_confcom/command/fragment_attach.py @@ -0,0 +1,46 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import subprocess +import tempfile +from typing import BinaryIO + + +def oras_attach( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + subprocess.run( + [ + "oras", + "attach", + "--artifact-type", "application/x-ms-ccepolicy-frag", + manifest_tag, + os.path.relpath(signed_fragment.name, start=os.getcwd()), + ], + check=True, + timeout=120, + ) + + +def fragment_attach( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + + if signed_fragment.name == "": + with tempfile.NamedTemporaryFile(delete=True) as temp_signed_fragment: + temp_signed_fragment.write(signed_fragment.read()) + temp_signed_fragment.flush() + oras_attach( + signed_fragment=temp_signed_fragment, + manifest_tag=manifest_tag, + ) + else: + oras_attach( + signed_fragment=signed_fragment, + manifest_tag=manifest_tag, + ) diff --git a/src/confcom/azext_confcom/command/fragment_push.py b/src/confcom/azext_confcom/command/fragment_push.py new file mode 100644 index 00000000000..89912c87637 --- /dev/null +++ b/src/confcom/azext_confcom/command/fragment_push.py @@ -0,0 +1,46 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import subprocess +import tempfile +from typing import BinaryIO + + +def oras_push( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + subprocess.run( + [ + "oras", + "push", + "--artifact-type", "application/x-ms-ccepolicy-frag", + manifest_tag, + os.path.relpath(signed_fragment.name, start=os.getcwd()), + ], + check=True, + timeout=120, + ) + + +def fragment_push( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + + if signed_fragment.name == "": + with tempfile.NamedTemporaryFile(delete=True) as temp_signed_fragment: + temp_signed_fragment.write(signed_fragment.read()) + temp_signed_fragment.flush() + oras_push( + signed_fragment=temp_signed_fragment, + manifest_tag=manifest_tag, + ) + else: + oras_push( + signed_fragment=signed_fragment, + manifest_tag=manifest_tag, + ) diff --git a/src/confcom/azext_confcom/commands.py b/src/confcom/azext_confcom/commands.py index 1d2bb45f724..7e1e93eabca 100644 --- a/src/confcom/azext_confcom/commands.py +++ b/src/confcom/azext_confcom/commands.py @@ -11,5 +11,9 @@ def load_command_table(self, _): g.custom_command("acifragmentgen", "acifragmentgen_confcom") g.custom_command("katapolicygen", "katapolicygen_confcom") + with self.command_group("confcom fragment") as g: + g.custom_command("attach", "fragment_attach", is_preview=True) + g.custom_command("push", "fragment_push", is_preview=True) + with self.command_group("confcom"): pass diff --git a/src/confcom/azext_confcom/custom.py b/src/confcom/azext_confcom/custom.py index 2f90c796bbd..1b243a31370 100644 --- a/src/confcom/azext_confcom/custom.py +++ b/src/confcom/azext_confcom/custom.py @@ -5,7 +5,8 @@ import os import sys -from typing import Optional +import tempfile +from typing import Optional, BinaryIO from azext_confcom import oras_proxy, os_util, security_policy from azext_confcom._validators import resolve_stdio @@ -22,6 +23,8 @@ get_image_name, inject_policy_into_template, inject_policy_into_yaml, pretty_print_func, print_existing_policy_from_arm_template, 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 knack.log import get_logger from pkg_resources import parse_version @@ -255,6 +258,7 @@ def acifragmentgen_confcom( upload_fragment: bool = False, no_print: bool = False, fragments_json: str = "", + out_signed_fragment: bool = False, ): if container_definitions is None: container_definitions = [] @@ -361,12 +365,16 @@ def acifragmentgen_confcom( fragment_text = policy.generate_fragment(namespace, svn, output_type, omit_id=omit_id) - if output_type != security_policy.OutputType.DEFAULT and not no_print: + if output_type != security_policy.OutputType.DEFAULT and not no_print and not out_signed_fragment: print(fragment_text) # take ".rego" off the end of the filename if it's there, it'll get added back later output_filename = output_filename.replace(".rego", "") filename = f"{output_filename or namespace}.rego" + + if out_signed_fragment: + filename = os.path.join(tempfile.gettempdir(), filename) + os_util.write_str_to_file(filename, fragment_text) if key: @@ -374,11 +382,23 @@ def acifragmentgen_confcom( iss = cose_proxy.create_issuer(chain) out_path = filename + ".cose" + if out_signed_fragment: + out_path = os.path.join(tempfile.gettempdir(), os.path.basename(out_path)) + cose_proxy.cose_sign(filename, key, chain, feed, iss, algo, out_path) - if upload_fragment and image_target: - oras_proxy.attach_fragment_to_image(image_target, out_path) - elif upload_fragment: - oras_proxy.push_fragment_to_registry(feed, out_path) + + # Preserve default behaviour established since version 1.1.0 of attaching + # the fragment to the first image specified in input + # (or --image-target if specified) + if upload_fragment: + oras_proxy.attach_fragment_to_image( + image_name=image_target or policy_images[0].containerImage, + filename=out_path, + ) + + if out_signed_fragment: + with open(out_path, "rb") as f: + sys.stdout.buffer.write(f.read()) def katapolicygen_confcom( @@ -512,3 +532,23 @@ def get_fragment_output_type(outraw): if outraw: output_type = security_policy.OutputType.RAW return output_type + + +def fragment_attach( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + _fragment_attach( + signed_fragment=signed_fragment, + manifest_tag=manifest_tag + ) + + +def fragment_push( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + _fragment_push( + signed_fragment=signed_fragment, + manifest_tag=manifest_tag + ) diff --git a/src/confcom/azext_confcom/data/genpolicy-settings.json b/src/confcom/azext_confcom/data/genpolicy-settings.json new file mode 100644 index 00000000000..73d9c1125bb --- /dev/null +++ b/src/confcom/azext_confcom/data/genpolicy-settings.json @@ -0,0 +1,338 @@ +{ + "pause_container": { + "Root": { + "Path": "$(cpath)/$(bundle-id)", + "Readonly": true + }, + "Mounts": [ + { + "destination": "/dev/shm", + "type_": "bind", + "source": "/run/kata-containers/sandbox/shm", + "options": [ + "rbind" + ] + }, + { + "destination": "/etc/resolv.conf", + "type_": "bind", + "options": [ + "rbind", + "ro", + "nosuid", + "nodev", + "noexec" + ] + } + ], + "Annotations": { + "io.kubernetes.cri.container-type": "sandbox", + "io.kubernetes.cri.sandbox-id": "^[a-z0-9]{64}$", + "io.kubernetes.cri.sandbox-log-directory": "^/var/log/pods/$(sandbox-namespace)_$(sandbox-name)_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + "io.katacontainers.pkg.oci.container_type": "pod_sandbox", + "io.kubernetes.cri.sandbox-namespace": "default", + "io.katacontainers.pkg.oci.bundle_path": "/run/containerd/io.containerd.runtime.v2.task/k8s.io/$(bundle-id)" + }, + "Process": { + "Args": [ + "/pause" + ] + }, + "Linux": { + "MaskedPaths": [ + "/proc/acpi", + "/proc/asound", + "/proc/kcore", + "/proc/keys", + "/proc/latency_stats", + "/proc/timer_list", + "/proc/timer_stats", + "/proc/sched_debug", + "/sys/firmware", + "/proc/scsi" + ], + "ReadonlyPaths": [ + "/proc/bus", + "/proc/fs", + "/proc/irq", + "/proc/sys", + "/proc/sysrq-trigger" + ] + } + }, + "other_container": { + "Root": { + "Path": "$(cpath)/$(bundle-id)" + }, + "Mounts": [ + { + "destination": "/etc/hosts", + "type_": "bind", + "options": [ + "rbind", + "rprivate", + "rw" + ] + }, + { + "destination": "/dev/termination-log", + "type_": "bind", + "options": [ + "rbind", + "rprivate", + "rw" + ] + }, + { + "destination": "/etc/hostname", + "type_": "bind", + "options": [ + "rbind", + "rprivate" + ] + }, + { + "destination": "/etc/resolv.conf", + "type_": "bind", + "options": [ + "rbind", + "rprivate" + ] + }, + { + "destination": "/dev/shm", + "type_": "bind", + "source": "/run/kata-containers/sandbox/shm", + "options": [ + "rbind" + ] + }, + { + "destination": "/var/run/secrets/kubernetes.io/serviceaccount", + "type_": "bind", + "options": [ + "rbind", + "rprivate", + "ro" + ] + }, + { + "destination": "/var/run/secrets/azure/tokens", + "source": "$(sfprefix)tokens$", + "type_": "bind", + "options": [ + "rbind", + "rprivate", + "ro" + ] + } + ], + "Annotations": { + "io.katacontainers.pkg.oci.bundle_path": "/run/containerd/io.containerd.runtime.v2.task/k8s.io/$(bundle-id)", + "io.kubernetes.cri.sandbox-id": "^[a-z0-9]{64}$", + "io.katacontainers.pkg.oci.container_type": "pod_container", + "io.kubernetes.cri.container-type": "container" + } + }, + "volumes": { + "emptyDir": { + "mount_type": "local", + "mount_source": "^$(cpath)/$(sandbox-id)/local/", + "mount_point": "^$(cpath)/$(sandbox-id)/local/", + "driver": "local", + "source": "local", + "fstype": "local", + "options": [ + "mode=0777" + ] + }, + "emptyDir_memory": { + "mount_type": "bind", + "mount_source": "^/run/kata-containers/sandbox/ephemeral/", + "mount_point": "^/run/kata-containers/sandbox/ephemeral/", + "driver": "ephemeral", + "source": "tmpfs", + "fstype": "tmpfs", + "options": [] + }, + "configMap": { + "mount_type": "bind", + "mount_source": "$(sfprefix)", + "mount_point": "^$(cpath)/watchable/$(bundle-id)-[a-z0-9]{16}-", + "driver": "watchable-bind", + "fstype": "bind", + "options": [ + "rbind", + "rprivate", + "ro" + ] + }, + "confidential_configMap": { + "mount_type": "bind", + "mount_source": "$(sfprefix)", + "mount_point": "$(sfprefix)", + "driver": "local", + "fstype": "bind", + "options": [ + "rbind", + "rprivate", + "ro" + ] + } + }, + "mount_destinations": [ + "/sys/fs/cgroup", + "/etc/hosts", + "/dev/termination-log", + "/etc/hostname", + "/etc/resolv.conf", + "/dev/shm", + "/var/run/secrets/kubernetes.io/serviceaccount", + "/var/run/secrets/azure/tokens" + ], + "sandbox": { + "storages": [ + { + "driver": "ephemeral", + "driver_options": [], + "source": "shm", + "fstype": "tmpfs", + "options": [ + "noexec", + "nosuid", + "nodev", + "mode=1777", + "size=67108864" + ], + "mount_point": "/run/kata-containers/sandbox/shm", + "fs_group": null + } + ] + }, + "common": { + "cpath": "/run/kata-containers/shared/containers", + "sfprefix": "^$(cpath)/$(bundle-id)-[a-z0-9]{16}-", + "spath": "/run/kata-containers/sandbox/storage", + "ip_p": "[0-9]{1,5}", + "ipv4_a": "((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}", + "svc_name": "[A-Z0-9_\\.\\-]+", + "dns_label": "[a-zA-Z0-9_\\.\\-]+", + "s_source1": "^..2[0-9]{3}_[0-1][0-9]_[0-3][0-9]_[0-2][0-9]_[0-5][0-9]_[0-5][0-9]\\.[0-9]{1,10}$", + "s_source2": "^..data/", + "default_caps": [ + "CAP_CHOWN", + "CAP_DAC_OVERRIDE", + "CAP_FSETID", + "CAP_FOWNER", + "CAP_MKNOD", + "CAP_NET_RAW", + "CAP_SETGID", + "CAP_SETUID", + "CAP_SETFCAP", + "CAP_SETPCAP", + "CAP_NET_BIND_SERVICE", + "CAP_SYS_CHROOT", + "CAP_KILL", + "CAP_AUDIT_WRITE" + ], + "privileged_caps": [ + "CAP_CHOWN", + "CAP_DAC_OVERRIDE", + "CAP_DAC_READ_SEARCH", + "CAP_FOWNER", + "CAP_FSETID", + "CAP_KILL", + "CAP_SETGID", + "CAP_SETUID", + "CAP_SETPCAP", + "CAP_LINUX_IMMUTABLE", + "CAP_NET_BIND_SERVICE", + "CAP_NET_BROADCAST", + "CAP_NET_ADMIN", + "CAP_NET_RAW", + "CAP_IPC_LOCK", + "CAP_IPC_OWNER", + "CAP_SYS_MODULE", + "CAP_SYS_RAWIO", + "CAP_SYS_CHROOT", + "CAP_SYS_PTRACE", + "CAP_SYS_PACCT", + "CAP_SYS_ADMIN", + "CAP_SYS_BOOT", + "CAP_SYS_NICE", + "CAP_SYS_RESOURCE", + "CAP_SYS_TIME", + "CAP_SYS_TTY_CONFIG", + "CAP_MKNOD", + "CAP_LEASE", + "CAP_AUDIT_WRITE", + "CAP_AUDIT_CONTROL", + "CAP_SETFCAP", + "CAP_MAC_OVERRIDE", + "CAP_MAC_ADMIN", + "CAP_SYSLOG", + "CAP_WAKE_ALARM", + "CAP_BLOCK_SUSPEND", + "CAP_AUDIT_READ", + "CAP_PERFMON", + "CAP_BPF", + "CAP_CHECKPOINT_RESTORE" + ], + "virtio_blk_storage_classes": [ + "cc-local-csi", + "cc-managed-csi", + "cc-managed-premium-csi" + ], + "smb_storage_classes": [ + { + "name": "azurefile-csi-kata-cc", + "mount_options": [ + "dir_mode=0777", + "file_mode=0777", + "mfsymlinks", + "cache=strict", + "nosharesock", + "actimeo=30", + "nobrl" + ] + } + ] + }, + "kata_config": { + "confidential_guest": true + }, + "cluster_config": { + "default_namespace": "default" + }, + "request_defaults": { + "CreateContainerRequest": { + "allow_env_regex": [ + "^HOSTNAME=$(dns_label)$", + "^$(svc_name)_PORT_$(ip_p)_TCP=tcp://$(ipv4_a):$(ip_p)$", + "^$(svc_name)_PORT_$(ip_p)_TCP_PROTO=tcp$", + "^$(svc_name)_PORT_$(ip_p)_TCP_PORT=$(ip_p)$", + "^$(svc_name)_PORT_$(ip_p)_TCP_ADDR=$(ipv4_a)$", + "^$(svc_name)_SERVICE_HOST=$(ipv4_a)$", + "^$(svc_name)_SERVICE_PORT=$(ip_p)$", + "^$(svc_name)_SERVICE_PORT_$(dns_label)=$(ip_p)$", + "^$(svc_name)_PORT=tcp://$(ipv4_a):$(ip_p)$", + "^AZURE_CLIENT_ID=[A-Fa-f0-9-]*$", + "^AZURE_TENANT_ID=[A-Fa-f0-9-]*$", + "^AZURE_FEDERATED_TOKEN_FILE=/var/run/secrets/azure/tokens/azure-identity-token$", + "^AZURE_AUTHORITY_HOST=https://login\\.microsoftonline\\.com/$", + "^TERM=xterm$" + ] + }, + "CopyFileRequest": [ + "$(sfprefix)" + ], + "ExecProcessRequest": { + "commands": [], + "regex": [] + }, + "CloseStdinRequest": false, + "ReadStreamRequest": true, + "UpdateEphemeralMountsRequest": false, + "WriteStreamRequest": false + } +} diff --git a/src/confcom/azext_confcom/data/rules.rego b/src/confcom/azext_confcom/data/rules.rego index a5208cf9d3b..4e4c3b3e03d 100644 --- a/src/confcom/azext_confcom/data/rules.rego +++ b/src/confcom/azext_confcom/data/rules.rego @@ -54,6 +54,7 @@ default AllowRequestsFailingPolicy := false # Constants S_NAME_KEY = "io.kubernetes.cri.sandbox-name" S_NAMESPACE_KEY = "io.kubernetes.cri.sandbox-namespace" +BUNDLE_ID = "[a-z0-9]{64}" CreateContainerRequest { # Check if the input request should be rejected even before checking the @@ -468,6 +469,9 @@ allow_by_bundle_or_sandbox_id(p_oci, i_oci, p_storages, i_storages) { bundle_path := i_oci.Annotations["io.katacontainers.pkg.oci.bundle_path"] bundle_id := replace(bundle_path, "/run/containerd/io.containerd.runtime.v2.task/k8s.io/", "") + bundle_id_format := concat("", ["^", BUNDLE_ID, "$"]) + regex.match(bundle_id_format, bundle_id) + key := "io.kubernetes.cri.sandbox-id" p_regex := p_oci.Annotations[key] @@ -1226,7 +1230,7 @@ CopyFileRequest { some regex1 in policy_data.request_defaults.CopyFileRequest regex2 := replace(regex1, "$(sfprefix)", policy_data.common.sfprefix) regex3 := replace(regex2, "$(cpath)", policy_data.common.cpath) - regex4 := replace(regex3, "$(bundle-id)", "[a-z0-9]{64}") + regex4 := replace(regex3, "$(bundle-id)", BUNDLE_ID) print("CopyFileRequest: regex4 =", regex4) regex.match(regex4, input.path) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py new file mode 100644 index 00000000000..e46b8e6385a --- /dev/null +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py @@ -0,0 +1,252 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import contextlib +import io +import json +import os +import subprocess +import tempfile +import pytest + +from azext_confcom.custom import acifragmentgen_confcom, fragment_push, fragment_attach + +TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), "..")) +SAMPLES_DIR = os.path.abspath(os.path.join(TEST_DIR, "..", "..", "..", "samples")) + + +@pytest.fixture() +def docker_image(): + + registry_id = subprocess.run( + ["docker", "run", "-d", "-p", "0:5000", "registry:2"], + stdout=subprocess.PIPE, + text=True, + ).stdout + + registry_port = subprocess.run( + ["docker", "port", registry_id], + stdout=subprocess.PIPE, + text=True, + ).stdout.split(":")[-1].strip() + + test_container_ref = f"localhost:{registry_port}/hello-world:latest" + subprocess.run(["docker", "pull", "hello-world"]) + subprocess.run(["docker", "tag", "hello-world", test_container_ref]) + subprocess.run(["docker", "push", test_container_ref]) + + with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", delete=True) as temp_file: + json.dump({ + "version": "1.0.0", + "containers": [ + { + "name": "hello-world", + "properties": { + "image": test_container_ref, + }, + } + ] + }, temp_file) + temp_file.flush() + + yield test_container_ref, temp_file.name + + subprocess.run(["docker", "stop", registry_id]) + + +@pytest.fixture(scope="session") +def cert_chain(): + with tempfile.TemporaryDirectory() as temp_dir: + subprocess.run( + [ + os.path.join(SAMPLES_DIR, "certs", "create_certchain.sh"), + temp_dir + ], + check=True, + ) + yield temp_dir + + +def test_acifragmentgen_fragment_gen(docker_image): + + image_ref, spec_file_path = docker_image + + with tempfile.TemporaryDirectory() as temp_dir: # Prevent test writing files to repo + acifragmentgen_confcom( + image_name=None, + tar_mapping_location=None, + key=None, + chain=None, + minimum_svn=None, + input_path=spec_file_path, + svn="1", + namespace="contoso", + feed="test-feed", + outraw=True, + output_filename=os.path.join(temp_dir, "fragment.rego"), + out_signed_fragment=False, + ) + + # TODO: Implement a proper validation for the fragment, this is hard + # because each test run will have a unique image to have unique local + # registries on different ports + + +def test_acifragmentgen_fragment_sign(docker_image, cert_chain): + + image_ref, spec_file_path = docker_image + + with tempfile.TemporaryDirectory() as temp_dir: # Prevent test writing files to repo + acifragmentgen_confcom( + image_name=None, + tar_mapping_location=None, + key=os.path.join(cert_chain, "intermediateCA", "private", "ec_p384_private.pem"), + chain=os.path.join(cert_chain, "intermediateCA", "certs", "www.contoso.com.chain.cert.pem"), + minimum_svn=None, + input_path=spec_file_path, + svn="1", + namespace="contoso", + feed="test-feed", + outraw=True, + output_filename=os.path.join(temp_dir, "fragment.rego"), + out_signed_fragment=False, + ) + + # TODO: Implement a proper validation for the cose document + + +def test_acifragmentgen_fragment_upload_fragment(docker_image, cert_chain): + + image_ref, spec_file_path = docker_image + + with tempfile.TemporaryDirectory() as temp_dir: # Prevent test writing files to repo + acifragmentgen_confcom( + image_name=None, + tar_mapping_location=None, + key=os.path.join(cert_chain, "intermediateCA", "private", "ec_p384_private.pem"), + chain=os.path.join(cert_chain, "intermediateCA", "certs", "www.contoso.com.chain.cert.pem"), + minimum_svn=None, + input_path=spec_file_path, + svn="1", + namespace="contoso", + feed="test-feed", + outraw=True, + upload_fragment=True, + output_filename=os.path.relpath(os.path.join(temp_dir, "fragment.rego"), os.getcwd()), # Must be relative for oras + out_signed_fragment=False, + ) + + # Confirm the fragment exists and is attached in the registry + oras_result = json.loads(subprocess.run( + ["oras", "discover", image_ref, "--format", "json"], + stdout=subprocess.PIPE, + check=True, + ).stdout) + + if "referrers" in oras_result: + fragment_ref = oras_result["referrers"][0]["reference"] + elif oras_result.get("manifests")[0].get("artifactType") == "application/x-ms-ccepolicy-frag": + fragment_ref = oras_result["manifests"][0]["reference"] + else: + raise AssertionError(f"{oras_result=}") + + fragment_path = json.loads(subprocess.run( + ["oras", "pull", fragment_ref, "--format", "json", "-o", tempfile.gettempdir()], + check=True, + stdout=subprocess.PIPE, + ).stdout)["files"][0]["path"] + + + with open(fragment_path, "rb") as actual_fragment_file: + with open(os.path.join(temp_dir, "fragment.rego.cose"), "rb") as expected_fragment_file: + assert actual_fragment_file.read() == expected_fragment_file.read() + + +def test_acifragmentgen_fragment_push(docker_image, cert_chain, capsysbinary): + + image_ref, spec_file_path = docker_image + fragment_ref = image_ref.replace("hello-world", "fragment") + + acifragmentgen_confcom( + image_name=None, + tar_mapping_location=None, + key=os.path.join(cert_chain, "intermediateCA", "private", "ec_p384_private.pem"), + chain=os.path.join(cert_chain, "intermediateCA", "certs", "www.contoso.com.chain.cert.pem"), + minimum_svn=None, + input_path=spec_file_path, + svn="1", + namespace="contoso", + feed="test-feed", + out_signed_fragment=True, + ) + + signed_fragment = capsysbinary.readouterr()[0] + signed_fragment_io = io.BytesIO(signed_fragment) + signed_fragment_io.name = "" + + fragment_push( + signed_fragment=signed_fragment_io, + manifest_tag=fragment_ref, + ) + + # Confirm the fragment exists in the registry + fragment_path = json.loads(subprocess.run( + ["oras", "pull", fragment_ref, "--format", "json", "-o", tempfile.gettempdir()], + check=True, + stdout=subprocess.PIPE, + ).stdout)["files"][0]["path"] + + with open(fragment_path, "rb") as f: + assert f.read() == signed_fragment + + +def test_acifragmentgen_fragment_attach(docker_image, cert_chain, capsysbinary): + + image_ref, spec_file_path = docker_image + + acifragmentgen_confcom( + image_name=None, + tar_mapping_location=None, + key=os.path.join(cert_chain, "intermediateCA", "private", "ec_p384_private.pem"), + chain=os.path.join(cert_chain, "intermediateCA", "certs", "www.contoso.com.chain.cert.pem"), + minimum_svn=None, + input_path=spec_file_path, + svn="1", + namespace="contoso", + feed="test-feed", + out_signed_fragment=True, + ) + + signed_fragment = capsysbinary.readouterr()[0] + signed_fragment_io = io.BytesIO(signed_fragment) + signed_fragment_io.name = "" + + fragment_attach( + signed_fragment=signed_fragment_io, + manifest_tag=image_ref, + ) + + # Confirm the fragment exists and is attached in the registry + oras_result = json.loads(subprocess.run( + ["oras", "discover", image_ref, "--format", "json"], + stdout=subprocess.PIPE, + check=True, + ).stdout) + + if "referrers" in oras_result: + fragment_ref = oras_result["referrers"][0]["reference"] + elif oras_result["manifests"][0].get("artifactType") == "application/x-ms-ccepolicy-frag": + fragment_ref = oras_result["manifests"][0]["reference"] + else: + raise AssertionError(f"{oras_result=}") + + fragment_path = json.loads(subprocess.run( + ["oras", "pull", fragment_ref, "--format", "json", "-o", tempfile.gettempdir()], + check=True, + stdout=subprocess.PIPE, + ).stdout)["files"][0]["path"] + + with open(fragment_path, "rb") as f: + assert f.read() == signed_fragment diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_arm.py b/src/confcom/azext_confcom/tests/latest/test_confcom_arm.py index cd5f0be05f6..9b764b58241 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_arm.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_arm.py @@ -3,10 +3,14 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import fcntl import os +import tempfile import unittest import json import deepdiff +import docker +import requests from unittest.mock import patch from azext_confcom.security_policy import ( @@ -24,6 +28,7 @@ ) TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), "..")) +PRUNE_LOCK_PATH = f"{tempfile.gettempdir()}/confcom_docker_prune.lock" class PolicyGeneratingArm(unittest.TestCase): @@ -5007,7 +5012,18 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): - cls.client.containers.prune() + # Coordinate cleanup across xdist workers to avoid prune conflicts. + with open(PRUNE_LOCK_PATH, "w") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + try: + cls.client.containers.prune() + except (docker.errors.APIError, requests.exceptions.ReadTimeout) as exc: + # Ignore conflicts (another prune in flight) or slow daemon timeouts. + status = getattr(getattr(exc, "response", None), "status_code", None) + if status not in (409, None) or not isinstance(exc, requests.exceptions.ReadTimeout): + raise + finally: + fcntl.flock(lock_file, fcntl.LOCK_UN) cls.client.close() def test_arm_template_security_context_no_run_as_group(self): diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py b/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py index 2725ede31c0..66102f151da 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py @@ -5,6 +5,7 @@ import json import os +from pathlib import Path import subprocess import tempfile import time @@ -493,7 +494,7 @@ def test_tar_file_fragment(self): try: with tempfile.TemporaryDirectory() as folder: filename = os.path.join(folder, "oci.tar") - filename2 = os.path.join(self.path, "oci2.tar") + filename2 = os.path.join(folder, "oci2.tar") tar_mapping_file = {"mcr.microsoft.com/aks/e2e/library-busybox:master.220314.1-linux-amd64": filename2} create_tar_file(filename) @@ -762,14 +763,16 @@ class FragmentPolicySigning(unittest.TestCase): """ @classmethod def setUpClass(cls): - cls.key_dir_parent = os.path.join(SAMPLES_DIR, 'certs') + cls.key_dir_parent = Path(tempfile.gettempdir(), "certchain") + cls.key_dir_parent.mkdir(parents=True, exist_ok=True) cls.key = os.path.join(cls.key_dir_parent, 'intermediateCA', 'private', 'ec_p384_private.pem') cls.chain = os.path.join(cls.key_dir_parent, 'intermediateCA', 'certs', 'www.contoso.com.chain.cert.pem') if not os.path.exists(cls.key) or not os.path.exists(cls.chain): - script_path = os.path.join(cls.key_dir_parent, 'create_certchain.sh') + script_path = os.path.join(SAMPLES_DIR, "certs", 'create_certchain.sh') arg_list = [ script_path, + cls.key_dir_parent.as_posix(), ] os.chmod(script_path, 0o755) @@ -777,8 +780,7 @@ def setUpClass(cls): item = subprocess.run( arg_list, check=False, - shell=True, - cwd=cls.key_dir_parent, + shell=False, env=os.environ.copy(), ) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py b/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py index ab2733745f5..1da2de3e90a 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py @@ -175,7 +175,7 @@ def test_oci_tar_file(self): try: with tempfile.TemporaryDirectory() as folder: filename = os.path.join(folder, "oci.tar") - filename2 = os.path.join(self.path, "oci2.tar") + filename2 = os.path.join(folder, "oci2.tar") tar_mapping_file = {"mcr.microsoft.com/aks/e2e/library-busybox:master.220314.1-linux-amd64": filename2} create_tar_file(filename) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_virtual_node.py b/src/confcom/azext_confcom/tests/latest/test_confcom_virtual_node.py index c6e8ad4a23a..2c6a3ad8766 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_virtual_node.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_virtual_node.py @@ -4,6 +4,8 @@ # -------------------------------------------------------------------------------------------- import os +from pathlib import Path +import tempfile import unittest import json import subprocess @@ -22,6 +24,7 @@ ) TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), "..")) +SAMPLES_DIR = os.path.abspath(os.path.join(TEST_DIR, "..", "..", "..", "samples")) class PolicyGeneratingVirtualNode(unittest.TestCase): @@ -338,17 +341,19 @@ class PolicyGeneratingVirtualNode(unittest.TestCase): ports: - containerPort: 80 name: web -""" + """ @classmethod def setUpClass(cls): - cls.key_dir_parent = os.path.join(TEST_DIR, '..', '..', '..', 'samples', 'certs') + cls.key_dir_parent = Path(tempfile.gettempdir(), "certchain") + cls.key_dir_parent.mkdir(parents=True, exist_ok=True) cls.key = os.path.join(cls.key_dir_parent, 'intermediateCA', 'private', 'ec_p384_private.pem') cls.chain = os.path.join(cls.key_dir_parent, 'intermediateCA', 'certs', 'www.contoso.com.chain.cert.pem') if not os.path.exists(cls.key) or not os.path.exists(cls.chain): - script_path = os.path.join(cls.key_dir_parent, 'create_certchain.sh') + script_path = os.path.join(SAMPLES_DIR, "certs", 'create_certchain.sh') arg_list = [ script_path, + cls.key_dir_parent.as_posix(), ] os.chmod(script_path, 0o755) @@ -356,8 +361,7 @@ def setUpClass(cls): item = subprocess.run( arg_list, check=False, - shell=True, - cwd=cls.key_dir_parent, + shell=False, env=os.environ.copy(), ) @@ -534,4 +538,4 @@ def test_custom_args(self): containers = json.loads(extract_containers_from_text(virtual_node_policy.get_serialized_output(OutputType.PRETTY_PRINT), container_start)) command = containers[0].get("command") - self.assertEqual(command[-2:], ["test", "values"]) \ No newline at end of file + self.assertEqual(command[-2:], ["test", "values"]) diff --git a/src/confcom/samples/certs/create_certchain.sh b/src/confcom/samples/certs/create_certchain.sh index 5e94f4c6f4e..48575efef3f 100755 --- a/src/confcom/samples/certs/create_certchain.sh +++ b/src/confcom/samples/certs/create_certchain.sh @@ -3,87 +3,91 @@ OriginalPath=`pwd` RootPath=`realpath $(dirname $0)` -cd $RootPath +OutPath=${1:-$RootPath} + +mkdir -p $OutPath + +cd $OutPath # create dirs for root CA -mkdir -p $RootPath/rootCA/{certs,crl,newcerts,private,csr} -mkdir -p $RootPath/intermediateCA/{certs,crl,newcerts,private,csr} +mkdir -p $OutPath/rootCA/{certs,crl,newcerts,private,csr} +mkdir -p $OutPath/intermediateCA/{certs,crl,newcerts,private,csr} # create index files -echo 1000 > $RootPath/rootCA/serial -echo 1000 > $RootPath/intermediateCA/serial +echo 1000 > $OutPath/rootCA/serial +echo 1000 > $OutPath/intermediateCA/serial # create crlnumbers -echo 0100 > $RootPath/rootCA/crlnumber -echo 0100 > $RootPath/intermediateCA/crlnumber +echo 0100 > $OutPath/rootCA/crlnumber +echo 0100 > $OutPath/intermediateCA/crlnumber # create index files -touch $RootPath/rootCA/index.txt -touch $RootPath/intermediateCA/index.txt +touch $OutPath/rootCA/index.txt +touch $OutPath/intermediateCA/index.txt # NOTE: needed for testing -echo "unique_subject = no" >> $RootPath/rootCA/index.txt.attr -echo "unique_subject = no" >> $RootPath/intermediateCA/index.txt.attr +echo "unique_subject = no" >> $OutPath/rootCA/index.txt.attr +echo "unique_subject = no" >> $OutPath/intermediateCA/index.txt.attr # generate root key -openssl genrsa -out $RootPath/rootCA/private/ca.key.pem 4096 -chmod 400 $RootPath/rootCA/private/ca.key.pem +openssl genrsa -out $OutPath/rootCA/private/ca.key.pem 4096 +chmod 400 $OutPath/rootCA/private/ca.key.pem # view the key -# openssl rsa -noout -text -in $RootPath/rootCA/private/ca.key.pem +# openssl rsa -noout -text -in $OutPath/rootCA/private/ca.key.pem # generate root cert -openssl req -config openssl_root.cnf -key $RootPath/rootCA/private/ca.key.pem -new -x509 -days 7300 -sha256 -extensions v3_ca -out $RootPath/rootCA/certs/ca.cert.pem -subj "/C=US/ST=Georgia/L=Atlanta/O=Microsoft/OU=ACCCT/CN=Root CA" +openssl req -config $RootPath/openssl_root.cnf -key $OutPath/rootCA/private/ca.key.pem -new -x509 -days 7300 -sha256 -extensions v3_ca -out $OutPath/rootCA/certs/ca.cert.pem -subj "/C=US/ST=Georgia/L=Atlanta/O=Microsoft/OU=ACCCT/CN=Root CA" # change permissions on root key so it's not globally readable -chmod 644 $RootPath/rootCA/certs/ca.cert.pem +chmod 644 $OutPath/rootCA/certs/ca.cert.pem # verify root cert -openssl x509 -noout -text -in $RootPath/rootCA/certs/ca.cert.pem +openssl x509 -noout -text -in $OutPath/rootCA/certs/ca.cert.pem # generate intermediate key -openssl genrsa -out $RootPath/intermediateCA/private/intermediate.key.pem 4096 -chmod 600 $RootPath/intermediateCA/private/intermediate.key.pem +openssl genrsa -out $OutPath/intermediateCA/private/intermediate.key.pem 4096 +chmod 600 $OutPath/intermediateCA/private/intermediate.key.pem # make CSR for intermediate -openssl req -config openssl_intermediate.cnf -key $RootPath/intermediateCA/private/intermediate.key.pem -new -sha256 -out $RootPath/intermediateCA/certs/intermediate.csr.pem -subj "/C=US/ST=Georgia/L=Atlanta/O=Microsoft/OU=ACCCT/CN=Intermediate CA" +openssl req -config $RootPath/openssl_intermediate.cnf -key $OutPath/intermediateCA/private/intermediate.key.pem -new -sha256 -out $OutPath/intermediateCA/certs/intermediate.csr.pem -subj "/C=US/ST=Georgia/L=Atlanta/O=Microsoft/OU=ACCCT/CN=Intermediate CA" # sign intermediate cert with root -openssl ca -config openssl_root.cnf -extensions v3_intermediate_ca -days 3650 -notext -md sha256 -in $RootPath/intermediateCA/certs/intermediate.csr.pem -out $RootPath/intermediateCA/certs/intermediate.cert.pem -batch +openssl ca -config $RootPath/openssl_root.cnf -extensions v3_intermediate_ca -days 3650 -notext -md sha256 -in $OutPath/intermediateCA/certs/intermediate.csr.pem -out $OutPath/intermediateCA/certs/intermediate.cert.pem -batch # make it readable by everyone -chmod 644 $RootPath/intermediateCA/certs/intermediate.cert.pem +chmod 644 $OutPath/intermediateCA/certs/intermediate.cert.pem # print the cert -# openssl x509 -noout -text -in $RootPath/intermediateCA/certs/intermediate.cert.pem +# openssl x509 -noout -text -in $OutPath/intermediateCA/certs/intermediate.cert.pem # verify intermediate cert -openssl verify -CAfile $RootPath/rootCA/certs/ca.cert.pem $RootPath/intermediateCA/certs/intermediate.cert.pem +openssl verify -CAfile $OutPath/rootCA/certs/ca.cert.pem $OutPath/intermediateCA/certs/intermediate.cert.pem # create chain file -cat $RootPath/intermediateCA/certs/intermediate.cert.pem $RootPath/rootCA/certs/ca.cert.pem > $RootPath/intermediateCA/certs/ca-chain.cert.pem +cat $OutPath/intermediateCA/certs/intermediate.cert.pem $OutPath/rootCA/certs/ca.cert.pem > $OutPath/intermediateCA/certs/ca-chain.cert.pem # verify chain -openssl verify -CAfile $RootPath/intermediateCA/certs/ca-chain.cert.pem $RootPath/intermediateCA/certs/intermediate.cert.pem +openssl verify -CAfile $OutPath/intermediateCA/certs/ca-chain.cert.pem $OutPath/intermediateCA/certs/intermediate.cert.pem # create server key -openssl ecparam -out $RootPath/intermediateCA/private/www.contoso.com.key.pem -name secp384r1 -genkey -openssl pkcs8 -topk8 -nocrypt -in $RootPath/intermediateCA/private/www.contoso.com.key.pem -out $RootPath/intermediateCA/private/ec_p384_private.pem +openssl ecparam -out $OutPath/intermediateCA/private/www.contoso.com.key.pem -name secp384r1 -genkey +openssl pkcs8 -topk8 -nocrypt -in $OutPath/intermediateCA/private/www.contoso.com.key.pem -out $OutPath/intermediateCA/private/ec_p384_private.pem -chmod 600 $RootPath/intermediateCA/private/www.contoso.com.key.pem +chmod 600 $OutPath/intermediateCA/private/www.contoso.com.key.pem # create csr for server -openssl req -config openssl_intermediate.cnf -key $RootPath/intermediateCA/private/www.contoso.com.key.pem -new -sha384 -out $RootPath/intermediateCA/csr/www.contoso.com.csr.pem -batch +openssl req -config $RootPath/openssl_intermediate.cnf -key $OutPath/intermediateCA/private/www.contoso.com.key.pem -new -sha384 -out $OutPath/intermediateCA/csr/www.contoso.com.csr.pem -batch # sign server cert with intermediate key -openssl ca -config openssl_intermediate.cnf -extensions server_cert -days 375 -notext -md sha384 -in $RootPath/intermediateCA/csr/www.contoso.com.csr.pem -out $RootPath/intermediateCA/certs/www.contoso.com.cert.pem -batch +openssl ca -config $RootPath/openssl_intermediate.cnf -extensions server_cert -days 375 -notext -md sha384 -in $OutPath/intermediateCA/csr/www.contoso.com.csr.pem -out $OutPath/intermediateCA/certs/www.contoso.com.cert.pem -batch # print the cert -# openssl x509 -noout -text -in $RootPath/intermediateCA/certs/www.contoso.com.cert.pem +# openssl x509 -noout -text -in $OutPath/intermediateCA/certs/www.contoso.com.cert.pem # make a public key -# openssl x509 -pubkey -noout -in $RootPath/intermediateCA/certs/www.contoso.com.cert.pem -out $RootPath/intermediateCA/certs/pubkey.pem +# openssl x509 -pubkey -noout -in $OutPath/intermediateCA/certs/www.contoso.com.cert.pem -out $OutPath/intermediateCA/certs/pubkey.pem # create chain file -cat $RootPath/intermediateCA/certs/www.contoso.com.cert.pem $RootPath/intermediateCA/certs/intermediate.cert.pem $RootPath/rootCA/certs/ca.cert.pem > $RootPath/intermediateCA/certs/www.contoso.com.chain.cert.pem +cat $OutPath/intermediateCA/certs/www.contoso.com.cert.pem $OutPath/intermediateCA/certs/intermediate.cert.pem $OutPath/rootCA/certs/ca.cert.pem > $OutPath/intermediateCA/certs/www.contoso.com.chain.cert.pem cd $OriginalPath \ No newline at end of file diff --git a/src/confcom/setup.py b/src/confcom/setup.py index 7b8c1157a0d..fe40522e879 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.4.5" +VERSION = "1.5.0" # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers From 1c7cee4428193eb11625332e1dba0d11122ace18 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Wed, 7 Jan 2026 20:34:59 +0000 Subject: [PATCH 14/14] Bump confcom version --- src/confcom/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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