Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ class CompareFields:
CompareFieldsMap = {
"appconfig": (CompareFields.CONTENT_TYPE, CompareFields.VALUE, CompareFields.TAGS),
"appservice": (CompareFields.VALUE, CompareFields.TAGS),
"aks": (CompareFields.CONTENT_TYPE, CompareFields.VALUE, CompareFields.TAGS),
"file": (CompareFields.CONTENT_TYPE, CompareFields.VALUE),
"kvset": (CompareFields.CONTENT_TYPE, CompareFields.VALUE, CompareFields.TAGS),
"restore": (CompareFields.VALUE, CompareFields.CONTENT_TYPE, CompareFields.LOCKED, CompareFields.TAGS)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ def get_serializer(level):
'''
Helper method that returns a serializer method called in formatting a string representation of a key-value.
'''
source_modes = ("appconfig", "appservice", "file")
source_modes = ("appconfig", "appservice", "file", "aks")
kvset_modes = ("kvset", "restore")

if level not in source_modes and level not in kvset_modes:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from azure.cli.core.util import user_confirmation
from azure.cli.core.azclierror import (
AzureInternalError,
AzureResponseError,
FileOperationError,
InvalidArgumentValueError,
RequiredArgumentMissingError,
Expand Down Expand Up @@ -550,7 +551,40 @@ def __read_kv_from_file(
except OSError:
raise FileOperationError("File is not available.")

flattened_data = __flatten_config_data(
config_data=config_data,
format_=format_,
content_type=content_type,
prefix_to_add=prefix_to_add,
depth=depth,
separator=separator
)

# convert to KeyValue list
key_values = []
for k, v in flattened_data.items():
if validate_import_key(key=k):
key_values.append(KeyValue(key=k, value=v))
return key_values


def __flatten_config_data(config_data, format_, content_type, prefix_to_add="", depth=None, separator=None):
"""
Flatten configuration data into a dictionary of key-value pairs.

Args:
config_data: The configuration data to flatten (dict or list)
format_ (str): The format of the configuration data ('json', 'yaml', 'properties')
content_type (str): Content type for JSON validation
prefix_to_add (str): Prefix to add to each key
depth (int): Maximum depth for flattening hierarchical data
separator (str): Separator for hierarchical keys

Returns:
dict: Flattened key-value pairs
"""
flattened_data = {}

if format_ == "json" and content_type and is_json_content_type(content_type):
for key in config_data:
__flatten_json_key_value(
Expand Down Expand Up @@ -582,13 +616,7 @@ def __read_kv_from_file(
separator=separator,
)

# convert to KeyValue list
key_values = []
for k, v in flattened_data.items():
if validate_import_key(key=k):
key_values.append(KeyValue(key=k, value=v))
return key_values

return flattened_data

# App Service <-> List of KeyValue object

Expand Down Expand Up @@ -715,6 +743,170 @@ def __read_kv_from_app_service(
raise CLIError("Failed to read key-values from appservice.\n" + str(exception))


def __read_kv_from_kubernetes_configmap(
cmd,
aks_cluster,
configmap_name,
format_,
namespace="default",
prefix_to_add="",
content_type=None,
depth=None,
separator=None
):
"""
Read key-value pairs from a Kubernetes ConfigMap using aks_runcommand.

Args:
cmd: The command context object
aks_cluster (str): Name of the AKS cluster
configmap_name (str): Name of the ConfigMap to read from
format_ (str): Format of the data in the ConfigMap (e.g., "json", "yaml")
namespace (str): Kubernetes namespace where the ConfigMap resides (default: "default")
prefix_to_add (str): Prefix to add to each key in the ConfigMap
content_type (str): Content type to apply to the key-values
depth (int): Maximum depth for flattening hierarchical data
separator (str): Separator for hierarchical keys

Returns:
list: List of KeyValue objects
"""
key_values = []
from azure.cli.command_modules.acs.custom import aks_runcommand
from azure.cli.command_modules.acs._client_factory import cf_managed_clusters

# Preserve only the necessary CLI context data
original_subscription = cmd.cli_ctx.data.get('subscription_id')
original_safe_params = cmd.cli_ctx.data.get('safe_params', [])

try:
# Temporarily modify the CLI context
cmd.cli_ctx.data['subscription_id'] = aks_cluster["subscription"]
Copy link
Copy Markdown
Contributor

@albertofori albertofori Jul 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean we are allowing the imports from a cluster in a different subscription? I would expect this to be scoped to the user's subscription.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why should we limit it to the user's subscription?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I think my comment can be ignored.
I was initially under the impression that we only take cluster name as input, but user can also pass the ARM ID , similar to app service. So I guess there need for the restriction.
Thanks for calling this out @ChristineWanjau !

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the event where the store and cluster are in different subscriptions, and we don't restore the original subscription value, wouldn't subsequent calls within the command that depend on cli_ctx, target the AKS cluster's subscription?

Copy link
Copy Markdown
Member Author

@RichardChen820 RichardChen820 Aug 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cli_ctx will not be used afterward since the appconfig client is already created before we modified the subscription. But from the function design perspective, this function should not have a side effect on the original data. I agree with your viewpoint, let's do the right thing.

params_to_keep = ["--debug", "--verbose"]
cmd.cli_ctx.data['safe_params'] = [p for p in original_safe_params if p in params_to_keep]
# It must be set to return the result.
cmd.cli_ctx.data['safe_params'].append("--output")
# Get the AKS client from the factory
aks_client = cf_managed_clusters(cmd.cli_ctx)

# Command to get the ConfigMap and output it as JSON
command = f"kubectl get configmap {configmap_name} -n {namespace} -o json"

# Execute the command on the cluster
result = aks_runcommand(cmd, aks_client, aks_cluster["resource_group"], aks_cluster["name"], command_string=command)

if hasattr(result, 'logs') and result.logs:
if not hasattr(result, 'exit_code') or result.exit_code == 0:
try:
configmap_data = json.loads(result.logs)

# Extract the data section which contains the key-value pairs
kvs = __extract_kv_from_configmap_data(
configmap_data, content_type, prefix_to_add, format_, depth, separator)

key_values.extend(kvs)
except json.JSONDecodeError:
raise ValueError(
f"The result from ConfigMap {configmap_name} could not be parsed. {result.logs.strip()}"
)
else:
raise AzureResponseError(f"{result.logs.strip()}")
else:
raise AzureResponseError("Unable to get the ConfigMap.")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious, what is returned when there is no data in the ConfigMap? Would it be {"logs": {}}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aks_runcommand result for configMap does not exist

{'additional_properties': {}, 'id': '1234d4bebe9e4a1581ff23d07155edb1', 'provisioning_state': 'Succeeded', 'exit_code': 1, 'started_at': datetime.datetime(2025, 8, 4, 6, 0, 31, tzinfo=<isodate.tzinfo.Utc object at 0x00000221D5B87290>), 'finished_at': datetime.datetime(2025, 8, 4, 6, 0, 32, tzinfo=<isodate.tzinfo.Utc object at 0x00000221D5B87290>), 'logs': 'Error from server (NotFound): configmaps "configmap3" not found\n', 'reason': None}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aks_runcommand result for configMap with no data

{'additional_properties': {}, 'id': '03ce213b8d4e473a92d82ed8b40e7f79', 'provisioning_state': 'Succeeded', 'exit_code': 0, 'started_at': datetime.datetime(2025, 8, 4, 5, 53, 26, tzinfo=<isodate.tzinfo.Utc object at 0x000001A838517350>), 'finished_at': datetime.datetime(2025, 8, 4, 5, 53, 27, tzinfo=<isodate.tzinfo.Utc object at 0x000001A838517350>), 'logs': '{\n    "apiVersion": "v1",\n    "kind": "ConfigMap",\n    "metadata": {\n        "annotations": {\n            "kubectl.kubernetes.io/last-applied-configuration": "{\\"apiVersion\\":\\"v1\\",\\"data\\":null,\\"kind\\":\\"ConfigMap\\",\\"metadata\\":{\\"annotations\\":{},\\"name\\":\\"configmap-demo\\",\\"namespace\\":\\"default\\"}}\\n"\n        },\n        "creationTimestamp": "2025-05-29T15:14:33Z",\n        "name": "configmap-demo",\n        "namespace": "default",\n        "resourceVersion": "280166617",\n        "uid": "68774c57-72a6-4052-9a94-99244d7cefc5"\n    }\n}\n', 'reason': None}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!


return key_values
except Exception as exception:
raise AzureInternalError(
f"Failed to read key-values from ConfigMap '{configmap_name}' in namespace '{namespace}'.\n{str(exception)}"
)
finally:
# Restore original CLI context data
cmd.cli_ctx.data['subscription_id'] = original_subscription
cmd.cli_ctx.data['safe_params'] = original_safe_params


def __extract_kv_from_configmap_data(configmap, content_type, prefix_to_add="", format_=None, depth=None, separator=None):
"""
Helper function to extract key-value pairs from ConfigMap data.

Args:
configmap (dict): The ConfigMap data as a dictionary
prefix_to_add (str): Prefix to add to each key
content_type (str): Content type to apply to the key-values
format_ (str): Format of the data in the ConfigMap (e.g., "json", "yaml")
depth (int): Maximum depth for flattening hierarchical data
separator (str): Separator for hierarchical keys

Returns:
list: List of KeyValue objects
"""
key_values = []

if not configmap.get('data', None):
logger.warning("ConfigMap exists but has no data")
return key_values

for key, value in configmap['data'].items():
if format_ in ("json", "yaml", "properties"):
if format_ == "json":
try:
value = json.loads(value)
except json.JSONDecodeError:
logger.warning(
'Value "%s" for key "%s" is not a well formatted JSON data.',
value, key
)
continue
elif format_ == "yaml":
try:
value = yaml.safe_load(value)
except yaml.YAMLError:
logger.warning(
'Value "%s" for key "%s" is not a well formatted YAML data.',
value, key
)
continue
else:
try:
value = javaproperties.load(io.StringIO(value))
except javaproperties.InvalidUEscapeError:
logger.warning(
'Value "%s" for key "%s" is not a well formatted properties data.',
value, key
)
continue

flattened_data = __flatten_config_data(
config_data=value,
format_=format_,
content_type=content_type,
prefix_to_add=prefix_to_add,
depth=depth,
separator=separator
)

for k, v in flattened_data.items():
if validate_import_key(key=k):
key_values.append(KeyValue(key=k, value=v))

elif validate_import_key(key):
# If content_type is JSON, validate the value
if content_type and is_json_content_type(content_type):
try:
json.loads(value)
except json.JSONDecodeError:
logger.warning(
'Value "%s" for key "%s" is not a valid JSON object, which conflicts with the provided content type "%s".',
value, key, content_type
)
continue

kv = KeyValue(key=prefix_to_add + key, value=value)
key_values.append(kv)

return key_values


def __validate_import_keyvault_ref(kv):
if kv and validate_import_key(kv.key):
try:
Expand Down
10 changes: 8 additions & 2 deletions src/azure-cli/azure/cli/command_modules/appconfig/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
get_default_location_from_resource_group
from ._constants import ImportExportProfiles, ImportMode, FeatureFlagConstants, ARMAuthenticationMode

from ._validators import (validate_appservice_name_or_id, validate_sku, validate_snapshot_query_fields,
from ._validators import (validate_appservice_name_or_id, validate_aks_cluster_name_or_id,
validate_sku, validate_snapshot_query_fields,
validate_connection_string, validate_datetime,
validate_export, validate_import,
validate_import_depth, validate_query_fields,
Expand Down Expand Up @@ -232,7 +233,7 @@ def load_arguments(self, _):
c.argument('label', help="Imported KVs and feature flags will be assigned with this label. If no label specified, will assign null label.")
c.argument('tags', nargs="*", help="Imported KVs and feature flags will be assigned with these tags. If no tags are specified, imported KVs and feature flags will retain existing tags. Support space-separated tags: key[=value] [key[=value] ...]. Use "" to clear existing tags.")
c.argument('prefix', help="This prefix will be appended to the front of imported keys. Prefix will be ignored for feature flags.")
c.argument('source', options_list=['--source', '-s'], arg_type=get_enum_type(['file', 'appconfig', 'appservice']), validator=validate_import, help="The source of importing. Note that importing feature flags from appservice is not supported.")
c.argument('source', options_list=['--source', '-s'], arg_type=get_enum_type(['file', 'appconfig', 'appservice', 'aks']), validator=validate_import, help="The source of importing. Note that importing feature flags from appservice is not supported.")
c.argument('yes', help="Do not prompt for preview.")
c.argument('skip_features', help="Import only key values and exclude all feature flags. By default, all feature flags will be imported from file or appconfig. Not applicable for appservice.", arg_type=get_three_state_flag())
c.argument('content_type', help='Content type of all imported items.')
Expand Down Expand Up @@ -264,6 +265,11 @@ def load_arguments(self, _):
with self.argument_context('appconfig kv import', arg_group='AppService') as c:
c.argument('appservice_account', validator=validate_appservice_name_or_id, help='ARM ID for AppService OR the name of the AppService, assuming it is in the same subscription and resource group as the App Configuration store. Required for AppService arguments')

with self.argument_context('appconfig kv import', arg_group='AKS') as c:
c.argument('aks_cluster', validator=validate_aks_cluster_name_or_id, help='ARM ID for AKS OR the name of the AKS, assuming it is in the same subscription and resource group as the App Configuration store. Required for AKS arguments')
c.argument('configmap_name', help='Name of the ConfigMap. Required for AKS arguments.')
Copy link

Copilot AI Jul 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The help message should start with an active voice verb instead of a noun. Consider changing 'Name of the ConfigMap...' to 'Specify name of the ConfigMap...' or 'Provide name of the ConfigMap...'

Copilot uses AI. Check for mistakes.
c.argument('configmap_namespace', help='Namespace of the ConfigMap. default to "default" namespace if not specified.')

with self.argument_context('appconfig kv export') as c:
c.argument('label', help="Only keys and feature flags with this label will be exported. If no label specified, export keys and feature flags with null label by default. When export destination is appconfig, or when export destination is file with `appconfig/kvset` profile, this argument supports asterisk and comma signs for label filtering, for instance, * means all labels, abc* means labels with abc as prefix, and abc,xyz means labels with abc or xyz.")
c.argument('prefix', help="Prefix to be trimmed from keys. Prefix will be ignored for feature flags.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ def validate_import(namespace):
elif source == 'appservice':
if namespace.appservice_account is None:
raise RequiredArgumentMissingError("Please provide '--appservice-account' argument")
elif source == 'aks':
if namespace.aks_cluster is None:
raise RequiredArgumentMissingError("Please provide '--aks-cluster' argument")
if namespace.configmap_name is None:
raise RequiredArgumentMissingError("Please provide '--configmap-name' argument")


def validate_export(namespace):
Expand Down Expand Up @@ -145,6 +150,29 @@ def validate_appservice_name_or_id(cmd, namespace):
namespace.appservice_account = parse_resource_id(namespace.appservice_account)


def validate_aks_cluster_name_or_id(cmd, namespace):
from azure.cli.core.commands.client_factory import get_subscription_id
from azure.mgmt.core.tools import is_valid_resource_id, parse_resource_id
if namespace.aks_cluster:
if not is_valid_resource_id(namespace.aks_cluster):
config_store_name = ""
if namespace.name:
config_store_name = namespace.name
elif namespace.connection_string:
config_store_name = get_store_name_from_connection_string(namespace.connection_string)
else:
raise ArgumentUsageError("Please provide App Configuration name or connection string for fetching the AKS cluster details. Alternatively, you can provide a valid ARM ID for the AKS cluster.")

resource_group, _ = resolve_store_metadata(cmd, config_store_name)
namespace.aks_cluster = {
"subscription": get_subscription_id(cmd.cli_ctx),
"resource_group": resource_group,
"name": namespace.aks_cluster
}
else:
namespace.aks_cluster = parse_resource_id(namespace.aks_cluster)


def validate_query_fields(namespace):
if namespace.fields:
fields = []
Expand Down
26 changes: 25 additions & 1 deletion src/azure-cli/azure/cli/command_modules/appconfig/keyvalue.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
__delete_configuration_setting_from_config_store,
__read_features_from_file,
__read_kv_from_app_service,
__read_kv_from_kubernetes_configmap,
__read_kv_from_file,
)
from knack.log import get_logger
Expand Down Expand Up @@ -92,7 +93,11 @@ def import_config(cmd,
src_endpoint=None,
src_tags=None, # tags to filter
# from-appservice parameters
appservice_account=None):
appservice_account=None,
# from-aks parameters
aks_cluster=None,
configmap_namespace="default",
configmap_name=None):

src_features = []
dest_features = []
Expand Down Expand Up @@ -176,6 +181,25 @@ def import_config(cmd,
src_kvs = __read_kv_from_app_service(
cmd, appservice_account=appservice_account, prefix_to_add=prefix, content_type=content_type)

elif source == 'aks':
if separator:
# If separator is provided, use max depth by default unless depth is specified.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we let a user know this that we use max depth by default if depth is not provided maybe somewhere in the params.py?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean this?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes this works! Hadn't seen it.

depth = sys.maxsize if depth is None else int(depth)
else:
if depth and int(depth) != 1:
logger.warning("Cannot flatten hierarchical data without a separator. --depth argument will be ignored.")
depth = 1

src_kvs = __read_kv_from_kubernetes_configmap(cmd,
aks_cluster=aks_cluster,
configmap_name=configmap_name,
namespace=configmap_namespace,
prefix_to_add=prefix,
content_type=content_type,
format_=format_,
separator=separator,
depth=depth)

if strict or not yes or import_mode == ImportMode.IGNORE_MATCH:
# fetch key values from user's configstore
dest_kvs = __read_kv_from_config_store(azconfig_client,
Expand Down
Loading
Loading