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/>.

16

17 from __future__ import annotations

18

19 import logging

20 from pathlib import PurePosixPath

21 from re import Match

22 from typing import TYPE_CHECKING

23

24 from gitlabracadabra.containers.registries import ReferenceParts, Registries

25 from gitlabracadabra.matchers import Matcher

26 from gitlabracadabra.objects.object import GitLabracadabraObject

27

28 if TYPE_CHECKING: 28 ↛ 29 line 28 didn't jump to line 29 because the condition on line 28 was never true

29 from typing import Any

30

31 from gitlabracadabra.containers.registry import Registry

32

33

34 logger = logging.getLogger(__name__)

35

36 Source = tuple[PurePosixPath, str, Match, str | None]

37

38

39 class ImageMirrorsMixin(GitLabracadabraObject):

40 """Object (Project) with image mirrors."""

41

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.

51

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

60

61 dest_registry, prefix = self._get_destination()

62

63 for image_mirror in param_value:

64 if not image_mirror.get("enabled", True): 64 ↛ 65 line 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)

67

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 ↛ 75 line 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)

77

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))

81

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 )

119

120 def _get_sources(self, from_param: Any, semver: str | None) -> list[Source]:

121 """Get sources.

122

123 Args:

124 from_param: The "from" param.

125 semver: Optional "semver" param.

126

127 Returns:

128 A list of tuples (base, repository, tag, digest)

129

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 ↛ 150 line 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)

152

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 ↛ 162 line 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 ↛ 165 line 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

178

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.

187

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.

194

195 Returns:

196 A tuple (repository, tag)

197

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 ↛ 213 line 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)

215

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