Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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 <workspace>` 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.
- `cloudsmith credential-helper install` and `uninstall` now use `~/.cloudsmith/bin` when you do not give `--bin-dir`. Before, they used the directory of the `cloudsmith` executable, which a package manager can overwrite or make read-only. Terraform still uses its plugin directory. If the directory is not on `PATH`, `install` shows the command that adds it. To remove a helper that an earlier version installed, give its directory with `--bin-dir`.
- The hint shown for a `401 - Unauthorized` error when no API key or SAML session is set no longer suggests `cloudsmith token`. It now suggests `CLOUDSMITH_API_KEY` or `cloudsmith auth`.

Expand Down
42 changes: 36 additions & 6 deletions cloudsmith_cli/cli/commands/credential_helper/manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,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
Expand All @@ -91,7 +92,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")
Expand All @@ -103,6 +104,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)


Expand Down Expand Up @@ -141,6 +144,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=<your-workspace> in the environment"
" that runs `terraform init`, or"
),
(
f" - add it to the args in {rc_path}, e.g."
' args = ["-w", "<your-workspace>", ...].'
),
]


# ---------------------------------------------------------------------------
# install
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -234,7 +262,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)

Expand Down Expand Up @@ -281,7 +309,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,
Expand Down
23 changes: 12 additions & 11 deletions cloudsmith_cli/cli/commands/credential_helper/terraform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
"""
Expand Down
13 changes: 10 additions & 3 deletions cloudsmith_cli/cli/tests/test_credential_helper_terraform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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(
Expand All @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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",
Expand All @@ -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", "<your-workspace>"' in result.output


@pytest.mark.parametrize(
Expand All @@ -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(
Expand Down
6 changes: 3 additions & 3 deletions cloudsmith_cli/credential_helpers/terraform/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
"""
Expand Down Expand Up @@ -299,8 +299,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.

Expand Down
8 changes: 4 additions & 4 deletions cloudsmith_cli/credential_helpers/terraform/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@
logger = logging.getLogger(__name__)

_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 = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading