cloudsmith-cli 1.20.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cloudsmith_cli/__init__.py +10 -0
- cloudsmith_cli/__main__.py +8 -0
- cloudsmith_cli/cli/__init__.py +1 -0
- cloudsmith_cli/cli/command.py +160 -0
- cloudsmith_cli/cli/commands/__init__.py +33 -0
- cloudsmith_cli/cli/commands/auth.py +173 -0
- cloudsmith_cli/cli/commands/check.py +129 -0
- cloudsmith_cli/cli/commands/copy.py +98 -0
- cloudsmith_cli/cli/commands/credential_helper/__init__.py +39 -0
- cloudsmith_cli/cli/commands/credential_helper/docker.py +66 -0
- cloudsmith_cli/cli/commands/credential_helper/manage.py +299 -0
- cloudsmith_cli/cli/commands/delete.py +67 -0
- cloudsmith_cli/cli/commands/dependencies.py +108 -0
- cloudsmith_cli/cli/commands/docs.py +16 -0
- cloudsmith_cli/cli/commands/download.py +620 -0
- cloudsmith_cli/cli/commands/entitlements.py +819 -0
- cloudsmith_cli/cli/commands/help_.py +12 -0
- cloudsmith_cli/cli/commands/list_.py +317 -0
- cloudsmith_cli/cli/commands/login.py +99 -0
- cloudsmith_cli/cli/commands/logout.py +151 -0
- cloudsmith_cli/cli/commands/main.py +73 -0
- cloudsmith_cli/cli/commands/mcp.py +523 -0
- cloudsmith_cli/cli/commands/metadata.py +503 -0
- cloudsmith_cli/cli/commands/metrics/__init__.py +2 -0
- cloudsmith_cli/cli/commands/metrics/command.py +20 -0
- cloudsmith_cli/cli/commands/metrics/entitlements.py +148 -0
- cloudsmith_cli/cli/commands/metrics/packages.py +134 -0
- cloudsmith_cli/cli/commands/move.py +111 -0
- cloudsmith_cli/cli/commands/policy/__init__.py +3 -0
- cloudsmith_cli/cli/commands/policy/command.py +20 -0
- cloudsmith_cli/cli/commands/policy/deny.py +248 -0
- cloudsmith_cli/cli/commands/policy/license.py +335 -0
- cloudsmith_cli/cli/commands/policy/vulnerability.py +322 -0
- cloudsmith_cli/cli/commands/push.py +1323 -0
- cloudsmith_cli/cli/commands/quarantine.py +148 -0
- cloudsmith_cli/cli/commands/quota/__init__.py +2 -0
- cloudsmith_cli/cli/commands/quota/command.py +20 -0
- cloudsmith_cli/cli/commands/quota/history.py +122 -0
- cloudsmith_cli/cli/commands/quota/quota.py +107 -0
- cloudsmith_cli/cli/commands/repos.py +330 -0
- cloudsmith_cli/cli/commands/resync.py +90 -0
- cloudsmith_cli/cli/commands/status.py +92 -0
- cloudsmith_cli/cli/commands/tags.py +375 -0
- cloudsmith_cli/cli/commands/tokens.py +318 -0
- cloudsmith_cli/cli/commands/upstream.py +479 -0
- cloudsmith_cli/cli/commands/vulnerabilities.py +141 -0
- cloudsmith_cli/cli/commands/whoami.py +188 -0
- cloudsmith_cli/cli/config.py +635 -0
- cloudsmith_cli/cli/decorators.py +624 -0
- cloudsmith_cli/cli/exceptions.py +215 -0
- cloudsmith_cli/cli/metadata_common.py +146 -0
- cloudsmith_cli/cli/saml.py +109 -0
- cloudsmith_cli/cli/table.py +59 -0
- cloudsmith_cli/cli/types.py +14 -0
- cloudsmith_cli/cli/utils.py +267 -0
- cloudsmith_cli/cli/validators.py +378 -0
- cloudsmith_cli/cli/webserver.py +263 -0
- cloudsmith_cli/core/__init__.py +1 -0
- cloudsmith_cli/core/api/__init__.py +1 -0
- cloudsmith_cli/core/api/distros.py +31 -0
- cloudsmith_cli/core/api/entitlements.py +130 -0
- cloudsmith_cli/core/api/exceptions.py +57 -0
- cloudsmith_cli/core/api/files.py +131 -0
- cloudsmith_cli/core/api/init.py +109 -0
- cloudsmith_cli/core/api/metadata.py +217 -0
- cloudsmith_cli/core/api/metrics.py +78 -0
- cloudsmith_cli/core/api/orgs.py +201 -0
- cloudsmith_cli/core/api/packages.py +309 -0
- cloudsmith_cli/core/api/quota.py +64 -0
- cloudsmith_cli/core/api/rates.py +28 -0
- cloudsmith_cli/core/api/repos.py +81 -0
- cloudsmith_cli/core/api/status.py +27 -0
- cloudsmith_cli/core/api/upstreams.py +72 -0
- cloudsmith_cli/core/api/user.py +109 -0
- cloudsmith_cli/core/api/version.py +15 -0
- cloudsmith_cli/core/api/vulnerabilities.py +230 -0
- cloudsmith_cli/core/cache_utils.py +160 -0
- cloudsmith_cli/core/config.py +140 -0
- cloudsmith_cli/core/credentials/__init__.py +0 -0
- cloudsmith_cli/core/credentials/chain.py +69 -0
- cloudsmith_cli/core/credentials/models.py +44 -0
- cloudsmith_cli/core/credentials/oidc/__init__.py +6 -0
- cloudsmith_cli/core/credentials/oidc/cache.py +220 -0
- cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +122 -0
- cloudsmith_cli/core/credentials/oidc/detectors/aws.py +85 -0
- cloudsmith_cli/core/credentials/oidc/detectors/azure_devops.py +70 -0
- cloudsmith_cli/core/credentials/oidc/detectors/base.py +26 -0
- cloudsmith_cli/core/credentials/oidc/detectors/bitbucket_pipelines.py +35 -0
- cloudsmith_cli/core/credentials/oidc/detectors/circleci.py +40 -0
- cloudsmith_cli/core/credentials/oidc/detectors/generic.py +42 -0
- cloudsmith_cli/core/credentials/oidc/detectors/github_actions.py +64 -0
- cloudsmith_cli/core/credentials/oidc/detectors/gitlab_ci.py +49 -0
- cloudsmith_cli/core/credentials/oidc/exchange.py +87 -0
- cloudsmith_cli/core/credentials/provider.py +17 -0
- cloudsmith_cli/core/credentials/providers/__init__.py +15 -0
- cloudsmith_cli/core/credentials/providers/cli_flag.py +24 -0
- cloudsmith_cli/core/credentials/providers/credentials_file.py +24 -0
- cloudsmith_cli/core/credentials/providers/env_var.py +24 -0
- cloudsmith_cli/core/credentials/providers/keyring_provider.py +59 -0
- cloudsmith_cli/core/credentials/providers/oidc_provider.py +115 -0
- cloudsmith_cli/core/download.py +594 -0
- cloudsmith_cli/core/keyring.py +171 -0
- cloudsmith_cli/core/mcp/__init__.py +0 -0
- cloudsmith_cli/core/mcp/data.py +17 -0
- cloudsmith_cli/core/mcp/server.py +786 -0
- cloudsmith_cli/core/pagination.py +131 -0
- cloudsmith_cli/core/ratelimits.py +88 -0
- cloudsmith_cli/core/rest.py +255 -0
- cloudsmith_cli/core/utils.py +95 -0
- cloudsmith_cli/core/version.py +20 -0
- cloudsmith_cli/credential_helpers/__init__.py +7 -0
- cloudsmith_cli/credential_helpers/backends.py +41 -0
- cloudsmith_cli/credential_helpers/common.py +111 -0
- cloudsmith_cli/credential_helpers/custom_domains.py +281 -0
- cloudsmith_cli/credential_helpers/docker/__init__.py +4 -0
- cloudsmith_cli/credential_helpers/docker/installer.py +349 -0
- cloudsmith_cli/credential_helpers/docker/runtime.py +117 -0
- cloudsmith_cli/credential_helpers/launchers.py +175 -0
- cloudsmith_cli/data/VERSION +1 -0
- cloudsmith_cli/data/config.ini +23 -0
- cloudsmith_cli/data/credentials.ini +14 -0
- cloudsmith_cli/templates/__init__.py +3 -0
- cloudsmith_cli/templates/auth_error.html +45 -0
- cloudsmith_cli/templates/auth_success.html +37 -0
- cloudsmith_cli-1.20.0.dist-info/METADATA +610 -0
- cloudsmith_cli-1.20.0.dist-info/RECORD +130 -0
- cloudsmith_cli-1.20.0.dist-info/WHEEL +5 -0
- cloudsmith_cli-1.20.0.dist-info/entry_points.txt +2 -0
- cloudsmith_cli-1.20.0.dist-info/licenses/LICENSE +201 -0
- cloudsmith_cli-1.20.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# Copyright 2026 Cloudsmith Ltd
|
|
2
|
+
"""Installer for the Docker credential helper.
|
|
3
|
+
|
|
4
|
+
Manages writing/removing the ``docker-credential-cloudsmith`` launcher and
|
|
5
|
+
patching ``~/.docker/config.json`` to enable the helper for Cloudsmith
|
|
6
|
+
registry hosts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ...core.cache_utils import merge_json_file
|
|
18
|
+
from ..backends import BackendKind
|
|
19
|
+
from ..custom_domains import get_format_domains
|
|
20
|
+
from ..launchers import is_on_path, remove_launcher, resolve_bin_dir, write_launcher
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _docker_config_path() -> Path:
|
|
26
|
+
"""Return the path to the Docker client configuration file.
|
|
27
|
+
|
|
28
|
+
Respects the ``DOCKER_CONFIG`` environment variable; otherwise returns
|
|
29
|
+
the platform default ``~/.docker/config.json``.
|
|
30
|
+
"""
|
|
31
|
+
docker_config_env = os.environ.get("DOCKER_CONFIG")
|
|
32
|
+
if docker_config_env:
|
|
33
|
+
return Path(docker_config_env) / "config.json"
|
|
34
|
+
return Path.home() / ".docker" / "config.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DockerInstaller:
|
|
38
|
+
"""Manages installation of the Docker credential helper for Cloudsmith.
|
|
39
|
+
|
|
40
|
+
This installer writes a ``docker-credential-cloudsmith`` launcher binary
|
|
41
|
+
and patches ``~/.docker/config.json`` to route the configured registry
|
|
42
|
+
hosts through the Cloudsmith credential helper.
|
|
43
|
+
|
|
44
|
+
Usage::
|
|
45
|
+
|
|
46
|
+
installer = DockerInstaller()
|
|
47
|
+
actions = installer.install(domains=["my-registry.example.com"])
|
|
48
|
+
for action in actions:
|
|
49
|
+
print(action)
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
LAUNCHER_NAME = "docker-credential-cloudsmith"
|
|
53
|
+
TARGET_CMD = "cloudsmith credential-helper docker"
|
|
54
|
+
HELPER_VALUE = "cloudsmith"
|
|
55
|
+
DEFAULT_HOST = "docker.cloudsmith.io"
|
|
56
|
+
|
|
57
|
+
name = "docker"
|
|
58
|
+
summary = "Docker credential helper for Cloudsmith registries"
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def _resolve_target_cmd(cls) -> str:
|
|
62
|
+
"""Return the command the launcher forwards to.
|
|
63
|
+
|
|
64
|
+
A pip/source install resolves the bare ``cloudsmith`` command via
|
|
65
|
+
``PATH``. A frozen standalone binary (PyInstaller) is not guaranteed
|
|
66
|
+
to be on ``PATH`` under that name, so point the launcher at the
|
|
67
|
+
absolute executable instead — mirroring the frozen handling in
|
|
68
|
+
:func:`cloudsmith_cli.cli.commands.mcp._get_server_config`. The path
|
|
69
|
+
is quoted so a directory containing spaces still execs correctly.
|
|
70
|
+
"""
|
|
71
|
+
if getattr(sys, "frozen", False):
|
|
72
|
+
return f'"{sys.executable}" credential-helper docker'
|
|
73
|
+
return cls.TARGET_CMD
|
|
74
|
+
|
|
75
|
+
def install(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
bin_dir: str | None = None,
|
|
79
|
+
domains: tuple[str, ...] = (),
|
|
80
|
+
discover: bool = True,
|
|
81
|
+
refresh: bool = False,
|
|
82
|
+
org: str | None = None,
|
|
83
|
+
api_key: str | None = None,
|
|
84
|
+
auth_type: str = "api_key",
|
|
85
|
+
api_host: str | None = None,
|
|
86
|
+
dry_run: bool = False,
|
|
87
|
+
) -> list[str]:
|
|
88
|
+
"""Install the Docker credential helper.
|
|
89
|
+
|
|
90
|
+
Writes the launcher binary and registers Cloudsmith registry hosts in
|
|
91
|
+
``~/.docker/config.json``.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
bin_dir:
|
|
96
|
+
Override for the directory to install the launcher. Defaults to
|
|
97
|
+
:func:`resolve_bin_dir` auto-detection.
|
|
98
|
+
domains:
|
|
99
|
+
Additional registry hostnames to configure (in addition to the
|
|
100
|
+
default ``docker.cloudsmith.io``).
|
|
101
|
+
discover:
|
|
102
|
+
When ``True`` (default), attempt to auto-discover Docker custom
|
|
103
|
+
domains via the Cloudsmith API. Discovery is best-effort and never
|
|
104
|
+
prevents the defaults from being registered.
|
|
105
|
+
refresh:
|
|
106
|
+
When ``True``, bypass the domain cache and fetch fresh data from
|
|
107
|
+
the API. Only meaningful when *discover* is also ``True``.
|
|
108
|
+
org:
|
|
109
|
+
Cloudsmith organisation slug used for custom-domain discovery.
|
|
110
|
+
api_key:
|
|
111
|
+
API key used for custom-domain discovery.
|
|
112
|
+
auth_type:
|
|
113
|
+
Credential type: ``"api_key"`` (default) or ``"bearer"``.
|
|
114
|
+
api_host:
|
|
115
|
+
Cloudsmith API host URL override.
|
|
116
|
+
dry_run:
|
|
117
|
+
When ``True``, compute and return planned actions without writing
|
|
118
|
+
any files.
|
|
119
|
+
|
|
120
|
+
Returns
|
|
121
|
+
-------
|
|
122
|
+
list[str]
|
|
123
|
+
Human-readable descriptions of actions taken (or planned, when
|
|
124
|
+
*dry_run* is ``True``).
|
|
125
|
+
"""
|
|
126
|
+
target_dir = resolve_bin_dir(bin_dir)
|
|
127
|
+
config_path = _docker_config_path()
|
|
128
|
+
|
|
129
|
+
actions: list[str] = []
|
|
130
|
+
|
|
131
|
+
# Start with the default host plus any explicitly requested domains.
|
|
132
|
+
hosts: list[str] = [self.DEFAULT_HOST, *domains]
|
|
133
|
+
|
|
134
|
+
# --- Custom-domain auto-discovery (best-effort) ---
|
|
135
|
+
if discover:
|
|
136
|
+
if org and api_key:
|
|
137
|
+
# Discovery boundary: network/SDK errors must never abort the
|
|
138
|
+
# default install. ApiException is already handled inside
|
|
139
|
+
# get_format_domains; this broad catch is the deliberate outer
|
|
140
|
+
# boundary (consistent with "boundary catches, library stays clean").
|
|
141
|
+
# Note: BaseException subclasses (KeyboardInterrupt/SystemExit)
|
|
142
|
+
# intentionally propagate — they are not caught by `except Exception`.
|
|
143
|
+
try:
|
|
144
|
+
discovered = get_format_domains(
|
|
145
|
+
org,
|
|
146
|
+
BackendKind.DOCKER,
|
|
147
|
+
api_key=api_key,
|
|
148
|
+
auth_type=auth_type,
|
|
149
|
+
api_host=api_host,
|
|
150
|
+
refresh=refresh,
|
|
151
|
+
)
|
|
152
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
153
|
+
# Discovery is best-effort: never let it abort the install of
|
|
154
|
+
# the defaults. (Network/SDK errors degrade to a warning;
|
|
155
|
+
# ApiException is already handled inside.)
|
|
156
|
+
actions.append(
|
|
157
|
+
f"WARNING: custom-domain auto-discovery failed: {exc}"
|
|
158
|
+
)
|
|
159
|
+
discovered = []
|
|
160
|
+
new_hosts = [h for h in discovered if h not in hosts]
|
|
161
|
+
hosts.extend(discovered)
|
|
162
|
+
actions.append(
|
|
163
|
+
f"discovered {len(new_hosts)} new Docker custom domain(s)"
|
|
164
|
+
)
|
|
165
|
+
else:
|
|
166
|
+
logger.debug(
|
|
167
|
+
"skipped auto-discovery"
|
|
168
|
+
" (no organization/credentials; pass --no-discover to silence)"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# De-duplicate while preserving order
|
|
172
|
+
seen: set[str] = set()
|
|
173
|
+
deduped: list[str] = []
|
|
174
|
+
for h in hosts:
|
|
175
|
+
if h not in seen:
|
|
176
|
+
seen.add(h)
|
|
177
|
+
deduped.append(h)
|
|
178
|
+
hosts = deduped
|
|
179
|
+
|
|
180
|
+
def mutate(config: dict) -> None:
|
|
181
|
+
helpers = config.get("credHelpers")
|
|
182
|
+
if not isinstance(helpers, dict):
|
|
183
|
+
helpers = config["credHelpers"] = {}
|
|
184
|
+
for host in hosts:
|
|
185
|
+
helpers[host] = self.HELPER_VALUE
|
|
186
|
+
|
|
187
|
+
if dry_run:
|
|
188
|
+
if os.name == "nt":
|
|
189
|
+
launcher_path = target_dir / f"{self.LAUNCHER_NAME}.cmd"
|
|
190
|
+
else:
|
|
191
|
+
launcher_path = target_dir / self.LAUNCHER_NAME
|
|
192
|
+
actions.append(f"would write launcher {launcher_path}")
|
|
193
|
+
|
|
194
|
+
would_change = merge_json_file(config_path, mutate, dry_run=True)
|
|
195
|
+
for host in hosts:
|
|
196
|
+
if would_change:
|
|
197
|
+
actions.append(
|
|
198
|
+
f"would set credHelpers[{host!r}]={self.HELPER_VALUE!r}"
|
|
199
|
+
f" in {config_path}"
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
actions.append(
|
|
203
|
+
f"credHelpers[{host!r}] already set"
|
|
204
|
+
f" in {config_path} (no change)"
|
|
205
|
+
)
|
|
206
|
+
return actions
|
|
207
|
+
|
|
208
|
+
# Real install
|
|
209
|
+
launcher_path = write_launcher(
|
|
210
|
+
target_dir, self.LAUNCHER_NAME, self._resolve_target_cmd()
|
|
211
|
+
)
|
|
212
|
+
actions.append(f"wrote launcher {launcher_path}")
|
|
213
|
+
|
|
214
|
+
changed = merge_json_file(config_path, mutate)
|
|
215
|
+
if changed:
|
|
216
|
+
for host in hosts:
|
|
217
|
+
actions.append(
|
|
218
|
+
f"set credHelpers[{host!r}]={self.HELPER_VALUE!r}"
|
|
219
|
+
f" in {config_path}"
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
actions.append(f"config.json already up to date ({config_path})")
|
|
223
|
+
|
|
224
|
+
if not is_on_path(target_dir):
|
|
225
|
+
actions.append(
|
|
226
|
+
f"WARNING: {target_dir} is not on PATH — "
|
|
227
|
+
"add it to your PATH so Docker can find docker-credential-cloudsmith"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return actions
|
|
231
|
+
|
|
232
|
+
def uninstall(
|
|
233
|
+
self, *, bin_dir: str | None = None, dry_run: bool = False
|
|
234
|
+
) -> list[str]:
|
|
235
|
+
"""Uninstall the Docker credential helper.
|
|
236
|
+
|
|
237
|
+
Removes the launcher binary and strips Cloudsmith-managed entries from
|
|
238
|
+
``~/.docker/config.json``.
|
|
239
|
+
|
|
240
|
+
Parameters
|
|
241
|
+
----------
|
|
242
|
+
bin_dir:
|
|
243
|
+
Override for the directory where the launcher was installed.
|
|
244
|
+
Defaults to :func:`resolve_bin_dir` auto-detection. Pass the same
|
|
245
|
+
value that was given to :meth:`install` so the correct launcher file
|
|
246
|
+
is found and removed.
|
|
247
|
+
dry_run:
|
|
248
|
+
When ``True``, return planned actions without writing any files.
|
|
249
|
+
|
|
250
|
+
Returns
|
|
251
|
+
-------
|
|
252
|
+
list[str]
|
|
253
|
+
Human-readable descriptions of actions taken (or planned).
|
|
254
|
+
"""
|
|
255
|
+
target_dir = resolve_bin_dir(bin_dir)
|
|
256
|
+
config_path = _docker_config_path()
|
|
257
|
+
|
|
258
|
+
def mutate(config: dict) -> None:
|
|
259
|
+
helpers = config.get("credHelpers")
|
|
260
|
+
if not isinstance(helpers, dict):
|
|
261
|
+
return
|
|
262
|
+
removed = [k for k, v in helpers.items() if v == self.HELPER_VALUE]
|
|
263
|
+
for key in removed:
|
|
264
|
+
del helpers[key]
|
|
265
|
+
if removed and not helpers:
|
|
266
|
+
del config["credHelpers"]
|
|
267
|
+
|
|
268
|
+
actions: list[str] = []
|
|
269
|
+
|
|
270
|
+
if os.name == "nt":
|
|
271
|
+
launcher_path = target_dir / f"{self.LAUNCHER_NAME}.cmd"
|
|
272
|
+
else:
|
|
273
|
+
launcher_path = target_dir / self.LAUNCHER_NAME
|
|
274
|
+
|
|
275
|
+
if dry_run:
|
|
276
|
+
if launcher_path.exists():
|
|
277
|
+
actions.append(f"would remove launcher {launcher_path}")
|
|
278
|
+
else:
|
|
279
|
+
actions.append(
|
|
280
|
+
f"launcher not found at {launcher_path} (nothing to remove)"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
would_change = merge_json_file(config_path, mutate, dry_run=True)
|
|
284
|
+
if would_change:
|
|
285
|
+
actions.append(
|
|
286
|
+
f"would remove credHelpers entries with value"
|
|
287
|
+
f" {self.HELPER_VALUE!r} from {config_path}"
|
|
288
|
+
)
|
|
289
|
+
else:
|
|
290
|
+
actions.append(f"no credHelpers entries to remove from {config_path}")
|
|
291
|
+
return actions
|
|
292
|
+
|
|
293
|
+
# Real uninstall
|
|
294
|
+
removed = remove_launcher(target_dir, self.LAUNCHER_NAME)
|
|
295
|
+
if removed:
|
|
296
|
+
actions.append(f"removed launcher {launcher_path}")
|
|
297
|
+
else:
|
|
298
|
+
actions.append(f"launcher not found at {launcher_path} (nothing to remove)")
|
|
299
|
+
|
|
300
|
+
changed = merge_json_file(config_path, mutate)
|
|
301
|
+
if changed:
|
|
302
|
+
actions.append(
|
|
303
|
+
f"removed credHelpers entries with value"
|
|
304
|
+
f" {self.HELPER_VALUE!r} from {config_path}"
|
|
305
|
+
)
|
|
306
|
+
else:
|
|
307
|
+
actions.append(f"no credHelpers entries to remove from {config_path}")
|
|
308
|
+
|
|
309
|
+
return actions
|
|
310
|
+
|
|
311
|
+
def status(self) -> dict:
|
|
312
|
+
"""Return current installation status.
|
|
313
|
+
|
|
314
|
+
Returns
|
|
315
|
+
-------
|
|
316
|
+
dict
|
|
317
|
+
A dict with keys:
|
|
318
|
+
|
|
319
|
+
``"launcher"``
|
|
320
|
+
The :class:`~pathlib.Path` of the launcher if it exists,
|
|
321
|
+
else ``None``.
|
|
322
|
+
``"hosts"``
|
|
323
|
+
List of hostnames in ``config.json``'s ``credHelpers`` block
|
|
324
|
+
whose value equals ``"cloudsmith"``.
|
|
325
|
+
"""
|
|
326
|
+
target_dir = resolve_bin_dir()
|
|
327
|
+
if os.name == "nt":
|
|
328
|
+
launcher_path: Path | None = target_dir / f"{self.LAUNCHER_NAME}.cmd"
|
|
329
|
+
else:
|
|
330
|
+
launcher_path = target_dir / self.LAUNCHER_NAME
|
|
331
|
+
|
|
332
|
+
if launcher_path is not None and not launcher_path.exists():
|
|
333
|
+
launcher_path = None
|
|
334
|
+
|
|
335
|
+
config_path = _docker_config_path()
|
|
336
|
+
hosts: list[str] = []
|
|
337
|
+
if config_path.exists():
|
|
338
|
+
try:
|
|
339
|
+
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
340
|
+
if isinstance(data, dict):
|
|
341
|
+
helpers = data.get("credHelpers", {})
|
|
342
|
+
hosts = [k for k, v in helpers.items() if v == self.HELPER_VALUE]
|
|
343
|
+
except (json.JSONDecodeError, OSError):
|
|
344
|
+
pass
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
"launcher": str(launcher_path) if launcher_path is not None else None,
|
|
348
|
+
"hosts": hosts,
|
|
349
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Copyright 2026 Cloudsmith Ltd
|
|
2
|
+
"""
|
|
3
|
+
Docker credential helper runtime.
|
|
4
|
+
|
|
5
|
+
Transport-light protocol logic for the Docker credential helper protocol.
|
|
6
|
+
This module is intentionally free of Click/sys imports so it can be unit-tested
|
|
7
|
+
without invoking the CLI machinery.
|
|
8
|
+
|
|
9
|
+
See: https://github.com/docker/docker-credential-helpers
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
from ..backends import BackendKind
|
|
16
|
+
from ..common import is_cloudsmith_domain
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_REFUSAL_MESSAGE = (
|
|
21
|
+
"Error: Unable to retrieve credentials. "
|
|
22
|
+
"Provide credentials via the CLOUDSMITH_API_KEY environment variable, "
|
|
23
|
+
"credentials.ini, the system keyring, or an OIDC service. "
|
|
24
|
+
"Verify current authentication with `cloudsmith whoami --verbose`."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_credentials(server_url, credential=None, api_host=None):
|
|
29
|
+
"""
|
|
30
|
+
Get credentials for a Cloudsmith Docker registry.
|
|
31
|
+
|
|
32
|
+
Verifies the URL is a Cloudsmith registry (including custom domains)
|
|
33
|
+
and returns credentials if available.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
server_url: The Docker registry server URL
|
|
37
|
+
credential: Pre-resolved CredentialResult from the provider chain
|
|
38
|
+
api_host: Cloudsmith API host URL
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
dict: Credentials with 'Username' and 'Secret' keys, or None
|
|
42
|
+
"""
|
|
43
|
+
if not credential or not credential.api_key:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
if not is_cloudsmith_domain(
|
|
47
|
+
server_url,
|
|
48
|
+
api_key=credential.api_key,
|
|
49
|
+
auth_type=getattr(credential, "auth_type", "api_key"),
|
|
50
|
+
api_host=api_host,
|
|
51
|
+
backend_kind=BackendKind.DOCKER,
|
|
52
|
+
):
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
return {"Username": "token", "Secret": credential.api_key}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _execute_get(stdin, credential, api_host) -> tuple[int, str | None, str | None]:
|
|
59
|
+
"""Handle the 'get' operation of the Docker credential helper protocol."""
|
|
60
|
+
try:
|
|
61
|
+
server_url = stdin.read().strip()
|
|
62
|
+
if not server_url:
|
|
63
|
+
return (1, None, "Error: No server URL provided on stdin")
|
|
64
|
+
|
|
65
|
+
creds = get_credentials(server_url, credential=credential, api_host=api_host)
|
|
66
|
+
if creds is None:
|
|
67
|
+
return (1, None, _REFUSAL_MESSAGE)
|
|
68
|
+
|
|
69
|
+
return (0, json.dumps(creds), None)
|
|
70
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
71
|
+
# Protocol boundary: a credential helper must never crash `docker pull`/`push`.
|
|
72
|
+
# Covers: broken-pipe OSError from stdin.read(), network/SDK errors from
|
|
73
|
+
# get_credentials, and TypeError from json.dumps — all degrade to a clean
|
|
74
|
+
# refusal (exit 1), not a traceback.
|
|
75
|
+
# This is the ONLY intentional broad except in this feature.
|
|
76
|
+
# (Exception does not catch KeyboardInterrupt/SystemExit, which is correct.)
|
|
77
|
+
logger.debug("docker credential-helper get failed: %s", exc, exc_info=True)
|
|
78
|
+
return (1, None, _REFUSAL_MESSAGE)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def execute(
|
|
82
|
+
operation, stdin, credential=None, api_host=None
|
|
83
|
+
) -> tuple[int, str | None, str | None]:
|
|
84
|
+
"""
|
|
85
|
+
Execute a Docker credential helper protocol operation.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
operation: One of 'get', 'store', 'erase', 'list'
|
|
89
|
+
stdin: A file-like object to read the server URL from (for 'get')
|
|
90
|
+
credential: Pre-resolved CredentialResult from the provider chain
|
|
91
|
+
api_host: Cloudsmith API host URL
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
A (exit_code, stdout_text, stderr_text) tuple. Either text value may
|
|
95
|
+
be None if there is nothing to write to that stream.
|
|
96
|
+
"""
|
|
97
|
+
if operation in ("store", "erase"):
|
|
98
|
+
# Drain stdin to keep Docker happy; guard against tty/pipe errors.
|
|
99
|
+
try:
|
|
100
|
+
if not stdin.isatty():
|
|
101
|
+
stdin.read()
|
|
102
|
+
except (OSError, ValueError, AttributeError):
|
|
103
|
+
pass
|
|
104
|
+
return (0, None, None)
|
|
105
|
+
|
|
106
|
+
if operation == "list":
|
|
107
|
+
return (0, "{}", None)
|
|
108
|
+
|
|
109
|
+
if operation == "get":
|
|
110
|
+
return _execute_get(stdin, credential, api_host)
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
1,
|
|
114
|
+
None,
|
|
115
|
+
f"Error: Unknown operation '{operation}'. "
|
|
116
|
+
"Valid operations: get, store, erase, list",
|
|
117
|
+
)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Copyright 2026 Cloudsmith Ltd
|
|
2
|
+
"""Launcher writer/remover for credential-helper on-PATH binaries.
|
|
3
|
+
|
|
4
|
+
Creates a thin shell script (Unix) or .cmd batch file (Windows) named
|
|
5
|
+
``docker-credential-cloudsmith`` (or similar) that forwards every call to the
|
|
6
|
+
single ``cloudsmith`` binary already installed on the user's PATH.
|
|
7
|
+
|
|
8
|
+
The platform-specific bits (file name, script body, user bin directory) live in
|
|
9
|
+
small pure helpers parameterised on ``windows`` so they can be unit-tested
|
|
10
|
+
without monkeypatching ``os.name`` — faking ``os.name`` on a posix host makes
|
|
11
|
+
``pathlib.Path`` raise ``NotImplementedError`` on Python < 3.12.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _is_windows() -> bool:
|
|
23
|
+
"""Return True when running on Windows."""
|
|
24
|
+
return os.name == "nt"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _launcher_filename(name: str, *, windows: bool) -> str:
|
|
28
|
+
"""Return the launcher file name for the platform (``.cmd`` on Windows)."""
|
|
29
|
+
return f"{name}.cmd" if windows else name
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _launcher_content(target_cmd: str, *, windows: bool) -> str:
|
|
33
|
+
"""Return the launcher script body for the platform.
|
|
34
|
+
|
|
35
|
+
Windows uses a ``.cmd`` batch file (``@echo off`` keeps stdout clean for
|
|
36
|
+
Docker's credential JSON); Unix uses a ``#!/bin/sh`` script that ``exec``s
|
|
37
|
+
the target so signals and the exit code pass straight through.
|
|
38
|
+
"""
|
|
39
|
+
if windows:
|
|
40
|
+
return f"@echo off\r\n{target_cmd} %*\r\n"
|
|
41
|
+
return f'#!/bin/sh\nexec {target_cmd} "$@"\n'
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _user_bin_dir(windows: bool) -> Path:
|
|
45
|
+
"""Return the user-local bin directory for the platform.
|
|
46
|
+
|
|
47
|
+
Unix → ``~/.local/bin``; Windows → ``%LOCALAPPDATA%\\Cloudsmith\\bin``
|
|
48
|
+
(falling back to the home directory when ``LOCALAPPDATA`` is unset).
|
|
49
|
+
"""
|
|
50
|
+
if windows:
|
|
51
|
+
localappdata = os.environ.get("LOCALAPPDATA")
|
|
52
|
+
base = Path(localappdata) if localappdata else Path.home()
|
|
53
|
+
return base / "Cloudsmith" / "bin"
|
|
54
|
+
return Path.home() / ".local" / "bin"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write_launcher(bin_dir: Path, name: str, target_cmd: str) -> Path:
|
|
58
|
+
"""Write a launcher script for *name* in *bin_dir* that execs *target_cmd*.
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
bin_dir:
|
|
63
|
+
Directory in which to create the launcher. Created if absent.
|
|
64
|
+
name:
|
|
65
|
+
Base name of the helper binary (e.g. ``docker-credential-cloudsmith``).
|
|
66
|
+
target_cmd:
|
|
67
|
+
The command the launcher forwards to (e.g.
|
|
68
|
+
``cloudsmith credential-helper docker``).
|
|
69
|
+
|
|
70
|
+
Returns
|
|
71
|
+
-------
|
|
72
|
+
Path
|
|
73
|
+
The path of the written file.
|
|
74
|
+
"""
|
|
75
|
+
windows = _is_windows()
|
|
76
|
+
bin_dir = Path(bin_dir)
|
|
77
|
+
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
|
|
79
|
+
dest = bin_dir / _launcher_filename(name, windows=windows)
|
|
80
|
+
dest.write_text(
|
|
81
|
+
_launcher_content(target_cmd, windows=windows), encoding="utf-8", newline=""
|
|
82
|
+
)
|
|
83
|
+
if not windows:
|
|
84
|
+
dest.chmod(0o755)
|
|
85
|
+
|
|
86
|
+
return dest
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def remove_launcher(bin_dir: Path, name: str) -> bool:
|
|
90
|
+
"""Remove a launcher previously created by :func:`write_launcher`.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
bin_dir:
|
|
95
|
+
Directory that contains (or contained) the launcher.
|
|
96
|
+
name:
|
|
97
|
+
Base name of the helper binary (without extension).
|
|
98
|
+
|
|
99
|
+
Returns
|
|
100
|
+
-------
|
|
101
|
+
bool
|
|
102
|
+
``True`` if a file was removed, ``False`` if no file was found.
|
|
103
|
+
"""
|
|
104
|
+
target = Path(bin_dir) / _launcher_filename(name, windows=_is_windows())
|
|
105
|
+
|
|
106
|
+
if target.exists():
|
|
107
|
+
target.unlink()
|
|
108
|
+
return True
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def resolve_bin_dir(override: str | None = None) -> Path:
|
|
113
|
+
"""Determine the best directory in which to place a launcher.
|
|
114
|
+
|
|
115
|
+
Resolution order
|
|
116
|
+
----------------
|
|
117
|
+
1. *override* → ``Path(override)``.
|
|
118
|
+
2. The directory of the running ``cloudsmith`` executable — if that
|
|
119
|
+
directory is writable.
|
|
120
|
+
3. The user-local bin directory (see :func:`_user_bin_dir`).
|
|
121
|
+
|
|
122
|
+
The chosen directory is **not** created here; that happens when the
|
|
123
|
+
launcher is written via :func:`write_launcher`.
|
|
124
|
+
|
|
125
|
+
Parameters
|
|
126
|
+
----------
|
|
127
|
+
override:
|
|
128
|
+
Explicit path supplied by the caller (e.g. ``--bin-dir`` CLI option).
|
|
129
|
+
|
|
130
|
+
Returns
|
|
131
|
+
-------
|
|
132
|
+
Path
|
|
133
|
+
The resolved directory.
|
|
134
|
+
"""
|
|
135
|
+
if override is not None:
|
|
136
|
+
return Path(override)
|
|
137
|
+
|
|
138
|
+
# Option 2: beside the running cloudsmith binary (if writable)
|
|
139
|
+
cloudsmith_path = shutil.which("cloudsmith")
|
|
140
|
+
if cloudsmith_path:
|
|
141
|
+
candidate = Path(os.path.dirname(os.path.realpath(cloudsmith_path)))
|
|
142
|
+
else:
|
|
143
|
+
candidate = Path(os.path.dirname(os.path.realpath(sys.argv[0])))
|
|
144
|
+
|
|
145
|
+
if os.access(candidate, os.W_OK | os.X_OK):
|
|
146
|
+
return candidate
|
|
147
|
+
|
|
148
|
+
# Option 3: user-local bin
|
|
149
|
+
return _user_bin_dir(_is_windows())
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def is_on_path(directory: Path) -> bool:
|
|
153
|
+
"""Return True if *directory* is an entry in the current ``$PATH``.
|
|
154
|
+
|
|
155
|
+
Comparison is case-insensitive on Windows (``os.path.normcase``) and
|
|
156
|
+
normalised via ``os.path.normpath`` on all platforms.
|
|
157
|
+
|
|
158
|
+
Parameters
|
|
159
|
+
----------
|
|
160
|
+
directory:
|
|
161
|
+
The directory to check.
|
|
162
|
+
|
|
163
|
+
Returns
|
|
164
|
+
-------
|
|
165
|
+
bool
|
|
166
|
+
``True`` if *directory* appears in ``$PATH``.
|
|
167
|
+
"""
|
|
168
|
+
needle = os.path.normcase(os.path.normpath(str(directory)))
|
|
169
|
+
path_env = os.environ.get("PATH", "")
|
|
170
|
+
for entry in path_env.split(os.pathsep):
|
|
171
|
+
if not entry:
|
|
172
|
+
continue
|
|
173
|
+
if os.path.normcase(os.path.normpath(entry)) == needle:
|
|
174
|
+
return True
|
|
175
|
+
return False
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.20.0
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Default configuration
|
|
2
|
+
[default]
|
|
3
|
+
# The API host to connect to (default: api.cloudsmith.io).
|
|
4
|
+
api_host=
|
|
5
|
+
|
|
6
|
+
# The API proxy to connect through (default: None).
|
|
7
|
+
api_proxy=
|
|
8
|
+
|
|
9
|
+
# Whether to verify SSL connection to the API (default: True)
|
|
10
|
+
api_ssl_verify=true
|
|
11
|
+
|
|
12
|
+
# The user agent to use for requests (default: calculated).
|
|
13
|
+
api_user_agent=
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Profile-based configuration
|
|
17
|
+
# You can set as many additional profiles as you need to provide
|
|
18
|
+
# for different configuration environments (e.g. prod vs staging).
|
|
19
|
+
# Add your overrides in the sections and then specify one of:
|
|
20
|
+
# * -P your-profile-name (as an argument)
|
|
21
|
+
# * --profile your-profile-name (an an argument)
|
|
22
|
+
# * CLOUDSMITH_PROFILE=your-profile-name (as an env variable)
|
|
23
|
+
[profile:your-profile-name]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Default configuration
|
|
2
|
+
[default]
|
|
3
|
+
# The API key for authenticating with the API.
|
|
4
|
+
api_key=
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Profile-based configuration
|
|
8
|
+
# You can set as many additional profiles as you need to provide
|
|
9
|
+
# for different configuration environments (e.g. prod vs staging).
|
|
10
|
+
# Add your overrides in the sections and then specify one of:
|
|
11
|
+
# * -P your-profile-name (as an argument)
|
|
12
|
+
# * --profile your-profile-name (an an argument)
|
|
13
|
+
# * CLOUDSMITH_PROFILE=your-profile-name (as an env variable)
|
|
14
|
+
[profile:your-profile-name]
|