Skip to content

Commit 89fa890

Browse files
Validate test runner (#39)
* Validate test runner before launching ETOS Co-authored-by: andjoe-axis <[email protected]>
1 parent 69f0a1b commit 89fa890

File tree

2 files changed

+197
-3
lines changed

2 files changed

+197
-3
lines changed

src/etos_api/library/docker.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# Copyright 2023 Axis Communications AB.
2+
#
3+
# For a full list of individual contributors, please see the commit history.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
"""Docker operations for the ETOS API."""
17+
import time
18+
import logging
19+
from threading import Lock
20+
import aiohttp
21+
22+
DEFAULT_TAG = "latest"
23+
DEFAULT_REGISTRY = "index.docker.io"
24+
REPO_DELIMITER = "/"
25+
TAG_DELIMITER = ":"
26+
27+
28+
class Docker:
29+
"""Docker handler for HTTP operations against docker registries.
30+
31+
This handler is heavily inspired by `crane digest`:
32+
https://github.com/google/go-containerregistry/tree/main/cmd/crane
33+
"""
34+
35+
logger = logging.getLogger(__name__)
36+
# In-memory database for stored authorization tokens.
37+
# This dictionary shares memory with all instances of `Docker`, by design.
38+
tokens = {}
39+
lock = Lock()
40+
41+
def token(self, manifest_url: str) -> str:
42+
"""Get a stored token, removing it if expired.
43+
44+
:param manifest_url: URL the token has been stored for.
45+
:return: A token or None.
46+
"""
47+
with self.lock:
48+
token = self.tokens.get(manifest_url, {})
49+
if token:
50+
if time.time() >= token["expire"]:
51+
self.logger.info("Registry token expired for %r", manifest_url)
52+
self.tokens.pop(manifest_url)
53+
token = {}
54+
return token.get("token")
55+
56+
async def head(
57+
self, session: aiohttp.ClientSession, url: str, token: str = None
58+
) -> aiohttp.ClientResponse:
59+
"""Make a HEAD request to a URL, adding token to headers if supplied.
60+
61+
:param session: Client HTTP session to use for HTTP request.
62+
:param url: URL to make HEAD request to.
63+
:param token: Optional authorization token.
64+
:return: HTTP response.
65+
"""
66+
headers = {}
67+
if token is not None:
68+
headers["Authorization"] = f"Bearer {token}"
69+
70+
async with session.head(url, headers=headers) as response:
71+
return response
72+
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.
77+
78+
:param session: Client HTTP session to use for HTTP request.
79+
:param response: HTTP response to get headers from.
80+
:return: Response JSON from authorization request.
81+
"""
82+
header = response.headers.get("www-authenticate")
83+
header = header.replace("Bearer ", "")
84+
parts = header.split(",")
85+
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('"')
94+
95+
async with session.get(url, params=query) as response:
96+
response.raise_for_status()
97+
return await response.json()
98+
99+
def tag(self, base: str) -> tuple[str, str]:
100+
"""Figure out tag from a container image name.
101+
102+
:param base: Name of image.
103+
:return: Base image name without tag and tag name.
104+
"""
105+
tag = ""
106+
107+
parts = base.split(TAG_DELIMITER)
108+
# Verify that we aren't confusing a tag for a hostname w/ port
109+
# If there are more than one ':' in the image name, we'll assume
110+
# that the container tag is after the second ':', not the first.
111+
# By checking if the first part (i.e. hostname w/ port) does not
112+
# have any '/', we will also catch cases where there's a
113+
# hostname w/ port and no tag in the image name.
114+
if len(parts) > 1 and REPO_DELIMITER not in parts[-1]:
115+
base = TAG_DELIMITER.join(parts[:-1])
116+
tag = parts[-1]
117+
self.logger.info("Assuming tag is %r", tag)
118+
if tag == "":
119+
tag = DEFAULT_TAG
120+
self.logger.info("Assuming default tag %r", tag)
121+
return base, tag
122+
123+
def repository(self, repo: str) -> tuple[str, str]:
124+
"""Figure out repository and registry from a container image name.
125+
126+
:param repo: Name of image, including or excluding registry URL.
127+
:return: Registry URL and the repo path for that registry.
128+
"""
129+
registry = ""
130+
parts = repo.split(REPO_DELIMITER, 1)
131+
if len(parts) == 2 and ("." in parts[0] or ":" in parts[0]):
132+
# The first part of the repository is treated as the registry domain
133+
# if it contains a '.' or ':' character, otherwise it is all repository
134+
# and the domain defaults to Docker Hub.
135+
registry = parts[0]
136+
repo = parts[1]
137+
self.logger.info(
138+
"Probably found a registry URL in image name: (registry=%r, repo=%r)",
139+
registry,
140+
repo,
141+
)
142+
if registry in ("", "docker.io"):
143+
self.logger.info("Assuming registry is %r for %r", DEFAULT_REGISTRY, repo)
144+
registry = DEFAULT_REGISTRY
145+
return registry, repo
146+
147+
async def digest(self, name: str) -> str:
148+
"""Get a sha256 digest from an image in an image repository.
149+
150+
:param name: The name of the container image.
151+
:return: The sha256 digest of the container image.
152+
"""
153+
self.logger.info("Figure out digest for %r", name)
154+
base, tag = self.tag(name)
155+
registry, repo = self.repository(base)
156+
manifest_url = f"https://{registry}/v2/{repo}/manifests/{tag}"
157+
158+
digest = None
159+
async with aiohttp.ClientSession() as session:
160+
self.logger.info("Get digest from %r", manifest_url)
161+
response = await self.head(session, manifest_url, self.token(manifest_url))
162+
try:
163+
if response.status == 401 and "www-authenticate" in response.headers:
164+
self.logger.info(
165+
"Generate a new authorization token for %r", manifest_url
166+
)
167+
response_json = await self.authorize(session, response)
168+
with self.lock:
169+
self.tokens[manifest_url] = {
170+
"token": response_json.get("token"),
171+
"expire": time.time() + response_json.get("expires_in"),
172+
}
173+
response = await self.head(
174+
session, manifest_url, self.token(manifest_url)
175+
)
176+
digest = response.headers.get("Docker-Content-Digest")
177+
except aiohttp.ClientResponseError as exception:
178+
self.logger.error("Error getting container image %r", exception)
179+
digest = None
180+
self.logger.info("Returning digest %r from %r", digest, manifest_url)
181+
return digest

src/etos_api/library/validator.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2020 Axis Communications AB.
1+
# Copyright 2020-2023 Axis Communications AB.
22
#
33
# For a full list of individual contributors, please see the commit history.
44
#
@@ -19,6 +19,7 @@
1919
from typing import Union, List
2020
from pydantic import BaseModel, validator, ValidationError, constr, conlist
2121
import requests
22+
from etos_api.library.docker import Docker
2223

2324

2425
class Environment(BaseModel):
@@ -178,5 +179,17 @@ async def validate(self, test_suite_url):
178179
:raises ValidationError: If the suite did not validate.
179180
"""
180181
downloaded_suite = await self._download_suite(test_suite_url)
181-
for suite in downloaded_suite:
182-
assert Suite(**suite)
182+
for suite_json in downloaded_suite:
183+
test_runners = set()
184+
suite = Suite(**suite_json)
185+
assert suite
186+
187+
for recipe in suite.recipes:
188+
for constraint in recipe.constraints:
189+
if constraint.key == "TEST_RUNNER":
190+
test_runners.add(constraint.value)
191+
docker = Docker()
192+
for test_runner in test_runners:
193+
assert (
194+
await docker.digest(test_runner) is not None
195+
), f"Test runner {test_runner} not found"

0 commit comments

Comments
 (0)