dockerls 1.0.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.
- dockerls/__init__.py +31 -0
- dockerls/application/__init__.py +0 -0
- dockerls/application/dto/__init__.py +3 -0
- dockerls/application/dto/analysis.py +257 -0
- dockerls/application/services/__init__.py +0 -0
- dockerls/application/services/alternatives_lookup.py +167 -0
- dockerls/application/services/composite_repository.py +106 -0
- dockerls/application/services/cross_validation.py +233 -0
- dockerls/application/services/ecosystems.py +350 -0
- dockerls/application/services/fallback_scanner.py +97 -0
- dockerls/application/services/hardening_analysis.py +174 -0
- dockerls/application/services/migration.py +297 -0
- dockerls/application/services/progress.py +56 -0
- dockerls/application/services/remediation.py +321 -0
- dockerls/application/services/scan_history_store.py +88 -0
- dockerls/application/services/scanner_factory.py +88 -0
- dockerls/application/services/source_registry.py +151 -0
- dockerls/application/services/tag_history_store.py +76 -0
- dockerls/application/services/teardown.py +50 -0
- dockerls/application/services/verdict.py +298 -0
- dockerls/application/services/version_discovery.py +108 -0
- dockerls/application/use_cases/__init__.py +0 -0
- dockerls/application/use_cases/analyze_dockerfile.py +103 -0
- dockerls/application/use_cases/analyze_image.py +173 -0
- dockerls/application/use_cases/build_image.py +1795 -0
- dockerls/application/use_cases/compare_images.py +91 -0
- dockerls/application/use_cases/fleet_scan.py +240 -0
- dockerls/application/use_cases/recommend_images.py +1078 -0
- dockerls/application/use_cases/registry_audit.py +133 -0
- dockerls/application/use_cases/search_images.py +23 -0
- dockerls/application/use_cases/upgrade_base.py +167 -0
- dockerls/cache/__init__.py +0 -0
- dockerls/cache/sqlite_cache.py +184 -0
- dockerls/cli/__init__.py +0 -0
- dockerls/cli/analysis_baseline.py +98 -0
- dockerls/cli/app.py +294 -0
- dockerls/cli/commands/__init__.py +0 -0
- dockerls/cli/commands/advisor.py +262 -0
- dockerls/cli/commands/alternatives.py +291 -0
- dockerls/cli/commands/analyze.py +429 -0
- dockerls/cli/commands/analyze_dockerfile.py +104 -0
- dockerls/cli/commands/base_cmd.py +244 -0
- dockerls/cli/commands/base_image.py +551 -0
- dockerls/cli/commands/build.py +1300 -0
- dockerls/cli/commands/cache_cmd.py +104 -0
- dockerls/cli/commands/compare.py +177 -0
- dockerls/cli/commands/controls.py +110 -0
- dockerls/cli/commands/doctor.py +566 -0
- dockerls/cli/commands/export.py +81 -0
- dockerls/cli/commands/fleet.py +159 -0
- dockerls/cli/commands/health.py +84 -0
- dockerls/cli/commands/login.py +53 -0
- dockerls/cli/commands/policy_cmd.py +111 -0
- dockerls/cli/commands/provenance_cmd.py +162 -0
- dockerls/cli/commands/recommend.py +761 -0
- dockerls/cli/commands/registry_audit_cmd.py +103 -0
- dockerls/cli/commands/sbom.py +144 -0
- dockerls/cli/commands/search.py +86 -0
- dockerls/cli/commands/verify.py +115 -0
- dockerls/cli/commands/version.py +12 -0
- dockerls/cli/commands/vex_cmd.py +117 -0
- dockerls/cli/dependencies.py +530 -0
- dockerls/cli/image_names.py +79 -0
- dockerls/cli/options.py +42 -0
- dockerls/cli/progress.py +145 -0
- dockerls/cli/publish_prompt.py +123 -0
- dockerls/cli/rendering.py +214 -0
- dockerls/cli/runtime.py +65 -0
- dockerls/cli/scan_failure.py +71 -0
- dockerls/cli/text.py +39 -0
- dockerls/cli/validators.py +35 -0
- dockerls/cli/vulnerability_view.py +154 -0
- dockerls/domain/__init__.py +0 -0
- dockerls/domain/entities/__init__.py +75 -0
- dockerls/domain/entities/declared_metadata.py +147 -0
- dockerls/domain/entities/dockerfile_analysis.py +318 -0
- dockerls/domain/entities/image.py +109 -0
- dockerls/domain/entities/image_facts.py +137 -0
- dockerls/domain/entities/recommendation.py +33 -0
- dockerls/domain/entities/scan_result.py +129 -0
- dockerls/domain/entities/vulnerability.py +232 -0
- dockerls/domain/interfaces/__init__.py +17 -0
- dockerls/domain/interfaces/cache_store.py +18 -0
- dockerls/domain/interfaces/dockerfile_validator.py +99 -0
- dockerls/domain/interfaces/eol_checker.py +11 -0
- dockerls/domain/interfaces/image_repository.py +15 -0
- dockerls/domain/interfaces/scanner.py +15 -0
- dockerls/domain/security_controls.py +362 -0
- dockerls/domain/value_objects/__init__.py +49 -0
- dockerls/domain/value_objects/attack_surface.py +198 -0
- dockerls/domain/value_objects/base_recipe.py +600 -0
- dockerls/domain/value_objects/base_upgrade.py +292 -0
- dockerls/domain/value_objects/build_labels.py +99 -0
- dockerls/domain/value_objects/build_policy.py +412 -0
- dockerls/domain/value_objects/confidence.py +156 -0
- dockerls/domain/value_objects/fleet.py +174 -0
- dockerls/domain/value_objects/gate.py +327 -0
- dockerls/domain/value_objects/hardening.py +303 -0
- dockerls/domain/value_objects/image_reference.py +90 -0
- dockerls/domain/value_objects/inheritance.py +352 -0
- dockerls/domain/value_objects/network_policy.py +280 -0
- dockerls/domain/value_objects/production_readiness.py +145 -0
- dockerls/domain/value_objects/provenance.py +211 -0
- dockerls/domain/value_objects/recipe_diff.py +188 -0
- dockerls/domain/value_objects/registry_audit.py +195 -0
- dockerls/domain/value_objects/registry_target.py +225 -0
- dockerls/domain/value_objects/remediation_score.py +62 -0
- dockerls/domain/value_objects/scan_history.py +183 -0
- dockerls/domain/value_objects/scan_plan.py +193 -0
- dockerls/domain/value_objects/scanner_db.py +143 -0
- dockerls/domain/value_objects/security_score.py +160 -0
- dockerls/domain/value_objects/security_tier.py +122 -0
- dockerls/domain/value_objects/tag_history.py +180 -0
- dockerls/domain/value_objects/tool_release.py +253 -0
- dockerls/domain/value_objects/tristate.py +47 -0
- dockerls/domain/value_objects/vex.py +249 -0
- dockerls/exit_codes.py +23 -0
- dockerls/exporters/__init__.py +0 -0
- dockerls/exporters/base.py +17 -0
- dockerls/exporters/csv_exporter.py +85 -0
- dockerls/exporters/factory.py +31 -0
- dockerls/exporters/html_exporter.py +105 -0
- dockerls/exporters/json_exporter.py +19 -0
- dockerls/exporters/markdown_exporter.py +84 -0
- dockerls/exporters/sarif_exporter.py +245 -0
- dockerls/infrastructure/__init__.py +0 -0
- dockerls/infrastructure/config/__init__.py +0 -0
- dockerls/infrastructure/config/policy_file.py +165 -0
- dockerls/infrastructure/config/settings.py +197 -0
- dockerls/infrastructure/database/__init__.py +0 -0
- dockerls/infrastructure/database/models.py +78 -0
- dockerls/infrastructure/dockerfile_validator.py +1899 -0
- dockerls/infrastructure/evidence.py +99 -0
- dockerls/infrastructure/hashing.py +165 -0
- dockerls/infrastructure/logging/__init__.py +0 -0
- dockerls/infrastructure/logging/setup.py +98 -0
- dockerls/infrastructure/network/__init__.py +0 -0
- dockerls/infrastructure/network/guarded_client.py +107 -0
- dockerls/infrastructure/network/host_guard.py +117 -0
- dockerls/infrastructure/redaction.py +135 -0
- dockerls/infrastructure/templates/hardening/alpine.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/debian.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/distroless.dockerfile +34 -0
- dockerls/infrastructure/templates/hardening/go-alpine.dockerfile +52 -0
- dockerls/infrastructure/templates/hardening/go-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/go-distroless.dockerfile +43 -0
- dockerls/infrastructure/templates/hardening/go-scratch.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/go.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/gradle-alpine.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/gradle.dockerfile +52 -0
- dockerls/infrastructure/templates/hardening/java-alpine.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/java-debian.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/java-distroless.dockerfile +39 -0
- dockerls/infrastructure/templates/hardening/java-ubuntu.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/java.dockerfile +60 -0
- dockerls/infrastructure/templates/hardening/maven-alpine.dockerfile +56 -0
- dockerls/infrastructure/templates/hardening/maven.dockerfile +57 -0
- dockerls/infrastructure/templates/hardening/node-alpine.dockerfile +47 -0
- dockerls/infrastructure/templates/hardening/node-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/node-distroless.dockerfile +46 -0
- dockerls/infrastructure/templates/hardening/node-ubuntu.dockerfile +63 -0
- dockerls/infrastructure/templates/hardening/node.dockerfile +61 -0
- dockerls/infrastructure/templates/hardening/php-alpine.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/php-debian.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/php-ubuntu.dockerfile +49 -0
- dockerls/infrastructure/templates/hardening/php.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/python-alpine.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/python-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/python-distroless.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/python-ubuntu.dockerfile +60 -0
- dockerls/infrastructure/templates/hardening/python.dockerfile +58 -0
- dockerls/infrastructure/templates/hardening/ruby-alpine.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/ruby-debian.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/rust-alpine.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/rust-debian.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/rust-scratch.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/rust.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/ubuntu.dockerfile +49 -0
- dockerls/infrastructure/toolchain/__init__.py +0 -0
- dockerls/infrastructure/toolchain/db_metadata.py +115 -0
- dockerls/infrastructure/toolchain/installer.py +435 -0
- dockerls/integrations/__init__.py +0 -0
- dockerls/integrations/dhi/__init__.py +0 -0
- dockerls/integrations/dhi/catalog.py +457 -0
- dockerls/integrations/dhi/definition.py +151 -0
- dockerls/integrations/dhi/repository.py +238 -0
- dockerls/integrations/dockerhub/__init__.py +0 -0
- dockerls/integrations/dockerhub/client.py +318 -0
- dockerls/integrations/dockerhub/urls.py +75 -0
- dockerls/integrations/endoflife/__init__.py +0 -0
- dockerls/integrations/endoflife/checker.py +216 -0
- dockerls/integrations/engine/__init__.py +0 -0
- dockerls/integrations/engine/batch.py +197 -0
- dockerls/integrations/engine/client.py +330 -0
- dockerls/integrations/engine/locator.py +96 -0
- dockerls/integrations/exploitdb/__init__.py +0 -0
- dockerls/integrations/exploitdb/client.py +271 -0
- dockerls/integrations/grype/__init__.py +0 -0
- dockerls/integrations/grype/scanner.py +336 -0
- dockerls/integrations/registry/__init__.py +0 -0
- dockerls/integrations/registry/hardened.py +284 -0
- dockerls/integrations/registry/inspector.py +420 -0
- dockerls/integrations/registry/oci.py +259 -0
- dockerls/integrations/registry/private.py +79 -0
- dockerls/integrations/registry/urls.py +36 -0
- dockerls/integrations/scan_errors.py +67 -0
- dockerls/integrations/scan_target.py +57 -0
- dockerls/integrations/signing/__init__.py +0 -0
- dockerls/integrations/signing/cosign.py +484 -0
- dockerls/integrations/threat_intel/__init__.py +0 -0
- dockerls/integrations/threat_intel/client.py +290 -0
- dockerls/integrations/trivy/__init__.py +0 -0
- dockerls/integrations/trivy/cache_pool.py +176 -0
- dockerls/integrations/trivy/scanner.py +447 -0
- dockerls/utils/__init__.py +0 -0
- dockerls/utils/auth.py +108 -0
- dockerls/utils/executables.py +39 -0
- dockerls/utils/ignore_file.py +130 -0
- dockerls/utils/rate_limit.py +130 -0
- dockerls/utils/resources.py +183 -0
- dockerls/utils/retry.py +31 -0
- dockerls/utils/safe_yaml.py +166 -0
- dockerls/utils/subprocess_runner.py +216 -0
- dockerls/utils/validation.py +72 -0
- dockerls-1.0.0.dist-info/METADATA +563 -0
- dockerls-1.0.0.dist-info/RECORD +230 -0
- dockerls-1.0.0.dist-info/WHEEL +5 -0
- dockerls-1.0.0.dist-info/entry_points.txt +2 -0
- dockerls-1.0.0.dist-info/licenses/LICENSE +21 -0
- dockerls-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Docker Hardened Images as an image source.
|
|
2
|
+
|
|
3
|
+
DHI is different from every other source this tool searches, in a way that
|
|
4
|
+
shapes the whole integration: its *catalogue* is public (a GitHub
|
|
5
|
+
repository of build definitions) while its *registry* is not (dhi.io refuses
|
|
6
|
+
anonymous pulls). Discovery therefore works for everyone; scanning works
|
|
7
|
+
only for a machine holding DHI credentials.
|
|
8
|
+
|
|
9
|
+
That split is not a limitation to work around -- it is the exact case the
|
|
10
|
+
rest of this codebase was built to handle honestly. A DHI candidate is
|
|
11
|
+
discovered from the catalogue and enters the same pipeline as everything
|
|
12
|
+
else. If the registry will not serve it, the scan fails, the candidate is
|
|
13
|
+
reported as UNVERIFIED, and it is never ranked, never scored, and never
|
|
14
|
+
called production ready. What a vendor says about its own hardening does not
|
|
15
|
+
substitute for a measurement, and this provider is where that rule is at its
|
|
16
|
+
most tempting to break.
|
|
17
|
+
|
|
18
|
+
The candidate a definition produces carries the definition's declared
|
|
19
|
+
metadata alongside it, so the CLI can explain *why* a DHI image is worth
|
|
20
|
+
considering (non-root by declaration, a supported lifecycle, a small package
|
|
21
|
+
set) while keeping every one of those claims labelled as a claim.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
from loguru import logger
|
|
30
|
+
|
|
31
|
+
from dockerls.domain.entities.image import DockerImage
|
|
32
|
+
from dockerls.domain.interfaces.image_repository import ImageRepositoryInterface
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from dockerls.domain.entities.declared_metadata import DeclaredImageMetadata
|
|
36
|
+
from dockerls.integrations.dhi.catalog import DHICatalogClient
|
|
37
|
+
|
|
38
|
+
DHI = "Docker Hardened Images"
|
|
39
|
+
DHI_REGISTRY = "dhi.io"
|
|
40
|
+
|
|
41
|
+
#: Query name -> catalogue directory, where DHI names an image differently
|
|
42
|
+
#: from the Docker Hub convention a user types.
|
|
43
|
+
ALIASES = {
|
|
44
|
+
"nodejs": "node",
|
|
45
|
+
"python3": "python",
|
|
46
|
+
"golang": "go",
|
|
47
|
+
"openjdk": "jdk",
|
|
48
|
+
"postgresql": "postgres",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#: Variant suffixes that are not the image most users want. `-dev` ships a
|
|
52
|
+
#: shell, a package manager and a compiler by design; `sfw`/`ent` are the
|
|
53
|
+
#: FIPS and enterprise builds, which are not pullable at all without the
|
|
54
|
+
#: corresponding entitlement. They are still discoverable by name -- they are
|
|
55
|
+
#: just not what a bare `recommend node` should fan out to.
|
|
56
|
+
DEPRIORITISED_MARKERS = ("dev", "sfw", "swf", "ent")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DHIRepository(ImageRepositoryInterface):
|
|
60
|
+
"""Discovers candidates from the DHI catalogue's build definitions."""
|
|
61
|
+
|
|
62
|
+
source = DHI
|
|
63
|
+
host = DHI_REGISTRY
|
|
64
|
+
|
|
65
|
+
def __init__(self, catalog: DHICatalogClient, definition_limit: int = 12):
|
|
66
|
+
self._catalog = catalog
|
|
67
|
+
self._definition_limit = max(1, definition_limit)
|
|
68
|
+
|
|
69
|
+
def repository_for(self, image_name: str) -> str | None:
|
|
70
|
+
"""Map a user's query onto a catalogue directory name.
|
|
71
|
+
|
|
72
|
+
Returns None for anything that already names a registry or a
|
|
73
|
+
namespace: `recommend ghcr.io/org/app` must not fan out to DHI, and
|
|
74
|
+
a slash in the query means the user has already been specific.
|
|
75
|
+
"""
|
|
76
|
+
name = image_name.strip().strip("/").lower()
|
|
77
|
+
if not name or "/" in name or ":" in name:
|
|
78
|
+
return None
|
|
79
|
+
return ALIASES.get(name, name)
|
|
80
|
+
|
|
81
|
+
async def search_tags(self, image_name: str, limit: int = 100) -> list[DockerImage]:
|
|
82
|
+
catalogue_name = self.repository_for(image_name)
|
|
83
|
+
if catalogue_name is None:
|
|
84
|
+
return []
|
|
85
|
+
|
|
86
|
+
variants = await self._catalog.variants(catalogue_name)
|
|
87
|
+
if not variants:
|
|
88
|
+
# Empty has three causes and only one of them is about the
|
|
89
|
+
# image. Saying which one happened is the difference between
|
|
90
|
+
# "DHI publishes no hardened build of this" and "nobody asked",
|
|
91
|
+
# and the second must not be reported as the first.
|
|
92
|
+
state = self._catalog.index_state
|
|
93
|
+
if state.is_conclusive:
|
|
94
|
+
logger.info(f"{DHI}: the catalogue has no {catalogue_name}")
|
|
95
|
+
else:
|
|
96
|
+
logger.warning(
|
|
97
|
+
f"{DHI}: no candidates for {catalogue_name}, and the catalogue index "
|
|
98
|
+
f"is {state} -- this is an absence of an answer, not a finding that "
|
|
99
|
+
"no hardened build exists"
|
|
100
|
+
)
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
candidates: list[DockerImage] = []
|
|
104
|
+
for path in self._selected_paths(variants):
|
|
105
|
+
declared = await self._catalog.definition(path)
|
|
106
|
+
if declared is None:
|
|
107
|
+
continue
|
|
108
|
+
image = self._build_image(declared)
|
|
109
|
+
if image is None:
|
|
110
|
+
continue
|
|
111
|
+
candidates.append(image)
|
|
112
|
+
if len(candidates) >= limit:
|
|
113
|
+
break
|
|
114
|
+
|
|
115
|
+
logger.info(
|
|
116
|
+
f"{DHI}: {len(candidates)} candidate(s) for {catalogue_name} "
|
|
117
|
+
f"(catalogue @{self._catalog.revision or 'unknown'})"
|
|
118
|
+
)
|
|
119
|
+
return candidates
|
|
120
|
+
|
|
121
|
+
def _selected_paths(self, variants: dict[str, list[str]]) -> list[str]:
|
|
122
|
+
"""Bound and order the definitions this query will read.
|
|
123
|
+
|
|
124
|
+
A popular image has dozens of definitions across OS variants and
|
|
125
|
+
build flavours. Fetching all of them would be a request each, so the
|
|
126
|
+
list is ordered -- runtime flavours before `-dev`, newest OS variant
|
|
127
|
+
first, newest version first -- and cut at `definition_limit`. The
|
|
128
|
+
ordering is total and deterministic, so two runs against the same
|
|
129
|
+
catalogue revision read the same files.
|
|
130
|
+
"""
|
|
131
|
+
paths = [path for variant_paths in variants.values() for path in variant_paths]
|
|
132
|
+
paths.sort(key=_definition_rank)
|
|
133
|
+
return paths[: self._definition_limit]
|
|
134
|
+
|
|
135
|
+
def _build_image(self, declared: DeclaredImageMetadata) -> DockerImage | None:
|
|
136
|
+
"""Turn a definition into a candidate pinned to its primary tag.
|
|
137
|
+
|
|
138
|
+
A definition publishes a dozen aliases of the same image (`22`,
|
|
139
|
+
`22.23`, `22.23.2-debian13`, ...). They are the same bytes, so
|
|
140
|
+
emitting one candidate per alias would multiply the scan work by
|
|
141
|
+
twelve for no additional information. The most specific tag is
|
|
142
|
+
chosen, because it is the one that stays meaningful over time.
|
|
143
|
+
"""
|
|
144
|
+
tag = _primary_tag(declared.tags)
|
|
145
|
+
repository = _repository_name(declared)
|
|
146
|
+
if not tag or not repository:
|
|
147
|
+
return None
|
|
148
|
+
return DockerImage(
|
|
149
|
+
name=repository,
|
|
150
|
+
tag=tag,
|
|
151
|
+
source=DHI,
|
|
152
|
+
os=declared.os_id or "linux",
|
|
153
|
+
available_architectures=[p.split("/")[-1] for p in declared.platforms],
|
|
154
|
+
declared=declared,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
async def get_image_metadata(self, image_name: str, tag: str) -> DockerImage | None:
|
|
158
|
+
for image in await self.search_tags(image_name, limit=self._definition_limit):
|
|
159
|
+
if image.tag == tag:
|
|
160
|
+
return image
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
async def tag_exists(self, image_name: str, tag: str) -> bool | None:
|
|
164
|
+
"""Whether the *catalogue* declares this tag.
|
|
165
|
+
|
|
166
|
+
Deliberately not a registry check: dhi.io refuses anonymous
|
|
167
|
+
requests, so this provider cannot confirm that a tag is actually
|
|
168
|
+
published. Returning True here would mean "the definition says this
|
|
169
|
+
tag exists", which is not what the caller is asking, so a catalogue
|
|
170
|
+
hit answers None -- unknown -- and the tag stands or falls on
|
|
171
|
+
whether the scanner could pull it.
|
|
172
|
+
"""
|
|
173
|
+
del image_name, tag
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
async def close(self) -> None:
|
|
177
|
+
await self._catalog.close()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _repository_name(declared: DeclaredImageMetadata) -> str:
|
|
181
|
+
"""The repository a definition publishes to, if it is a DHI repository.
|
|
182
|
+
|
|
183
|
+
The definition states it as `image: dhi.io/node`. It is *checked* here
|
|
184
|
+
rather than trusted: a definition that names some other host would
|
|
185
|
+
otherwise let catalogue content redirect a scan at an arbitrary
|
|
186
|
+
registry. Anything that is not a `dhi.io/...` path yields no candidate.
|
|
187
|
+
"""
|
|
188
|
+
repository = declared.registry_repository.strip().lower()
|
|
189
|
+
prefix = f"{DHI_REGISTRY}/"
|
|
190
|
+
if not repository.startswith(prefix):
|
|
191
|
+
return ""
|
|
192
|
+
path = repository[len(prefix) :]
|
|
193
|
+
if not path or not _REPOSITORY_PATH.match(path):
|
|
194
|
+
return ""
|
|
195
|
+
return f"{DHI_REGISTRY}/{path}"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_REPOSITORY_PATH = re.compile(r"^[a-z0-9][a-z0-9._/-]{0,127}$")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _primary_tag(tags: tuple[str, ...]) -> str:
|
|
202
|
+
"""The most specific tag a definition publishes.
|
|
203
|
+
|
|
204
|
+
Specificity is measured by dotted components then by length, so
|
|
205
|
+
`22.23.2-debian13` beats `22`. A pinned tag is the one worth recording
|
|
206
|
+
in a recommendation: `22` moves with every patch release, and a
|
|
207
|
+
recommendation that moves is a recommendation that was never verified.
|
|
208
|
+
"""
|
|
209
|
+
if not tags:
|
|
210
|
+
return ""
|
|
211
|
+
return max(tags, key=lambda tag: (tag.count("."), len(tag), tag))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _definition_rank(path: str) -> tuple[int, str]:
|
|
215
|
+
"""Order definitions: plain runtime flavours first, newest variant first.
|
|
216
|
+
|
|
217
|
+
The variant directory (`debian-13`, `alpine-3.24`) is reversed so the
|
|
218
|
+
highest version sorts first, and any definition whose file name carries
|
|
219
|
+
a de-prioritised marker sinks below the plain ones.
|
|
220
|
+
"""
|
|
221
|
+
parts = path.split("/")
|
|
222
|
+
variant = parts[2] if len(parts) > 3 else ""
|
|
223
|
+
filename = parts[-1].rsplit(".", 1)[0]
|
|
224
|
+
markers = set(filename.lower().replace(".", "-").split("-"))
|
|
225
|
+
deprioritised = 1 if markers & set(DEPRIORITISED_MARKERS) else 0
|
|
226
|
+
# Reverse-lexicographic on variant and file name, achieved by negating
|
|
227
|
+
# the ordering with a descending key built from the strings themselves.
|
|
228
|
+
return (deprioritised, _descending(f"{variant}/{filename}"))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _descending(value: str) -> str:
|
|
232
|
+
"""Key that sorts `value` in reverse order under an ascending sort.
|
|
233
|
+
|
|
234
|
+
Inverting each code point keeps the comparison total and stable without
|
|
235
|
+
needing a second sort pass or a reverse=True that would also flip the
|
|
236
|
+
de-prioritisation flag.
|
|
237
|
+
"""
|
|
238
|
+
return "".join(chr(0x10FFFF - ord(ch)) for ch in value)
|
|
File without changes
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
from dockerls.domain.entities.image import DockerImage
|
|
12
|
+
from dockerls.domain.interfaces.image_repository import ImageRepositoryInterface
|
|
13
|
+
from dockerls.infrastructure.network.guarded_client import guarded_async_client
|
|
14
|
+
from dockerls.integrations.dockerhub.urls import build_tag_api_url
|
|
15
|
+
from dockerls.utils.retry import (
|
|
16
|
+
DEFAULT_BACKOFF_BASE,
|
|
17
|
+
DEFAULT_MAX_ATTEMPTS,
|
|
18
|
+
retry_policy,
|
|
19
|
+
)
|
|
20
|
+
from dockerls.utils.validation import sanitize_image_name
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from dockerls.domain.interfaces.cache_store import CacheStoreInterface
|
|
24
|
+
from dockerls.infrastructure.network.host_guard import HostGuard
|
|
25
|
+
|
|
26
|
+
# Anonymous Docker Hub requests are rate limited, and a `recommend` run
|
|
27
|
+
# checks one tag per candidate. Existence of a tag changes rarely, so a
|
|
28
|
+
# local TTL cache keeps repeated runs well under the limit. Kept shorter
|
|
29
|
+
# than the analysis cache: a tag disappearing matters sooner than a score
|
|
30
|
+
# going slightly stale.
|
|
31
|
+
TAG_EXISTS_TTL_SECONDS = 6 * 3600
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DockerHubClient(ImageRepositoryInterface):
|
|
35
|
+
BASE_URL = "https://hub.docker.com/v2"
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
# Defaults vazios: "sem credencial", não uma senha embutida.
|
|
40
|
+
username: str = "", # nosec B107
|
|
41
|
+
token: str = "", # nosec B107
|
|
42
|
+
timeout: int = 30,
|
|
43
|
+
cache: CacheStoreInterface | None = None,
|
|
44
|
+
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
|
|
45
|
+
backoff_base: float = DEFAULT_BACKOFF_BASE,
|
|
46
|
+
tag_ttl_seconds: int = TAG_EXISTS_TTL_SECONDS,
|
|
47
|
+
guard: HostGuard | None = None,
|
|
48
|
+
):
|
|
49
|
+
# hub.docker.com is a fixed host, but it answers with redirects and
|
|
50
|
+
# this client follows them. A redirect target is chosen by the far
|
|
51
|
+
# end, so it is policed per hop rather than trusted because the
|
|
52
|
+
# first hop was fine.
|
|
53
|
+
self._guard = guard
|
|
54
|
+
self._username = username
|
|
55
|
+
self._token = token
|
|
56
|
+
self._timeout = timeout
|
|
57
|
+
self._auth_token: str = ""
|
|
58
|
+
self._cache = cache
|
|
59
|
+
self._max_attempts = max_attempts
|
|
60
|
+
self._backoff_base = backoff_base
|
|
61
|
+
self._tag_ttl_seconds = tag_ttl_seconds
|
|
62
|
+
self._client: httpx.AsyncClient | None = None
|
|
63
|
+
self._client_lock = asyncio.Lock()
|
|
64
|
+
# In-flight `tag_exists` checks, keyed by cache key. `_verify_tags`
|
|
65
|
+
# runs up to ten of them concurrently and several candidates can
|
|
66
|
+
# share a tag, so without this a cold cache means every one of them
|
|
67
|
+
# goes to the network before the first result is written back --
|
|
68
|
+
# a textbook stampede against a rate-limited API.
|
|
69
|
+
self._tag_checks: dict[str, asyncio.Future[bool | None]] = {}
|
|
70
|
+
|
|
71
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
72
|
+
"""One client for this repository's lifetime.
|
|
73
|
+
|
|
74
|
+
A fresh `AsyncClient` per call meant a new connection pool and a new
|
|
75
|
+
TLS handshake for every page of a listing and every tag check, with
|
|
76
|
+
nothing kept alive between them. Sharing one gives HTTP keep-alive
|
|
77
|
+
across the whole run.
|
|
78
|
+
"""
|
|
79
|
+
if self._client is None:
|
|
80
|
+
async with self._client_lock:
|
|
81
|
+
if self._client is None:
|
|
82
|
+
self._client = guarded_async_client(
|
|
83
|
+
self._guard,
|
|
84
|
+
timeout=self._timeout,
|
|
85
|
+
headers={"Accept": "application/json"},
|
|
86
|
+
follow_redirects=True,
|
|
87
|
+
)
|
|
88
|
+
return self._client
|
|
89
|
+
|
|
90
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
91
|
+
"""Per-request auth, so a token obtained by a later `authenticate()`
|
|
92
|
+
applies to a client that was already created."""
|
|
93
|
+
return {"Authorization": f"Bearer {self._auth_token}"} if self._auth_token else {}
|
|
94
|
+
|
|
95
|
+
async def close(self) -> None:
|
|
96
|
+
"""Release the shared connection pool."""
|
|
97
|
+
client, self._client = self._client, None
|
|
98
|
+
if client is not None:
|
|
99
|
+
await client.aclose()
|
|
100
|
+
|
|
101
|
+
async def authenticate(self) -> bool:
|
|
102
|
+
if not self._username or not self._token:
|
|
103
|
+
return False
|
|
104
|
+
try:
|
|
105
|
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
106
|
+
resp = await client.post(
|
|
107
|
+
f"{self.BASE_URL}/users/login/",
|
|
108
|
+
json={"username": self._username, "password": self._token},
|
|
109
|
+
)
|
|
110
|
+
if resp.status_code == 200:
|
|
111
|
+
self._auth_token = resp.json().get("token", "")
|
|
112
|
+
return bool(self._auth_token)
|
|
113
|
+
except (httpx.HTTPError, ValueError) as e:
|
|
114
|
+
# ValueError covers a 200 whose body is not JSON -- a captive
|
|
115
|
+
# portal or proxy error page. Failing to authenticate degrades to
|
|
116
|
+
# anonymous access; it must never abort the command.
|
|
117
|
+
logger.warning(f"Docker Hub auth failed: {e}")
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
async def _get_json(self, client: httpx.AsyncClient, url: str) -> httpx.Response:
|
|
121
|
+
"""Perform a single GET with retry scoped to *this* request only, so
|
|
122
|
+
a transient failure deep into a paginated fetch doesn't force the
|
|
123
|
+
whole listing to restart from page one. Honors Retry-After on 429.
|
|
124
|
+
|
|
125
|
+
The policy is built per call so `retry_max_attempts` and
|
|
126
|
+
`retry_backoff_base` reach it; as a decorator it was fixed at
|
|
127
|
+
import time and the settings could never apply.
|
|
128
|
+
"""
|
|
129
|
+
policy = retry_policy(self._max_attempts, self._backoff_base)
|
|
130
|
+
resp: httpx.Response = await policy(self._get_once, client, url)
|
|
131
|
+
return resp
|
|
132
|
+
|
|
133
|
+
async def _get_once(self, client: httpx.AsyncClient, url: str) -> httpx.Response:
|
|
134
|
+
resp = await client.get(url, headers=self._auth_headers())
|
|
135
|
+
if resp.status_code == 429:
|
|
136
|
+
retry_after = resp.headers.get("Retry-After")
|
|
137
|
+
wait_s = float(retry_after) if retry_after and retry_after.isdigit() else 2.0
|
|
138
|
+
logger.warning(f"Rate limited by Docker Hub, waiting {wait_s}s")
|
|
139
|
+
await asyncio.sleep(wait_s)
|
|
140
|
+
resp.raise_for_status()
|
|
141
|
+
return resp
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _parse_images(images: list[dict[str, Any]]) -> tuple[int, str, str, list[str]]:
|
|
145
|
+
"""Return (size, digest, primary_architecture, all_architectures)."""
|
|
146
|
+
archs = [img.get("architecture", "unknown") for img in images]
|
|
147
|
+
for img in images:
|
|
148
|
+
if img.get("architecture") == "amd64":
|
|
149
|
+
return img.get("size", 0), img.get("digest", ""), "amd64", archs
|
|
150
|
+
if images:
|
|
151
|
+
first = images[0]
|
|
152
|
+
return (
|
|
153
|
+
first.get("size", 0),
|
|
154
|
+
first.get("digest", ""),
|
|
155
|
+
first.get("architecture", "unknown"),
|
|
156
|
+
archs,
|
|
157
|
+
)
|
|
158
|
+
return 0, "", "amd64", archs
|
|
159
|
+
|
|
160
|
+
async def search_tags(self, image_name: str, limit: int = 100) -> list[DockerImage]:
|
|
161
|
+
safe_name = sanitize_image_name(image_name)
|
|
162
|
+
namespace = "library" if "/" not in safe_name else safe_name.split("/")[0]
|
|
163
|
+
repo = safe_name if "/" not in safe_name else safe_name.split("/", 1)[1]
|
|
164
|
+
|
|
165
|
+
tags: list[DockerImage] = []
|
|
166
|
+
page_size = min(limit, 100)
|
|
167
|
+
url: str | None = (
|
|
168
|
+
f"{self.BASE_URL}/repositories/{namespace}/{repo}/tags/"
|
|
169
|
+
f"?page_size={page_size}&ordering=last_updated"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
client = await self._get_client()
|
|
173
|
+
while url and len(tags) < limit:
|
|
174
|
+
try:
|
|
175
|
+
resp = await self._get_json(client, url)
|
|
176
|
+
if resp.status_code == 404:
|
|
177
|
+
logger.warning(f"Image not found: {safe_name}")
|
|
178
|
+
return []
|
|
179
|
+
resp.raise_for_status()
|
|
180
|
+
data = resp.json()
|
|
181
|
+
|
|
182
|
+
for tag_data in data.get("results", []):
|
|
183
|
+
tag_name = tag_data.get("name", "")
|
|
184
|
+
if not tag_name:
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
last_updated = None
|
|
188
|
+
lu_str = tag_data.get("last_updated")
|
|
189
|
+
if lu_str:
|
|
190
|
+
with contextlib.suppress(ValueError):
|
|
191
|
+
last_updated = datetime.fromisoformat(lu_str.replace("Z", "+00:00"))
|
|
192
|
+
|
|
193
|
+
size, digest, arch, archs = self._parse_images(tag_data.get("images", []))
|
|
194
|
+
|
|
195
|
+
tags.append(
|
|
196
|
+
DockerImage(
|
|
197
|
+
name=safe_name,
|
|
198
|
+
tag=tag_name,
|
|
199
|
+
digest=digest,
|
|
200
|
+
size_bytes=size,
|
|
201
|
+
architecture=arch,
|
|
202
|
+
available_architectures=archs,
|
|
203
|
+
last_updated=last_updated,
|
|
204
|
+
is_official=namespace == "library",
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
url = data.get("next")
|
|
209
|
+
except httpx.HTTPError as e:
|
|
210
|
+
# Network blips or non-429 API errors degrade to a
|
|
211
|
+
# partial result (whatever pages already fetched)
|
|
212
|
+
# instead of crashing the whole search.
|
|
213
|
+
logger.error(f"Docker Hub API error, returning partial results: {e}")
|
|
214
|
+
break
|
|
215
|
+
|
|
216
|
+
return tags[:limit]
|
|
217
|
+
|
|
218
|
+
async def tag_exists(self, image_name: str, tag: str) -> bool | None:
|
|
219
|
+
"""Confirm `tag` really exists on Docker Hub.
|
|
220
|
+
|
|
221
|
+
Returns True/False on a definitive API answer, and None when the
|
|
222
|
+
answer is unknown -- the image is not hosted on Docker Hub, or the
|
|
223
|
+
network call failed. `None` must not be reported to the user as
|
|
224
|
+
"tag missing"; it means "not verified".
|
|
225
|
+
|
|
226
|
+
Concurrent checks for the same tag are coalesced onto one request:
|
|
227
|
+
`_verify_tags` fires up to ten of these at once, and on a cold cache
|
|
228
|
+
every one of them would otherwise reach the network before the first
|
|
229
|
+
answer was written back.
|
|
230
|
+
"""
|
|
231
|
+
url = build_tag_api_url(image_name, tag)
|
|
232
|
+
if not url:
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
cache_key = f"hubtag:{image_name}:{tag}"
|
|
236
|
+
if self._cache:
|
|
237
|
+
cached = await self._cache.get(cache_key)
|
|
238
|
+
if isinstance(cached, bool):
|
|
239
|
+
return cached
|
|
240
|
+
|
|
241
|
+
in_flight = self._tag_checks.get(cache_key)
|
|
242
|
+
if in_flight is not None:
|
|
243
|
+
return await asyncio.shield(in_flight)
|
|
244
|
+
|
|
245
|
+
future: asyncio.Future[bool | None] = asyncio.get_running_loop().create_future()
|
|
246
|
+
self._tag_checks[cache_key] = future
|
|
247
|
+
try:
|
|
248
|
+
result = await self._check_tag(image_name, tag, url, cache_key)
|
|
249
|
+
except BaseException as e:
|
|
250
|
+
future.set_exception(e)
|
|
251
|
+
# Nobody may be awaiting this future; without retrieving the
|
|
252
|
+
# exception asyncio logs a spurious "never retrieved" warning.
|
|
253
|
+
future.exception()
|
|
254
|
+
raise
|
|
255
|
+
else:
|
|
256
|
+
future.set_result(result)
|
|
257
|
+
return result
|
|
258
|
+
finally:
|
|
259
|
+
self._tag_checks.pop(cache_key, None)
|
|
260
|
+
|
|
261
|
+
async def _check_tag(self, image_name: str, tag: str, url: str, cache_key: str) -> bool | None:
|
|
262
|
+
try:
|
|
263
|
+
client = await self._get_client()
|
|
264
|
+
resp = await self._get_json(client, url)
|
|
265
|
+
except httpx.HTTPError as e:
|
|
266
|
+
logger.warning(f"Could not verify tag {image_name}:{tag} on Docker Hub: {e}")
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
if resp.status_code == 404:
|
|
270
|
+
exists = False
|
|
271
|
+
elif resp.is_success:
|
|
272
|
+
exists = True
|
|
273
|
+
else:
|
|
274
|
+
logger.warning(
|
|
275
|
+
f"Unexpected status {resp.status_code} verifying {image_name}:{tag} on Docker Hub"
|
|
276
|
+
)
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
if self._cache:
|
|
280
|
+
await self._cache.set(cache_key, exists, ttl_seconds=self._tag_ttl_seconds)
|
|
281
|
+
return exists
|
|
282
|
+
|
|
283
|
+
async def get_image_metadata(self, image_name: str, tag: str) -> DockerImage | None:
|
|
284
|
+
safe_name = sanitize_image_name(image_name)
|
|
285
|
+
namespace = "library" if "/" not in safe_name else safe_name.split("/")[0]
|
|
286
|
+
repo = safe_name if "/" not in safe_name else safe_name.split("/", 1)[1]
|
|
287
|
+
|
|
288
|
+
url = f"{self.BASE_URL}/repositories/{namespace}/{repo}/tags/{tag}"
|
|
289
|
+
|
|
290
|
+
client = await self._get_client()
|
|
291
|
+
try:
|
|
292
|
+
resp = await self._get_json(client, url)
|
|
293
|
+
if resp.status_code == 404:
|
|
294
|
+
return None
|
|
295
|
+
resp.raise_for_status()
|
|
296
|
+
data = resp.json()
|
|
297
|
+
|
|
298
|
+
last_updated = None
|
|
299
|
+
lu_str = data.get("last_updated")
|
|
300
|
+
if lu_str:
|
|
301
|
+
with contextlib.suppress(ValueError):
|
|
302
|
+
last_updated = datetime.fromisoformat(lu_str.replace("Z", "+00:00"))
|
|
303
|
+
|
|
304
|
+
size, digest, arch, archs = self._parse_images(data.get("images", []))
|
|
305
|
+
|
|
306
|
+
return DockerImage(
|
|
307
|
+
name=safe_name,
|
|
308
|
+
tag=tag,
|
|
309
|
+
digest=digest,
|
|
310
|
+
size_bytes=size,
|
|
311
|
+
architecture=arch,
|
|
312
|
+
available_architectures=archs,
|
|
313
|
+
last_updated=last_updated,
|
|
314
|
+
is_official=namespace == "library",
|
|
315
|
+
)
|
|
316
|
+
except httpx.HTTPError as e:
|
|
317
|
+
logger.error(f"Failed to get metadata for {safe_name}:{tag}: {e}")
|
|
318
|
+
return None
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
HUB_WEB_BASE = "https://hub.docker.com"
|
|
6
|
+
HUB_API_BASE = "https://hub.docker.com/v2"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _is_registry_host(segment: str) -> bool:
|
|
10
|
+
"""A leading path segment is a registry host (ghcr.io, cgr.dev,
|
|
11
|
+
registry.internal:5000) rather than a Docker Hub namespace when it
|
|
12
|
+
carries a dot or a port."""
|
|
13
|
+
return "." in segment or ":" in segment
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def split_repository(image: str) -> tuple[str, str] | None:
|
|
17
|
+
"""Split a Docker Hub image name into (namespace, repository).
|
|
18
|
+
|
|
19
|
+
Official images ("node") map to the implicit "library" namespace, which
|
|
20
|
+
is what the Hub API expects. Returns None for references that are not
|
|
21
|
+
hosted on Docker Hub (e.g. "ghcr.io/org/app", "cgr.dev/chainguard/node")
|
|
22
|
+
so callers never build a link that would 404.
|
|
23
|
+
"""
|
|
24
|
+
name = image.strip().strip("/")
|
|
25
|
+
if not name:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
# Strip any tag/digest suffix that leaked into the name.
|
|
29
|
+
if "@" in name:
|
|
30
|
+
name = name.split("@", 1)[0]
|
|
31
|
+
|
|
32
|
+
parts = name.split("/")
|
|
33
|
+
if _is_registry_host(parts[0]) and parts[0] not in ("docker.io", "index.docker.io"):
|
|
34
|
+
return None
|
|
35
|
+
if parts[0] in ("docker.io", "index.docker.io"):
|
|
36
|
+
parts = parts[1:]
|
|
37
|
+
|
|
38
|
+
if not parts or not parts[0]:
|
|
39
|
+
return None
|
|
40
|
+
if len(parts) == 1:
|
|
41
|
+
return "library", parts[0]
|
|
42
|
+
if len(parts) == 2:
|
|
43
|
+
return parts[0], parts[1]
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_dockerhub_url(image: str, tag: str) -> str:
|
|
48
|
+
"""Return the canonical Docker Hub web URL for `image`:`tag`.
|
|
49
|
+
|
|
50
|
+
Official images live under the `_/<repo>` path and expose their tags via
|
|
51
|
+
a query parameter; everything else lives under `r/<ns>/<repo>/tags`.
|
|
52
|
+
Returns "" when the image is not on Docker Hub.
|
|
53
|
+
"""
|
|
54
|
+
split = split_repository(image)
|
|
55
|
+
if split is None:
|
|
56
|
+
return ""
|
|
57
|
+
namespace, repo = split
|
|
58
|
+
safe_tag = quote(tag, safe="")
|
|
59
|
+
|
|
60
|
+
if namespace == "library":
|
|
61
|
+
return f"{HUB_WEB_BASE}/_/{quote(repo, safe='')}?tab=tags&name={safe_tag}"
|
|
62
|
+
ns = quote(namespace, safe="")
|
|
63
|
+
return f"{HUB_WEB_BASE}/r/{ns}/{quote(repo, safe='')}/tags?name={safe_tag}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_tag_api_url(image: str, tag: str) -> str:
|
|
67
|
+
"""Return the Hub API endpoint that confirms a tag actually exists."""
|
|
68
|
+
split = split_repository(image)
|
|
69
|
+
if split is None:
|
|
70
|
+
return ""
|
|
71
|
+
namespace, repo = split
|
|
72
|
+
return (
|
|
73
|
+
f"{HUB_API_BASE}/repositories/{quote(namespace, safe='')}/"
|
|
74
|
+
f"{quote(repo, safe='')}/tags/{quote(tag, safe='')}"
|
|
75
|
+
)
|
|
File without changes
|