Skip to content

Commit

Permalink
feat: associate existing images with a new repository
Browse files Browse the repository at this point in the history
This adds a new behavior.

If asked to create an image associated with a repository, and the image
does not exist at all - then create it, as before.

If the image does exist, but is not associated with the desired
repository, then submit a PATCH request to update it, adding the desired
repository.

Signed-off-by: Ralph Bean <[email protected]>
  • Loading branch information
ralphbean committed Nov 8, 2024
1 parent 149e5ac commit d04a890
Show file tree
Hide file tree
Showing 3 changed files with 185 additions and 32 deletions.
115 changes: 83 additions & 32 deletions pyxis/create_container_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,27 +132,27 @@ def setup_argparser() -> Any: # pragma: no cover
return parser


def proxymap(repository):
def proxymap(repository: str) -> str:
"""Map a backend repo name to its proxy equivalent.
i.e., map quay.io/redhat-pending/foo----bar to foo/bar
"""
return repository.split("/")[-1].replace("----", "/")


def image_already_exists(args, digest: str, repository: str) -> bool:
def image_already_exists(args, digest: str, repository: str) -> Any:
"""Function to check if a containerImage with the given digest and repository
already exists in the pyxis instance
If `repository` is None, then the return True if the image exists at all.
:return: True if one exists, else false
:return: the image id, if one exists, else None if not found
"""

# quote is needed to urlparse the quotation marks
raw_filter = f'repositories.manifest_schema2_digest=="{digest}";not(deleted==true);'
raw_filter = f'repositories.manifest_schema2_digest=="{digest}";not(deleted==true)'
if repository:
raw_filter += f'repositories.repository=="{proxymap(repository)}"'
raw_filter += f';repositories.repository=="{proxymap(repository)}"'
filter_str = quote(raw_filter)

check_url = urljoin(args.pyxis_url, f"v1/images?page_size=1&filter={filter_str}")
Expand All @@ -164,14 +164,14 @@ def image_already_exists(args, digest: str, repository: str) -> bool:
query_results = rsp.json()["data"]

if len(query_results) == 0:
return False
return None

if "_id" in query_results[0]:
LOGGER.info(f"Found image id is: {query_results[0]['_id']}")
else:
raise Exception("Image metadata was found in Pyxis, but the id key was missing.")

return True
return query_results[0]['_id']


def prepare_parsed_data(args) -> Dict[str, Any]:
Expand Down Expand Up @@ -223,6 +223,28 @@ def prepare_parsed_data(args) -> Dict[str, Any]:
return parsed_data


def pyxis_tags(args, date_now):
"""Return list of tags formatted for pyxis"""
tags = args.tags.split()
if args.is_latest == "true":
tags.append("latest")
return [
{
"added_date": date_now,
"name": tag,
}
for tag in tags
]


def repository_digest_values(args, docker_image_digest):
"""Return digest values for the repository entry in the image entity"""
result = {"manifest_schema2_digest": args.architecture_digest}
if args.media_type in MANIFEST_LIST_TYPES:
result["manifest_list_digest"] = docker_image_digest
return result


def create_container_image(args, parsed_data: Dict[str, Any]):
"""Function to create a new containerImage entry in a pyxis instance"""

Expand Down Expand Up @@ -255,25 +277,14 @@ def create_container_image(args, parsed_data: Dict[str, Any]):

upload_url = urljoin(args.pyxis_url, "v1/images")

tags = args.tags.split()
if args.is_latest == "true":
tags.append("latest")
pyxis_tags = [
{
"added_date": date_now,
"name": tag,
}
for tag in tags
]

container_image_payload = {
"repositories": [
{
"published": False,
"registry": image_registry,
"repository": image_repo,
"push_date": date_now,
"tags": pyxis_tags,
"tags": pyxis_tags(args, date_now),
}
],
"certified": json.loads(args.certified.lower()),
Expand All @@ -285,20 +296,18 @@ def create_container_image(args, parsed_data: Dict[str, Any]):
"uncompressed_top_layer_id": uncompressed_top_layer_id,
}

container_image_payload["repositories"][0][
"manifest_schema2_digest"
] = args.architecture_digest
if args.media_type in MANIFEST_LIST_TYPES:
container_image_payload["repositories"][0][
"manifest_list_digest"
] = docker_image_digest
container_image_payload["repositories"][0].update(
repository_digest_values(args, docker_image_digest)
)

# For images released to registry.redhat.io we need a second repository item
# with published=true and registry and repository converted.
# E.g. if the name in the oras manifest result is
# "quay.io/redhat-prod/rhtas-tech-preview----cosign-rhel9",
# repository will be "rhtas-tech-preview/cosign-rhel9"
if args.rh_push == "true":
if not args.rh_push == "true":
LOGGER.info("--rh-push is not set. Skipping public registry association.")
else:
repo = container_image_payload["repositories"][0].copy()
repo["published"] = True
repo["registry"] = "registry.access.redhat.com"
Expand All @@ -314,6 +323,44 @@ def create_container_image(args, parsed_data: Dict[str, Any]):
raise Exception("Image metadata was not successfully added to Pyxis.")


def add_container_image_repository(args, parsed_data: Dict[str, Any], identifier: str):
if not args.rh_push == "true":
LOGGER.info("--rh-push is not set. Skipping public registry association.")
return

LOGGER.info(f"Adding repository to container image {identifier}")

date_now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f+00:00")

image_name = parsed_data["name"]
docker_image_digest = parsed_data["digest"]

patch_url = urljoin(args.pyxis_url, f"v1/images/id/{identifier}")

container_image_payload = {
"repositories": [
{
"published": True,
"registry": "registry.access.redhat.com",
"repository": proxymap(image_name),
"push_date": date_now,
"tags": pyxis_tags(args, date_now),
},
]
}
container_image_payload["repositories"][0].update(
repository_digest_values(args, docker_image_digest)
)

rsp = pyxis.patch(patch_url, container_image_payload).json()

# Make sure container metadata was successfully added to Pyxis
if "_id" in rsp:
LOGGER.info(f"The image id is: {rsp['_id']}")
else:
raise Exception("Image metadata was not successfully added to Pyxis.")


def main(): # pragma: no cover
"""Main func"""

Expand All @@ -324,18 +371,22 @@ def main(): # pragma: no cover

parsed_data = prepare_parsed_data(args)

if image_already_exists(args, args.architecture_digest, repository=None):
# First check if it exists at all
identifier = image_already_exists(args, args.architecture_digest, repository=None)
if identifier:
# Then, check if it exists in association with the given repository
if image_already_exists(args, args.architecture_digest, repository=args.name):
LOGGER.info(
"Image with given docker_image_digest already exists "
f"and is associated with {args.name}. "
f"Image with given docker_image_digest already exists as {identifier} "
f"and is associated with repository {args.name}. "
"Skipping the image creation."
)
else:
LOGGER.info(
"Image with given docker_image_digest exists, but is not yet associated with {args.name}."
f"Image with given docker_image_digest exists as {identifier}, but "
f"is not yet associated with repository {args.name}."
)
raise NotImplementedError("Add repository with a PATCH")
add_container_image_repository(args, parsed_data, identifier)
else:
LOGGER.info("Image with given docker_image_digest doesn't exist yet.")
create_container_image(args, parsed_data)
Expand Down
28 changes: 28 additions & 0 deletions pyxis/pyxis.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,34 @@ def post(url: str, body: Dict[str, Any]) -> requests.Response:
return resp


def patch(url: str, body: Dict[str, Any]) -> requests.Response:
"""PATCH pyxis API request to given URL with given payload
Args:
url (str): Pyxis API URL
body (Dict[str, Any]): Request payload
:return: Pyxis response
"""
global session
if session is None:
session = _get_session()

LOGGER.debug(f"PATCH request URL: {url}")
LOGGER.debug(f"PATCH request body: {body}")
resp = session.patch(url, json=body)

try:
LOGGER.debug(f"PATCH request response: {resp.text}")
resp.raise_for_status()
except requests.HTTPError:
LOGGER.exception(
f"Pyxis PATCH query failed with {url} - {resp.status_code} - {resp.text}"
)
raise
return resp


def graphql_query(graphql_api: str, body: Dict[str, Any]) -> Dict[str, Any]:
"""Make a request to Pyxis GraphQL API
Expand Down
74 changes: 74 additions & 0 deletions pyxis/test_create_container_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from create_container_image import (
image_already_exists,
create_container_image,
add_container_image_repository,
prepare_parsed_data,
)

Expand Down Expand Up @@ -60,6 +61,32 @@ def test_image_already_exists__image_does_not_exist(mock_get):
assert not exists


@patch("create_container_image.pyxis.get")
def test_image_already_exists__image_does_exist_but_no_repo(mock_get):
# Arrange
mock_rsp = MagicMock()
mock_get.return_value = mock_rsp
args = MagicMock()
args.pyxis_url = mock_pyxis_url
args.architecture_digest = "some_digest"
args.name = "server/org/some_name"

# Image already exists
mock_rsp.json.return_value = {"data": [{"_id": 0}]}

# Act
exists = image_already_exists(args, args.architecture_digest, None)

# Assert
assert exists
mock_get.assert_called_once_with(
mock_pyxis_url
+ "v1/images?page_size=1&filter="
+ "repositories.manifest_schema2_digest%3D%3D%22some_digest%22"
+ "%3Bnot%28deleted%3D%3Dtrue%29%3B"
)


@patch("create_container_image.pyxis.post")
@patch("create_container_image.datetime")
def test_create_container_image(mock_datetime, mock_post):
Expand Down Expand Up @@ -114,6 +141,53 @@ def test_create_container_image(mock_datetime, mock_post):
)


@patch("create_container_image.pyxis.patch")
@patch("create_container_image.datetime")
def test_add_container_image_repository(mock_datetime, mock_patch):
# Mock an _id in the response for logger check
mock_patch.return_value.json.return_value = {"_id": 0}

# mock date
mock_datetime.now = MagicMock(return_value=datetime(1970, 10, 10, 10, 10, 10))

args = MagicMock()
args.pyxis_url = mock_pyxis_url
args.tags = "some_version"
args.rh_push = "true"
args.architecture_digest = "arch specific digest"
args.media_type = "single architecture"

# Act
add_container_image_repository(
args,
{"architecture": "ok", "digest": "some_digest", "name": "quay.io/some_repo"},
"some_id",
)

# Assert
mock_patch.assert_called_with(
mock_pyxis_url + "v1/images/id/some_id",
{
"repositories": [
{
"published": True,
"registry": "registry.access.redhat.com",
"repository": "some_repo",
"push_date": "1970-10-10T10:10:10.000000+00:00",
"tags": [
{
"added_date": "1970-10-10T10:10:10.000000+00:00",
"name": "some_version",
}
],
# Note, no manifest_list_digest here. Single arch.
"manifest_schema2_digest": "arch specific digest",
}
],
},
)


@patch("create_container_image.pyxis.post")
@patch("create_container_image.datetime")
def test_create_container_image_latest(mock_datetime, mock_post):
Expand Down

0 comments on commit d04a890

Please sign in to comment.