Skip to content

Commit f8849c8

Browse files
Import key-values from AKS ConfigMap
1 parent 088ca11 commit f8849c8

File tree

8 files changed

+34181
-12
lines changed

8 files changed

+34181
-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.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: 183 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,39 @@ def __read_kv_from_file(
549549
except OSError:
550550
raise FileOperationError("File is not available.")
551551

552+
flattened_data = flatten_config_data(
553+
config_data=config_data,
554+
format_=format_,
555+
content_type=content_type,
556+
prefix_to_add=prefix_to_add,
557+
depth=depth,
558+
separator=separator
559+
)
560+
561+
# convert to KeyValue list
562+
key_values = []
563+
for k, v in flattened_data.items():
564+
if validate_import_key(key=k):
565+
key_values.append(KeyValue(key=k, value=v))
566+
return key_values
567+
568+
def flatten_config_data(config_data, format_, content_type, prefix_to_add="", depth=None, separator=None):
569+
"""
570+
Flatten configuration data into a dictionary of key-value pairs.
571+
572+
Args:
573+
config_data: The configuration data to flatten (dict or list)
574+
format_ (str): The format of the configuration data ('json', 'yaml', 'properties')
575+
content_type (str): Content type for JSON validation
576+
prefix_to_add (str): Prefix to add to each key
577+
depth (int): Maximum depth for flattening hierarchical data
578+
separator (str): Separator for hierarchical keys
579+
580+
Returns:
581+
dict: Flattened key-value pairs
582+
"""
552583
flattened_data = {}
584+
553585
if format_ == "json" and content_type and is_json_content_type(content_type):
554586
for key in config_data:
555587
__flatten_json_key_value(
@@ -580,14 +612,8 @@ def __read_kv_from_file(
580612
depth=depth,
581613
separator=separator,
582614
)
583-
584-
# convert to KeyValue list
585-
key_values = []
586-
for k, v in flattened_data.items():
587-
if validate_import_key(key=k):
588-
key_values.append(KeyValue(key=k, value=v))
589-
return key_values
590-
615+
616+
return flattened_data
591617

592618
# App Service <-> List of KeyValue object
593619

@@ -713,6 +739,155 @@ def __read_kv_from_app_service(
713739
except Exception as exception:
714740
raise CLIError("Failed to read key-values from appservice.\n" + str(exception))
715741

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

717892
def __validate_import_keyvault_ref(kv):
718893
if kv and validate_import_key(kv.key):

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,
@@ -217,7 +218,7 @@ def load_arguments(self, _):
217218
with self.argument_context('appconfig kv import') as c:
218219
c.argument('label', help="Imported KVs and feature flags will be assigned with this label. If no label specified, will assign null label.")
219220
c.argument('prefix', help="This prefix will be appended to the front of imported keys. Prefix will be ignored for feature flags.")
220-
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.")
221+
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.")
221222
c.argument('yes', help="Do not prompt for preview.")
222223
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())
223224
c.argument('content_type', help='Content type of all imported items.')
@@ -247,6 +248,11 @@ def load_arguments(self, _):
247248
with self.argument_context('appconfig kv import', arg_group='AppService') as c:
248249
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')
249250

251+
with self.argument_context('appconfig kv import', arg_group='AKS') as c:
252+
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'),
253+
c.argument('configmap_name', help='Name of the ConfigMap. Required for AKS arguments.')
254+
c.argument('configmap_namespace', help='Namespace of the ConfigMap. default to "default" namespace if not specified.')
255+
250256
with self.argument_context('appconfig kv export') as c:
251257
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.")
252258
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: 27 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):
@@ -144,6 +149,28 @@ def validate_appservice_name_or_id(cmd, namespace):
144149
else:
145150
namespace.appservice_account = parse_resource_id(namespace.appservice_account)
146151

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

148175
def validate_query_fields(namespace):
149176
if namespace.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
@@ -89,7 +90,11 @@ def import_config(cmd,
8990
src_auth_mode="key",
9091
src_endpoint=None,
9192
# from-appservice parameters
92-
appservice_account=None):
93+
appservice_account=None,
94+
# from-aks parameters
95+
aks_cluster=None,
96+
configmap_namespace="default",
97+
configmap_name=None):
9398

9499
src_features = []
95100
dest_features = []
@@ -170,6 +175,25 @@ def import_config(cmd,
170175
elif source == 'appservice':
171176
src_kvs = __read_kv_from_app_service(
172177
cmd, appservice_account=appservice_account, prefix_to_add=prefix, content_type=content_type)
178+
179+
elif source == 'aks':
180+
if separator:
181+
# If separator is provided, use max depth by default unless depth is specified.
182+
depth = sys.maxsize if depth is None else int(depth)
183+
else:
184+
if depth and int(depth) != 1:
185+
logger.warning("Cannot flatten hierarchical data without a separator. --depth argument will be ignored.")
186+
depth = 1
187+
188+
src_kvs = __read_kv_from_kubernetes_configmap(cmd,
189+
aks_cluster=aks_cluster,
190+
configmap_name=configmap_name,
191+
namespace=configmap_namespace,
192+
prefix_to_add=prefix,
193+
content_type=content_type,
194+
format_=format_,
195+
separator=separator,
196+
depth=depth)
173197

174198
if strict or not yes or import_mode == ImportMode.IGNORE_MATCH:
175199
# fetch key values from user's configstore

0 commit comments

Comments
 (0)