Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/azure-cli/HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ Release History

* `az containerapp env workload-profile add`: Simplify workload-profile creation with default profile name (#32713)

**Container**

* Fix #32899: `az container create` and `az container container-group-profile create`: Add `--environment-variables-file` and `--secure-environment-variables-file` parameters to load environment variables from a JSON file, allowing values that contain special shell characters such as double-quotes and carets that are stripped by PowerShell or CMD (#32899)

**Event Hubs**

* Fix #31108, #32073: `az eventhubs`: Regex updated for commands with `--namespace-name` arguments (#32472)
Expand Down
2 changes: 2 additions & 0 deletions src/azure-cli/azure/cli/command_modules/container/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
text: az container create -g MyResourceGroup --name myapp --image myimage:latest --command-line "echo hello" --restart-policy Never
- name: Create a container in a container group with environment variables.
text: az container create -g MyResourceGroup --name myapp --image myimage:latest --environment-variables key1=value1 key2=value2
- name: Create a container with environment variables from a JSON file (for values with special characters such as quotes or carets).
text: az container create -g MyResourceGroup --name myapp --image myimage:latest --environment-variables-file /path/to/env.json
- name: Create a container in a container group using container image from Azure Container Registry.
text: az container create -g MyResourceGroup --name myapp --image myAcrRegistry.azurecr.io/myimage:latest --registry-password password
- name: Create a container in a container group that mounts an Azure File share as volume.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ def load_arguments(self, _):
c.argument('restart_policy', arg_type=get_enum_type(ContainerGroupRestartPolicy), help='Restart policy for all containers within the container group')
c.argument('command_line', help='The command line to run when the container is started, e.g. \'/bin/bash -c myscript.sh\'')
c.argument('environment_variables', nargs='+', options_list=['--environment-variables', '-e'], type=_environment_variables_type, help='A list of environment variable for the container. Space-separated values in \'key=value\' format.')
c.argument('environment_variables_file', options_list=['--environment-variables-file'], help='Path to a JSON file containing environment variables for the container. The file must contain a JSON object of key-value pairs. May be used together with --environment-variables and is useful when values contain special shell characters such as double-quotes or carets. If the same key appears in both, the value from --environment-variables takes precedence.')
c.argument('secure_environment_variables', nargs='+', type=_secure_environment_variables_type, help='A list of secure environment variable for the container. Space-separated values in \'key=value\' format.')
c.argument('secure_environment_variables_file', options_list=['--secure-environment-variables-file'], help='Path to a JSON file containing secure (masked) environment variables for the container. The file must contain a JSON object of key-value pairs. May be used together with --secure-environment-variables and is useful when values contain special shell characters such as double-quotes or carets. If the same key appears in both, the value from --secure-environment-variables takes precedence.')
c.argument('secrets', secrets_type)
c.argument('secrets_mount_path', validator=validate_volume_mount_path, help="The path within the container where the secrets volume should be mounted. Must not contain colon ':'.")
c.argument('file', options_list=['--file', '-f'], help="The path to the input file.")
Expand Down Expand Up @@ -176,7 +178,9 @@ def load_arguments(self, _):
c.argument('restart_policy', arg_type=get_enum_type(ContainerGroupRestartPolicy), help='Restart policy for all containers within the container group')
c.argument('command_line', help='The command line to run when the container is started, e.g. \'/bin/bash -c myscript.sh\'')
c.argument('environment_variables', nargs='+', options_list=['--environment-variables', '-e'], type=_environment_variables_type, help='A list of environment variable for the container. Space-separated values in \'key=value\' format.')
c.argument('environment_variables_file', options_list=['--environment-variables-file'], help='Path to a JSON file containing environment variables for the container. The file must contain a JSON object of key-value pairs. May be used together with --environment-variables and is useful when values contain special shell characters such as double-quotes or carets. If the same key appears in both, the value from --environment-variables takes precedence.')
c.argument('secure_environment_variables', nargs='+', type=_secure_environment_variables_type, help='A list of secure environment variable for the container. Space-separated values in \'key=value\' format.')
c.argument('secure_environment_variables_file', options_list=['--secure-environment-variables-file'], help='Path to a JSON file containing secure (masked) environment variables for the container. The file must contain a JSON object of key-value pairs. May be used together with --secure-environment-variables and is useful when values contain special shell characters such as double-quotes or carets. If the same key appears in both, the value from --secure-environment-variables takes precedence.')
c.argument('secrets', secrets_type)
c.argument('secrets_mount_path', validator=validate_volume_mount_path, help="The path within the container where the secrets volume should be mounted. Must not contain colon ':'.")
c.argument('file', options_list=['--file', '-f'], help="The path to the input file.")
Expand Down
100 changes: 100 additions & 0 deletions src/azure-cli/azure/cli/command_modules/container/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,66 @@
MSI_LOCAL_ID = '[system]'


def _load_env_vars_from_file(file_path, secure=False):
"""Load environment variables from a JSON file.

The file must contain a JSON object where each key-value pair represents
an environment variable name and its value. This allows passing values
that contain special characters (e.g. quotes, carets) that would otherwise
be mishandled by the shell before reaching the CLI.

Example file content::

{
"APP_DB_PASSWORD": "p@ssw0rd^with\"quotes",
"SMTP_HOST": "mail.example.com"
}

:param file_path: Path to the JSON file.
:param secure: When True, variables are returned with the ``secureValue``
key (masked in logs and output) instead of ``value``.
:returns: List of dicts compatible with the ``--environment-variables``
argument, i.e. ``[{'name': 'K', 'value': 'V'}, ...]``.
"""
import json

try:
with open(file_path, 'r', encoding='utf-8') as env_file:
data = json.load(env_file)
except (IOError, OSError) as exc:
raise CLIError(f'Failed to read environment variables file "{file_path}": {exc}')
except json.JSONDecodeError as exc:
raise CLIError(
f'Failed to parse environment variables file "{file_path}" as JSON: {exc}. '
'The file must contain a JSON object with string keys and string values, '
'e.g. {"KEY1": "value1", "KEY2": "value2"}.'
)

if not isinstance(data, dict):
raise CLIError(
f'Environment variables file "{file_path}" must contain a JSON object '
f'(key-value pairs), not a {type(data).__name__}.'
)

result = []
value_key = 'secureValue' if secure else 'value'
for key, value in data.items():
if not isinstance(key, str):
raise CLIError(
f'Environment variable name must be a string, got {type(key).__name__!r} '
f'for key {key!r} in "{file_path}".'
)
if not isinstance(value, str):
logger.warning(
'Environment variable "%s" has a non-string value (%s); converting to string.',
key, type(value).__name__
)
value = str(value)
result.append({'name': key, value_key: value})

return result


def list_containers(client, resource_group_name=None):
"""List all container groups in a resource group. """
if resource_group_name is None:
Expand Down Expand Up @@ -89,6 +149,8 @@ def create_container(cmd,
command_line=None,
environment_variables=None,
secure_environment_variables=None,
environment_variables_file=None,
secure_environment_variables_file=None,
registry_login_server=None,
registry_username=None,
registry_password=None,
Expand Down Expand Up @@ -209,6 +271,24 @@ def create_container(cmd,
volumes.append(gitrepo_volume)
mounts.append(gitrepo_volume_mount)

# Merge environment variables from files with those supplied on the command line.
# File-based variables allow values containing special shell characters (e.g. " ^)
# that would be stripped by PowerShell or CMD before reaching the CLI.
# CLI-provided values take precedence: if the same key appears in both sources,
# the value from --environment-variables is used and the file-sourced entry is dropped.
if environment_variables_file:
file_env_vars = _load_env_vars_from_file(environment_variables_file, secure=False)
cli_env_keys = {v['name'] for v in (environment_variables or [])}
environment_variables = (environment_variables or []) + [
v for v in file_env_vars if v['name'] not in cli_env_keys
]
if secure_environment_variables_file:
file_secure_env_vars = _load_env_vars_from_file(secure_environment_variables_file, secure=True)
cli_secure_keys = {v['name'] for v in (secure_environment_variables or [])}
secure_environment_variables = (secure_environment_variables or []) + [
v for v in file_secure_env_vars if v['name'] not in cli_secure_keys
]

# Concatenate secure and standard environment variables
if environment_variables and secure_environment_variables:
environment_variables = environment_variables + secure_environment_variables
Expand Down Expand Up @@ -331,6 +411,8 @@ def create_container_group_profile(cmd,
command_line=None,
environment_variables=None,
secure_environment_variables=None,
environment_variables_file=None,
secure_environment_variables_file=None,
registry_login_server=None,
registry_username=None,
registry_password=None,
Expand Down Expand Up @@ -431,6 +513,24 @@ def create_container_group_profile(cmd,
volumes.append(gitrepo_volume)
mounts.append(gitrepo_volume_mount)

# Merge environment variables from files with those supplied on the command line.
# File-based variables allow values containing special shell characters (e.g. " ^)
# that would be stripped by PowerShell or CMD before reaching the CLI.
# CLI-provided values take precedence: if the same key appears in both sources,
# the value from --environment-variables is used and the file-sourced entry is dropped.
if environment_variables_file:
file_env_vars = _load_env_vars_from_file(environment_variables_file, secure=False)
cli_env_keys = {v['name'] for v in (environment_variables or [])}
environment_variables = (environment_variables or []) + [
v for v in file_env_vars if v['name'] not in cli_env_keys
]
if secure_environment_variables_file:
file_secure_env_vars = _load_env_vars_from_file(secure_environment_variables_file, secure=True)
cli_secure_keys = {v['name'] for v in (secure_environment_variables or [])}
secure_environment_variables = (secure_environment_variables or []) + [
v for v in file_secure_env_vars if v['name'] not in cli_secure_keys
]

# Concatenate secure and standard environment variables
if environment_variables and secure_environment_variables:
environment_variables = environment_variables + secure_environment_variables
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
# exclusions for the container module

container:
rule_exclusions:
- require_wait_command_if_no_wait

container container-group-profile:
rule_exclusions:
- require_wait_command_if_no_wait

container create:
parameters:
environment_variables_file:
rule_exclusions:
- option_length_too_long
- missing_parameter_test_coverage
secure_environment_variables_file:
rule_exclusions:
- option_length_too_long
- missing_parameter_test_coverage

container container-group-profile create:
parameters:
environment_variables_file:
rule_exclusions:
- option_length_too_long
- missing_parameter_test_coverage
secure_environment_variables_file:
rule_exclusions:
- option_length_too_long
- missing_parameter_test_coverage
Original file line number Diff line number Diff line change
Expand Up @@ -1431,6 +1431,70 @@ def test_container_group_profile_create_spot_priority(self, resource_group, reso
self.check(
'containers[0].resources.requests.memoryInGb', memory)])


class ContainerEnvVarsFileTest(unittest.TestCase):
"""Unit tests for the _load_env_vars_from_file helper in custom.py."""

def _load(self, content, secure=False):
"""Write content to a temp JSON file and call the helper."""
from azure.cli.command_modules.container.custom import _load_env_vars_from_file
with tempfile.NamedTemporaryFile(mode='w', suffix='.json',
delete=False, encoding='utf-8') as tmp:
tmp.write(content)
tmp_path = tmp.name
try:
return _load_env_vars_from_file(tmp_path, secure=secure)
finally:
import os
os.unlink(tmp_path)

def test_valid_json_returns_list_of_dicts(self):
result = self._load('{"KEY1": "value1", "KEY2": "value2"}')
self.assertEqual(len(result), 2)
names = {item['name'] for item in result}
self.assertIn('KEY1', names)
self.assertIn('KEY2', names)
for item in result:
self.assertIn('value', item)
self.assertNotIn('secureValue', item)

def test_secure_true_uses_secure_value_key(self):
result = self._load('{"SECRET": "my_secret"}', secure=True)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['name'], 'SECRET')
self.assertIn('secureValue', result[0])
self.assertNotIn('value', result[0])
self.assertEqual(result[0]['secureValue'], 'my_secret')

def test_special_characters_preserved(self):
content = '{"KEY": "value with \\"quotes\\" and ^carets^"}'
result = self._load(content)
self.assertEqual(result[0]['value'], 'value with "quotes" and ^carets^')

def test_non_string_value_converted_with_warning(self):
result = self._load('{"PORT": 8080}')
self.assertEqual(result[0]['value'], '8080')

def test_invalid_json_raises_cli_error(self):
from knack.util import CLIError
with self.assertRaises(CLIError):
self._load('not valid json')

def test_non_dict_json_raises_cli_error(self):
from knack.util import CLIError
with self.assertRaises(CLIError):
self._load('[{"name": "KEY", "value": "val"}]')

def test_empty_dict_returns_empty_list(self):
result = self._load('{}')
self.assertEqual(result, [])

def test_missing_file_raises_cli_error(self):
from azure.cli.command_modules.container.custom import _load_env_vars_from_file
from knack.util import CLIError
with self.assertRaises(CLIError):
_load_env_vars_from_file('/nonexistent/path/env.json')

# Test delete
self.cmd('container container-group-profile delete -g {rg} -n {container_group_profile_name} -y')

Expand Down
Loading