-
Notifications
You must be signed in to change notification settings - Fork 37
feat: add Docker credential helper for Cloudsmith registries #277
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
cloudsmith-iduffy
wants to merge
7
commits into
master
Choose a base branch
from
iduffy/credential-helper-base
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
40a9961
feat: add credential provider chain concept
cloudsmith-iduffy f30e428
fix: review feedback
cloudsmith-iduffy 05fd479
feat: add Docker credential helper for Cloudsmith registries
cloudsmith-iduffy 5a7ce30
Merge origin/master into iduffy/credential-helper-base
Copilot 8f5e800
Potential fix for pull request finding 'CodeQL / Incomplete URL subst…
BartoszBlizniak 78e06ff
fix: copilot feedback
BartoszBlizniak f74c304
fix: cache file handling
BartoszBlizniak File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
| auth, | ||
| check, | ||
| copy, | ||
| credential_helper, | ||
| delete, | ||
| dependencies, | ||
| docs, | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| """ | ||
| Credential helper commands for Cloudsmith. | ||
|
|
||
| This module provides credential helper commands for package managers | ||
| that follow their respective credential helper protocols. | ||
| """ | ||
|
|
||
| import click | ||
|
|
||
| from ..main import main | ||
| from .docker import docker as docker_cmd | ||
|
|
||
|
|
||
| @click.group() | ||
| def credential_helper(): | ||
| """ | ||
| Credential helpers for package managers. | ||
|
|
||
| These commands provide credentials for package managers like Docker. | ||
| They are typically called by wrapper binaries | ||
| (e.g., docker-credential-cloudsmith) or used directly for debugging. | ||
|
|
||
| Examples: | ||
| # Test Docker credential helper | ||
| $ echo "docker.cloudsmith.io" | cloudsmith credential-helper docker | ||
| """ | ||
|
|
||
|
|
||
| credential_helper.add_command(docker_cmd, name="docker") | ||
|
|
||
| main.add_command(credential_helper, name="credential-helper") | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| """ | ||
|
BartoszBlizniak marked this conversation as resolved.
|
||
| Docker credential helper command. | ||
|
|
||
| Implements the Docker credential helper protocol for Cloudsmith registries. | ||
|
|
||
| See: https://github.com/docker/docker-credential-helpers | ||
| """ | ||
|
|
||
| import json | ||
| import sys | ||
|
|
||
| import click | ||
|
|
||
| from ....credential_helpers.docker import get_credentials | ||
| from ...decorators import common_api_auth_options, resolve_credentials | ||
|
|
||
|
|
||
| @click.command() | ||
| @common_api_auth_options | ||
| @resolve_credentials | ||
| def docker(opts): | ||
|
BartoszBlizniak marked this conversation as resolved.
BartoszBlizniak marked this conversation as resolved.
|
||
| """ | ||
| Docker credential helper for Cloudsmith registries. | ||
|
|
||
| Reads a Docker registry server URL from stdin and returns credentials in JSON format. | ||
| This command implements the 'get' operation of the Docker credential helper protocol. | ||
|
|
||
| Only provides credentials for Cloudsmith Docker registries: `*.cloudsmith.io` | ||
| and any custom domains configured for the organization (requires CLOUDSMITH_ORG | ||
| and a valid API key/token). | ||
|
|
||
| Input (stdin): | ||
| Server URL as plain text (e.g., "docker.cloudsmith.io") | ||
|
|
||
| Output (stdout): | ||
| JSON: {"Username": "token", "Secret": "<cloudsmith-token>"} | ||
|
|
||
| Exit codes: | ||
| 0: Success | ||
| 1: Error (no credentials available, not a Cloudsmith registry, etc.) | ||
|
|
||
| Examples: | ||
| # Manual testing | ||
| $ echo "docker.cloudsmith.io" | cloudsmith credential-helper docker | ||
| {"Username":"token","Secret":"eyJ0eXAiOiJKV1Qi..."} | ||
|
|
||
| # Called by Docker via wrapper | ||
| $ echo "docker.cloudsmith.io" | docker-credential-cloudsmith get | ||
| {"Username":"token","Secret":"eyJ0eXAiOiJKV1Qi..."} | ||
|
|
||
| Environment variables: | ||
| CLOUDSMITH_API_KEY: API key for authentication (optional) | ||
| CLOUDSMITH_ORG: Organization slug (required for custom domain support) | ||
| """ | ||
| try: | ||
| server_url = sys.stdin.read().strip() | ||
|
|
||
| if not server_url: | ||
| click.echo("Error: No server URL provided on stdin", err=True) | ||
| sys.exit(1) | ||
|
|
||
| credentials = get_credentials( | ||
| server_url, | ||
| credential=opts.credential, | ||
| session=opts.session, | ||
| api_host=opts.api_host or "https://api.cloudsmith.io", | ||
| ) | ||
|
|
||
| if not credentials: | ||
| click.echo( | ||
| "Error: Unable to retrieve credentials. " | ||
| "Provide credentials via the CLOUDSMITH_API_KEY environment variable, " | ||
| "credentials.ini, the system keyring, or an OIDC service. " | ||
| "Verify current authentication with `cloudsmith whoami --verbose`.", | ||
| err=True, | ||
| ) | ||
|
BartoszBlizniak marked this conversation as resolved.
|
||
| sys.exit(1) | ||
|
|
||
| click.echo(json.dumps(credentials)) | ||
|
|
||
| except OSError as e: | ||
| click.echo(f"Error: {e}", err=True) | ||
| sys.exit(1) | ||
131 changes: 131 additions & 0 deletions
131
cloudsmith_cli/cli/tests/commands/test_credential_helper.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| """Tests for the `cloudsmith credential-helper docker` command.""" | ||
|
|
||
| import io | ||
| import json | ||
| from unittest.mock import patch | ||
|
|
||
| import pytest | ||
|
|
||
| from ....cli.commands.credential_helper.docker import docker | ||
| from ....core.credentials.models import CredentialResult | ||
| from ....credential_helpers.custom_domains import get_cache_path, write_cache | ||
| from ....credential_helpers.docker.credentials import ( | ||
| get_credentials as helper_get_credentials, | ||
| ) | ||
| from ....credential_helpers.docker.wrapper import main as docker_wrapper_main | ||
|
|
||
|
|
||
| class TestDockerCredentialHelper: | ||
| """Test suite for the Docker credential helper CLI command.""" | ||
|
|
||
| def test_get_credentials_for_cloudsmith_io(self, runner): | ||
| """`*.cloudsmith.io` URLs should return credentials JSON on stdout.""" | ||
| fake_creds = {"Username": "token", "Secret": "k_abc"} | ||
|
|
||
| with patch( | ||
| "cloudsmith_cli.cli.commands.credential_helper.docker.get_credentials" | ||
| ) as mock_get: | ||
| mock_get.return_value = fake_creds | ||
| result = runner.invoke( | ||
| docker, input="docker.cloudsmith.io", catch_exceptions=False | ||
| ) | ||
|
|
||
| assert result.exit_code == 0 | ||
| # stdout should contain the serialized JSON exactly as produced by the command. | ||
| assert json.dumps(fake_creds) in result.stdout | ||
| mock_get.assert_called_once() | ||
| # The first positional argument to get_credentials is the server URL. | ||
| called_args, _called_kwargs = mock_get.call_args | ||
| assert called_args[0] == "docker.cloudsmith.io" | ||
|
|
||
| def test_refuses_non_cloudsmith_domain(self, runner): | ||
| """Non-Cloudsmith URLs should exit 1 with an error message on stderr.""" | ||
| with patch( | ||
| "cloudsmith_cli.cli.commands.credential_helper.docker.get_credentials" | ||
| ) as mock_get: | ||
| mock_get.return_value = None | ||
| result = runner.invoke( | ||
| docker, input="evil.example.com", catch_exceptions=False | ||
| ) | ||
|
|
||
| assert result.exit_code == 1 | ||
| assert "Unable to retrieve credentials" in result.output | ||
| mock_get.assert_called_once() | ||
|
|
||
| def test_empty_stdin_exits_1(self, runner): | ||
| """Empty stdin should exit 1 with a descriptive error on stderr.""" | ||
| with patch( | ||
| "cloudsmith_cli.cli.commands.credential_helper.docker.get_credentials" | ||
| ) as mock_get: | ||
| result = runner.invoke(docker, input="", catch_exceptions=False) | ||
|
|
||
| assert result.exit_code == 1 | ||
| assert "No server URL provided" in result.output | ||
| # get_credentials should never be called when there is no URL. | ||
| mock_get.assert_not_called() | ||
|
|
||
| def test_custom_domain_with_cached_response(self, tmp_path, monkeypatch): | ||
| """A cached custom-domain entry should authorise credential issuance. | ||
|
|
||
| This exercises the helper-level `get_credentials` (not the click command) | ||
| so the on-disk custom-domain cache lookup runs end to end. The click | ||
| command's wiring is covered by the other tests in this class. | ||
| """ | ||
| # Point the cache base at a per-test temp directory. | ||
| monkeypatch.setattr( | ||
| "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", | ||
| lambda: str(tmp_path), | ||
| ) | ||
| # is_cloudsmith_domain reads CLOUDSMITH_ORG from the environment. | ||
| monkeypatch.setenv("CLOUDSMITH_ORG", "acme") | ||
|
|
||
| # Seed the cache file at the path the helper will read from. | ||
| cache_path = get_cache_path("acme") | ||
| assert cache_path.parent.exists(), "get_cache_path should create the dir" | ||
| write_cache(cache_path, ["docker.acme.com"]) | ||
|
|
||
| credential = CredentialResult(api_key="k_xyz", source_name="test") | ||
|
|
||
| # Sentinel session; the cache hit means no HTTP call should be made. | ||
| class _BoomSession: | ||
| def get(self, *_args, **_kwargs): | ||
| raise AssertionError( | ||
| "Network call attempted despite a valid custom-domain cache" | ||
| ) | ||
|
|
||
| result = helper_get_credentials( | ||
| "docker.acme.com", | ||
| credential=credential, | ||
| session=_BoomSession(), | ||
| api_host="https://api.cloudsmith.io", | ||
| ) | ||
|
|
||
| assert result == {"Username": "token", "Secret": "k_xyz"} | ||
|
|
||
| @pytest.mark.parametrize("operation", ["store", "erase"]) | ||
| def test_wrapper_read_only_operations_are_noops( | ||
| self, operation, monkeypatch, capsys | ||
| ): | ||
| """Docker's write operations should succeed without storing anything.""" | ||
| monkeypatch.setattr("sys.argv", ["docker-credential-cloudsmith", operation]) | ||
| monkeypatch.setattr("sys.stdin", io.StringIO('{"ServerURL":"example.com"}')) | ||
|
|
||
| with pytest.raises(SystemExit) as exc: | ||
| docker_wrapper_main() | ||
|
|
||
| assert exc.value.code == 0 | ||
| output = capsys.readouterr() | ||
| assert output.out == "" | ||
| assert output.err == "" | ||
|
|
||
| def test_wrapper_list_returns_empty_json(self, monkeypatch, capsys): | ||
| """Docker's list operation should return an empty credential object.""" | ||
| monkeypatch.setattr("sys.argv", ["docker-credential-cloudsmith", "list"]) | ||
|
|
||
| with pytest.raises(SystemExit) as exc: | ||
| docker_wrapper_main() | ||
|
|
||
| assert exc.value.code == 0 | ||
| output = capsys.readouterr() | ||
| assert output.out == "{}\n" | ||
| assert output.err == "" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| # Copyright 2026 Cloudsmith Ltd | ||
| """Shared utilities for on-disk credential and cache storage.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
| import tempfile | ||
| from typing import Any | ||
|
|
||
|
|
||
| def atomic_write_json(path: str | os.PathLike, data: Any, *, mode: int = 0o600) -> None: | ||
| """Atomically write JSON to a file with restrictive permissions. | ||
|
|
||
| Writes to a sibling temp file, fsyncs, sets mode, then renames over the | ||
| destination. Concurrent readers never see a partial file. Temp file is | ||
| removed on error. Caller is responsible for ensuring the parent directory | ||
| exists. | ||
| """ | ||
| dest = os.fspath(path) | ||
| parent = os.path.dirname(dest) or "." | ||
| tmp_fd, tmp_path = tempfile.mkstemp(dir=parent, prefix=".tmp_", suffix=".json") | ||
| try: | ||
| with os.fdopen(tmp_fd, "w", encoding="utf-8") as f: | ||
| json.dump(data, f) | ||
| f.flush() | ||
| os.fsync(f.fileno()) | ||
| os.chmod(tmp_path, mode) | ||
| os.replace(tmp_path, dest) | ||
| except (OSError, TypeError, ValueError): | ||
| try: | ||
| os.unlink(tmp_path) | ||
| except OSError: | ||
| pass | ||
| raise |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| """ | ||
|
BartoszBlizniak marked this conversation as resolved.
|
||
| Credential helpers for various package managers. | ||
|
|
||
| This package provides credential helper implementations for Docker, pip, npm, etc. | ||
| Each helper follows its respective package manager's credential helper protocol. | ||
| """ | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.