Coverage for src/gitlabracadabra/mixins/image_mirrors.py: 82%
91 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-14 23:10 +0200
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-14 23:10 +0200
1#
2# Copyright (C) 2019-2025 Mathieu Parent <math.parent@gmail.com>
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU Lesser General Public License as published by
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Lesser General Public License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
17from __future__ import annotations
19import logging
20from pathlib import PurePosixPath
21from re import Match
22from typing import TYPE_CHECKING
24from gitlabracadabra.containers.registries import ReferenceParts, Registries
25from gitlabracadabra.matchers import Matcher
26from gitlabracadabra.objects.object import GitLabracadabraObject
28if TYPE_CHECKING: 28 ↛ 29line 28 didn't jump to line 29 because the condition on line 28 was never true
29 from typing import Any
31 from gitlabracadabra.containers.registry import Registry
34logger = logging.getLogger(__name__)
36Source = tuple[PurePosixPath, str, Match, str | None]
39class ImageMirrorsMixin(GitLabracadabraObject):
40 """Object (Project) with image mirrors."""
42 def _process_image_mirrors(
43 self,
44 param_name: str,
45 param_value: Any,
46 *,
47 dry_run: bool = False,
48 skip_save: bool = False,
49 ) -> None:
50 """Process the image_mirrors param.
52 Args:
53 param_name: "image_mirrors".
54 param_value: List of image mirror dicts.
55 dry_run: Dry run.
56 skip_save: False.
57 """
58 assert param_name == "image_mirrors" # noqa: S101
59 assert not skip_save # noqa: S101
61 dest_registry, prefix = self._get_destination()
63 for image_mirror in param_value:
64 if not image_mirror.get("enabled", True): 64 ↛ 65line 64 didn't jump to line 65 because the condition on line 64 was never true
65 continue
66 self._mirror(image_mirror, dest_registry, prefix, dry_run=dry_run)
68 def _get_destination(self) -> tuple[Registry, PurePosixPath]:
69 # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/54090
70 try:
71 container_registry_image_prefix = self._obj.container_registry_image_prefix
72 except AttributeError:
73 container_registry_image_prefix = None
74 if not isinstance(container_registry_image_prefix, str): 74 ↛ 75line 74 didn't jump to line 75 because the condition on line 74 was never true
75 msg = f"Unexpected type for container_registry_image_prefix: {type(container_registry_image_prefix)}"
76 raise TypeError(msg)
78 netloc, path = container_registry_image_prefix.split("/", 1)
79 registry = Registries().get_registry(netloc, self.connection.registry_session_callback)
80 return (registry, PurePosixPath(path))
82 def _mirror(
83 self,
84 image_mirror: dict,
85 dest_registry: Registry,
86 prefix: PurePosixPath,
87 *,
88 dry_run: bool,
89 ) -> None:
90 sources = self._get_sources(image_mirror.get("from"), image_mirror.get("semver"))
91 for source in sources:
92 dest = self._get_dest(
93 source[0],
94 source[1],
95 source[2],
96 image_mirror.get("to"),
97 )
98 digest_suffix = ""
99 if source[3]:
100 digest_suffix = f"@{source[3]}"
101 source_manifest = Registries().get_manifest(f"{source[0] / source[1]!s}:{source[2][0]}{digest_suffix}")
102 source_manifest.forced_digest = source[3] is not None
103 dest_manifest = Registries().get_manifest(
104 ReferenceParts(
105 dest_registry.hostname,
106 str(prefix / dest[0]),
107 dest[1],
108 None,
109 )
110 )
111 dest_manifest.registry.import_manifest(
112 source_manifest,
113 dest_manifest.manifest_name,
114 tag=dest_manifest.tag,
115 platform=None,
116 log_prefix=f"[{self._name}] ",
117 dry_run=dry_run,
118 )
120 def _get_sources(self, from_param: Any, semver: str | None) -> list[Source]:
121 """Get sources.
123 Args:
124 from_param: The "from" param.
125 semver: Optional "semver" param.
127 Returns:
128 A list of tuples (base, repository, tag, digest)
130 Raises:
131 ValueError: Unexpected from param type.
132 """
133 default_tag = "latest"
134 if semver:
135 default_tag = "/.*/"
136 if isinstance(from_param, str):
137 parts = Registries().full_reference_parts(from_param)
138 return self._get_sources_from_parts(
139 PurePosixPath(parts.hostname),
140 [parts.manifest_name],
141 [parts.tag or default_tag],
142 parts.digest,
143 semver,
144 )
145 if isinstance(from_param, dict): 145 ↛ 150line 145 didn't jump to line 150 because the condition on line 145 was always true
146 base = PurePosixPath(from_param.get("base", ""))
147 repositories = from_param.get("repositories", None)
148 tags = from_param.get("tags", [default_tag])
149 return self._get_sources_from_parts(base, repositories, tags, None, semver)
150 msg = f"Unexpected from param type: {type(from_param)}"
151 raise ValueError(msg)
153 def _get_sources_from_parts(
154 self,
155 base: PurePosixPath,
156 repositories: list[str],
157 tags: list[str],
158 digest: str | None,
159 semver: str | None,
160 ) -> list[Source]:
161 if not isinstance(repositories, list): 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true
162 msg = f"Unexpected from.repositories param type: {type(repositories)}" # type: ignore[unreachable]
163 raise TypeError(msg)
164 if not isinstance(tags, list): 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true
165 msg = f"Unexpected from.tags param type: {type(tags)}" # type: ignore[unreachable]
166 raise TypeError(msg)
167 sources: list[Source] = []
168 for repository in repositories:
169 matcher = Matcher(
170 tags,
171 semver,
172 log_prefix=f"[{self._name}] ",
173 )
174 manifest = Registries().get_manifest(str(base / repository))
175 for match in matcher.match(manifest.tag_list):
176 sources.append((base, repository, match, digest)) # noqa: PERF401
177 return sources
179 def _get_dest(
180 self,
181 source_base: PurePosixPath,
182 source_repository: str,
183 source_tag: Match,
184 to_param: Any,
185 ) -> tuple[str, str]:
186 """Get Destination.
188 Args:
189 source_base: base as PurePosixPath.
190 source_repository: source repository, relative to base.
191 source_tag: source tag.
192 source_digest: source digest.
193 to_param: "to" parameter.
195 Returns:
196 A tuple (repository, tag)
198 Raises:
199 ValueError: Unexpected to param type.
200 """
201 if to_param is None:
202 repository = str(source_base / source_repository).split("/", 1).pop()
203 return repository, source_tag[0]
204 if isinstance(to_param, str):
205 tag: str | None
206 try:
207 to_param, tag = to_param.rsplit(":", 1)
208 except ValueError:
209 tag = None
210 return to_param, tag or source_tag[0]
211 if isinstance(to_param, dict): 211 ↛ 213line 211 didn't jump to line 213 because the condition on line 211 was always true
212 return self._get_dest_from_dict(source_base, source_repository, source_tag, to_param)
213 msg = f"Unexpected to param type: {type(to_param)}"
214 raise ValueError(msg)
216 def _get_dest_from_dict(
217 self,
218 source_base: PurePosixPath,
219 source_repository: str,
220 source_tag: Match,
221 to_param: dict,
222 ) -> tuple[str, str]:
223 base = PurePosixPath(to_param.get("base", source_base) or "")
224 repository = to_param.get("repository", source_repository) or ""
225 tag = source_tag.expand(to_param.get("tag") or source_tag[0])
226 return str(base / repository), tag