From 3f996eb13eac25b6d390bc22f617fb33f625b447 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Thu, 3 Oct 2024 11:59:06 -0700 Subject: [PATCH 1/4] test: Fix slack payload in integration workflow (#649) --- .github/workflows/e2e-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-suite.yml b/.github/workflows/e2e-suite.yml index d1b8b91fa..b5e92fa60 100644 --- a/.github/workflows/e2e-suite.yml +++ b/.github/workflows/e2e-suite.yml @@ -196,7 +196,7 @@ jobs: "fields": [ { "type": "mrkdwn", - "text": "*Build Result:*\n${{ steps.integration_tests.outcome == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}" + "text": "*Build Result:*\n${{ needs.integration_tests.result == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}" }, { "type": "mrkdwn", From 601dc5469d7bff57ed707b29b3a2f9166316fbe6 Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Tue, 8 Oct 2024 16:26:13 -0400 Subject: [PATCH 2/4] Override images replicate output (#653) --- linodecli/overrides.py | 53 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/linodecli/overrides.py b/linodecli/overrides.py index 290df65f6..b7e843bc9 100644 --- a/linodecli/overrides.py +++ b/linodecli/overrides.py @@ -4,7 +4,7 @@ large changes to the OpenAPI spec. """ -from typing import Dict +from typing import Dict, List from rich import box from rich import print as rprint @@ -57,6 +57,15 @@ def handle_types_region_prices_list( return linode_types_with_region_prices(operation, output_handler, json_data) +@output_override("images", "replicate", OutputMode.table) +def handle_image_replicate(operation, output_handler, json_data) -> bool: + # pylint: disable=unused-argument + """ + Override the output of 'linode-cli images replicate'. + """ + return image_replicate_output(json_data) + + def linode_types_with_region_prices( operation, output_handler, json_data ) -> bool: @@ -137,3 +146,45 @@ def format_region_prices(data: Dict[str, any]) -> any: sub_table.add_row(*region_price_row) return sub_table + + +def build_replicas_output(replicas: List) -> Table: + """ + Format nested replicas list to a sub-table. + """ + replicas_output = Table(show_header=False, box=None) + replicas_headers = replicas[0].keys() + for replica in replicas: + row = [] + for h in replicas_headers: + row.append(Align(str(replica[h]), align="left")) + replicas_output.add_row(*row) + + return replicas_output + + +def image_replicate_output(json_data) -> bool: + """ + Parse and format the image replicate output table. + """ + output = Table( + header_style="bold", + show_lines=True, + ) + + row = [] + for header in json_data.keys(): + if header == "regions" and len(json_data[header]) > 0: + # leverage `replicas` in output for readability + output.add_column("replicas", justify="center") + row.append(build_replicas_output(json_data[header])) + elif json_data[header] is not None: + output.add_column(header, justify="center") + row.append(Align(str(json_data[header]), align="left")) + + output.add_row(*row) + + console = Console() + console.print(output) + + return False From e7ed7a4f353f1e4d4cc86fa1f74f8b8f763682f2 Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Fri, 11 Oct 2024 20:02:37 +0200 Subject: [PATCH 3/4] fix: get-kubeconfig merge into empty lists (#648) --- linodecli/plugins/get-kubeconfig.py | 46 +++++++++++++++----------- tests/unit/test_plugin_kubeconfig.py | 49 +++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 20 deletions(-) diff --git a/linodecli/plugins/get-kubeconfig.py b/linodecli/plugins/get-kubeconfig.py index 137298981..1652b999f 100644 --- a/linodecli/plugins/get-kubeconfig.py +++ b/linodecli/plugins/get-kubeconfig.py @@ -90,7 +90,7 @@ def call(args, context): else cluster_config ) if parsed.dry_run: - print(cluster_config) + print(yaml.dump(cluster_config)) else: _dump_config(kubeconfig_path, cluster_config) @@ -146,27 +146,35 @@ def _dump_config(filepath, data): yaml.dump(data, file_descriptor) -# Merges the lists in the provided dicts. If non-list properties of the two -# dicts differ, uses the value from the first dict. def _merge_dict(dict_1, dict_2): + """ + Merges two dicts: + * Lists that are present in both dicts are merged together by their "name" key + (preferring duplicate values in the first dict) + * `None` or missing keys in the first dict are set to the second dict's value + * Other values are preferred from the first dict + """ # Return a new dict to prevent any accidental mutations result = {} - for key in dict_1: - if not isinstance(dict_1[key], list): - result[key] = dict_1[key] - continue - - merge_map = {sub["name"]: sub for sub in dict_1[key]} - - for sub in dict_2[key]: - # If the name is already in the merge map, skip - if sub["name"] in merge_map: - continue - - merge_map[sub["name"]] = sub - - # Convert back to a list - result[key] = list(merge_map.values()) + for key, dict_1_value in dict_1.items(): + if dict_1_value is None and (dict_2_value := dict_2.get(key)): + # Replace null value in previous config + result[key] = dict_2_value + elif isinstance(dict_1_value, list) and ( + dict_2_value := dict_2.get(key) + ): + merge_map = {sub["name"]: sub for sub in dict_1_value} + for list_2_item in dict_2_value: + if (list_2_name := list_2_item["name"]) not in merge_map: + merge_map[list_2_name] = list_2_item + # Convert back to a list + result[key] = list(merge_map.values()) + else: + result[key] = dict_1_value + + # Process keys missing in dict_1 + for key in set(dict_2.keys()).difference(dict_1.keys()): + result[key] = dict_2[key] return result diff --git a/tests/unit/test_plugin_kubeconfig.py b/tests/unit/test_plugin_kubeconfig.py index 8ee9c3dca..ff192f57b 100644 --- a/tests/unit/test_plugin_kubeconfig.py +++ b/tests/unit/test_plugin_kubeconfig.py @@ -42,7 +42,6 @@ - item: property-1: a property-2: b - property-3: c name: item-1 - item: property-1: d @@ -51,6 +50,12 @@ dictionary: {"foo": "bar"} """ +TEST_YAML_EMPTY_CONFIG = """ +name: testing-kubeconfig +things: null +items: null +""" + # Test the output of --help def test_print_help(): @@ -199,6 +204,37 @@ def test_merge(mock_cli, fake_kubeconfig_file): assert result["dictionary"] == yaml_a["dictionary"] +def test_merge_to_empty_config(mock_cli, fake_kubeconfig_file_without_entries): + stdout_buf = io.StringIO() + mock_cli.call_operation = mock_call_operation + + file_path = fake_kubeconfig_file_without_entries + + try: + with contextlib.redirect_stdout(stdout_buf): + plugin.call( + [ + "--label", + "nonempty_data", + "--kubeconfig", + file_path, + "--dry-run", + ], + PluginContext("REALTOKEN", mock_cli), + ) + except SystemExit as err: + assert err.code == 0 + + result = yaml.safe_load(stdout_buf.getvalue()) + yaml_a = yaml.safe_load(TEST_YAML_EMPTY_CONFIG) + yaml_b = yaml.safe_load(TEST_YAML_CONTENT_B) + + assert result["name"] == yaml_a["name"] + assert result["things"] is None + assert result["items"] == yaml_b["items"] + assert result["dictionary"] == yaml_b["dictionary"] + + @pytest.fixture(scope="session", autouse=True) def fake_kubeconfig_file(): with tempfile.NamedTemporaryFile(delete=False) as fp: @@ -220,6 +256,17 @@ def fake_empty_file(): os.remove(file_path) +@pytest.fixture(scope="session", autouse=True) +def fake_kubeconfig_file_without_entries(): + with tempfile.NamedTemporaryFile("wt", delete=False) as fp: + fp.write(TEST_YAML_EMPTY_CONFIG) + file_path = fp.name + + yield file_path + + os.remove(file_path) + + def mock_call_operation(command, action, **kwargs): if ( command == "lke" From 268d689d15b784e4bd7bed76db3b6c8c797efd87 Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Wed, 16 Oct 2024 10:08:39 -0400 Subject: [PATCH 4/4] new: Allow specifying a custom configuration path through environment variable (#652) --- linodecli/configuration/config.py | 11 ++++------- linodecli/configuration/helpers.py | 16 ++++++++++++++- linodecli/help_pages.py | 2 ++ tests/unit/test_configuration.py | 23 ++++++++++++++++++++++ wiki/Configuration.md | 3 +++ wiki/development/Development - Overview.md | 3 ++- 6 files changed, 49 insertions(+), 9 deletions(-) diff --git a/linodecli/configuration/config.py b/linodecli/configuration/config.py index 88c557bd4..d857d0227 100644 --- a/linodecli/configuration/config.py +++ b/linodecli/configuration/config.py @@ -290,7 +290,10 @@ def update( ): print(f"User {username} is not configured.") sys.exit(ExitCodes.USERNAME_ERROR) - if not self.config.has_section(username) or allowed_defaults is None: + if ( + not self.config.has_section(username) + and self.config.default_section is None + ) or allowed_defaults is None: return namespace warn_dict = {} @@ -335,12 +338,6 @@ def write_config(self): to save values they've set, and is used internally to update the config on disk when a new user if configured. """ - - # Create the config path isf necessary - config_path = f"{os.path.expanduser('~')}/.config" - if not os.path.exists(config_path): - os.makedirs(config_path) - with open(_get_config_path(), "w", encoding="utf-8") as f: self.config.write(f) diff --git a/linodecli/configuration/helpers.py b/linodecli/configuration/helpers.py index d80f377ca..b9d502a04 100644 --- a/linodecli/configuration/helpers.py +++ b/linodecli/configuration/helpers.py @@ -15,6 +15,8 @@ "XDG_CONFIG_HOME", f"{os.path.expanduser('~')}/.config" ) +ENV_CONFIG_FILE_PATH = "LINODE_CLI_CONFIG" + # this is a list of browser that _should_ work for web-based auth. This is mostly # intended to exclude lynx and other terminal browsers which could be opened, but # won't work. @@ -38,11 +40,23 @@ def _get_config_path() -> str: :returns: The path to the local config file. :rtype: str """ + custom_path = os.getenv(ENV_CONFIG_FILE_PATH, None) + + if custom_path is not None: + custom_path = os.path.expanduser(custom_path) + if not os.path.exists(custom_path): + os.makedirs(os.path.dirname(custom_path), exist_ok=True) + return custom_path + path = f"{LEGACY_CONFIG_DIR}/{LEGACY_CONFIG_NAME}" if os.path.exists(path): return path - return f"{CONFIG_DIR}/{CONFIG_NAME}" + path = f"{CONFIG_DIR}/{CONFIG_NAME}" + if not os.path.exists(path): + os.makedirs(CONFIG_DIR, exist_ok=True) + + return path def _get_config(load: bool = True): diff --git a/linodecli/help_pages.py b/linodecli/help_pages.py index c249dd8fb..2af635fb3 100644 --- a/linodecli/help_pages.py +++ b/linodecli/help_pages.py @@ -30,6 +30,8 @@ "(e.g. 'v4beta')", "LINODE_CLI_API_SCHEME": "Overrides the target scheme used for API requests. " "(e.g. 'https')", + "LINODE_CLI_CONFIG": "Overrides the default configuration file path. " + "(e.g '~/.linode/my-cli-config')", } HELP_TOPICS = { diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index 2c43a078c..4015a69e9 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -609,3 +609,26 @@ def test_bool_input_default(self, monkeypatch): output = stdout_buf.getvalue() assert "foo [y/N]: " in output assert result + + def test_custom_config_path(self, monkeypatch, tmp_path): + """ + Test use a custom configuration path + """ + conf = self._build_test_config() + custom_path = tmp_path / "test-cli-config" + + with ( + patch.dict( + os.environ, + {"LINODE_CLI_CONFIG": custom_path.absolute().as_posix()}, + ), + ): + conf.write_config() + + configs = custom_path.read_text().splitlines() + expected_configs = self.mock_config_file.splitlines() + + assert len(configs) == len(expected_configs) + 1 + + for i, _ in enumerate(expected_configs): + assert expected_configs[i] == configs[i] diff --git a/wiki/Configuration.md b/wiki/Configuration.md index ed595da48..06006e578 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -41,6 +41,9 @@ environment variable. If you wish to hide the API Version warning you can use the `LINODE_CLI_SUPPRESS_VERSION_WARNING` environment variable. +You may also specify a custom configuration path using the `LINODE_CLI_CONFIG` environment variable +to replace the default path `~/.config/linode-cli`. + ## Configurable API URL In some cases you may want to run linode-cli against a non-default Linode API URL. diff --git a/wiki/development/Development - Overview.md b/wiki/development/Development - Overview.md index 78cc95d47..a354103f7 100644 --- a/wiki/development/Development - Overview.md +++ b/wiki/development/Development - Overview.md @@ -51,7 +51,8 @@ configure the following: - Overrides for the target API URL (hostname, version, scheme, etc.) This command serves as an interactive prompt and outputs a configuration file to `~/.config/linode-cli`. -This file is in a simple INI format and can be easily modified manually by users. +This file is in a simple INI format and can be easily modified manually by users. +You may also specify a custom configuration file path using the `LINODE_CLI_CONFIG` environment variable. Additionally, multiple users can be created for the CLI which can be designated when running commands using the `--as-user` argument or using the `default-user` config variable.