From f06fcade23bdea9c7d3ec9022372ce7a9081996f Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Thu, 15 Oct 2026 17:26:35 +0100 Subject: [PATCH] fix(no-ticket): bake the Workspace and API host into the Terraform helper The Terraform helper matches custom domains at run time, through the Workspace and the API host. The install wrote --org into the terraformrc args, and it did not write the API host. A helper installed against another API host then looked up the domains on the default API host. - Write -w into the args. Blocks with --org still work, because --org is an alias of --workspace. - Write --api-host into the args when an API host is set. - Without a Workspace, tell the user how to set one. - Use Workspace in the Terraform messages and help. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../cli/commands/credential_helper/manage.py | 42 ++++++++++-- .../commands/credential_helper/terraform.py | 23 ++++--- .../tests/test_credential_helper_terraform.py | 13 +++- ...t_credential_helper_terraform_installer.py | 67 +++++++++++++++++-- .../credential_helpers/terraform/installer.py | 6 +- .../credential_helpers/terraform/runtime.py | 8 +-- .../terraform/terraformrc.py | 2 +- 8 files changed, 128 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10a54dc9..db37a651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `cloudsmith domains list` keeps version 1 of its JSON document. The API does not give `primary` and `created_at`, so these fields are now always `null`. The hosts are in the preference order of the API. `domain_type` can also be `cloudsmith_api`. - Without a Workspace, `cloudsmith domains list` lists the domains that earlier runs cached. If no domains are cached, it gets the domains of a Workspace that the credential is a member of. Without a credential, the list is empty. - `cloudsmith credential-helper install` now configures the Cloudsmith-owned hosts that the API lists for the Workspace. It uses its built-in default host, such as `docker.cloudsmith.io`, only when it cannot get the domains from the API. +- `cloudsmith credential-helper install terraform` now writes `-w ` into the `terraformrc` args, not `--org`. A block that has `--org` continues to work. The install also writes `--api-host` when you set an API host, so the helper finds the domains on that API host. Without a Workspace, the install tells you how to set one. ### Removed diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index ad980d0e..82508564 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -90,10 +90,11 @@ def _get_installer(name: str): def _terraform_helper_args(ctx, opts, repo: str | None = None) -> tuple[str, ...]: """Build the terraformrc ``args`` list from the resolved config. - Baking ``--org``/``-r``/``-P`` into the block means ``terraform init`` works + Baking ``-w``/``-r``/``-P`` into the block means ``terraform init`` works with no environment variables and no hand-edited config. Only values - actually supplied are written, so an install without ``--org``/``--repo``/ - ``-P`` leaves ``args = []``. + actually supplied are written, so an install without ``-w``/``--repo``/ + ``-P`` leaves ``args = []``. The API host is baked in too, so the helper + looks up domains on the same API host as the install. A non-default ``--config-file``/``--credentials-file`` is also baked in: the helper is later invoked by ``terraform init`` in a fresh process with none @@ -103,7 +104,7 @@ def _terraform_helper_args(ctx, opts, repo: str | None = None) -> tuple[str, ... """ args: list[str] = [] if opts.org: - args.extend(["--org", opts.org]) + args.extend(["-w", opts.org]) if repo: args.extend(["-r", repo]) profile = ctx.meta.get("profile") @@ -115,6 +116,8 @@ def _terraform_helper_args(ctx, opts, repo: str | None = None) -> tuple[str, ... creds_file = ctx.meta.get("creds_file") if creds_file: args.extend(["--credentials-file", creds_file]) + if opts.api_host: + args.extend(["--api-host", opts.api_host]) return tuple(args) @@ -153,6 +156,31 @@ def _terraform_next_steps(helper_args: tuple[str, ...]) -> list[str]: ] +def _terraform_workspace_next_steps(helper_args: tuple[str, ...]) -> list[str]: + """Return the post-install guidance when no Workspace is baked in. + + The helper needs a Workspace to match custom domains and to scope the + token for a Cloudsmith-owned host. + """ + if "-w" in helper_args: + return [] + rc_path = _terraformrc_path() + return [ + ( + "Next steps: the Terraform helper needs a Workspace to match custom" + " domains and to scope tokens. Provide it in one of these ways:" + ), + ( + " - export CLOUDSMITH_WORKSPACE= in the environment" + " that runs `terraform init`, or" + ), + ( + f" - add it to the args in {rc_path}, e.g." + ' args = ["-w", "", ...].' + ), + ] + + # --------------------------------------------------------------------------- # install # --------------------------------------------------------------------------- @@ -246,7 +274,7 @@ def install_cmd( \b # Terraform: bake the repository into the terraformrc args so # `terraform init` needs no CLOUDSMITH_REPO env var - $ cloudsmith credential-helper install terraform --org acme --repo my-repo + $ cloudsmith credential-helper install terraform -w acme --repo my-repo """ installer = _get_installer(helper) @@ -293,7 +321,9 @@ def install_cmd( next_steps: list[str] = [] if helper == "terraform": - next_steps = _terraform_next_steps(helper_args) + next_steps = _terraform_workspace_next_steps( + helper_args + ) + _terraform_next_steps(helper_args) data = { "helper": helper, diff --git a/cloudsmith_cli/cli/commands/credential_helper/terraform.py b/cloudsmith_cli/cli/commands/credential_helper/terraform.py index a152b548..c49008f6 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/terraform.py +++ b/cloudsmith_cli/cli/commands/credential_helper/terraform.py @@ -46,8 +46,9 @@ def terraform(opts, repo, params): Provides credentials for all Cloudsmith Terraform registries: ``*.cloudsmith.io``, ``*.cloudsmith.com``, and any custom domains - configured for the organisation (requires an organisation - ``--org``, - CLOUDSMITH_ORG or ``org`` in ``config.ini`` - and a valid API key/token). + configured for the Workspace (requires a Workspace - ``-w/--workspace``, + CLOUDSMITH_WORKSPACE or ``workspace`` in ``config.ini`` - and a valid API + key/token). Accepts Terraform's calling convention — an optional verb (``get``, ``store``, ``forget``) followed by the hostname — so the on-PATH launcher @@ -76,18 +77,18 @@ def terraform(opts, repo, params): 0: Token returned, or the host is not a Cloudsmith registry 1: Cloudsmith host with no credentials available, or an error occurred - The token is scoped to the repository. On a standard - ``*.cloudsmith.io``/``*.cloudsmith.com`` host it is ``{org}/{repo}/{token}`` - and the organisation is required (``--org``, ``CLOUDSMITH_ORG`` or ``org`` - in ``config.ini``); a standard host requested without an organisation is a + The token is scoped to the repository. On a Cloudsmith-owned host it is + ``{workspace}/{repo}/{token}`` and the Workspace is required + (``-w/--workspace``, ``CLOUDSMITH_WORKSPACE`` or ``workspace`` in + ``config.ini``); a Cloudsmith-owned host requested without a Workspace is a non-zero exit. On a custom domain — which is already bound to a single - organisation — the org is omitted and the token is ``{repo}/{token}``. The + Workspace — the Workspace is omitted and the token is ``{repo}/{token}``. The repository is always required (``-r/--repo/--repository`` or ``CLOUDSMITH_REPO``): Terraform does not tell a credentials helper which repository is being requested. A non-Cloudsmith host still returns ``{}`` and exit 0. The profile can also be supplied with ``-P/--profile``. The launcher forwards Terraform's ``args`` verbatim, so a terraformrc block such - as ``credentials_helper "cloudsmith" { args = ["--org=acme", + as ``credentials_helper "cloudsmith" { args = ["-w", "acme", "--repo=my-repo", "-P", "ci"] }`` reaches this command as those options. \b @@ -101,13 +102,13 @@ def terraform(opts, repo, params): # Terraform's calling convention (verb + hostname) $ cloudsmith credential-helper terraform --repo my-repo get terraform.cloudsmith.io \b - # Select an org and profile explicitly (no env vars needed) - $ cloudsmith credential-helper terraform --org=acme -P ci get terraform.cloudsmith.io + # Select a Workspace and profile explicitly (no env vars needed) + $ cloudsmith credential-helper terraform -w acme -P ci get terraform.cloudsmith.io \b Environment variables: CLOUDSMITH_API_KEY: API key for authentication (optional) - CLOUDSMITH_ORG: Organisation slug (required to scope the token) + CLOUDSMITH_WORKSPACE: Workspace slug (required to scope the token) CLOUDSMITH_REPO: Repository slug the registry serves (required) CLOUDSMITH_PROFILE: Configuration profile to load (optional) """ diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_terraform.py b/cloudsmith_cli/cli/tests/test_credential_helper_terraform.py index 07dc267a..6278365b 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_terraform.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_terraform.py @@ -258,6 +258,13 @@ def test_execute_get_refuses_cloudsmith_host_without_an_org(credential): assert (exit_code, stdout, stderr) == (1, None, _MISSING_ORG_MESSAGE) +def test_missing_workspace_message_names_the_workspace_options(): + """The error names the Workspace option, variable and config key.""" + assert "--workspace" in _MISSING_ORG_MESSAGE + assert "CLOUDSMITH_WORKSPACE" in _MISSING_ORG_MESSAGE + assert "'workspace' key in config.ini" in _MISSING_ORG_MESSAGE + + def test_execute_get_refuses_cloudsmith_host_without_a_repo(credential): """A Cloudsmith host needs a repo to build the scoped token: exit 1 with an actionable error, not a malformed ``org/None/token`` credential.""" @@ -529,7 +536,7 @@ def test_cli_org_is_required_for_a_cloudsmith_host(runner): """A Cloudsmith host without an org is a non-zero exit, not a token. The token is scoped as ``{org}/{repo}/{token}``, so an org must be - configured (``--org``/``CLOUDSMITH_ORG``/``org`` in config.ini) before a + configured (``-w``/``CLOUDSMITH_WORKSPACE``/``workspace`` in config.ini) before a credential can be emitted. """ with patch( @@ -539,14 +546,14 @@ def test_cli_org_is_required_for_a_cloudsmith_host(runner): result = runner.invoke( terraform, args=["-k", "k_abc", "--repo", "myrepo", "get", CLOUDSMITH_HOST], - env={"CLOUDSMITH_ORG": ""}, + env={"CLOUDSMITH_ORG": "", "CLOUDSMITH_WORKSPACE": ""}, catch_exceptions=False, ) assert result.exit_code == 1 # No token must leak on stdout on the refusal path. assert result.stdout == "" - assert "organisation" in result.output.lower() + assert "workspace" in result.output.lower() def test_cli_repo_from_env_var(runner): diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_terraform_installer.py b/cloudsmith_cli/cli/tests/test_credential_helper_terraform_installer.py index 3c6abac7..5de0e361 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_terraform_installer.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_terraform_installer.py @@ -4,7 +4,7 @@ Covers the pure ``terraformrc`` block helpers (add/update/remove/conflict), the ``TerraformInstaller`` (launcher into the plugin dir + terraformrc block), and the ``credential-helper install/uninstall terraform`` CLI wiring — including -that the resolved ``--org``/``-P`` land in the terraformrc ``args`` list. +that the resolved ``-w``/``-P`` land in the terraformrc ``args`` list. """ from __future__ import annotations @@ -402,8 +402,14 @@ def test_installer_status_type_contract(tmp_path, monkeypatch): # --------------------------------------------------------------------------- -def test_cli_install_bakes_org_and_profile_into_args(runner, tmp_path, monkeypatch): - """`install terraform --org --P` writes those into the terraformrc args list.""" +@pytest.mark.parametrize("workspace_flag", [["-w", "acme"], ["--org=acme"]]) +def test_cli_install_bakes_workspace_and_profile_into_args( + runner, tmp_path, monkeypatch, workspace_flag +): + """`install terraform -w -P` writes `-w` and `-P` into the terraformrc args. + + The legacy `--org` alias is written as `-w` too. + """ monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) monkeypatch.delenv("TF_CLI_CONFIG_FILE", raising=False) @@ -413,7 +419,7 @@ def test_cli_install_bakes_org_and_profile_into_args(runner, tmp_path, monkeypat install_cmd, [ "terraform", - "--org=acme", + *workspace_flag, "-P", "ci", "--no-discover", @@ -425,7 +431,56 @@ def test_cli_install_bakes_org_and_profile_into_args(runner, tmp_path, monkeypat assert result.exit_code == 0, result.output rc = (tmp_path / ".terraformrc").read_text(encoding="utf-8") - assert 'args = ["--org", "acme", "-P", "ci"]' in rc + assert 'args = ["-w", "acme", "-P", "ci"]' in rc + + +def test_cli_install_bakes_api_host_into_args(runner, tmp_path, monkeypatch): + """The helper looks up domains on the API host that the install used.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + monkeypatch.delenv("TF_CLI_CONFIG_FILE", raising=False) + + from ...cli.commands.credential_helper.manage import install_cmd + + result = runner.invoke( + install_cmd, + [ + "terraform", + "-w", + "acme", + "--api-host", + "https://api.example.com", + "--no-discover", + "-k", + "k_flag", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + rc = (tmp_path / ".terraformrc").read_text(encoding="utf-8") + assert 'args = ["-w", "acme", "--api-host", "https://api.example.com"]' in rc + + +def test_cli_install_without_workspace_says_how_to_set_one( + runner, tmp_path, monkeypatch +): + """Without a Workspace, the helper cannot match custom domains.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + monkeypatch.delenv("TF_CLI_CONFIG_FILE", raising=False) + monkeypatch.delenv("CLOUDSMITH_WORKSPACE", raising=False) + monkeypatch.delenv("CLOUDSMITH_ORG", raising=False) + + from ...cli.commands.credential_helper.manage import install_cmd + + result = runner.invoke( + install_cmd, + ["terraform", "--repo", "my-repo", "--no-discover", "-k", "k_flag"], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + assert "CLOUDSMITH_WORKSPACE" in result.output + assert '"-w", ""' in result.output @pytest.mark.parametrize( @@ -452,7 +507,7 @@ def test_cli_install_bakes_repo_into_args(runner, tmp_path, monkeypatch, repo_fl assert result.exit_code == 0, result.output rc = (tmp_path / ".terraformrc").read_text(encoding="utf-8") - assert 'args = ["--org", "acme", "-r", "my-repo"]' in rc + assert 'args = ["-w", "acme", "-r", "my-repo"]' in rc def test_cli_install_bakes_config_and_credentials_files_into_args( diff --git a/cloudsmith_cli/credential_helpers/terraform/installer.py b/cloudsmith_cli/credential_helpers/terraform/installer.py index f27d38df..d11e62e6 100644 --- a/cloudsmith_cli/credential_helpers/terraform/installer.py +++ b/cloudsmith_cli/credential_helpers/terraform/installer.py @@ -180,7 +180,7 @@ class TerraformInstaller: Usage:: installer = TerraformInstaller() - actions = installer.install(helper_args=["--org", "acme"]) + actions = installer.install(helper_args=["-w", "acme"]) for action in actions: print(action) """ @@ -314,8 +314,8 @@ def install( Defaults to ``~/.terraform.d/plugins``. helper_args: Values for the block's ``args`` list, forwarded to the CLI on every - invocation — e.g. ``("--org", "acme", "-P", "ci")`` to pin the - organisation and profile without environment variables. + invocation — e.g. ``("-w", "acme", "-P", "ci")`` to pin the + Workspace and profile without environment variables. dry_run: When ``True``, compute and return planned actions without writing. diff --git a/cloudsmith_cli/credential_helpers/terraform/runtime.py b/cloudsmith_cli/credential_helpers/terraform/runtime.py index 926796c2..ce74b793 100644 --- a/cloudsmith_cli/credential_helpers/terraform/runtime.py +++ b/cloudsmith_cli/credential_helpers/terraform/runtime.py @@ -49,10 +49,10 @@ ) _MISSING_ORG_MESSAGE = ( - "Error: No organisation configured. " - "Provide the Cloudsmith organisation via the --org flag, the " - "CLOUDSMITH_ORG environment variable, or the 'org' key in config.ini so " - "the token can be scoped as '{org}/{repo}/{token}'." + "Error: No Workspace configured. " + "Provide the Cloudsmith Workspace via the -w/--workspace flag, the " + "CLOUDSMITH_WORKSPACE environment variable, or the 'workspace' key in " + "config.ini so the token can be scoped as '{workspace}/{repo}/{token}'." ) _MISSING_REPO_MESSAGE = ( diff --git a/cloudsmith_cli/credential_helpers/terraform/terraformrc.py b/cloudsmith_cli/credential_helpers/terraform/terraformrc.py index ebc8bd0c..1d7c0d15 100644 --- a/cloudsmith_cli/credential_helpers/terraform/terraformrc.py +++ b/cloudsmith_cli/credential_helpers/terraform/terraformrc.py @@ -58,7 +58,7 @@ def render_block(args: list[str] | tuple[str, ...] = ()) -> str: Args: args: The values for the block's ``args = [...]`` list, e.g. - ``["--org", "acme", "-P", "ci"]``. + ``["-w", "acme", "-P", "ci"]``. Returns: The HCL block as text, without a trailing newline.