Coverage for src/gitlabracadabra/mixins/image_mirrors.py: 84%

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:

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

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

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 ↛ 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", [])

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

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

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