diff --git a/linter_exclusions.yml b/linter_exclusions.yml index e5775a7fa6a..8098e82d980 100644 --- a/linter_exclusions.yml +++ b/linter_exclusions.yml @@ -3567,3 +3567,15 @@ confcom containers from_image: image: rule_exclusions: - no_positional_parameters + +confcom containers from_radius: + parameters: + template: + rule_exclusions: + - no_positional_parameters + +confcom radius policy_insert: + parameters: + policy_file: + rule_exclusions: + - no_positional_parameters diff --git a/src/confcom/HISTORY.rst b/src/confcom/HISTORY.rst index 30bae35cc27..f54194f0a92 100644 --- a/src/confcom/HISTORY.rst +++ b/src/confcom/HISTORY.rst @@ -3,6 +3,11 @@ Release History =============== +1.9.0 ++++++ +* Add command for generating container policy defintions from radius application templates +* Add command to insert generated policy into radius application templates + 1.8.0 +++++ * Ensure that fragments are attached to the correct manifest for a multiarch image. diff --git a/src/confcom/azext_confcom/_params.py b/src/confcom/azext_confcom/_params.py index 464a540186d..8d29ee703f5 100644 --- a/src/confcom/azext_confcom/_params.py +++ b/src/confcom/azext_confcom/_params.py @@ -8,6 +8,7 @@ import argparse import sys from knack.arguments import CLIArgumentType +from argcomplete.completers import FilesCompleter from azext_confcom._validators import ( validate_params_file, validate_diff, @@ -504,3 +505,62 @@ def load_arguments(self, _): type=str, help='The name of the container in the template to use. If omitted, all containers are returned.' ) + + with self.argument_context("confcom containers from_radius") as c: + c.positional( + "template", + type=str, + completer=FilesCompleter(), + help="Radius Bicep template to create container definition from", + ) + c.argument( + "parameters", + options_list=['--parameters', '-p'], + action='append', + nargs='+', + completer=FilesCompleter(), + required=False, + default=[], + help='The parameters for the radius template' + ) + c.argument( + "container_index", + options_list=['--idx'], + required=False, + default=0, + type=int, + help='The index of the container definition in the template to use' + ) + c.argument( + "platform", + options_list=["--platform"], + required=False, + default="aci", + type=str, + help="Platform to create container definition for", + ) + + with self.argument_context("confcom radius policy_insert") as c: + c.positional( + "policy_file", + nargs='?', + type=argparse.FileType('rb'), + default=sys.stdin.buffer, + help="Policy file to insert (reads from stdin if not provided)", + ) + c.argument( + "template_path", + options_list=['--template', '-t'], + required=True, + type=str, + completer=FilesCompleter(), + help='Path to Radius Bicep template to update with the policy', + ) + c.argument( + "container_index", + options_list=['--idx'], + required=False, + default=0, + type=int, + help='Index of the container in the template to update (0-based). Defaults to 0.' + ) diff --git a/src/confcom/azext_confcom/command/containers_from_radius.py b/src/confcom/azext_confcom/command/containers_from_radius.py new file mode 100644 index 00000000000..3cb63976905 --- /dev/null +++ b/src/confcom/azext_confcom/command/containers_from_radius.py @@ -0,0 +1,440 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +""" +Extract container definitions from Radius templates for policy generation. + +Supports two Radius container resource types: + - Applications.Core/containers (singular ``container`` property) + - Radius.Compute/containers (``containers`` dict of named containers) + +Each Radius template field is mapped to its corresponding policy container +field by a dedicated ``_map_*`` function. The overall flow is: + + 1. ``from_image`` produces the **image-base** container definition + (id, name, layers, platform mounts, command, env_rules, working_dir, + signals). + 2. The ``_map_*`` helpers read Radius template fields and return the + **template overrides** expressed as policy fields. + 3. ``merge_containers`` combines the two: list fields (env_rules, mounts, + exec_processes, signals) are concatenated; scalar fields are replaced + by the template value when present. + +The ``_map_*`` functions operate on a canonical container dict with inline +``volumes`` and ``{kind, command, ...}``-style probes. For +``Radius.Compute`` resources, ``_normalize_compute_container`` converts +each container to that canonical form first. + +Template field Policy field Mapper +-------------------------------------- ---------------- -------------------------- +container.image id, name, layers from_image +container.command / container.args command _map_command +container.workingDir working_dir _map_working_dir +container.env env_rules _map_env_rules +resource.connections env_rules _map_connection_env_rules +container.volumes mounts _map_volume_mounts +container.livenessProbe exec_processes _map_exec_processes +container.readinessProbe exec_processes _map_exec_processes +runtimes.kubernetes.pod.containers (sidecars — Applications.Core only) +""" + +import json +import os +import tempfile +import re + +from azext_confcom.lib.containers import from_image, merge_containers +from azext_confcom.lib.templates import parse_deployment_template + + +# --------------------------------------------------------------------------- +# Template field → Policy field mappers +# --------------------------------------------------------------------------- + +def _map_command(container: dict, image_command: list) -> list | None: + """Template: container.command, container.args → Policy: command + + Radius uses 'command' for the entrypoint and 'args' for arguments. + - If command is specified, it replaces the image entrypoint entirely, + with args appended. + - If only args is specified, they are appended to the image entrypoint. + - If neither is specified, returns None (image default is kept). + """ + template_command = container.get("command") + template_args = container.get("args") + + if template_command is not None: + return list(template_command) + list(template_args or []) + if template_args is not None: + return list(image_command) + list(template_args) + return None + + +def _map_working_dir(container: dict) -> str | None: + """Template: container.workingDir → Policy: working_dir""" + return container.get("workingDir") or None + + +def _map_env_rules(container: dict) -> list[dict]: + """Template: container.env → Policy: env_rules[] + + - Plain values produce a string-match rule: NAME=value + - Secret references (valueFrom) produce a regex rule: NAME=.+ + + Handles both Radius dict format (main containers) and Kubernetes list + format (sidecar containers from runtimes.kubernetes.pod.containers). + """ + env = container.get("env") + if not env: + return [] + + # Kubernetes list format: [{name: "X", value: "Y"}, ...] + if isinstance(env, list): + env = {item["name"]: {k: v for k, v in item.items() if k != "name"} for item in env} + + rules = [] + for env_name, env_spec in env.items(): + if "value" in env_spec: + rules.append({ + "pattern": f'{env_name}={env_spec["value"]}', + "strategy": "string", + "required": False, + }) + elif "valueFrom" in env_spec: + rules.append({ + "pattern": f"{env_name}=.+", + "strategy": "re2", + "required": False, + }) + return rules + + +def _map_connection_env_rules(resource: dict) -> list[dict]: + """Template: resource.connections → Policy: env_rules[] + + Radius injects CONNECTIONS__* environment variables for each + connection defined on the resource, unless the connection sets + disableDefaultEnvVars to true. + """ + return [ + { + "pattern": f"CONNECTIONS_{name.upper()}_.+=.+", + "strategy": "re2", + "required": True, + } + for name, conn in resource.get("connections", {}).items() + if not conn.get("disableDefaultEnvVars") + ] + + +def _map_volume_mounts(container: dict) -> list[dict]: + """Template: container.volumes → Policy: mounts[] + + Each Radius volume maps to a bind mount: + volumes[name].mountPath → mount.destination + volumes[name].source → mount.source (persistent) or ephemeral:// + volumes[name].permission → mount.options (read-only for persistent unless 'write') + volumes[name].rbac → (legacy alias for permission) + + Ephemeral volumes (kind=='ephemeral') are writable by default. + Persistent volumes default to read-only per the Radius spec. + """ + mounts = [] + for volume_name, mount_info in container.get("volumes", {}).items(): + options = ["rbind", "rshared"] + + is_ephemeral = mount_info.get("kind") == "ephemeral" + # The API reference uses "permission"; the human-readable docs use "rbac". + access = mount_info.get("permission") or mount_info.get("rbac") + + if is_ephemeral: + read_only = access == "read" + else: + read_only = access != "write" + + if read_only: + options.append("ro") + + mounts.append({ + "destination": mount_info.get("mountPath"), + "options": options, + "source": mount_info.get("source") or f"ephemeral://{volume_name}", + "type": "bind", + }) + return mounts + + +def _map_exec_processes(container: dict) -> list[dict]: + """Template: container.livenessProbe, container.readinessProbe → Policy: exec_processes[] + + Only exec-kind probes are mapped; HTTP/TCP probes are ignored. + """ + processes = [] + for probe_key in ("livenessProbe", "readinessProbe"): + probe = container.get(probe_key, {}) + if probe.get("kind") == "exec" and probe.get("command"): + command = probe["command"] + if isinstance(command, str): + command = [command] + processes.append({"command": command, "signals": []}) + return processes + + +# --------------------------------------------------------------------------- +# Radius.Compute schema normalization +# --------------------------------------------------------------------------- + +# Both resource types represent container workloads; the schema differs. +_CONTAINER_RESOURCE_TYPES = { + "Applications.Core/containers", + "Radius.Compute/containers", +} + + +def _normalize_compute_probe(probe: dict) -> dict | None: + """Convert a Radius.Compute probe to canonical {kind, command, ...} format.""" + if not probe: + return None + if "exec" in probe: + return {"kind": "exec", "command": probe["exec"].get("command")} + if "httpGet" in probe: + hg = probe["httpGet"] + return {"kind": "httpGet", "containerPort": hg.get("port"), "path": hg.get("path")} + if "tcpSocket" in probe: + return {"kind": "tcp", "containerPort": probe["tcpSocket"].get("port")} + return None + + +def _normalize_compute_container(container: dict, resource_volumes: dict) -> dict: + """Normalize a Radius.Compute/containers entry to canonical internal format. + + Converts: + - volumeMounts[] + resource-level volumes → inline volumes dict + - Structured probes (exec/httpGet/tcpSocket) → {kind, ...} format + All other fields (image, command, args, env, workingDir) are identical. + """ + normalized = dict(container) + + # --- volumeMounts + resource-level volumes → legacy inline volumes --- + volume_mounts = container.get("volumeMounts", []) + if volume_mounts and resource_volumes: + old_volumes = {} + for vm in volume_mounts: + vol_name = vm["volumeName"] + vol_def = resource_volumes.get(vol_name, {}) + mount_path = vm["mountPath"] + + if "emptyDir" in vol_def: + old_volumes[vol_name] = { + "kind": "ephemeral", + "mountPath": mount_path, + "managedStore": vol_def["emptyDir"].get("medium", "disk"), + } + elif "persistentVolume" in vol_def: + pv = vol_def["persistentVolume"] + access = pv.get("accessMode", "ReadWriteOnce") + old_volumes[vol_name] = { + "kind": "persistent", + "mountPath": mount_path, + "source": pv.get("resourceId", ""), + "permission": "read" if access == "ReadOnlyMany" else "write", + } + elif "secretName" in vol_def: + old_volumes[vol_name] = { + "kind": "persistent", + "mountPath": mount_path, + "source": vol_def["secretName"], + "permission": "read", + } + normalized["volumes"] = old_volumes + + # --- probes: structured → legacy --- + for probe_key in ("livenessProbe", "readinessProbe"): + probe = container.get(probe_key) + if probe: + legacy = _normalize_compute_probe(probe) + if legacy: + normalized[probe_key] = legacy + + return normalized + + +# --------------------------------------------------------------------------- +# Per-resource-type container collectors +# --------------------------------------------------------------------------- + +def _collect_applications_core_containers(resource: dict) -> list[dict]: + """Extract containers from an Applications.Core/containers resource. + + The main container lives at ``properties.container`` (singular). + Sidecar containers come from ``runtimes.kubernetes.pod.containers``. + """ + props = resource.get("properties", {}) + results = [] + + main_container = props.get("container", {}) + if main_container.get("image"): + results.append({"container": main_container, "resource": props}) + + runtimes = props.get("runtimes", {}) + pod_spec = runtimes.get("kubernetes", {}).get("pod", {}) + for sidecar in pod_spec.get("containers", []): + if sidecar.get("image"): + results.append({"container": sidecar, "resource": props}) + + return results + + +def _collect_radius_compute_containers(resource: dict) -> list[dict]: + """Extract containers from a Radius.Compute/containers resource. + + All containers live in ``properties.containers`` (dict of named + containers). Volumes are defined at ``properties.volumes`` and + referenced via ``volumeMounts`` inside each container. Each container + is normalized to the canonical internal format before being returned. + """ + props = resource.get("properties", {}) + resource_volumes = props.get("volumes", {}) + results = [] + + for _name, raw_container in props.get("containers", {}).items(): + if raw_container.get("image"): + container = _normalize_compute_container(raw_container, resource_volumes) + results.append({"container": container, "resource": props}) + + return results + + +# --------------------------------------------------------------------------- +# Container extraction +# --------------------------------------------------------------------------- + +def _extract_container_def(container: dict, resource: dict, platform: str) -> dict: + """ + Build a policy container definition from a Radius container spec. + + The base definition comes from the Docker image (via ``from_image``). + Template-level overrides are computed by the ``_map_*`` helpers above, + then merged on top of the image defaults via ``merge_containers``. + """ + image = container.get("image") + if not image: + raise ValueError("Container must have an image") + + image_def = from_image(image, platform) + + template_def = {} + + command = _map_command(container, image_def.get("command", [])) + if command is not None: + template_def["command"] = command + + working_dir = _map_working_dir(container) + if working_dir is not None: + template_def["working_dir"] = working_dir + + env_rules = _map_env_rules(container) + _map_connection_env_rules(resource) + if env_rules: + template_def["env_rules"] = env_rules + + mounts = _map_volume_mounts(container) + if mounts: + template_def["mounts"] = mounts + + exec_processes = _map_exec_processes(container) + if exec_processes: + template_def["exec_processes"] = exec_processes + + return merge_containers(image_def, template_def) + + +def containers_from_radius( + az_cli_command, + template: str, + parameters: list, + container_index: int, + platform: str, +) -> str: + """ + Extract container definitions from a Radius bicep template. + + Args: + az_cli_command: Azure CLI command context + template: Path to the Radius bicep template + parameters: List of parameter files/values + container_index: Index of container to extract (0 = main container, + higher indices may refer to sidecars) + platform: Target platform (aci, vn2) + + Returns: + JSON string containing the container definition for policy generation. + """ + + # Remove radius extension lines to avoid bicep compilation errors. + # Uses line-level regex so that 'extension radiusResources' etc. are + # removed cleanly without leaving stray text. + with tempfile.NamedTemporaryFile('w+', delete=True, suffix=".bicep") as temp_template_file: + with open(template, 'r') as f: + content = re.sub(r'^extension\s+\S+.*$', '', f.read(), flags=re.MULTILINE) + temp_template_file.write(content) + temp_template_file.flush() + + # Handle parameters file if it's a path + if len(parameters) > 0 and isinstance(parameters[0][0], str) and os.path.isfile(parameters[0][0]): + parameters_path = parameters[0][0] + with open(parameters_path, 'r') as params_file: + params_content = params_file.read() + + # Replace any references to the original template file with the temporary one + params_content = re.sub( + r"using\s+'.*\.bicep'", + f"using '{os.path.basename(temp_template_file.name)}'", + params_content + ) + + with tempfile.NamedTemporaryFile('w+', delete=False, suffix=".bicepparam") as temp_params_file: + temp_params_file.write(params_content) + temp_params_file.flush() + parameters = [[temp_params_file.name]] + + parsed_template = parse_deployment_template( + az_cli_command, + temp_template_file.name, + parameters, + ) + + # Find all container resources (both resource type schemas) + container_resources = [ + r for r in parsed_template.get("resources", []) + if r.get("type") in _CONTAINER_RESOURCE_TYPES + ] + + # Each resource type has its own extraction function that returns a flat + # list of (container_dict, resource_props) pairs. + _RESOURCE_COLLECTORS = { + "Applications.Core/containers": _collect_applications_core_containers, + "Radius.Compute/containers": _collect_radius_compute_containers, + } + + all_containers = [] + for resource in container_resources: + collector = _RESOURCE_COLLECTORS.get(resource.get("type", "")) + if collector: + all_containers.extend(collector(resource)) + + if container_index >= len(all_containers): + raise IndexError( + f"Container index {container_index} out of range. " + f"Template has {len(all_containers)} container(s)." + ) + + target = all_containers[container_index] + container_def = _extract_container_def( + target["container"], + target["resource"], + platform, + ) + + return json.dumps(container_def) diff --git a/src/confcom/azext_confcom/command/radius_policy_insert.py b/src/confcom/azext_confcom/command/radius_policy_insert.py new file mode 100644 index 00000000000..edfa0100747 --- /dev/null +++ b/src/confcom/azext_confcom/command/radius_policy_insert.py @@ -0,0 +1,75 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import tempfile +from typing import BinaryIO +from azext_confcom.lib.serialization import policy_deserialize, policy_serialize +import re +import base64 + + +# ccePolicy pattern: any key containing 'ccepolicy' (case-insensitive) followed by a quoted value +_CCE_POLICY_PATTERN = re.compile( + r'["\']?[^"\']*[cC][cC][eE][pP][oO][lL][iI][cC][yY][^"\']*["\']?\s*:\s*["\'][^"\']*["\']' +) + + +def insert_policy_into_template( + encoded_policy: str, + template_content: str, + container_index: int, +) -> str: + """Replace the nth ccePolicy value in *template_content* with *encoded_policy*. + + Preserves the original quote style (single or double) around the value. + Returns the modified template string unchanged when *container_index* is + out of range. + """ + + def replace_cce_policy(match): + full_match = match.group(0) + colon_match = re.search(r':\s*', full_match) + key_part = full_match[:colon_match.end()].rstrip() + value_quote_match = re.search(r':\s*(["\'])', full_match) + value_quote = value_quote_match.group(1) if value_quote_match else '"' + return f'{key_part} {value_quote}{encoded_policy}{value_quote}' + + matches = list(_CCE_POLICY_PATTERN.finditer(template_content)) + + if container_index < len(matches): + target_match = matches[container_index] + start, end = target_match.span() + replacement = replace_cce_policy(target_match) + return template_content[:start] + replacement + template_content[end:] + + return template_content + + +def radius_policy_insert( + policy_file: BinaryIO, + template_path: str, + container_index: int, +) -> None: + + if policy_file.name == "": + with tempfile.NamedTemporaryFile(delete=True) as temp_policy_file: + temp_policy_file.write(policy_file.read()) + temp_policy_file.flush() + policy = policy_deserialize(temp_policy_file.name) + else: + policy = policy_deserialize(policy_file.name) + + serialized_policy = policy_serialize(policy) + encoded_policy = base64.b64encode(serialized_policy.encode()).decode() + + with open(template_path, 'r') as template_file: + template_content = template_file.read() + + updated_content = insert_policy_into_template( + encoded_policy, template_content, container_index, + ) + + with open(template_path, 'w') as template_file: + template_file.write(updated_content) diff --git a/src/confcom/azext_confcom/commands.py b/src/confcom/azext_confcom/commands.py index 7c43df474f4..0c0df1d035d 100644 --- a/src/confcom/azext_confcom/commands.py +++ b/src/confcom/azext_confcom/commands.py @@ -21,3 +21,7 @@ def load_command_table(self, _): with self.command_group("confcom containers") as g: g.custom_command("from_vn2", "containers_from_vn2") g.custom_command("from_image", "containers_from_image") + g.custom_command("from_radius", "containers_from_radius", is_preview=True) + + with self.command_group("confcom radius") as g: + g.custom_command("policy_insert", "radius_policy_insert", is_preview=True) diff --git a/src/confcom/azext_confcom/custom.py b/src/confcom/azext_confcom/custom.py index ff2b03f6b6e..78bd9828707 100644 --- a/src/confcom/azext_confcom/custom.py +++ b/src/confcom/azext_confcom/custom.py @@ -27,6 +27,8 @@ 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 azext_confcom.command.containers_from_vn2 import containers_from_vn2 as _containers_from_vn2 +from azext_confcom.command.containers_from_radius import containers_from_radius as _containers_from_radius +from azext_confcom.command.radius_policy_insert import radius_policy_insert as _radius_policy_insert from knack.log import get_logger from packaging.version import Version @@ -593,3 +595,31 @@ def containers_from_vn2( template=template, container_name=container_name, )) + + +def containers_from_radius( + cmd, + template: str, + parameters: list, + container_index: int = 0, + platform: str = "aci", +) -> None: + print(_containers_from_radius( + az_cli_command=cmd, + template=template, + parameters=parameters, + container_index=container_index, + platform=platform, + )) + + +def radius_policy_insert( + policy_file, + template_path: str, + container_index: int = 0, +) -> None: + _radius_policy_insert( + policy_file=policy_file, + template_path=template_path, + container_index=container_index, + ) diff --git a/src/confcom/azext_confcom/lib/templates.py b/src/confcom/azext_confcom/lib/templates.py new file mode 100644 index 00000000000..289ddcde9cc --- /dev/null +++ b/src/confcom/azext_confcom/lib/templates.py @@ -0,0 +1,93 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import json +import re + +from azure.cli.command_modules.resource.custom import ( + _prepare_deployment_properties_unmodified, +) +from azure.cli.core.profiles import ResourceType + + +class _ResourceDeploymentCommandAdapter: + """Ensure required resource type defaults are present when reusing resource module helpers.""" + + def __init__(self, cmd): + self._cmd = cmd + self.cli_ctx = cmd.cli_ctx + + def get_models(self, *attr_args, **kwargs): + kwargs.setdefault('resource_type', ResourceType.MGMT_RESOURCE_DEPLOYMENTS) + return self._cmd.get_models(*attr_args, **kwargs) + + def __getattr__(self, name): + return getattr(self._cmd, name) + + +def get_parameters( + arm_template: dict, + arm_template_parameters: dict, +) -> dict: + + return { + parameter_key: ( + arm_template_parameters.get(parameter_key, {}).get("value") + or arm_template.get("parameters", {}).get(parameter_key, {}).get("value") + or arm_template.get("parameters", {}).get(parameter_key, {}).get("defaultValue") + ) + for parameter_key in arm_template.get("parameters", {}).keys() + } + + +def eval_parameters( + arm_template: dict, + arm_template_parameters: dict, +) -> dict: + + parameters = get_parameters(arm_template, arm_template_parameters) + return json.loads(re.compile(r"\[parameters\(\s*'([^']+)'\s*\)\]").sub( + lambda match: json.dumps(parameters.get(match.group(1)) or match.group(0))[1:-1], + json.dumps(arm_template), + )) + + +def eval_variables( + arm_template: dict, + _arm_template_parameters: dict, +) -> dict: + + variables = arm_template.get("variables", {}) + return json.loads(re.compile(r"\[variables\(\s*'([^']+)'\s*\)\]").sub( + lambda match: json.dumps(variables.get(match.group(1), match.group(0)))[1:-1], + json.dumps(arm_template), + )) + + +EVAL_FUNCS = [ + eval_parameters, + eval_variables, +] + + +def parse_deployment_template( + az_cli_command, + template: str, + parameters: dict, +) -> dict: + properties = _prepare_deployment_properties_unmodified( + cmd=_ResourceDeploymentCommandAdapter(az_cli_command), + deployment_scope='resourceGroup', + template_file=template, + parameters=parameters, + no_prompt=True, + ) + template = json.loads(properties.template) + parameters = properties.parameters or {} + + for eval_func in EVAL_FUNCS: + template = eval_func(template, parameters) + + return template diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_containers_from_radius.py b/src/confcom/azext_confcom/tests/latest/test_confcom_containers_from_radius.py new file mode 100644 index 00000000000..bad4b22839a --- /dev/null +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_containers_from_radius.py @@ -0,0 +1,193 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +""" +Command-level tests for containers_from_radius. + +Uses golden sample files from samples/radius/: +- Each sample directory has app.bicep (input) and aci_container*.inc.rego (expected output) +- Tests parameterize over samples and container indices +- DeepDiff compares actual vs expected output +""" + +import json +import pytest +import subprocess +import tempfile + +from pathlib import Path +from deepdiff import DeepDiff + +from azext_confcom.command.containers_from_radius import containers_from_radius +from azext_confcom.lib.templates import EVAL_FUNCS + + +TEST_DIR = Path(__file__).parent +CONFCOM_DIR = TEST_DIR.parent.parent.parent +SAMPLES_ROOT = CONFCOM_DIR / "samples" / "radius" + + +def _parse_deployment_template_via_bicep(_az_cli_command, template, parameters): + """Compile a bicep file to ARM JSON using the bicep CLI.""" + result = subprocess.run( + ["bicep", "build", template, "--stdout"], + capture_output=True, text=True, check=True, + ) + arm = json.loads(result.stdout) + for eval_func in EVAL_FUNCS: + arm = eval_func(arm, {}) + return arm + + +@pytest.fixture(autouse=True) +def _patch_parse_deployment_template(): + """Use bicep CLI instead of Azure CLI for template parsing in tests.""" + fn = containers_from_radius + original = fn.__globals__.get("parse_deployment_template") + fn.__globals__["parse_deployment_template"] = _parse_deployment_template_via_bicep + yield + if original is not None: + fn.__globals__["parse_deployment_template"] = original + + +# --------------------------------------------------------------------------- +# Golden sample tests +# --------------------------------------------------------------------------- + +def _get_sample_containers(sample_dir: Path) -> list: + """Get container indices for a sample based on golden files present.""" + indices = [] + for f in sample_dir.glob("aci_container*.inc.rego"): + name = f.name.split(".")[0] + if name == "aci_container": + indices.append(0) + elif name.startswith("aci_container_"): + try: + indices.append(int(name.split("_")[-1])) + except ValueError: + pass + return sorted(indices) if indices else [0] + + +def _get_test_cases(): + """Generate test cases: (sample_name, platform, container_index).""" + cases = [] + if not SAMPLES_ROOT.exists(): + return cases + for sample_dir in SAMPLES_ROOT.iterdir(): + if not sample_dir.is_dir() or not (sample_dir / "app.bicep").exists(): + continue + for idx in _get_sample_containers(sample_dir): + cases.append((sample_dir.name, "aci", idx)) + return cases + + +@pytest.mark.parametrize( + "sample_name, platform, container_index", + _get_test_cases() or [pytest.param("skip", "aci", 0, marks=pytest.mark.skip(reason="No radius samples found"))], + ids=lambda x: str(x) if not isinstance(x, tuple) else f"{x[0]}-{x[1]}-c{x[2]}", +) +def test_golden_sample(sample_name, platform, container_index): + """Test containers_from_radius against golden output files.""" + if sample_name == "skip": + pytest.skip("No radius samples found") + + sample_dir = SAMPLES_ROOT / sample_name + template_path = sample_dir / "app.bicep" + + if container_index == 0: + golden_path = sample_dir / f"{platform}_container.inc.rego" + else: + golden_path = sample_dir / f"{platform}_container_{container_index}.inc.rego" + + if not golden_path.exists(): + pytest.skip(f"Golden file not found: {golden_path}") + + with golden_path.open("r", encoding="utf-8") as f: + expected = json.load(f) + + if "TODO" in expected: + pytest.skip(f"Golden file is placeholder: {golden_path}") + + result = containers_from_radius( + az_cli_command=None, + template=str(template_path), + parameters=[], + container_index=container_index, + platform=platform, + ) + actual = json.loads(result) + + diff = DeepDiff(actual, expected, ignore_order=True) + assert diff == {}, f"Mismatch for {sample_name}/{platform}/container_{container_index}:\n{diff}" + + +def test_index_out_of_range_raises_error(): + """Should raise IndexError when container_index exceeds available containers.""" + template_content = """ +extension radius + +resource c 'Applications.Core/containers@2023-10-01-preview' = { + name: 'test' + properties: { + container: { + image: 'alpine:latest' + } + } +} +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.bicep', delete=False) as f: + f.write(template_content) + f.flush() + with pytest.raises(IndexError, match="out of range"): + containers_from_radius( + az_cli_command=None, + template=f.name, + parameters=[], + container_index=99, + platform="aci", + ) + + +def test_ignores_non_container_resources(): + """Should only extract from Applications.Core/containers resources.""" + template_content = """ +extension radius + +resource app 'Applications.Core/applications@2023-10-01-preview' = { + name: 'myapp' +} + +resource container 'Applications.Core/containers@2023-10-01-preview' = { + name: 'mycontainer' + properties: { + container: { + image: 'alpine:latest' + } + } +} +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.bicep', delete=False) as f: + f.write(template_content) + f.flush() + + result = containers_from_radius( + az_cli_command=None, + template=f.name, + parameters=[], + container_index=0, + platform="aci", + ) + parsed = json.loads(result) + assert "alpine" in parsed.get("id", "") + + with pytest.raises(IndexError): + containers_from_radius( + az_cli_command=None, + template=f.name, + parameters=[], + container_index=1, + platform="aci", + ) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_radius_map_helpers.py b/src/confcom/azext_confcom/tests/latest/test_confcom_radius_map_helpers.py new file mode 100644 index 00000000000..c1c33c9fc8e --- /dev/null +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_radius_map_helpers.py @@ -0,0 +1,417 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +""" +Unit tests for the _map_* and _normalize_* helper functions in +containers_from_radius. + +Each function maps one Radius template field to its corresponding policy field. +Tests are grouped by helper function. +""" + +from azext_confcom.command.containers_from_radius import ( + _map_command, + _map_working_dir, + _map_env_rules, + _map_connection_env_rules, + _map_volume_mounts, + _map_exec_processes, + _normalize_compute_container, + _normalize_compute_probe, +) + + +# --------------------------------------------------------------------------- +# _map_command +# --------------------------------------------------------------------------- + +def test_command_no_overrides_returns_none(): + assert _map_command({}, ["/bin/sh"]) is None + + +def test_command_replaces_image_entrypoint(): + result = _map_command({"command": ["/app/run"]}, ["/bin/sh"]) + assert result == ["/app/run"] + + +def test_command_with_args(): + result = _map_command({"command": ["/app/run"], "args": ["--verbose"]}, ["/bin/sh"]) + assert result == ["/app/run", "--verbose"] + + +def test_command_args_only_appends_to_image(): + result = _map_command({"args": ["--debug"]}, ["/bin/sh", "-c"]) + assert result == ["/bin/sh", "-c", "--debug"] + + +def test_command_args_only_with_empty_image_command(): + result = _map_command({"args": ["start"]}, []) + assert result == ["start"] + + +def test_command_empty_is_not_none(): + """An explicit empty command list is a valid override.""" + result = _map_command({"command": []}, ["/bin/sh"]) + assert result == [] + + +# --------------------------------------------------------------------------- +# _map_working_dir +# --------------------------------------------------------------------------- + +def test_working_dir_absent(): + assert _map_working_dir({}) is None + + +def test_working_dir_returns_value(): + assert _map_working_dir({"workingDir": "/app"}) == "/app" + + +def test_working_dir_empty_string(): + assert _map_working_dir({"workingDir": ""}) is None + + +# --------------------------------------------------------------------------- +# _map_env_rules +# --------------------------------------------------------------------------- + +def test_env_rules_empty(): + assert _map_env_rules({}) == [] + + +def test_env_rules_plain_value(): + result = _map_env_rules({"env": {"MY_VAR": {"value": "hello"}}}) + assert result == [{"pattern": "MY_VAR=hello", "strategy": "string", "required": False}] + + +def test_env_rules_value_from_secret(): + result = _map_env_rules({"env": {"SECRET": {"valueFrom": {"secretRef": {}}}}}) + assert result == [{"pattern": "SECRET=.+", "strategy": "re2", "required": False}] + + +def test_env_rules_mixed_values(): + result = _map_env_rules({"env": { + "A": {"value": "1"}, + "B": {"valueFrom": {"secretRef": {}}}, + }}) + assert len(result) == 2 + patterns = {r["pattern"] for r in result} + assert "A=1" in patterns + assert "B=.+" in patterns + + +def test_env_rules_kubernetes_list_format(): + """Sidecar containers use Kubernetes [{name, value}] format.""" + result = _map_env_rules({"env": [ + {"name": "FOO", "value": "bar"}, + {"name": "BAZ", "value": "qux"}, + ]}) + assert len(result) == 2 + patterns = {r["pattern"] for r in result} + assert "FOO=bar" in patterns + assert "BAZ=qux" in patterns + + +def test_env_rules_kubernetes_list_with_value_from(): + result = _map_env_rules({"env": [ + {"name": "SECRET", "valueFrom": {"secretKeyRef": {"name": "s", "key": "k"}}}, + ]}) + assert result == [{"pattern": "SECRET=.+", "strategy": "re2", "required": False}] + + +# --------------------------------------------------------------------------- +# _map_connection_env_rules +# --------------------------------------------------------------------------- + +def test_connection_env_no_connections(): + assert _map_connection_env_rules({}) == [] + + +def test_connection_env_single(): + result = _map_connection_env_rules({"connections": {"db": {"source": "x"}}}) + assert result == [{ + "pattern": "CONNECTIONS_DB_.+=.+", "strategy": "re2", "required": True, + }] + + +def test_connection_env_multiple(): + result = _map_connection_env_rules({"connections": { + "redis": {"source": "a"}, + "sql": {"source": "b"}, + }}) + names = {r["pattern"].split("_")[1] for r in result} + assert names == {"REDIS", "SQL"} + + +def test_connection_env_disable_default_env_vars(): + """Connections with disableDefaultEnvVars should be skipped.""" + result = _map_connection_env_rules({"connections": { + "db": {"source": "a"}, + "metrics": {"source": "b", "disableDefaultEnvVars": True}, + }}) + assert len(result) == 1 + assert "DB" in result[0]["pattern"] + + +def test_connection_env_all_disabled(): + result = _map_connection_env_rules({"connections": { + "a": {"source": "x", "disableDefaultEnvVars": True}, + }}) + assert result == [] + + +# --------------------------------------------------------------------------- +# _map_volume_mounts +# --------------------------------------------------------------------------- + +def test_volumes_none(): + assert _map_volume_mounts({}) == [] + + +def test_volumes_ephemeral_is_writable(): + """Ephemeral volumes should be writable by default.""" + result = _map_volume_mounts({"volumes": { + "tmp": {"kind": "ephemeral", "mountPath": "/tmp", "managedStore": "memory"}, + }}) + assert len(result) == 1 + assert "ro" not in result[0]["options"] + assert result[0]["destination"] == "/tmp" + assert result[0]["source"] == "ephemeral://tmp" + + +def test_volumes_persistent_default_readonly(): + """Persistent volumes default to read-only per the Radius spec.""" + result = _map_volume_mounts({"volumes": { + "data": {"kind": "persistent", "mountPath": "/data", "source": "vol.id"}, + }}) + assert "ro" in result[0]["options"] + assert result[0]["source"] == "vol.id" + + +def test_volumes_persistent_permission_write(): + """API reference uses 'permission' field.""" + result = _map_volume_mounts({"volumes": { + "data": {"kind": "persistent", "mountPath": "/data", "source": "v", "permission": "write"}, + }}) + assert "ro" not in result[0]["options"] + + +def test_volumes_persistent_rbac_write(): + """Human-readable docs use 'rbac' field.""" + result = _map_volume_mounts({"volumes": { + "data": {"kind": "persistent", "mountPath": "/data", "source": "v", "rbac": "write"}, + }}) + assert "ro" not in result[0]["options"] + + +def test_volumes_persistent_permission_read(): + result = _map_volume_mounts({"volumes": { + "cfg": {"kind": "persistent", "mountPath": "/cfg", "source": "v", "permission": "read"}, + }}) + assert "ro" in result[0]["options"] + + +def test_volumes_ephemeral_explicit_read(): + """Ephemeral volume can be explicitly set to read-only.""" + result = _map_volume_mounts({"volumes": { + "ro_tmp": {"kind": "ephemeral", "mountPath": "/ro", "managedStore": "disk", "permission": "read"}, + }}) + assert "ro" in result[0]["options"] + + +def test_volumes_multiple(): + result = _map_volume_mounts({"volumes": { + "a": {"kind": "ephemeral", "mountPath": "/a", "managedStore": "memory"}, + "b": {"kind": "persistent", "mountPath": "/b", "source": "vol"}, + }}) + assert len(result) == 2 + + +# --------------------------------------------------------------------------- +# _map_exec_processes +# --------------------------------------------------------------------------- + +def test_exec_no_probes(): + assert _map_exec_processes({}) == [] + + +def test_exec_liveness_probe(): + result = _map_exec_processes({"livenessProbe": {"kind": "exec", "command": "ls"}}) + assert result == [{"command": ["ls"], "signals": []}] + + +def test_exec_readiness_probe(): + result = _map_exec_processes({"readinessProbe": {"kind": "exec", "command": ["cat", "/tmp/ready"]}}) + assert result == [{"command": ["cat", "/tmp/ready"], "signals": []}] + + +def test_exec_both_probes(): + result = _map_exec_processes({ + "livenessProbe": {"kind": "exec", "command": "live"}, + "readinessProbe": {"kind": "exec", "command": "ready"}, + }) + assert len(result) == 2 + + +def test_exec_httpget_probe_ignored(): + """Only exec probes generate exec_processes.""" + result = _map_exec_processes({ + "livenessProbe": {"kind": "httpGet", "containerPort": 8080, "path": "/health"}, + }) + assert result == [] + + +def test_exec_tcp_probe_ignored(): + result = _map_exec_processes({ + "readinessProbe": {"kind": "tcp", "containerPort": 3000}, + }) + assert result == [] + + +def test_exec_probe_without_command_ignored(): + result = _map_exec_processes({"livenessProbe": {"kind": "exec"}}) + assert result == [] + + +# --------------------------------------------------------------------------- +# _normalize_compute_probe +# --------------------------------------------------------------------------- + +def test_normalize_probe_exec(): + result = _normalize_compute_probe({"exec": {"command": ["cat", "/tmp/healthy"]}}) + assert result == {"kind": "exec", "command": ["cat", "/tmp/healthy"]} + + +def test_normalize_probe_httpget(): + result = _normalize_compute_probe({"httpGet": {"path": "/health", "port": 8080}}) + assert result == {"kind": "httpGet", "containerPort": 8080, "path": "/health"} + + +def test_normalize_probe_tcp(): + result = _normalize_compute_probe({"tcpSocket": {"port": 3000}}) + assert result == {"kind": "tcp", "containerPort": 3000} + + +def test_normalize_probe_empty(): + assert _normalize_compute_probe({}) is None + assert _normalize_compute_probe(None) is None + + +# --------------------------------------------------------------------------- +# _normalize_compute_container +# --------------------------------------------------------------------------- + +def test_normalize_compute_passthrough_fields(): + """Fields shared between schemas pass through unchanged.""" + container = { + "image": "nginx:latest", + "command": ["/bin/sh"], + "args": ["-c", "echo hi"], + "workingDir": "/app", + "env": {"FOO": {"value": "bar"}}, + } + result = _normalize_compute_container(container, {}) + assert result["image"] == "nginx:latest" + assert result["command"] == ["/bin/sh"] + assert result["args"] == ["-c", "echo hi"] + assert result["workingDir"] == "/app" + assert result["env"] == {"FOO": {"value": "bar"}} + + +def test_normalize_compute_emptydir_volume(): + """emptyDir volumes normalize to ephemeral with managedStore.""" + container = { + "image": "nginx", + "volumeMounts": [{"volumeName": "tmp", "mountPath": "/tmp/data"}], + } + resource_volumes = {"tmp": {"emptyDir": {"medium": "memory"}}} + result = _normalize_compute_container(container, resource_volumes) + + assert "volumes" in result + assert result["volumes"]["tmp"] == { + "kind": "ephemeral", + "mountPath": "/tmp/data", + "managedStore": "memory", + } + + +def test_normalize_compute_emptydir_default_medium(): + """emptyDir without medium defaults to 'disk'.""" + container = { + "image": "nginx", + "volumeMounts": [{"volumeName": "scratch", "mountPath": "/scratch"}], + } + resource_volumes = {"scratch": {"emptyDir": {}}} + result = _normalize_compute_container(container, resource_volumes) + assert result["volumes"]["scratch"]["managedStore"] == "disk" + + +def test_normalize_compute_persistent_volume(): + """persistentVolume normalizes to persistent with source and permission.""" + container = { + "image": "nginx", + "volumeMounts": [{"volumeName": "data", "mountPath": "/data"}], + } + resource_volumes = {"data": {"persistentVolume": {"resourceId": "vol.id"}}} + result = _normalize_compute_container(container, resource_volumes) + + assert result["volumes"]["data"] == { + "kind": "persistent", + "mountPath": "/data", + "source": "vol.id", + "permission": "write", # ReadWriteOnce default → write + } + + +def test_normalize_compute_persistent_readonly(): + """ReadOnlyMany access mode maps to permission 'read'.""" + container = { + "image": "nginx", + "volumeMounts": [{"volumeName": "cfg", "mountPath": "/cfg"}], + } + resource_volumes = {"cfg": {"persistentVolume": { + "resourceId": "vol.id", + "accessMode": "ReadOnlyMany", + }}} + result = _normalize_compute_container(container, resource_volumes) + assert result["volumes"]["cfg"]["permission"] == "read" + + +def test_normalize_compute_secret_volume(): + """secretName volumes normalize to persistent read-only.""" + container = { + "image": "nginx", + "volumeMounts": [{"volumeName": "certs", "mountPath": "/etc/certs"}], + } + resource_volumes = {"certs": {"secretName": "my-tls-secret"}} + result = _normalize_compute_container(container, resource_volumes) + + assert result["volumes"]["certs"] == { + "kind": "persistent", + "mountPath": "/etc/certs", + "source": "my-tls-secret", + "permission": "read", + } + + +def test_normalize_compute_probes(): + """Structured probes convert to canonical {kind, ...} format.""" + container = { + "image": "nginx", + "livenessProbe": {"exec": {"command": ["cat", "/tmp/healthy"]}}, + "readinessProbe": {"httpGet": {"path": "/ready", "port": 8080}}, + } + result = _normalize_compute_container(container, {}) + + assert result["livenessProbe"] == {"kind": "exec", "command": ["cat", "/tmp/healthy"]} + assert result["readinessProbe"] == {"kind": "httpGet", "containerPort": 8080, "path": "/ready"} + + +def test_normalize_compute_no_volumes_or_probes(): + """Minimal container with no volumes or probes passes through cleanly.""" + container = {"image": "nginx", "ports": {"web": {"containerPort": 80}}} + result = _normalize_compute_container(container, {}) + assert result["image"] == "nginx" + assert "volumes" not in result diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_radius_policy_insert.py b/src/confcom/azext_confcom/tests/latest/test_confcom_radius_policy_insert.py new file mode 100644 index 00000000000..92bd9d49804 --- /dev/null +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_radius_policy_insert.py @@ -0,0 +1,94 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +""" +Command-level tests for insert_policy_into_template. + +Tests the core regex replacement that inserts base64-encoded policy strings +into bicep template ccePolicy annotation values. +""" + +from azext_confcom.command.radius_policy_insert import insert_policy_into_template + + +def test_replaces_first_ccepolicy_in_bicep(): + """Should replace the first ccePolicy placeholder with the policy string.""" + template = """ +resource container 'Applications.Core/containers@2023-10-01-preview' = { + properties: { + extensions: [ + { + kind: 'kubernetesMetadata' + annotations: { + 'microsoft.containerinstance.virtualnode.ccepolicy': '' + } + } + ] + } +} +""" + result = insert_policy_into_template("test-policy-base64", template, 0) + assert "'microsoft.containerinstance.virtualnode.ccepolicy': 'test-policy-base64'" in result + + +def test_replaces_nth_ccepolicy_by_index(): + """Should replace only the nth ccePolicy when container_index > 0.""" + template = """ +resource c1 'Applications.Core/containers@2023-10-01-preview' = { + properties: { + extensions: [{ + annotations: { 'microsoft.containerinstance.virtualnode.ccepolicy': 'first' } + }] + } +} + +resource c2 'Applications.Core/containers@2023-10-01-preview' = { + properties: { + extensions: [{ + annotations: { 'microsoft.containerinstance.virtualnode.ccepolicy': 'second' } + }] + } +} +""" + result = insert_policy_into_template("replacement", template, 1) + + assert "'microsoft.containerinstance.virtualnode.ccepolicy': 'first'" in result + assert "'microsoft.containerinstance.virtualnode.ccepolicy': 'replacement'" in result + assert "'microsoft.containerinstance.virtualnode.ccepolicy': 'second'" not in result + + +def test_preserves_single_quote_style(): + """Should preserve single quotes around the value.""" + template = "{ 'microsoft.containerinstance.virtualnode.ccepolicy': '' }" + result = insert_policy_into_template("policy", template, 0) + assert "'policy'" in result + + +def test_preserves_double_quote_style(): + """Should preserve double quotes around the value.""" + template = '{ "microsoft.containerinstance.virtualnode.ccepolicy": "" }' + result = insert_policy_into_template("policy", template, 0) + assert '"policy"' in result + + +def test_no_change_when_index_out_of_range(): + """Should return unchanged template when index exceeds matches.""" + template = "{ 'microsoft.containerinstance.virtualnode.ccepolicy': '' }" + result = insert_policy_into_template("policy", template, 99) + assert result == template + + +def test_matches_direct_cce_policy_key(): + """Should match a bare ccePolicy key (not in annotation string).""" + template = "{ ccePolicy: '' }" + result = insert_policy_into_template("p1", template, 0) + assert "ccePolicy: 'p1'" in result + + +def test_matches_case_insensitive_annotation(): + """Should match ccePolicy regardless of casing in the annotation key.""" + template = "{ 'Microsoft.ContainerInstance.VirtualNode.CcePolicy': '' }" + result = insert_policy_into_template("p2", template, 0) + assert "'p2'" in result diff --git a/src/confcom/samples/radius/command-args/aci_container.inc.rego b/src/confcom/samples/radius/command-args/aci_container.inc.rego new file mode 100644 index 00000000000..cf80371a98a --- /dev/null +++ b/src/confcom/samples/radius/command-args/aci_container.inc.rego @@ -0,0 +1,32 @@ +{ + "id": "alpine:3.19", + "name": "alpine:3.19", + "layers": [ + "6f937bc4d3707c87d1207acd64290d97ec90c8b87a7785cb307808afa49ff892" + ], + "mounts": [ + { + "destination": "/etc/resolv.conf", + "source": "sandbox:///tmp/atlas/resolvconf/.+", + "type": "bind", + "options": [ + "rbind", + "rshared", + "rw" + ] + } + ], + "command": [ + "/bin/sh", + "-c", + "echo hello && sleep infinity" + ], + "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/radius/command-args/app.bicep b/src/confcom/samples/radius/command-args/app.bicep new file mode 100644 index 00000000000..e5b22e72193 --- /dev/null +++ b/src/confcom/samples/radius/command-args/app.bicep @@ -0,0 +1,19 @@ +extension radius + +// Feature: command + args override +// Template fields: container.command, container.args +// Policy field: command + +param application string + +resource container 'Applications.Core/containers@2023-10-01-preview' = { + name: 'command-args' + properties: { + application: application + container: { + image: 'alpine:3.19' + command: ['/bin/sh'] + args: ['-c', 'echo hello && sleep infinity'] + } + } +} diff --git a/src/confcom/samples/radius/compute-basic/aci_container.inc.rego b/src/confcom/samples/radius/compute-basic/aci_container.inc.rego new file mode 100644 index 00000000000..dd892788f5f --- /dev/null +++ b/src/confcom/samples/radius/compute-basic/aci_container.inc.rego @@ -0,0 +1,51 @@ +{ + "id": "ghcr.io/radius-project/samples/demo:latest", + "name": "ghcr.io/radius-project/samples/demo:latest", + "layers": [ + "75f76b2620207ef52a83803bb27b3243a51b13304950ee97fd4a2540cd2f465f", + "59c31a97dcce2c0ad99f2cf17259d8219e6c4cd124bc1394ab095d06c4ef7f5b", + "0f3d7530222006ed59264af03b28095e595766c0b30a93d91468a717dc7616aa", + "0d4f8d1e29f9e79bba609429da3cef21f17e57a1be70a03f3831865ee6893897", + "7bc9bcba198443ecd416542322e965fdae2217058deba096d1489a309d24e2cf", + "a21d4aba807cccaae033de52089054b0cf8c68ad96205205c5a5c631e308173f", + "ed09faca596368d6fc491376f8313170615ee85ae8367c339830753f7d284189" + ], + "mounts": [ + { + "destination": "/etc/resolv.conf", + "source": "sandbox:///tmp/atlas/resolvconf/.+", + "type": "bind", + "options": [ + "rbind", + "rshared", + "rw" + ] + } + ], + "command": [ + "docker-entrypoint.sh" + ], + "env_rules": [ + { + "pattern": "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "strategy": "string", + "required": false + }, + { + "pattern": "NODE_VERSION=22.22.0", + "strategy": "string", + "required": false + }, + { + "pattern": "YARN_VERSION=1.22.22", + "strategy": "string", + "required": false + }, + { + "pattern": "PORT=3000", + "strategy": "string", + "required": false + } + ], + "working_dir": "/usr/src/app" +} \ No newline at end of file diff --git a/src/confcom/samples/radius/compute-basic/app.bicep b/src/confcom/samples/radius/compute-basic/app.bicep new file mode 100644 index 00000000000..d73062add62 --- /dev/null +++ b/src/confcom/samples/radius/compute-basic/app.bicep @@ -0,0 +1,34 @@ +// Radius.Compute/containers: properties.containers..image → policy id, name, layers +extension radius +extension radiusResources + +@description('The Radius Application ID. Injected automatically by the rad CLI.') +param application string +@description('The ID of your Radius Environment. Set automatically by the rad CLI.') +param environment string + + +resource myapplication 'Applications.Core/applications@2023-10-01-preview' = { + name: 'radius-app-2' + properties: { + environment: environment + } +} + +resource demo 'Radius.Compute/containers@2025-08-01-preview' = { + name: 'demo' + properties: { + environment: environment + application: myapplication.id + containers: { + demo: { + image: 'ghcr.io/radius-project/samples/demo:latest' + ports: { + web: { + containerPort: 3000 + } + } + } + } + } +} diff --git a/src/confcom/samples/radius/compute-volumes/aci_container.inc.rego b/src/confcom/samples/radius/compute-volumes/aci_container.inc.rego new file mode 100644 index 00000000000..82cfda73ffb --- /dev/null +++ b/src/confcom/samples/radius/compute-volumes/aci_container.inc.rego @@ -0,0 +1,74 @@ +{ + "id": "ghcr.io/radius-project/samples/demo:latest", + "name": "ghcr.io/radius-project/samples/demo:latest", + "layers": [ + "75f76b2620207ef52a83803bb27b3243a51b13304950ee97fd4a2540cd2f465f", + "59c31a97dcce2c0ad99f2cf17259d8219e6c4cd124bc1394ab095d06c4ef7f5b", + "0f3d7530222006ed59264af03b28095e595766c0b30a93d91468a717dc7616aa", + "0d4f8d1e29f9e79bba609429da3cef21f17e57a1be70a03f3831865ee6893897", + "7bc9bcba198443ecd416542322e965fdae2217058deba096d1489a309d24e2cf", + "a21d4aba807cccaae033de52089054b0cf8c68ad96205205c5a5c631e308173f", + "ed09faca596368d6fc491376f8313170615ee85ae8367c339830753f7d284189" + ], + "mounts": [ + { + "destination": "/etc/resolv.conf", + "source": "sandbox:///tmp/atlas/resolvconf/.+", + "type": "bind", + "options": [ + "rbind", + "rshared", + "rw" + ] + }, + { + "destination": "/tmp/scratch", + "options": [ + "rbind", + "rshared" + ], + "source": "ephemeral://scratch", + "type": "bind" + } + ], + "command": [ + "docker-entrypoint.sh" + ], + "env_rules": [ + { + "pattern": "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "strategy": "string", + "required": false + }, + { + "pattern": "NODE_VERSION=22.22.0", + "strategy": "string", + "required": false + }, + { + "pattern": "YARN_VERSION=1.22.22", + "strategy": "string", + "required": false + }, + { + "pattern": "PORT=3000", + "strategy": "string", + "required": false + }, + { + "pattern": "LOG_LEVEL=debug", + "strategy": "string", + "required": false + } + ], + "working_dir": "/usr/src/app", + "exec_processes": [ + { + "command": [ + "cat", + "/tmp/scratch/healthy" + ], + "signals": [] + } + ] +} \ No newline at end of file diff --git a/src/confcom/samples/radius/compute-volumes/app.bicep b/src/confcom/samples/radius/compute-volumes/app.bicep new file mode 100644 index 00000000000..bd9ee28bfdf --- /dev/null +++ b/src/confcom/samples/radius/compute-volumes/app.bicep @@ -0,0 +1,56 @@ +// Radius.Compute/containers: volumes + probes in the Radius.Compute schema +// properties.volumes..emptyDir → mount (ephemeral) +// containers..volumeMounts → references to properties.volumes +// containers..livenessProbe.exec → exec_processes +extension radius +extension radiusResources + +param application string +param environment string + +resource app 'Applications.Core/applications@2023-10-01-preview' = { + name: 'volumes-app' + properties: { + environment: environment + } +} + +resource container 'Radius.Compute/containers@2025-08-01-preview' = { + name: 'worker' + properties: { + environment: environment + application: app.id + containers: { + worker: { + image: 'ghcr.io/radius-project/samples/demo:latest' + env: { + LOG_LEVEL: { + value: 'debug' + } + } + volumeMounts: [ + { + volumeName: 'scratch' + mountPath: '/tmp/scratch' + } + ] + livenessProbe: { + exec: { + command: [ + 'cat' + '/tmp/scratch/healthy' + ] + } + periodSeconds: 10 + } + } + } + volumes: { + scratch: { + emptyDir: { + medium: 'memory' + } + } + } + } +} diff --git a/src/confcom/samples/radius/demo/aci_container.inc.rego b/src/confcom/samples/radius/demo/aci_container.inc.rego new file mode 100644 index 00000000000..caa118625ed --- /dev/null +++ b/src/confcom/samples/radius/demo/aci_container.inc.rego @@ -0,0 +1,56 @@ +{ + "id": "ghcr.io/radius-project/samples/demo:latest", + "name": "ghcr.io/radius-project/samples/demo:latest", + "layers": [ + "75f76b2620207ef52a83803bb27b3243a51b13304950ee97fd4a2540cd2f465f", + "59c31a97dcce2c0ad99f2cf17259d8219e6c4cd124bc1394ab095d06c4ef7f5b", + "0f3d7530222006ed59264af03b28095e595766c0b30a93d91468a717dc7616aa", + "0d4f8d1e29f9e79bba609429da3cef21f17e57a1be70a03f3831865ee6893897", + "7bc9bcba198443ecd416542322e965fdae2217058deba096d1489a309d24e2cf", + "a21d4aba807cccaae033de52089054b0cf8c68ad96205205c5a5c631e308173f", + "ed09faca596368d6fc491376f8313170615ee85ae8367c339830753f7d284189" + ], + "mounts": [ + { + "destination": "/etc/resolv.conf", + "source": "sandbox:///tmp/atlas/resolvconf/.+", + "type": "bind", + "options": [ + "rbind", + "rshared", + "rw" + ] + } + ], + "command": [ + "docker-entrypoint.sh" + ], + "env_rules": [ + { + "pattern": "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "strategy": "string", + "required": false + }, + { + "pattern": "NODE_VERSION=22.22.0", + "strategy": "string", + "required": false + }, + { + "pattern": "YARN_VERSION=1.22.22", + "strategy": "string", + "required": false + }, + { + "pattern": "PORT=3000", + "strategy": "string", + "required": false + }, + { + "pattern": "CONNECTIONS_REDIS_.+=.+", + "strategy": "re2", + "required": true + } + ], + "working_dir": "/usr/src/app" +} diff --git a/src/confcom/samples/radius/demo/app.bicep b/src/confcom/samples/radius/demo/app.bicep new file mode 100644 index 00000000000..4d31bd0bb06 --- /dev/null +++ b/src/confcom/samples/radius/demo/app.bicep @@ -0,0 +1,40 @@ +extension radius + +param application string +param environment string + +param image string = 'ghcr.io/radius-project/samples/demo:latest' + +resource demo 'Applications.Core/containers@2023-10-01-preview' = { + name: 'demo' + properties: { + application: application + container: { + image: image + ports: { + web: { + containerPort: 3000 + } + } + livenessProbe: { + kind: 'httpGet' + containerPort: 3000 + path: '/healthz' + initialDelaySeconds: 10 + } + } + connections: { + redis: { + source: db.id + } + } + } +} + +resource db 'Applications.Datastores/redisCaches@2023-10-01-preview' = { + name: 'db' + properties: { + application: application + environment: environment + } +} diff --git a/src/confcom/samples/radius/exec-probes/aci_container.inc.rego b/src/confcom/samples/radius/exec-probes/aci_container.inc.rego new file mode 100644 index 00000000000..26e031f9dd6 --- /dev/null +++ b/src/confcom/samples/radius/exec-probes/aci_container.inc.rego @@ -0,0 +1,44 @@ +{ + "id": "alpine:3.19", + "name": "alpine:3.19", + "layers": [ + "6f937bc4d3707c87d1207acd64290d97ec90c8b87a7785cb307808afa49ff892" + ], + "mounts": [ + { + "destination": "/etc/resolv.conf", + "source": "sandbox:///tmp/atlas/resolvconf/.+", + "type": "bind", + "options": [ + "rbind", + "rshared", + "rw" + ] + } + ], + "command": [ + "/bin/sh" + ], + "env_rules": [ + { + "pattern": "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "strategy": "string", + "required": false + } + ], + "working_dir": "/", + "exec_processes": [ + { + "command": [ + "cat /tmp/healthy" + ], + "signals": [] + }, + { + "command": [ + "cat /tmp/ready" + ], + "signals": [] + } + ] +} diff --git a/src/confcom/samples/radius/exec-probes/app.bicep b/src/confcom/samples/radius/exec-probes/app.bicep new file mode 100644 index 00000000000..28d8863eff6 --- /dev/null +++ b/src/confcom/samples/radius/exec-probes/app.bicep @@ -0,0 +1,25 @@ +extension radius + +// Feature: exec health probes +// Template fields: container.livenessProbe (kind=exec), container.readinessProbe (kind=exec) +// Policy field: exec_processes + +param application string + +resource container 'Applications.Core/containers@2023-10-01-preview' = { + name: 'exec-probes' + properties: { + application: application + container: { + image: 'alpine:3.19' + livenessProbe: { + kind: 'exec' + command: 'cat /tmp/healthy' + } + readinessProbe: { + kind: 'exec' + command: 'cat /tmp/ready' + } + } + } +} diff --git a/src/confcom/samples/radius/sidecar/aci_container.inc.rego b/src/confcom/samples/radius/sidecar/aci_container.inc.rego new file mode 100644 index 00000000000..cb45e9d2a87 --- /dev/null +++ b/src/confcom/samples/radius/sidecar/aci_container.inc.rego @@ -0,0 +1,59 @@ +{ + "id": "nginx:1.25-alpine", + "name": "nginx:1.25-alpine", + "layers": [ + "af70d2752ed4084435638a6bc29243d0dfb963b5dc40ef40dbe61d506881755d", + "d677bb421ff112833206c65e20cdb95cbeba14740c1aac7df88e7848e3af30a9", + "fcee4d8911b2794484cb8bf8e2cafacfed443c4ab90096ea5afd434cca1e74e9", + "1e133baa89894446e550e8120f068ec53441a6839807aaa25b27e16798aa0a4d", + "1db928b8f92d675449dc15d24ffea0855d5072d64bb1b89aea85f2a4e44f367b", + "c08e1e04c7dafad80b1571f01b8d7adcce9205702e3bdf68dd0de20129acaf01", + "c175180cacfc123a931898521e280d53738388e4c95108a28f61339aeca96e32", + "f07da15381f6470075eb0662d39d693c83c1bc6d5f3dd571b03fe326379fb71f" + ], + "mounts": [ + { + "destination": "/etc/resolv.conf", + "source": "sandbox:///tmp/atlas/resolvconf/.+", + "type": "bind", + "options": [ + "rbind", + "rshared", + "rw" + ] + } + ], + "command": [ + "/docker-entrypoint.sh" + ], + "env_rules": [ + { + "pattern": "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "strategy": "string", + "required": false + }, + { + "pattern": "NGINX_VERSION=1.25.5", + "strategy": "string", + "required": false + }, + { + "pattern": "PKG_RELEASE=1", + "strategy": "string", + "required": false + }, + { + "pattern": "NJS_VERSION=0.8.4", + "strategy": "string", + "required": false + }, + { + "pattern": "NJS_RELEASE=3", + "strategy": "string", + "required": false + } + ], + "signals": [ + 3 + ] +} diff --git a/src/confcom/samples/radius/sidecar/aci_container_1.inc.rego b/src/confcom/samples/radius/sidecar/aci_container_1.inc.rego new file mode 100644 index 00000000000..52d548eeae6 --- /dev/null +++ b/src/confcom/samples/radius/sidecar/aci_container_1.inc.rego @@ -0,0 +1,62 @@ +{ + "id": "fluent/fluent-bit:2.1", + "name": "fluent/fluent-bit:2.1", + "layers": [ + "c9ece343c622c4ec9c420cefb552e74ecf9cd6d695ceec0a507e29f2a94469b5", + "82fb7fc0439dda36d1d3145378a8b1f5e5eb6f3c3053876b331c0e7839b5a8aa", + "2d5007a19e4369e60a149d3fc10342275e186448c0d355e04414f74a4c1cabfa", + "6c23cf620e0f8fd2f630bad98814040e89768652d62ed40dbb73aaf2ebed028c", + "2c6f540907e3fd5b6182cded7492b68f8ce9332f30e89436afab59271c8592d8", + "ac3801059d83cfb321d565fb31653ae6e1a8741bc29c54fe6b1229b097e469e5", + "7b4fbef988c8f7e20a7e469cf1109e74018bebac8221a5e27b0fdaac3b6f1772", + "1e9c60962369a61fca177bf0421e6b11f1758cb3f1630cfe6a8693dba1a48910", + "305b68459e325133b30c7846567a7ba0ca291d8d6d5091205aa5f6ccf2b2982e", + "b0b8d88bc0fa3e6b9ad462ad384793b3aa6278822d5a6c1ea29e2f23f0c66992", + "f9d43418841515426a14c08a998d1e680b76cf578ab8515af97401cb12e8dbaa", + "b9051e32eeec3446b227c38d01d76f2a57d1a972517256f7f4ac8bb791ae5ac0", + "06a5da170bb9bec3146fa1652b4b0823d52b00bdfb60abb2a9ba5a2cbaec92d9", + "bdc57a4217ffbd60a494f10b29c63c85ab729fa95f8d406343cfcbedd73f535a", + "e2a7e7f6f652f64fd74dd3d39ffe3ca1e67e74e4322c417590a934d3554ece7b", + "2d5054f1223c542eec390a27f5ad159f9bbeebfcd454597471c0d4ec95a803a9", + "de09ba99efa50232090cb64f96a2e8e9216b24ac5316c0978fafd86c51916aa6", + "06da377ea013ef94f430ee264721162741e4c3cd60b0a2b84943241938432d3f" + ], + "mounts": [ + { + "destination": "/etc/resolv.conf", + "source": "sandbox:///tmp/atlas/resolvconf/.+", + "type": "bind", + "options": [ + "rbind", + "rshared", + "rw" + ] + } + ], + "command": [ + "/fluent-bit/bin/fluent-bit" + ], + "env_rules": [ + { + "pattern": "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "strategy": "string", + "required": false + }, + { + "pattern": "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt", + "strategy": "string", + "required": false + }, + { + "pattern": "FLUENT_BIT_VERSION=2.1.10", + "strategy": "string", + "required": false + }, + { + "pattern": "FLUENT_OUTPUT=stdout", + "strategy": "string", + "required": false + } + ], + "working_dir": "/" +} diff --git a/src/confcom/samples/radius/sidecar/aci_container_2.inc.rego b/src/confcom/samples/radius/sidecar/aci_container_2.inc.rego new file mode 100644 index 00000000000..bed15e78fa8 --- /dev/null +++ b/src/confcom/samples/radius/sidecar/aci_container_2.inc.rego @@ -0,0 +1,31 @@ +{ + "id": "prom/node-exporter:v1.6.0", + "name": "prom/node-exporter:v1.6.0", + "layers": [ + "7a580d3787151687569bf746ee21f3efff8a44c25a05516422f081b0b37ede8a", + "99078a17fc9f1f4e964f4f07056b14ace4f15cf6cce47534f94ce79b90190d5f", + "2d01b6595212164df2bb77813c093e4a5b7541ec7eb78bdd01c6a88d79a1bfc2" + ], + "mounts": [ + { + "destination": "/etc/resolv.conf", + "source": "sandbox:///tmp/atlas/resolvconf/.+", + "type": "bind", + "options": [ + "rbind", + "rshared", + "rw" + ] + } + ], + "command": [ + "/bin/node_exporter" + ], + "env_rules": [ + { + "pattern": "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "strategy": "string", + "required": false + } + ] +} diff --git a/src/confcom/samples/radius/sidecar/app.bicep b/src/confcom/samples/radius/sidecar/app.bicep new file mode 100644 index 00000000000..df1cd6c6119 --- /dev/null +++ b/src/confcom/samples/radius/sidecar/app.bicep @@ -0,0 +1,49 @@ +extension radius + +// Sample covering: sidecar containers via runtimes.kubernetes.pod.containers + +param environment string + +resource app 'Applications.Core/applications@2023-10-01-preview' = { + name: 'sidecarapp' + properties: { + environment: environment + } +} + +resource container 'Applications.Core/containers@2023-10-01-preview' = { + name: 'main' + properties: { + application: app.id + container: { + image: 'nginx:1.25-alpine' + ports: { + http: { + containerPort: 80 + } + } + } + runtimes: { + kubernetes: { + pod: { + containers: [ + { + name: 'log-collector' + image: 'fluent/fluent-bit:2.1' + env: [ + { + name: 'FLUENT_OUTPUT' + value: 'stdout' + } + ] + } + { + name: 'metrics-exporter' + image: 'prom/node-exporter:v1.6.0' + } + ] + } + } + } + } +} diff --git a/src/confcom/samples/radius/volumes/aci_container.inc.rego b/src/confcom/samples/radius/volumes/aci_container.inc.rego new file mode 100644 index 00000000000..896abd01105 --- /dev/null +++ b/src/confcom/samples/radius/volumes/aci_container.inc.rego @@ -0,0 +1,58 @@ +{ + "id": "alpine:3.19", + "name": "alpine:3.19", + "layers": [ + "6f937bc4d3707c87d1207acd64290d97ec90c8b87a7785cb307808afa49ff892" + ], + "mounts": [ + { + "destination": "/etc/resolv.conf", + "source": "sandbox:///tmp/atlas/resolvconf/.+", + "type": "bind", + "options": [ + "rbind", + "rshared", + "rw" + ] + }, + { + "destination": "/tmp/scratch", + "options": [ + "rbind", + "rshared" + ], + "source": "ephemeral://scratch", + "type": "bind" + }, + { + "destination": "/data", + "options": [ + "rbind", + "rshared" + ], + "source": "volume.id", + "type": "bind" + }, + { + "destination": "/config", + "options": [ + "rbind", + "rshared", + "ro" + ], + "source": "configvol.id", + "type": "bind" + } + ], + "command": [ + "/bin/sh" + ], + "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/radius/volumes/app.bicep b/src/confcom/samples/radius/volumes/app.bicep new file mode 100644 index 00000000000..0ad409f5637 --- /dev/null +++ b/src/confcom/samples/radius/volumes/app.bicep @@ -0,0 +1,35 @@ +extension radius + +// Feature: volume mounts (ephemeral + persistent) +// Template fields: container.volumes[].kind, .mountPath, .source, .permission +// Policy field: mounts + +param application string + +resource container 'Applications.Core/containers@2023-10-01-preview' = { + name: 'volumes' + properties: { + application: application + container: { + image: 'alpine:3.19' + volumes: { + scratch: { + kind: 'ephemeral' + mountPath: '/tmp/scratch' + managedStore: 'memory' + } + data: { + kind: 'persistent' + mountPath: '/data' + source: 'volume.id' + permission: 'write' + } + config: { + kind: 'persistent' + mountPath: '/config' + source: 'configvol.id' + } + } + } + } +} diff --git a/src/confcom/samples/radius/working-dir/aci_container.inc.rego b/src/confcom/samples/radius/working-dir/aci_container.inc.rego new file mode 100644 index 00000000000..4ee72711dcc --- /dev/null +++ b/src/confcom/samples/radius/working-dir/aci_container.inc.rego @@ -0,0 +1,30 @@ +{ + "id": "alpine:3.19", + "name": "alpine:3.19", + "layers": [ + "6f937bc4d3707c87d1207acd64290d97ec90c8b87a7785cb307808afa49ff892" + ], + "mounts": [ + { + "destination": "/etc/resolv.conf", + "source": "sandbox:///tmp/atlas/resolvconf/.+", + "type": "bind", + "options": [ + "rbind", + "rshared", + "rw" + ] + } + ], + "command": [ + "/bin/sh" + ], + "env_rules": [ + { + "pattern": "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "strategy": "string", + "required": false + } + ], + "working_dir": "/app/src" +} diff --git a/src/confcom/samples/radius/working-dir/app.bicep b/src/confcom/samples/radius/working-dir/app.bicep new file mode 100644 index 00000000000..cb266c79191 --- /dev/null +++ b/src/confcom/samples/radius/working-dir/app.bicep @@ -0,0 +1,18 @@ +extension radius + +// Feature: working directory override +// Template field: container.workingDir +// Policy field: working_dir + +param application string + +resource container 'Applications.Core/containers@2023-10-01-preview' = { + name: 'working-dir' + properties: { + application: application + container: { + image: 'alpine:3.19' + workingDir: '/app/src' + } + } +} diff --git a/src/confcom/setup.py b/src/confcom/setup.py index a4cbb850a58..3de4dd8f79d 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.8.0" +VERSION = "1.9.0" # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers