diff --git a/oks_cli/cluster.py b/oks_cli/cluster.py index d1d14c7..9868f5f 100644 --- a/oks_cli/cluster.py +++ b/oks_cli/cluster.py @@ -23,7 +23,7 @@ ctx_update, set_cluster_id, get_cluster_id, get_project_id, \ get_template, get_cluster_name, format_changed_row, \ is_interesting_status, profile_completer, project_completer, \ - kubeconfig_parse_fields, print_table, format_row + kubeconfig_parse_fields, print_table, format_row, apply_set_fields from .profile import add_profile from .project import project_create, project_login @@ -371,8 +371,9 @@ def _create_cluster(project_name, cluster_config, output): @click.option('--output', '-o', type=click.Choice(["json", "yaml"]), help="Specify output format, by default is json") @click.option('--filename', '-f', type=click.File("r"), help="Path to file to use to create the cluster ") @click.option('--profile', help="Configuration profile to use", shell_complete=profile_completer) +@click.option('--set', 'set_fields', multiple=True, help="Set arbitrary nested fields, e.g. auth.oidc.issuer-url=value") @click.pass_context -def cluster_create_command(ctx, project_name, cluster_name, description, admin, version, cidr_pods, cidr_service, control_plane, zone, enable_admission_plugins, disable_admission_plugins, quirk, tags, disable_api_termination, cp_multi_az, dry_run, output, filename, profile): +def cluster_create_command(ctx, project_name, cluster_name, description, admin, version, cidr_pods, cidr_service, control_plane, zone, enable_admission_plugins, disable_admission_plugins, quirk, tags, disable_api_termination, cp_multi_az, dry_run, output, filename, profile, set_fields): """CLI command to create a new Kubernetes cluster with optional configuration parameters.""" project_name, cluster_name, profile = ctx_update(ctx, project_name, cluster_name, profile) login_profile(profile) @@ -443,6 +444,8 @@ def cluster_create_command(ctx, project_name, cluster_name, description, admin, if cp_multi_az is not None: cluster_config["cp_multi_az"] = cp_multi_az + apply_set_fields(cluster_config, set_fields) + if not dry_run: _create_cluster(project_name, cluster_config, output) else: @@ -466,8 +469,9 @@ def cluster_create_command(ctx, project_name, cluster_name, description, admin, @click.option('--output', '-o', type=click.Choice(["json", "yaml"]), help="Specify output format, by default is json") @click.option('--filename', '-f', type=click.File("r"), help="Path to file to use to update the cluster ") @click.option('--profile', help="Configuration profile to use", shell_complete=profile_completer) +@click.option('--set', 'set_fields', multiple=True, help="Set arbitrary nested fields, e.g. auth.oidc.issuer-url=value") @click.pass_context -def cluster_update_command(ctx, project_name, cluster_name, description, admin, version, tags, enable_admission_plugins, disable_admission_plugins, quirk, disable_api_termination, control_plane, dry_run, output, filename, profile): +def cluster_update_command(ctx, project_name, cluster_name, description, admin, version, tags, enable_admission_plugins, disable_admission_plugins, quirk, disable_api_termination, control_plane, dry_run, output, filename, profile, set_fields): """CLI command to update an existing Kubernetes cluster with new configuration options.""" project_name, cluster_name, profile = ctx_update(ctx, project_name, cluster_name, profile) login_profile(profile) @@ -545,6 +549,8 @@ def cluster_update_command(ctx, project_name, cluster_name, description, admin, if control_plane: cluster_config['control_planes'] = control_plane + + apply_set_fields(cluster_config, set_fields) if dry_run: print_output(cluster_config, output) diff --git a/oks_cli/project.py b/oks_cli/project.py index 1b042a6..6c1e3b2 100644 --- a/oks_cli/project.py +++ b/oks_cli/project.py @@ -10,7 +10,7 @@ from .utils import do_request, print_output, print_table, find_project_id_by_name, get_project_id, set_project_id, \ detect_and_parse_input, transform_tuple, ctx_update, set_cluster_id, get_template, get_project_name, \ format_changed_row, is_interesting_status, login_profile, profile_completer, project_completer, \ - format_row + format_row, apply_set_fields # DEIFNE THE PROJECT COMMAND GROUP @click.group(help="Project related commands.") @@ -188,8 +188,9 @@ def project_list(ctx, project_name, deleted, plain, msword, uuid, watch, output, @click.option('--output', '-o', type=click.Choice(["json", "yaml", "silent"]), help="Specify output format, by default is json") @click.option('--filename', '-f', type=click.File("r"), help="Path to file to use to create the project") @click.option('--profile', help="Configuration profile to use", shell_complete=profile_completer) +@click.option('--set', 'set_fields', multiple=True, help="Set arbitrary nested fields, e.g. auth.oidc.issuer-url=value") @click.pass_context -def project_create(ctx, project_name, description, cidr, quirk, tags, disable_api_termination, dry_run, output, filename, profile): +def project_create(ctx, project_name, description, cidr, quirk, tags, disable_api_termination, dry_run, output, filename, profile, set_fields): """Create a new project from options or file, with support for dry-run and output formatting.""" project_name, _, profile = ctx_update(ctx, project_name, None, profile) login_profile(profile) @@ -230,6 +231,8 @@ def project_create(ctx, project_name, description, cidr, quirk, tags, disable_ap if disable_api_termination is not None: project_config["disable_api_termination"] = disable_api_termination + + apply_set_fields(project_config, set_fields) if not dry_run: data = do_request("POST", 'projects', json=project_config) @@ -295,8 +298,9 @@ def project_delete_command(ctx, project_name, output, dry_run, force, profile): @click.option('--output', '-o', type=click.Choice(["json", "yaml"]), help="Specify output format, by default is json") @click.option('--dry-run', is_flag=True, help="Run without any action") @click.option('--profile', help="Configuration profile to use", shell_complete=profile_completer) +@click.option('--set', 'set_fields', multiple=True, help="Set arbitrary nested fields, e.g. auth.oidc.issuer-url=value") @click.pass_context -def project_update_command(ctx, project_name, description, quirk, tags, disable_api_termination, output, dry_run, profile): +def project_update_command(ctx, project_name, description, quirk, tags, disable_api_termination, output, dry_run, profile, set_fields): """Update project details by name, supporting dry-run and output formatting.""" project_name, _, profile = ctx_update(ctx, project_name, None, profile) login_profile(profile) @@ -326,6 +330,8 @@ def project_update_command(ctx, project_name, description, quirk, tags, disable_ parsed_tags[key.strip()] = value.strip() project_config['tags'] = parsed_tags + + apply_set_fields(project_config, set_fields) if dry_run: print_output(project_config, output) diff --git a/oks_cli/utils.py b/oks_cli/utils.py index fd800f2..0ef7a66 100644 --- a/oks_cli/utils.py +++ b/oks_cli/utils.py @@ -1070,4 +1070,80 @@ def format_changed_row(table, row): def is_interesting_status(status): """Check if status is in the list of interesting statuses.""" interesting_statuses = ["pending", "deploying", "updating", "upgrading", "deleting"] - return status in interesting_statuses \ No newline at end of file + return status in interesting_statuses + +def normalize_key_path(key_path: str) -> str: + return re.sub(r'\[(\d+)\]', r'.\1', key_path) + +def parse_value(value): + value = value.strip() + + # Inline list: [a,b,c] + if value.startswith('[') and value.endswith(']'): + inner = value[1:-1].strip() + if not inner: + return [] + return [parse_value(v.strip()) for v in inner.split(',')] + + if value.lower() == "true": + return True + if value.lower() == "false": + return False + + try: + return int(value) + except ValueError: + pass + + try: + return float(value) + except ValueError: + pass + + if ',' in value: + return [parse_value(v) for v in value.split(',')] + + return value + +def apply_set_fields(target: dict, set_fields): + for field in set_fields: + if '=' not in field: + raise click.ClickException( + f"Malformed --set argument: '{field}' (expected key=value)" + ) + + raw_key, value_str = field.split('=', 1) + key_path = normalize_key_path(raw_key) + key_parts = key_path.split('.') + value = parse_value(value_str) + + current = target + for i, part in enumerate(key_parts[:-1]): + next_part = key_parts[i + 1] + + is_index = part.isdigit() + next_is_index = next_part.isdigit() + + if is_index: + idx = int(part) + if not isinstance(current, list): + current_parent = [] + current[:] = current_parent # if needed + while len(current) <= idx: + current.append({}) + current = current[idx] + else: + if part not in current: + current[part] = [] if next_is_index else {} + current = current[part] + + last = key_parts[-1] + if last.isdigit(): + idx = int(last) + if not isinstance(current, list): + current[last] = [] + while len(current) <= idx: + current.append(None) + current[idx] = value + else: + current[last] = value \ No newline at end of file