From 21fe5cfb165b060185f9fa05ae725869aa1a8615 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Sun, 15 Mar 2026 21:43:06 +0000 Subject: [PATCH] feat: add credential helper for docker --- cloudsmith_cli/cli/commands/__init__.py | 1 + .../commands/credential_helper/__init__.py | 31 +++ .../cli/commands/credential_helper/docker.py | 80 +++++++ cloudsmith_cli/credential_helpers/__init__.py | 6 + cloudsmith_cli/credential_helpers/common.py | 86 ++++++++ .../credential_helpers/custom_domains.py | 199 ++++++++++++++++++ .../credential_helpers/docker/__init__.py | 38 ++++ .../credential_helpers/docker/wrapper.py | 76 +++++++ setup.py | 5 +- 9 files changed, 521 insertions(+), 1 deletion(-) create mode 100644 cloudsmith_cli/cli/commands/credential_helper/__init__.py create mode 100644 cloudsmith_cli/cli/commands/credential_helper/docker.py create mode 100644 cloudsmith_cli/credential_helpers/__init__.py create mode 100644 cloudsmith_cli/credential_helpers/common.py create mode 100644 cloudsmith_cli/credential_helpers/custom_domains.py create mode 100644 cloudsmith_cli/credential_helpers/docker/__init__.py create mode 100644 cloudsmith_cli/credential_helpers/docker/wrapper.py diff --git a/cloudsmith_cli/cli/commands/__init__.py b/cloudsmith_cli/cli/commands/__init__.py index af10c90e..d7d588ee 100644 --- a/cloudsmith_cli/cli/commands/__init__.py +++ b/cloudsmith_cli/cli/commands/__init__.py @@ -4,6 +4,7 @@ auth, check, copy, + credential_helper, delete, dependencies, docs, diff --git a/cloudsmith_cli/cli/commands/credential_helper/__init__.py b/cloudsmith_cli/cli/commands/credential_helper/__init__.py new file mode 100644 index 00000000..10727bf4 --- /dev/null +++ b/cloudsmith_cli/cli/commands/credential_helper/__init__.py @@ -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") diff --git a/cloudsmith_cli/cli/commands/credential_helper/docker.py b/cloudsmith_cli/cli/commands/credential_helper/docker.py new file mode 100644 index 00000000..0b57aa55 --- /dev/null +++ b/cloudsmith_cli/cli/commands/credential_helper/docker.py @@ -0,0 +1,80 @@ +""" +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): + """ + 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 (docker.cloudsmith.io). + + Input (stdin): + Server URL as plain text (e.g., "docker.cloudsmith.io") + + Output (stdout): + JSON: {"Username": "token", "Secret": ""} + + 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. " + "Make sure you have a valid cloudsmith-cli session, " + "this can be checked with `cloudsmith whoami`.", + err=True, + ) + sys.exit(1) + + click.echo(json.dumps(credentials)) + + except Exception as e: # pylint: disable=broad-exception-caught + click.echo(f"Error: {e}", err=True) + sys.exit(1) diff --git a/cloudsmith_cli/credential_helpers/__init__.py b/cloudsmith_cli/credential_helpers/__init__.py new file mode 100644 index 00000000..6e6715ce --- /dev/null +++ b/cloudsmith_cli/credential_helpers/__init__.py @@ -0,0 +1,6 @@ +""" +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. +""" diff --git a/cloudsmith_cli/credential_helpers/common.py b/cloudsmith_cli/credential_helpers/common.py new file mode 100644 index 00000000..300d468b --- /dev/null +++ b/cloudsmith_cli/credential_helpers/common.py @@ -0,0 +1,86 @@ +""" +Shared utilities for credential helpers. + +Provides domain checking used by all credential helpers. +""" + +import logging +import os + +logger = logging.getLogger(__name__) + + +def extract_hostname(url): + """ + Extract bare hostname from any URL format. + + Handles protocols, sparse+ prefix, ports, paths, and trailing slashes. + + Args: + url: URL in any format (e.g., "sparse+https://cargo.cloudsmith.io/org/repo/") + + Returns: + str: Lowercase hostname (e.g., "cargo.cloudsmith.io") + """ + if not url: + return "" + + normalized = url.lower().strip() + + # Remove sparse+ prefix (Cargo) + if normalized.startswith("sparse+"): + normalized = normalized[7:] + + # Remove protocol + if "://" in normalized: + normalized = normalized.split("://", 1)[1] + + # Remove userinfo (user@host) + if "@" in normalized.split("/")[0]: + normalized = normalized.split("@", 1)[1] + + # Extract hostname (before first / or :) + hostname = normalized.split("/")[0].split(":")[0] + + return hostname + + +def is_cloudsmith_domain(url, session=None, api_key=None, api_host=None): + """ + Check if a URL points to a Cloudsmith service. + + Checks standard *.cloudsmith.io domains first (no auth needed). + If not a standard domain, queries the Cloudsmith API for custom domains. + + Args: + url: URL or hostname to check + session: Pre-configured requests.Session with proxy/SSL settings + api_key: API key for authenticating custom domain lookups + api_host: Cloudsmith API host URL + + Returns: + bool: True if this is a Cloudsmith domain + """ + hostname = extract_hostname(url) + if not hostname: + return False + + # Standard Cloudsmith domains — no auth needed + if hostname.endswith("cloudsmith.io") or hostname == "cloudsmith.io": + return True + + # Custom domains require org + auth + org = os.environ.get("CLOUDSMITH_ORG", "").strip() + if not org: + return False + + if not api_key: + return False + + from .custom_domains import get_custom_domains_for_org + + custom_domains = get_custom_domains_for_org( + org, session=session, api_key=api_key, api_host=api_host + ) + + return hostname in [d.lower() for d in custom_domains] diff --git a/cloudsmith_cli/credential_helpers/custom_domains.py b/cloudsmith_cli/credential_helpers/custom_domains.py new file mode 100644 index 00000000..d34c594b --- /dev/null +++ b/cloudsmith_cli/credential_helpers/custom_domains.py @@ -0,0 +1,199 @@ +""" +Helper for discovering Cloudsmith custom domains. + +This module provides functions to fetch custom domains from the Cloudsmith API +for use in credential helpers. Results are cached on the filesystem. +""" + +import json +import logging +import time +from pathlib import Path +from typing import List, Optional + +logger = logging.getLogger(__name__) + +# Cache custom domains for 1 hour +CACHE_TTL_SECONDS = 3600 + + +def get_cache_dir() -> Path: + """ + Get the cache directory for custom domains. + + Returns: + Path to cache directory (e.g., ~/.cloudsmith/cache/custom_domains/) + """ + home = Path.home() + cache_dir = home / ".cloudsmith" / "cache" / "custom_domains" + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir + + +def get_cache_path(org: str) -> Path: + """ + Get the cache file path for an organization's custom domains. + + Args: + org: Organization slug + + Returns: + Path to cache file + """ + cache_dir = get_cache_dir() + safe_org = "".join(c if c.isalnum() or c in "-_" else "_" for c in org) + return cache_dir / f"{safe_org}.json" + + +def is_cache_valid(cache_path: Path) -> bool: + """ + Check if a cache file exists and is still valid. + + Args: + cache_path: Path to cache file + + Returns: + bool: True if cache exists and hasn't expired + """ + if not cache_path.exists(): + return False + + try: + mtime = cache_path.stat().st_mtime + age = time.time() - mtime + return age < CACHE_TTL_SECONDS + except OSError: + return False + + +def read_cache(cache_path: Path) -> Optional[List[str]]: + """ + Read custom domains from cache file. + + Args: + cache_path: Path to cache file + + Returns: + List of domain strings or None if cache invalid/missing + """ + if not is_cache_valid(cache_path): + return None + + try: + with open(cache_path, encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict) and "domains" in data: + domains = data["domains"] + if isinstance(domains, list): + logger.debug( + "Read %d domains from cache: %s", len(domains), cache_path + ) + return domains + except (OSError, json.JSONDecodeError) as exc: + logger.debug("Failed to read cache %s: %s", cache_path, exc) + + return None + + +def write_cache(cache_path: Path, domains: List[str]) -> None: + """ + Write custom domains to cache file. + + Args: + cache_path: Path to cache file + domains: List of domain strings to cache + """ + try: + data = { + "domains": domains, + "cached_at": time.time(), + } + with open(cache_path, "w", encoding="utf-8") as f: + json.dump(data, f) + logger.debug("Wrote %d domains to cache: %s", len(domains), cache_path) + except OSError as exc: + logger.debug("Failed to write cache %s: %s", cache_path, exc) + + +def get_custom_domains_for_org( # pylint: disable=too-many-return-statements + org: str, + session=None, + api_key: str = None, + api_host: str = None, +) -> List[str]: + """ + Fetch custom domains for a Cloudsmith organization. + + Results are cached on the filesystem for 1 hour to avoid excessive API calls. + + Args: + org: Organization slug + session: Pre-configured requests.Session with proxy/SSL settings. + If None, a plain requests session is used. + api_key: Optional API key for authentication + api_host: Cloudsmith API host URL. Defaults to https://api.cloudsmith.io. + + Returns: + List of custom domain strings (e.g., ['docker.customer.com', 'dl.customer.com']) + Empty list if API call fails or org has no custom domains + """ + cache_path = get_cache_path(org) + cached_domains = read_cache(cache_path) + if cached_domains is not None: + logger.debug("Using cached custom domains for %s", org) + return cached_domains + + logger.debug("Fetching custom domains from API for %s", org) + + try: + if session is None: + import requests + + session = requests.Session() + + if api_key: + session.headers["Authorization"] = f"Bearer {api_key}" + + host = api_host or "https://api.cloudsmith.io" + url = f"{host}/orgs/{org}/custom-domains/" + + response = session.get(url, timeout=10) + + if response.status_code in (401, 403): + logger.debug( + "Custom domains API requires auth - assuming no custom domains for %s", + org, + ) + return [] # Don't cache 401/403 - might work later with auth + + if response.status_code == 404: + logger.debug("Organization %s not found or has no custom domains", org) + write_cache(cache_path, []) # Cache empty result to avoid repeated 404s + return [] + + if response.status_code != 200: + logger.debug( + "Failed to fetch custom domains for %s: HTTP %d", + org, + response.status_code, + ) + return [] + + data = response.json() + + # Expected format: [{"host": "docker.customer.com", ...}, ...] + domains = [] + if isinstance(data, list): + for item in data: + if isinstance(item, dict) and "host" in item: + domains.append(item["host"]) + + logger.debug("Fetched %d custom domains for %s", len(domains), org) + + write_cache(cache_path, domains) + + return domains + + except Exception as exc: # pylint: disable=broad-exception-caught + logger.debug("Error fetching custom domains: %s", exc) + return [] diff --git a/cloudsmith_cli/credential_helpers/docker/__init__.py b/cloudsmith_cli/credential_helpers/docker/__init__.py new file mode 100644 index 00000000..a5f4e9bd --- /dev/null +++ b/cloudsmith_cli/credential_helpers/docker/__init__.py @@ -0,0 +1,38 @@ +""" +Docker credential helper logic for Cloudsmith. + +This module provides functions for retrieving credentials for Docker registries +using the existing Cloudsmith credential provider chain (OIDC, API keys, config, keyring). +""" + +from ..common import is_cloudsmith_domain + + +def get_credentials(server_url, credential=None, session=None, api_host=None): + """ + Get credentials for a Cloudsmith Docker registry. + + Verifies the URL is a Cloudsmith registry (including custom domains) + and returns credentials if available. + + Args: + server_url: The Docker registry server URL + credential: Pre-resolved CredentialResult from the provider chain + session: Pre-configured requests.Session with proxy/SSL settings + api_host: Cloudsmith API host URL + + Returns: + dict: Credentials with 'Username' and 'Secret' keys, or None + """ + if not credential or not credential.api_key: + return None + + if not is_cloudsmith_domain( + server_url, + session=session, + api_key=credential.api_key, + api_host=api_host, + ): + return None + + return {"Username": "token", "Secret": credential.api_key} diff --git a/cloudsmith_cli/credential_helpers/docker/wrapper.py b/cloudsmith_cli/credential_helpers/docker/wrapper.py new file mode 100644 index 00000000..85f050ee --- /dev/null +++ b/cloudsmith_cli/credential_helpers/docker/wrapper.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python +""" +Wrapper for docker-credential-cloudsmith. + +This is the entry point binary that Docker calls. It delegates to the main +cloudsmith credential-helper docker command. + +See: https://github.com/docker/docker-credential-helpers + +Configure in ~/.docker/config.json: + { + "credHelpers": { + "docker.cloudsmith.io": "cloudsmith" + } + } +""" +import subprocess +import sys + + +def main(): + """ + Docker credential helper wrapper. + + Docker calls this with the operation as argv[1]: + - get: Retrieve credentials + - store: Store credentials (not supported) + - erase: Erase credentials (not supported) + - list: List credentials (not supported) + + We only support 'get' and delegate to: cloudsmith credential-helper docker + """ + if len(sys.argv) < 2: + print( + "Error: Missing operation argument. " + "Usage: docker-credential-cloudsmith ", + file=sys.stderr, + ) + sys.exit(1) + + operation = sys.argv[1] + + if operation == "get": + try: + result = subprocess.run( + ["cloudsmith", "credential-helper", "docker"], + stdin=sys.stdin, + capture_output=False, + check=False, + ) + sys.exit(result.returncode) + except FileNotFoundError: + print( + "Error: 'cloudsmith' command not found. " + "Make sure cloudsmith-cli is installed.", + file=sys.stderr, + ) + sys.exit(1) + elif operation in ("store", "erase", "list"): + print( + f"Error: Operation '{operation}' is not supported. " + "Only 'get' is available for Cloudsmith credential helper.", + file=sys.stderr, + ) + sys.exit(1) + else: + print( + f"Error: Unknown operation '{operation}'. " + "Valid operations: get, store, erase, list", + file=sys.stderr, + ) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py index e6775e2d..36c5453a 100644 --- a/setup.py +++ b/setup.py @@ -65,7 +65,10 @@ def get_long_description(): "urllib3>=2.5", ], entry_points={ - "console_scripts": ["cloudsmith=cloudsmith_cli.cli.commands.main:main"] + "console_scripts": [ + "cloudsmith=cloudsmith_cli.cli.commands.main:main", + "docker-credential-cloudsmith=cloudsmith_cli.credential_helpers.docker.wrapper:main", + ] }, keywords=["cloudsmith", "cli", "devops"], classifiers=[