Skip to content

Commit fc7dacc

Browse files
cloudsmith-iduffyCopilotBartoszBlizniakgithub-advanced-security[bot]claude
authored
feat: add Docker credential helper for Cloudsmith registries (#277)
* feat: add credential provider chain concept * fix: review feedback * feat: add Docker credential helper for Cloudsmith registries * Potential fix for pull request finding 'CodeQL / Incomplete URL substring sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix: copilot feedback * fix: cache file handling * fix: move to top level import * fix: import level and add .com to CS domains * refactor: change custom domain calls to use sdk * feat(credential-helper): add merge_json_file safe JSON writer Extends cache_utils.py with merge_json_file, a safe atomic JSON-merge writer for editing user-owned config files (e.g. ~/.docker/config.json). Supports read-or-empty fallback, in-place mutation, stable indent=2 serialisation, idempotent change detection, optional .bak backup, dry_run mode, parent-dir creation, and atomic temp+replace writes. Extracts _atomic_write_text as a shared private helper so atomic_write_json retains its public signature and behaviour. Adds 27 tests covering all nine specified behaviours (foreign-key preservation, missing file/parent-dir creation, backup semantics, idempotency, dry_run, malformed input, serialisation format, return value, and atomic_write_json round-trip + permissions). Co-Authored-By: Claude <noreply@anthropic.com> * feat(credential-helper): structured custom-domain discovery + format filter Reshape the custom-domain layer to return typed CustomDomain dataclass records (host, backend_kind, enabled, validated) instead of bare host strings. Add BackendKind IntEnum mirroring the server enum. Expose get_custom_domains() and get_format_domains() so callers can filter by format. Switch list_custom_domains() in core/api/orgs.py to return to_dict() records following the repo pattern. Tighten exception handling to ApiException-only (no bare except). Rewire is_cloudsmith_domain to use enabled+validated check. Update tests for the new structured API. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(credential-helper): extract docker runtime + boundary error handling Move Docker credential-helper protocol logic from the click command into a transport-light `credential_helpers/docker/runtime.py`. The command is now a thin shim that calls `execute()` and passes stdout/stderr/exit-code back to the caller. The D17 protocol-boundary broad-except in `_execute_get` ensures that network/SDK errors degrade to a clean exit-1 refusal rather than crashing `docker pull`/`push`. `credentials.py` is removed; its logic lives in `runtime.py`. Co-Authored-By: Claude <noreply@anthropic.com> * feat(credential-helper): add install/uninstall/list command + launchers Replaces the old Python wrapper entry-point with an on-PATH shell launcher written by `cloudsmith credential-helper install docker`. The installer patches ~/.docker/config.json via merge_json_file (preserving foreign keys, atomic write, .bak backup) and supports --domain, --bin-dir, and --dry-run. Adds launchers.py (write/remove/resolve_bin_dir/is_on_path), docker/installer.py (DockerInstaller), and cli/commands/credential_helper/manage.py (install/uninstall/list commands). Removes credential_helpers/docker/wrapper.py and its setup.py console_scripts entry point. Co-Authored-By: Claude <noreply@anthropic.com> * feat(credential-helper): auto-discover Docker custom domains on install Wires custom-domain autodiscovery into `credential-helper install docker`. Discovery is best-effort: if org/credentials are absent, or if the API call fails, the default host is still registered and a clear info/warning action is returned. - custom_domains.py: add `refresh: bool = False` to `get_custom_domains` and `get_format_domains`; when True, skips the cache read and always fetches from the API (writing the fresh result back to cache). - installer.py: extend `DockerInstaller.install` with `discover`, `refresh`, `org`, `api_key`, `auth_type`, `api_host` parameters; adds a single deliberate broad-except discovery boundary with `# pylint: disable=broad-except`. - manage.py: add `--no-discover`, `--refresh`, `--org` (envvar CLOUDSMITH_ORG) options; apply `@common_api_auth_options` + `@resolve_credentials`; thread all new params into `installer.install`. - test_credential_helper_install.py: new test classes covering discovery on/off, missing org/creds skip, failure graceful degradation, dedup, --refresh cache-bypass, and CliRunner smoke tests for the new CLI flags. Co-Authored-By: Claude <noreply@anthropic.com> * fix(credential-helper): honor --bin-dir on uninstall Add a `bin_dir` keyword param to `DockerInstaller.uninstall` and wire it through to `resolve_bin_dir`, so that `uninstall docker --bin-dir /custom` locates and removes the launcher written by `install --bin-dir /custom` instead of silently looking in the auto-resolved directory. Thread the new `--bin-dir` Click option into `uninstall_cmd` in manage.py (matching the help-text style of the install counterpart). Two on-disk tests verify the fix: one confirms the launcher is gone after a matching uninstall, the other confirms a mismatched dir leaves the launcher intact. Co-Authored-By: Claude <noreply@anthropic.com> * feat(credential-helper): support -F/--output-format json for install/uninstall/list Add -F/--output-format {pretty,json,pretty_json} to the three credential-helper management commands (install, uninstall, list) following the repo convention. JSON mode emits {"data": ...} on stdout with no human text; pretty mode is unchanged. The runtime docker command is untouched. Co-Authored-By: Claude <noreply@anthropic.com> * feat(credential-helper): scope docker runtime to docker-format custom domains Pass BackendKind.DOCKER to is_cloudsmith_domain so the Docker credential helper only vouches for Docker-format custom domains. Adds backend_kind optional param to is_cloudsmith_domain (None default preserves existing generic behavior); updates docker/runtime.py to pass BackendKind.DOCKER; extends TestIsCloudsmithDomain and adds TestDockerRuntimeBackendKindFiltering to cover filtering correctness. Co-Authored-By: Claude <noreply@anthropic.com> * test(credential-helper): consolidate tests to one-per-behavior Collapse 108 tests (45 + 63) down to 61 (34 + 27) by merging near-identical cases into parametrized tests and removing layer-duplicates. All five retained guards survive: broken-pipe boundary, legacy-cache format miss, uppercase-host casing, status str-not-Path serialisation, and graceful discovery failure. Co-Authored-By: Claude <noreply@anthropic.com> * fix(credential-helper): make launcher tests Python 3.11-safe The launcher/resolve tests monkeypatched os.name="nt" on a posix host to exercise the Windows code paths. On Python < 3.12 that makes pathlib.Path build a WindowsPath, which raises NotImplementedError — crashing the whole pytest session in CI (3.11) during failure-report/cache-write. It passed locally only because 3.12+ rewrote pathlib to tolerate it. Extract the platform-specific logic (launcher filename, script body, user bin dir) into pure helpers parameterised on `windows: bool`, and test those directly instead of faking os.name. Public API and return types unchanged; behaviour on real Windows is identical. Co-Authored-By: Claude <noreply@anthropic.com> * fix(credential-helper): harden config merge + exact launcher bytes (copilot review) Coerce non-dict credHelpers to {} on install (Fix 1), guard non-dict credHelpers as a no-op on uninstall (Fix 2), pass newline="" to write_text to prevent \r\r\n on Windows (Fix 3), and require W_OK|X_OK when selecting candidate bin dir (Fix 4). Add tests 17 and 18 covering the two malformed-credHelpers edge cases. Co-Authored-By: Claude <noreply@anthropic.com> * chore(credential-helper): add copyright headers to new modules (copilot review) Add missing '# Copyright 2026 Cloudsmith Ltd' header to five new source files flagged by Copilot for inconsistent licensing headers. Matches the convention established in cache_utils.py and backends.py exactly. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: BB <55028730+BartoszBlizniak@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Bartosz Blizniak <bblizniak@cloudsmith.io> Co-authored-by: Claude <noreply@anthropic.com>
1 parent d7ae816 commit fc7dacc

20 files changed

Lines changed: 3313 additions & 5 deletions

File tree

cloudsmith_cli/cli/commands/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
auth,
55
check,
66
copy,
7+
credential_helper,
78
delete,
89
dependencies,
910
docs,
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Copyright 2026 Cloudsmith Ltd
2+
"""
3+
Credential helper commands for Cloudsmith.
4+
5+
This module provides credential helper commands for package managers
6+
that follow their respective credential helper protocols.
7+
"""
8+
9+
import click
10+
11+
from ..main import main
12+
from .docker import docker as docker_cmd
13+
from .manage import install_cmd, list_cmd, uninstall_cmd
14+
15+
16+
@click.group()
17+
def credential_helper():
18+
"""
19+
Credential helpers for package managers.
20+
21+
These commands provide credentials for package managers like Docker.
22+
Use ``install`` to set up the on-PATH launcher and configure the package
23+
manager automatically, or run the runtime command directly for debugging.
24+
25+
Examples:
26+
# Install Docker credential helper
27+
$ cloudsmith credential-helper install docker
28+
29+
# Test Docker credential helper directly
30+
$ echo "docker.cloudsmith.io" | cloudsmith credential-helper docker
31+
"""
32+
33+
34+
credential_helper.add_command(docker_cmd, name="docker")
35+
credential_helper.add_command(install_cmd, name="install")
36+
credential_helper.add_command(uninstall_cmd, name="uninstall")
37+
credential_helper.add_command(list_cmd, name="list")
38+
39+
main.add_command(credential_helper, name="credential-helper")
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Copyright 2026 Cloudsmith Ltd
2+
"""
3+
Docker credential helper command.
4+
5+
Implements the Docker credential helper protocol for Cloudsmith registries.
6+
7+
See: https://github.com/docker/docker-credential-helpers
8+
"""
9+
10+
import sys
11+
12+
import click
13+
14+
from ....credential_helpers.docker import execute
15+
from ...decorators import common_api_auth_options, resolve_credentials
16+
17+
18+
@click.command()
19+
@click.argument("operation", required=False, default="get")
20+
@common_api_auth_options
21+
@resolve_credentials
22+
def docker(opts, operation):
23+
"""
24+
Docker credential helper for Cloudsmith registries.
25+
26+
Reads a Docker registry server URL from stdin and returns credentials in
27+
JSON format. Implements the full Docker credential helper protocol
28+
(get/store/erase/list).
29+
30+
Provides credentials for all Cloudsmith Docker registries: ``*.cloudsmith.io``,
31+
``*.cloudsmith.com``, and any custom domains configured for the organisation
32+
(requires CLOUDSMITH_ORG and a valid API key/token).
33+
34+
Input (stdin):
35+
Server URL as plain text (e.g. "docker.cloudsmith.io")
36+
37+
Output (stdout):
38+
JSON: {"Username": "token", "Secret": "<cloudsmith-token>"}
39+
40+
Exit codes:
41+
0: Success
42+
1: Error (no credentials available, not a Cloudsmith registry, etc.)
43+
44+
Examples:
45+
# Manual testing
46+
$ echo "docker.cloudsmith.io" | cloudsmith credential-helper docker
47+
48+
# Called by Docker via launcher
49+
$ echo "docker.cloudsmith.io" | docker-credential-cloudsmith get
50+
51+
Environment variables:
52+
CLOUDSMITH_API_KEY: API key for authentication (optional)
53+
CLOUDSMITH_ORG: Organisation slug (required for custom domain support)
54+
"""
55+
exit_code, stdout, stderr = execute(
56+
operation,
57+
sys.stdin,
58+
credential=opts.credential,
59+
api_host=opts.api_host,
60+
)
61+
62+
if stdout is not None:
63+
click.echo(stdout)
64+
if stderr is not None:
65+
click.echo(stderr, err=True)
66+
sys.exit(exit_code)

0 commit comments

Comments
 (0)