16
16
"""Docker operations for the ETOS API."""
17
17
import time
18
18
import logging
19
+ from typing import Optional , Mapping
19
20
from threading import Lock
20
21
import aiohttp
21
22
@@ -33,12 +34,14 @@ class Docker:
33
34
"""
34
35
35
36
logger = logging .getLogger (__name__ )
37
+ # The amount of time in seconds before the token expires where we should recreate it.
38
+ token_expire_modifier = 30
36
39
# In-memory database for stored authorization tokens.
37
40
# This dictionary shares memory with all instances of `Docker`, by design.
38
41
tokens = {}
39
42
lock = Lock ()
40
43
41
- def token (self , manifest_url : str ) -> str :
44
+ def token (self , manifest_url : str ) -> Optional [ str ] :
42
45
"""Get a stored token, removing it if expired.
43
46
44
47
:param manifest_url: URL the token has been stored for.
@@ -54,7 +57,7 @@ def token(self, manifest_url: str) -> str:
54
57
return token .get ("token" )
55
58
56
59
async def head (
57
- self , session : aiohttp .ClientSession , url : str , token : str = None
60
+ self , session : aiohttp .ClientSession , url : str , token : Optional [ str ] = None
58
61
) -> aiohttp .ClientResponse :
59
62
"""Make a HEAD request to a URL, adding token to headers if supplied.
60
63
@@ -70,36 +73,78 @@ async def head(
70
73
async with session .head (url , headers = headers ) as response :
71
74
return response
72
75
73
- async def authorize (
74
- self , session : aiohttp .ClientSession , response : aiohttp . ClientResponse
75
- ) -> str :
76
- """Get a token from an unauthorized request to image repository.
76
+ async def get_token_from_container_registry (
77
+ self , session : aiohttp .ClientSession , realm : str , parameters : dict
78
+ ) -> dict :
79
+ """Get a token from an unauthorized request to container repository.
77
80
78
81
:param session: Client HTTP session to use for HTTP request.
79
- :param response: HTTP response to get headers from.
82
+ :param realm: The realm to authorize against.
83
+ :param parameters: Parameters to use for the authorization request.
80
84
:return: Response JSON from authorization request.
81
85
"""
82
- www_auth_header = response . headers . get ("www-authenticate" )
83
- challenge = www_auth_header . replace ( "Bearer " , "" )
84
- parts = challenge . split ( "," )
86
+ async with session . get (realm , params = parameters ) as response :
87
+ response . raise_for_status ( )
88
+ return await response . json ( )
85
89
86
- url = None
87
- query = {}
88
- for part in parts :
89
- key , value = part .split ("=" )
90
- if key == "realm" :
91
- url = value .strip ('"' )
92
- else :
93
- query [key ] = value .strip ('"' )
90
+ async def authorize (
91
+ self , session : aiohttp .ClientSession , response : aiohttp .ClientResponse , manifest_url : str
92
+ ) -> str :
93
+ """Authorize against container registry.
94
94
95
+ :param session: Client HTTP session to use for HTTP request.
96
+ :param response: Response from a previous request to parse headers from.
97
+ :param manifest_url: Manifest URL to query should a new auth request be needed.
98
+ :return: A token retrieved from container registry.
99
+ """
100
+ parameters = await self .parse_headers (response .headers )
101
+ url = parameters .get ("realm" )
95
102
if not isinstance (url , str ) or not (
96
103
url .startswith ("http://" ) or url .startswith ("https://" )
97
104
):
98
- raise ValueError (f"No realm URL found in www-authenticate header: { www_auth_header } " )
105
+ self .logger .warning ("No realm in original request, retrying without a token" )
106
+ with self .lock :
107
+ try :
108
+ del self .tokens [manifest_url ]
109
+ except KeyError :
110
+ pass
111
+ response = await self .head (session , manifest_url )
112
+ parameters = await self .parse_headers (response .headers )
113
+ url = parameters .get ("realm" )
114
+ if not isinstance (url , str ) or not (
115
+ url .startswith ("http://" ) or url .startswith ("https://" )
116
+ ):
117
+ raise ValueError (
118
+ f"No realm URL found in www-authenticate header: { response .headers } "
119
+ )
120
+ url = parameters .pop ("realm" )
121
+ response_json = await self .get_token_from_container_registry (session , url , parameters )
122
+ with self .lock :
123
+ self .tokens [manifest_url ] = {
124
+ "token" : response_json .get ("token" ),
125
+ "expire" : time .time ()
126
+ + response_json .get ("expires_in" , 0.0 )
127
+ - self .token_expire_modifier ,
128
+ }
129
+ return ""
130
+
131
+ async def parse_headers (self , headers : Mapping ) -> dict :
132
+ """Parse the www-authenticate header and convert it to a dict.
133
+
134
+ :param headers: Headers to parse.
135
+ :return: Dictionary of the keys in the www-authenticate header.
136
+ """
137
+ www_auth_header = headers .get ("www-authenticate" )
138
+ if www_auth_header is None :
139
+ return {}
140
+ challenge = www_auth_header .replace ("Bearer " , "" )
141
+ parts = challenge .split ("," )
99
142
100
- async with session .get (url , params = query ) as response :
101
- response .raise_for_status ()
102
- return await response .json ()
143
+ parameters = {}
144
+ for part in parts :
145
+ key , value = part .split ("=" )
146
+ parameters [key ] = value .strip ('"' )
147
+ return parameters
103
148
104
149
def tag (self , base : str ) -> tuple [str , str ]:
105
150
"""Figure out tag from a container image name.
@@ -149,7 +194,7 @@ def repository(self, repo: str) -> tuple[str, str]:
149
194
registry = DEFAULT_REGISTRY
150
195
return registry , repo
151
196
152
- async def digest (self , name : str ) -> str :
197
+ async def digest (self , name : str ) -> Optional [ str ] :
153
198
"""Get a sha256 digest from an image in an image repository.
154
199
155
200
:param name: The name of the container image.
@@ -167,13 +212,8 @@ async def digest(self, name: str) -> str:
167
212
try :
168
213
if response .status == 401 and "www-authenticate" in response .headers :
169
214
self .logger .info ("Generate a new authorization token for %r" , manifest_url )
170
- response_json = await self .authorize (session , response )
171
- with self .lock :
172
- self .tokens [manifest_url ] = {
173
- "token" : response_json .get ("token" ),
174
- "expire" : time .time () + response_json .get ("expires_in" ),
175
- }
176
- response = await self .head (session , manifest_url , self .token (manifest_url ))
215
+ token = await self .authorize (session , response , manifest_url )
216
+ response = await self .head (session , manifest_url , token )
177
217
digest = response .headers .get ("Docker-Content-Digest" )
178
218
except aiohttp .ClientResponseError as exception :
179
219
self .logger .error ("Error getting container image %r" , exception )
0 commit comments