Skip to content
Open
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
### Added

- Added `-w` and `--workspace` as the shared Workspace option used by authentication, OIDC, and custom-domain discovery. Set `CLOUDSMITH_WORKSPACE` or `workspace` in `config.ini` to configure it once for every command.
- Added a Terraform credentials helper for Cloudsmith registries. `cloudsmith credential-helper install terraform` writes a `terraform-credentials-cloudsmith` launcher into Terraform's plugin directory (`~/.terraform.d/plugins` by default; override with `--bin-dir`) and adds a `credentials_helper "cloudsmith"` block to `~/.terraformrc`, so `terraform init` authenticates against a Cloudsmith Terraform registry with no token on disk, using your existing CLI credentials (env, config, keyring, or OIDC). The resolved `--org` and `-P/--profile` are baked into the terraformrc `args` list, so no environment variables are needed at `terraform init` time. `get` returns `{"token": "..."}` for a Cloudsmith host (including custom domains) and `{}` for any other host so Terraform falls back to its own credential sources; `store`/`forget` are unsupported. Missing credentials for a Cloudsmith host produce an actionable error rather than a traceback. Manage with `cloudsmith credential-helper uninstall terraform` and `cloudsmith credential-helper list`.

### Changed

Expand Down
2 changes: 2 additions & 0 deletions cloudsmith_cli/cli/commands/credential_helper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .generic import generic as generic_cmd
from .manage import install_cmd, list_cmd, uninstall_cmd
from .pnpm import pnpm as pnpm_cmd
from .terraform import terraform as terraform_cmd


@click.group()
Expand Down Expand Up @@ -50,5 +51,6 @@ def credential_helper():
credential_helper.add_command(uninstall_cmd, name="uninstall")
credential_helper.add_command(list_cmd, name="list")
credential_helper.add_command(cargo_cmd, name="cargo")
credential_helper.add_command(terraform_cmd, name="terraform")

main.add_command(credential_helper, name="credential-helper")
113 changes: 103 additions & 10 deletions cloudsmith_cli/cli/commands/credential_helper/manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
from cloudsmith_cli.credential_helpers.cargo.installer import CargoInstaller
from cloudsmith_cli.credential_helpers.generic import PartialInstallError
from cloudsmith_cli.credential_helpers.pnpm.installer import PNPMInstaller
from cloudsmith_cli.credential_helpers.terraform.installer import TerraformInstaller
from cloudsmith_cli.credential_helpers.terraform.terraformrc import (
TerraformrcConflictError,
)

from ....credential_helpers.docker.installer import DockerInstaller
from ... import utils
Expand All @@ -33,6 +37,7 @@
"docker": DockerInstaller,
"pnpm": PNPMInstaller,
"cargo": CargoInstaller,
"terraform": TerraformInstaller,
}


Expand Down Expand Up @@ -65,6 +70,59 @@
return cls()


def _terraform_helper_args(ctx, opts, repo: str | None = None) -> tuple[str, ...]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this work with --config-file / --credentials-file flags? If an installation uses either option, terraform init may not resolve the same credentials (?)

"""Build the terraformrc ``args`` list from the resolved org, repo and profile.

Baking ``--org``/``-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 = []``.
"""
args: list[str] = []
if opts.org:
args.extend(["--org", opts.org])
if repo:
args.extend(["-r", repo])
profile = ctx.meta.get("profile")
if profile:
args.extend(["-P", profile])
return tuple(args)


_REPO_FLAGS = ("-r", "--repo", "--repository")


def _terraform_next_steps(helper_args: tuple[str, ...]) -> list[str]:
"""Return the post-install guidance for the required repository.

Terraform never tells the credentials helper which repository is being
requested, so the helper needs the repository supplied out-of-band. If it
was not baked into the generated ``args`` at install time, tell the user how
to provide it: set ``CLOUDSMITH_REPO`` or add ``--repo`` to the terraformrc
``args``. Returns an empty list when a repository is already configured.
"""
has_repo = any(
a in _REPO_FLAGS or a.split("=", 1)[0] in _REPO_FLAGS for a in helper_args
)
if has_repo:
return []
return [
(
"Next steps: the Terraform helper requires a repository, which"
" Terraform does not pass to credentials helpers. Provide it in one"
" of these ways:"
),
(
" - export CLOUDSMITH_REPO=<your-repo> in the environment that runs"
" `terraform init`, or"
),
(
" - add it to the args in ~/.terraformrc, e.g."
' args = ["--repo", "<your-repo>", ...].'
),
]


# ---------------------------------------------------------------------------
# install
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -99,6 +157,16 @@
default=False,
help="Bypass the custom-domain cache and fetch fresh data from the API.",
)
@click.option(
"-r",
"--repo",
"--repository",
"repo",
default=None,
help="Terraform only: bake this repository into the terraformrc `args` "
"list (as `-r <repo>`) so `terraform init` needs no CLOUDSMITH_REPO env "
"var. Terraform does not pass the repository to a credentials helper.",
)
@common_cli_config_options
@common_cli_output_options
@common_api_auth_options
Expand All @@ -113,6 +181,7 @@
dry_run: bool,
no_discover: bool,
refresh: bool,
repo: str | None,
) -> None:
"""Install a credential helper launcher and configure the package manager.

Expand Down Expand Up @@ -140,19 +209,35 @@
\b
# Disable automatic custom-domain discovery
$ cloudsmith credential-helper install HELPER --no-discover

\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
"""
installer = _get_installer(helper)

install_kwargs = {
"bin_dir": bin_dir,
"domains": domains,
"dry_run": dry_run,
"discover": not no_discover,
"refresh": refresh,
"org": opts.org,
"credential": opts.credential,
"api_host": opts.api_host,
}

# Terraform bakes the resolved org/profile into the terraformrc `args` list
# so `terraform init` needs neither env vars nor a hand-edited config. The
# wrapper forwards these to the CLI ahead of the hostname at call time.
if helper == "terraform":
install_kwargs["helper_args"] = _terraform_helper_args(ctx, opts, repo)

try:
actions = installer.install(
bin_dir=bin_dir,
domains=domains,
dry_run=dry_run,
discover=not no_discover,
refresh=refresh,
org=opts.org,
credential=opts.credential,
api_host=opts.api_host,
)
actions = installer.install(**install_kwargs)
except TerraformrcConflictError as exc:
raise click.ClickException(str(exc))
except OSError as exc:
raise click.ClickException(
f"Failed to install {helper!r} credential helper: {exc}"
Expand All @@ -166,11 +251,17 @@
use_stderr = utils.should_use_stderr(opts)
warnings = [a for a in actions if a.startswith("WARNING")]
normal = [a for a in actions if not a.startswith("WARNING")]

next_steps: list[str] = []
if helper == "terraform":
next_steps = _terraform_next_steps(install_kwargs.get("helper_args", ()))

Check failure on line 257 in cloudsmith_cli/cli/commands/credential_helper/manage.py

View workflow job for this annotation

GitHub Actions / ty

ty (invalid-argument-type)

cloudsmith_cli/cli/commands/credential_helper/manage.py:257:44: invalid-argument-type: Argument to function `_terraform_next_steps` is incorrect: Expected `tuple[str, ...]`, found `tuple[str, ...] | str | None | bool | Unknown` info: element `str` of union `tuple[str, ...] | str | None | bool | Unknown` is not assignable to `tuple[str, ...]` cloudsmith_cli/cli/commands/credential_helper/manage.py:95:5: info: Function defined here cloudsmith_cli/cli/commands/credential_helper/manage.py:95:27: Parameter declared here

data = {
"helper": helper,
"dry_run": dry_run,
"actions": normal,
"warnings": warnings,
"next_steps": next_steps,
}
if utils.maybe_print_as_json(opts, data):
sys.exit(ec)
Expand All @@ -181,6 +272,8 @@
click.echo(f" {action}" if dry_run else action, err=use_stderr)
for warning in warnings:
click.secho(f" {warning}" if dry_run else warning, err=True, fg="yellow")
for line in next_steps:
click.secho(line, err=use_stderr, fg="cyan")
sys.exit(ec)


Expand Down
137 changes: 137 additions & 0 deletions cloudsmith_cli/cli/commands/credential_helper/terraform.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Copyright 2026 Cloudsmith Ltd
"""
Terraform credentials helper command.

Implements the ``get`` verb of Terraform's credentials-helper protocol for
Cloudsmith registries. The installed ``terraform-credentials-cloudsmith``
launcher forwards Terraform's invocation to this command.

See: https://developer.hashicorp.com/terraform/internals/credentials-helpers
"""

import sys

import click

from ....credential_helpers.terraform import execute
from ...decorators import (
common_api_auth_options,
common_cli_config_options,
resolve_credentials,
)


@click.command(context_settings={"ignore_unknown_options": True})
@click.option(
"-r",
"--repo",
"--repository",
"repo",
required=True,
envvar="CLOUDSMITH_REPO",
help="The Cloudsmith repository the registry serves. Terraform does not "
"pass the repository to a credentials helper, so it must be configured "
"here (e.g. in the terraformrc args) to build a repository-scoped token.",
)
@click.argument("params", nargs=-1, type=click.UNPROCESSED)
@common_cli_config_options
@common_api_auth_options
@resolve_credentials
def terraform(opts, repo, params):
"""
Terraform credentials helper for Cloudsmith registries.

Resolves the token for a Cloudsmith Terraform registry and prints it in
Terraform's expected JSON credentials format: ``{"token": "..."}``.

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).

Accepts Terraform's calling convention — an optional verb (``get``,
``store``, ``forget``) followed by the hostname — so the on-PATH launcher
can forward Terraform's arguments verbatim. When no verb is given the
action is ``get``. When no hostname is given it is read from stdin. Only
``get`` is served; ``store``/``forget`` return an error and a non-zero exit.

A hostname that is not a Cloudsmith registry yields an empty object
(``{}``) and exit 0 so Terraform falls back to its own credential sources.

\b
Input (arguments or stdin):
[VERB] HOSTNAME — e.g. "get terraform.cloudsmith.io" or just
"terraform.cloudsmith.io"; HOSTNAME alone may also come from stdin.

\b
Output (stdout):
JSON: {"token": "<cloudsmith-token>"} (Cloudsmith host, token found)
JSON: {} (not a Cloudsmith host)

\b
Exit codes:
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
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
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",
"--repo=my-repo", "-P", "ci"] }`` reaches this command as those options.

\b
Examples:
# Direct usage
$ cloudsmith credential-helper terraform --repo my-repo terraform.cloudsmith.io
{"token": "..."}

# Terraform's calling convention (verb + hostname)
$ cloudsmith credential-helper terraform --repo my-repo get terraform.cloudsmith.io

# Select an org and profile explicitly (no env vars needed)
$ cloudsmith credential-helper terraform --org=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_REPO: Repository slug the registry serves (required)
CLOUDSMITH_PROFILE: Configuration profile to load (optional)
"""
# Terraform passes "<verb> <hostname>"; direct/manual use may pass just the
# hostname (verb defaults to "get") or nothing (hostname read from stdin).
verb = "get"
hostname: str | None = None
if len(params) >= 2:
verb, hostname = params[-2], params[-1]
elif len(params) == 1:
hostname = params[0]

if not hostname:
try:
hostname = sys.stdin.read().strip()
except (OSError, ValueError):
hostname = ""

exit_code, stdout, stderr = execute(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The helper reaches execute() without reading stdin for store. Terraform requires unsupported store operations to consume the complete payload before returning an error. Could stdin be drained first?

verb,
hostname,
credential=opts.credential,
api_host=opts.api_host,
org=opts.org,
repo=repo,
)

if stdout is not None:
click.echo(stdout)
if stderr is not None:
click.echo(stderr, err=True)
sys.exit(exit_code)
30 changes: 29 additions & 1 deletion cloudsmith_cli/cli/tests/commands/test_credential_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
from ....core.api.init import initialise_api
from ....core.credentials.models import CredentialResult
from ....credential_helpers.backends import BackendKind
from ....credential_helpers.common import is_cloudsmith_domain
from ....credential_helpers.common import (
is_cloudsmith_domain,
is_standard_cloudsmith_domain,
)
from ....credential_helpers.custom_domains import (
CACHE_FORMAT_VERSION,
CustomDomain,
Expand Down Expand Up @@ -720,6 +723,31 @@ def test_is_cloudsmith_domain(
assert result is expected


@pytest.mark.parametrize(
"url,expected",
[
# Standard apex + subdomains, any scheme/path/casing → True
("cloudsmith.io", True),
("cloudsmith.com", True),
("docker.cloudsmith.io", True),
("https://terraform.cloudsmith.io/acme/repo/", True),
("TERRAFORM.CLOUDSMITH.COM", True),
# Custom domains and foreign hosts → False (never standard)
("tf.acme.com", False),
("docker.acme.com", False),
("evil.example.com", False),
# Lookalikes that must not match the suffix check
("notcloudsmith.io", False),
("cloudsmith.io.evil.com", False),
("", False),
],
)
def test_is_standard_cloudsmith_domain(url, expected):
"""Only *.cloudsmith.io/.com (and the apexes) are standard; custom domains
and lookalikes are not — no API/auth is consulted."""
assert is_standard_cloudsmith_domain(url) is expected


# ---------------------------------------------------------------------------
# 10. Docker runtime backend_kind wiring
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading