Skip to content

Commit be770fe

Browse files
[App Config] az appconfig kv import: Support importing key-values from AKS ConfigMap (#31861)
1 parent e41a787 commit be770fe

File tree

8 files changed

+34992
-12
lines changed

8 files changed

+34992
-12
lines changed

src/azure-cli/azure/cli/command_modules/appconfig/_constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ class CompareFields:
144144
CompareFieldsMap = {
145145
"appconfig": (CompareFields.CONTENT_TYPE, CompareFields.VALUE, CompareFields.TAGS),
146146
"appservice": (CompareFields.VALUE, CompareFields.TAGS),
147+
"aks": (CompareFields.CONTENT_TYPE, CompareFields.VALUE, CompareFields.TAGS),
147148
"file": (CompareFields.CONTENT_TYPE, CompareFields.VALUE),
148149
"kvset": (CompareFields.CONTENT_TYPE, CompareFields.VALUE, CompareFields.TAGS),
149150
"restore": (CompareFields.VALUE, CompareFields.CONTENT_TYPE, CompareFields.LOCKED, CompareFields.TAGS)

src/azure-cli/azure/cli/command_modules/appconfig/_diff_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def get_serializer(level):
141141
'''
142142
Helper method that returns a serializer method called in formatting a string representation of a key-value.
143143
'''
144-
source_modes = ("appconfig", "appservice", "file")
144+
source_modes = ("appconfig", "appservice", "file", "aks")
145145
kvset_modes = ("kvset", "restore")
146146

147147
if level not in source_modes and level not in kvset_modes:

src/azure-cli/azure/cli/command_modules/appconfig/_kv_import_helpers.py

Lines changed: 199 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from azure.cli.core.util import user_confirmation
2323
from azure.cli.core.azclierror import (
2424
AzureInternalError,
25+
AzureResponseError,
2526
FileOperationError,
2627
InvalidArgumentValueError,
2728
RequiredArgumentMissingError,
@@ -550,7 +551,40 @@ def __read_kv_from_file(
550551
except OSError:
551552
raise FileOperationError("File is not available.")
552553

554+
flattened_data = __flatten_config_data(
555+
config_data=config_data,
556+
format_=format_,
557+
content_type=content_type,
558+
prefix_to_add=prefix_to_add,
559+
depth=depth,
560+
separator=separator
561+
)
562+
563+
# convert to KeyValue list
564+
key_values = []
565+
for k, v in flattened_data.items():
566+
if validate_import_key(key=k):
567+
key_values.append(KeyValue(key=k, value=v))
568+
return key_values
569+
570+
571+
def __flatten_config_data(config_data, format_, content_type, prefix_to_add="", depth=None, separator=None):
572+
"""
573+
Flatten configuration data into a dictionary of key-value pairs.
574+
575+
Args:
576+
config_data: The configuration data to flatten (dict or list)
577+
format_ (str): The format of the configuration data ('json', 'yaml', 'properties')
578+
content_type (str): Content type for JSON validation
579+
prefix_to_add (str): Prefix to add to each key
580+
depth (int): Maximum depth for flattening hierarchical data
581+
separator (str): Separator for hierarchical keys
582+
583+
Returns:
584+
dict: Flattened key-value pairs
585+
"""
553586
flattened_data = {}
587+
554588
if format_ == "json" and content_type and is_json_content_type(content_type):
555589
for key in config_data:
556590
__flatten_json_key_value(
@@ -582,13 +616,7 @@ def __read_kv_from_file(
582616
separator=separator,
583617
)
584618

585-
# convert to KeyValue list
586-
key_values = []
587-
for k, v in flattened_data.items():
588-
if validate_import_key(key=k):
589-
key_values.append(KeyValue(key=k, value=v))
590-
return key_values
591-
619+
return flattened_data
592620

593621
# App Service <-> List of KeyValue object
594622

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

717745

746+
def __read_kv_from_kubernetes_configmap(
747+
cmd,
748+
aks_cluster,
749+
configmap_name,
750+
format_,
751+
namespace="default",
752+
prefix_to_add="",
753+
content_type=None,
754+
depth=None,
755+
separator=None
756+
):
757+
"""
758+
Read key-value pairs from a Kubernetes ConfigMap using aks_runcommand.
759+
760+
Args:
761+
cmd: The command context object
762+
aks_cluster (str): Name of the AKS cluster
763+
configmap_name (str): Name of the ConfigMap to read from
764+
format_ (str): Format of the data in the ConfigMap (e.g., "json", "yaml")
765+
namespace (str): Kubernetes namespace where the ConfigMap resides (default: "default")
766+
prefix_to_add (str): Prefix to add to each key in the ConfigMap
767+
content_type (str): Content type to apply to the key-values
768+
depth (int): Maximum depth for flattening hierarchical data
769+
separator (str): Separator for hierarchical keys
770+
771+
Returns:
772+
list: List of KeyValue objects
773+
"""
774+
key_values = []
775+
from azure.cli.command_modules.acs.custom import aks_runcommand
776+
from azure.cli.command_modules.acs._client_factory import cf_managed_clusters
777+
778+
# Preserve only the necessary CLI context data
779+
original_subscription = cmd.cli_ctx.data.get('subscription_id')
780+
original_safe_params = cmd.cli_ctx.data.get('safe_params', [])
781+
782+
try:
783+
# Temporarily modify the CLI context
784+
cmd.cli_ctx.data['subscription_id'] = aks_cluster["subscription"]
785+
params_to_keep = ["--debug", "--verbose"]
786+
cmd.cli_ctx.data['safe_params'] = [p for p in original_safe_params if p in params_to_keep]
787+
# It must be set to return the result.
788+
cmd.cli_ctx.data['safe_params'].append("--output")
789+
# Get the AKS client from the factory
790+
aks_client = cf_managed_clusters(cmd.cli_ctx)
791+
792+
# Command to get the ConfigMap and output it as JSON
793+
command = f"kubectl get configmap {configmap_name} -n {namespace} -o json"
794+
795+
# Execute the command on the cluster
796+
result = aks_runcommand(cmd, aks_client, aks_cluster["resource_group"], aks_cluster["name"], command_string=command)
797+
798+
if hasattr(result, 'logs') and result.logs:
799+
if not hasattr(result, 'exit_code') or result.exit_code == 0:
800+
try:
801+
configmap_data = json.loads(result.logs)
802+
803+
# Extract the data section which contains the key-value pairs
804+
kvs = __extract_kv_from_configmap_data(
805+
configmap_data, content_type, prefix_to_add, format_, depth, separator)
806+
807+
key_values.extend(kvs)
808+
except json.JSONDecodeError:
809+
raise ValueError(
810+
f"The result from ConfigMap {configmap_name} could not be parsed. {result.logs.strip()}"
811+
)
812+
else:
813+
raise AzureResponseError(f"{result.logs.strip()}")
814+
else:
815+
raise AzureResponseError("Unable to get the ConfigMap.")
816+
817+
return key_values
818+
except Exception as exception:
819+
raise AzureInternalError(
820+
f"Failed to read key-values from ConfigMap '{configmap_name}' in namespace '{namespace}'.\n{str(exception)}"
821+
)
822+
finally:
823+
# Restore original CLI context data
824+
cmd.cli_ctx.data['subscription_id'] = original_subscription
825+
cmd.cli_ctx.data['safe_params'] = original_safe_params
826+
827+
828+
def __extract_kv_from_configmap_data(configmap, content_type, prefix_to_add="", format_=None, depth=None, separator=None):
829+
"""
830+
Helper function to extract key-value pairs from ConfigMap data.
831+
832+
Args:
833+
configmap (dict): The ConfigMap data as a dictionary
834+
prefix_to_add (str): Prefix to add to each key
835+
content_type (str): Content type to apply to the key-values
836+
format_ (str): Format of the data in the ConfigMap (e.g., "json", "yaml")
837+
depth (int): Maximum depth for flattening hierarchical data
838+
separator (str): Separator for hierarchical keys
839+
840+
Returns:
841+
list: List of KeyValue objects
842+
"""
843+
key_values = []
844+
845+
if not configmap.get('data', None):
846+
logger.warning("ConfigMap exists but has no data")
847+
return key_values
848+
849+
for key, value in configmap['data'].items():
850+
if format_ in ("json", "yaml", "properties"):
851+
if format_ == "json":
852+
try:
853+
value = json.loads(value)
854+
except json.JSONDecodeError:
855+
logger.warning(
856+
'Value "%s" for key "%s" is not a well formatted JSON data.',
857+
value, key
858+
)
859+
continue
860+
elif format_ == "yaml":
861+
try:
862+
value = yaml.safe_load(value)
863+
except yaml.YAMLError:
864+
logger.warning(
865+
'Value "%s" for key "%s" is not a well formatted YAML data.',
866+
value, key
867+
)
868+
continue
869+
else:
870+
try:
871+
value = javaproperties.load(io.StringIO(value))
872+
except javaproperties.InvalidUEscapeError:
873+
logger.warning(
874+
'Value "%s" for key "%s" is not a well formatted properties data.',
875+
value, key
876+
)
877+
continue
878+
879+
flattened_data = __flatten_config_data(
880+
config_data=value,
881+
format_=format_,
882+
content_type=content_type,
883+
prefix_to_add=prefix_to_add,
884+
depth=depth,
885+
separator=separator
886+
)
887+
888+
for k, v in flattened_data.items():
889+
if validate_import_key(key=k):
890+
key_values.append(KeyValue(key=k, value=v))
891+
892+
elif validate_import_key(key):
893+
# If content_type is JSON, validate the value
894+
if content_type and is_json_content_type(content_type):
895+
try:
896+
json.loads(value)
897+
except json.JSONDecodeError:
898+
logger.warning(
899+
'Value "%s" for key "%s" is not a valid JSON object, which conflicts with the provided content type "%s".',
900+
value, key, content_type
901+
)
902+
continue
903+
904+
kv = KeyValue(key=prefix_to_add + key, value=value)
905+
key_values.append(kv)
906+
907+
return key_values
908+
909+
718910
def __validate_import_keyvault_ref(kv):
719911
if kv and validate_import_key(kv.key):
720912
try:

src/azure-cli/azure/cli/command_modules/appconfig/_params.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
get_default_location_from_resource_group
1717
from ._constants import ImportExportProfiles, ImportMode, FeatureFlagConstants, ARMAuthenticationMode
1818

19-
from ._validators import (validate_appservice_name_or_id, validate_sku, validate_snapshot_query_fields,
19+
from ._validators import (validate_appservice_name_or_id, validate_aks_cluster_name_or_id,
20+
validate_sku, validate_snapshot_query_fields,
2021
validate_connection_string, validate_datetime,
2122
validate_export, validate_import,
2223
validate_import_depth, validate_query_fields,
@@ -232,7 +233,7 @@ def load_arguments(self, _):
232233
c.argument('label', help="Imported KVs and feature flags will be assigned with this label. If no label specified, will assign null label.")
233234
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.")
234235
c.argument('prefix', help="This prefix will be appended to the front of imported keys. Prefix will be ignored for feature flags.")
235-
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.")
236+
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.")
236237
c.argument('yes', help="Do not prompt for preview.")
237238
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())
238239
c.argument('content_type', help='Content type of all imported items.')
@@ -264,6 +265,11 @@ def load_arguments(self, _):
264265
with self.argument_context('appconfig kv import', arg_group='AppService') as c:
265266
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')
266267

268+
with self.argument_context('appconfig kv import', arg_group='AKS') as c:
269+
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')
270+
c.argument('configmap_name', help='Name of the ConfigMap. Required for AKS arguments.')
271+
c.argument('configmap_namespace', help='Namespace of the ConfigMap. default to "default" namespace if not specified.')
272+
267273
with self.argument_context('appconfig kv export') as c:
268274
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.")
269275
c.argument('prefix', help="Prefix to be trimmed from keys. Prefix will be ignored for feature flags.")

src/azure-cli/azure/cli/command_modules/appconfig/_validators.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ def validate_import(namespace):
105105
elif source == 'appservice':
106106
if namespace.appservice_account is None:
107107
raise RequiredArgumentMissingError("Please provide '--appservice-account' argument")
108+
elif source == 'aks':
109+
if namespace.aks_cluster is None:
110+
raise RequiredArgumentMissingError("Please provide '--aks-cluster' argument")
111+
if namespace.configmap_name is None:
112+
raise RequiredArgumentMissingError("Please provide '--configmap-name' argument")
108113

109114

110115
def validate_export(namespace):
@@ -145,6 +150,29 @@ def validate_appservice_name_or_id(cmd, namespace):
145150
namespace.appservice_account = parse_resource_id(namespace.appservice_account)
146151

147152

153+
def validate_aks_cluster_name_or_id(cmd, namespace):
154+
from azure.cli.core.commands.client_factory import get_subscription_id
155+
from azure.mgmt.core.tools import is_valid_resource_id, parse_resource_id
156+
if namespace.aks_cluster:
157+
if not is_valid_resource_id(namespace.aks_cluster):
158+
config_store_name = ""
159+
if namespace.name:
160+
config_store_name = namespace.name
161+
elif namespace.connection_string:
162+
config_store_name = get_store_name_from_connection_string(namespace.connection_string)
163+
else:
164+
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.")
165+
166+
resource_group, _ = resolve_store_metadata(cmd, config_store_name)
167+
namespace.aks_cluster = {
168+
"subscription": get_subscription_id(cmd.cli_ctx),
169+
"resource_group": resource_group,
170+
"name": namespace.aks_cluster
171+
}
172+
else:
173+
namespace.aks_cluster = parse_resource_id(namespace.aks_cluster)
174+
175+
148176
def validate_query_fields(namespace):
149177
if namespace.fields:
150178
fields = []

src/azure-cli/azure/cli/command_modules/appconfig/keyvalue.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
__delete_configuration_setting_from_config_store,
3131
__read_features_from_file,
3232
__read_kv_from_app_service,
33+
__read_kv_from_kubernetes_configmap,
3334
__read_kv_from_file,
3435
)
3536
from knack.log import get_logger
@@ -92,7 +93,11 @@ def import_config(cmd,
9293
src_endpoint=None,
9394
src_tags=None, # tags to filter
9495
# from-appservice parameters
95-
appservice_account=None):
96+
appservice_account=None,
97+
# from-aks parameters
98+
aks_cluster=None,
99+
configmap_namespace="default",
100+
configmap_name=None):
96101

97102
src_features = []
98103
dest_features = []
@@ -176,6 +181,25 @@ def import_config(cmd,
176181
src_kvs = __read_kv_from_app_service(
177182
cmd, appservice_account=appservice_account, prefix_to_add=prefix, content_type=content_type)
178183

184+
elif source == 'aks':
185+
if separator:
186+
# If separator is provided, use max depth by default unless depth is specified.
187+
depth = sys.maxsize if depth is None else int(depth)
188+
else:
189+
if depth and int(depth) != 1:
190+
logger.warning("Cannot flatten hierarchical data without a separator. --depth argument will be ignored.")
191+
depth = 1
192+
193+
src_kvs = __read_kv_from_kubernetes_configmap(cmd,
194+
aks_cluster=aks_cluster,
195+
configmap_name=configmap_name,
196+
namespace=configmap_namespace,
197+
prefix_to_add=prefix,
198+
content_type=content_type,
199+
format_=format_,
200+
separator=separator,
201+
depth=depth)
202+
179203
if strict or not yes or import_mode == ImportMode.IGNORE_MATCH:
180204
# fetch key values from user's configstore
181205
dest_kvs = __read_kv_from_config_store(azconfig_client,

0 commit comments

Comments
 (0)