Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.

### Removed

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 @@ -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
Expand All @@ -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")
Expand All @@ -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)


Expand Down Expand Up @@ -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=<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 @@ -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)

Expand Down Expand Up @@ -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,
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 @@ -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.

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 @@ -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 = (
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