Coverage for src/gitlabracadabra/containers/registries.py: 100%

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 from re import search as re_search

20 from re import sub as re_sub

21 from typing import TYPE_CHECKING, NamedTuple

22

23 from gitlabracadabra.containers.const import DOCKER_HOSTNAME, DOCKER_REGISTRY

24 from gitlabracadabra.containers.manifest import Manifest

25 from gitlabracadabra.containers.registry import Registry

26 from gitlabracadabra.singleton import SingletonMeta

27

28 if TYPE_CHECKING:

29 from collections.abc import Callable

30

31 from gitlabracadabra.containers.authenticated_session import AuthenticatedSession

32

33

34 class ReferenceParts(NamedTuple):

35 hostname: str

36 manifest_name: str

37 tag: str | None

38 digest: str | None

39

40

41 class Registries(metaclass=SingletonMeta):

42 """All registies by name."""

43

44 def __init__(self) -> None:

45 """All connected registries.

46

47 Intented to be used as a singleton.

48 """

49 self._registries: dict[str, Registry] = {}

50

51 def reset(self) -> None:

52 """Reset registry cache."""

53 self._registries = {}

54

55 def get_registry(

56 self,

57 hostname: str,

58 session_callback: Callable[[AuthenticatedSession], None] | None = None,

59 ) -> Registry:

60 """Get a registry connection.

61

62 Args:

63 hostname: fqdn of a registry.

64 session_callback: Callback to enhance session.

65

66 Returns:

67 The registry with the given hostname

68 """

69 if hostname == DOCKER_REGISTRY:

70 hostname = DOCKER_HOSTNAME

71 if hostname not in self._registries:

72 self._registries[hostname] = Registry(hostname, session_callback)

73 return self._registries[hostname]

74

75 def get_manifest(self, name: str | ReferenceParts) -> Manifest:

76 """Get a manifest.

77

78 Args:

79 name: Reference name, or reference parts.

80

81 Returns:

82 The Manifest with the given full reference name.

83 """

84 full_reference_parts = self.full_reference_parts(name) if isinstance(name, str) else name

85 registry = self.get_registry(full_reference_parts.hostname)

86 return Manifest(

87 registry,

88 full_reference_parts.manifest_name,

89 full_reference_parts.digest,

90 tag=full_reference_parts.tag or "latest",

91 )

92

93 @classmethod

94 def short_reference(cls, name: str) -> str:

95 """Get short reference (i.e. familiar name).

96

97 Args:

98 name: Reference name.

99

100 Returns:

101 The corresponding short reference name.

102 """

103 short_reference = cls.full_reference(name)

104 if short_reference.startswith(f"{DOCKER_HOSTNAME}/library/"):

105 prefix_len = len(DOCKER_HOSTNAME) + 1 + len("library") + 1

106 short_reference = short_reference[prefix_len:]

107 if short_reference.startswith(f"{DOCKER_HOSTNAME}/"):

108 prefix_len = len(DOCKER_HOSTNAME) + 1

109 short_reference = short_reference[prefix_len:]

110 return re_sub(":latest(@sha256:[0-9A-Fa-f]{64})?$", r"\1", short_reference)

111

112 @classmethod

113 def full_reference(cls, name: str) -> str:

114 """Get full reference.

115

116 Args:

117 name: Reference name.

118

119 Returns:

120 The corresponding full reference name.

121 """

122 full_reference_parts = cls.full_reference_parts(name)

123 full_reference = f"{full_reference_parts.hostname}/{full_reference_parts.manifest_name}"

124 if full_reference_parts.tag:

125 full_reference = f"{full_reference}:{full_reference_parts.tag}"

126 if full_reference_parts.digest:

127 full_reference = f"{full_reference}@{full_reference_parts.digest}"

128 return full_reference

129

130 @classmethod

131 def full_reference_parts(cls, name: str) -> ReferenceParts:

132 """Get full reference parts (hostname, manifest_name, tag, digest).

133

134 Args:

135 name: Reference name.

136

137 Returns:

138 The corresponding full reference parts.

139 """

140 hostname, remaining = cls._split_docker_domain(name)

141 digest: str | None

142 tag: str | None

143 try:

144 remaining, digest = remaining.split("@", 1)

145 except ValueError:

146 digest = None

147 try:

148 remaining, tag = remaining.split(":", 1)

149 except ValueError:

150 tag = None

151 if hostname == DOCKER_HOSTNAME and "/" not in remaining:

152 remaining = f"library/{remaining}"

153 return ReferenceParts(hostname, remaining, tag, digest)

154

155 @classmethod

156 def _split_docker_domain(cls, name: str) -> tuple[str, str]:

157 parts = name.split("/", 1)

158 if len(parts) == 2 and re_search(r"^localhost$|:\d|\.", parts[0]): # noqa: PLR2004

159 return parts[0], parts[1]

160 return DOCKER_HOSTNAME, name