Compare commits
26 Commits
v1.0.0-bet
...
main
Author | SHA1 | Date |
---|---|---|
|
b692c2be09 | |
|
f3fed327bb | |
|
1228925026 | |
|
c88d87055e | |
|
9474295fbc | |
|
08c7858bd1 | |
|
532fdbb8fa | |
|
540fdca826 | |
|
d6067ee49e | |
|
1002360e64 | |
|
f0e0c2b046 | |
|
c74ded2a9f | |
|
122532f4d7 | |
|
6b8b068b95 | |
|
62eaed0b6f | |
|
80af3ae997 | |
|
a4cfa50daf | |
|
6d26f9ecde | |
|
43e692749f | |
|
ed931091a7 | |
|
5e42e18f6a | |
|
a9f8d54b6b | |
|
97ba84a2d6 | |
|
064f98b861 | |
|
14a289b942 | |
|
eb69e879f2 |
|
@ -1,6 +1,10 @@
|
|||
name: Doc
|
||||
|
||||
on: [push, workflow_dispatch]
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
branches: '**'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
@ -19,7 +23,7 @@ jobs:
|
|||
make doc
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
if: ${{ github.event_name == 'release' && github.event.release.prerelease == false }}
|
||||
with:
|
||||
publish_branch: doc
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Test Credentials
|
||||
if: github.event.release.prerelease == true
|
||||
run: |
|
||||
touch $HOME/.pypirc
|
||||
echo "[testpypi]" >> $HOME/.pypirc
|
||||
echo " username = __token__" >> $HOME/.pypirc
|
||||
echo " password = ${{ secrets.TEST_PYPI_TOKEN }}" >> $HOME/.pypirc
|
||||
- name: Setup Credentials
|
||||
if: github.event.release.prerelease == false
|
||||
run: |
|
||||
touch $HOME/.pypirc
|
||||
echo "[pypi]" >> $HOME/.pypirc
|
||||
echo " username = __token__" >> $HOME/.pypirc
|
||||
echo " password = ${{ secrets.PYPI_TOKEN }}" >> $HOME/.pypirc
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
make release-dependencies
|
||||
- name: Test Release Distribution
|
||||
if: github.event.release.prerelease == true
|
||||
run: |
|
||||
make release
|
||||
- name: Release Distribution
|
||||
if: github.event.release.prerelease == false
|
||||
run: |
|
||||
PYPI=pypi make release
|
22
Makefile
22
Makefile
|
@ -1,5 +1,6 @@
|
|||
.PHONY: doc
|
||||
PYTHON ?= /usr/bin/python3
|
||||
PYPI ?= testpypi
|
||||
|
||||
############
|
||||
# Pre-commit recipes
|
||||
|
@ -31,6 +32,27 @@ build-dependencies:
|
|||
build:
|
||||
$(PYTHON) -m build
|
||||
|
||||
#########
|
||||
# Publish recipes
|
||||
#
|
||||
# Install the required dependencies for the publish recipe
|
||||
publish-dependencies:
|
||||
$(PYTHON) -m pip install -r ci/requirements.publish.txt
|
||||
|
||||
# Publish the built python distribution
|
||||
# Assumes the python distribution was already built using build recipe
|
||||
publish:
|
||||
$(PYTHON) -m twine upload --repository $(PYPI) dist/*
|
||||
|
||||
#########
|
||||
# Release recipes
|
||||
#
|
||||
# Install the required dependencies for the release recipe
|
||||
release-dependencies: build-dependencies publish-dependencies
|
||||
|
||||
# Release the python distribution by building and publishing it
|
||||
release: build publish
|
||||
|
||||
#####
|
||||
# Doc recipes
|
||||
#
|
||||
|
|
14
README.md
14
README.md
|
@ -1,4 +1,4 @@
|
|||

|
||||

|
||||
|
||||
# containerimage-py
|
||||
|
||||
|
@ -8,7 +8,7 @@ A python library for interacting with container images and container image regis
|
|||
|
||||
**Docs**: https://containers.github.io/containerimage-py/
|
||||
|
||||
**Contributing**: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
**Contributing**: [CONTRIBUTING.md](https://github.com/containers/containerimage-py/blob/main/CONTRIBUTING.md)
|
||||
|
||||
## Quick Example
|
||||
|
||||
|
@ -28,6 +28,12 @@ print(
|
|||
f"Digest for {str(my_image)}: " + \
|
||||
my_image.get_digest(auth={}) # sha256:1ff6c18fbef2045af6b9c16bf034cc421a29027b800e4f9b68ae9b1cb3e9ae07
|
||||
)
|
||||
|
||||
# Inspect the container image image for a more consolidated summary
|
||||
print(
|
||||
f"Inspect for {str(my_image)}:\n" + \
|
||||
str(my_image.inspect(auth={})) # Same as skopeo inspect docker://registry.k8s.io/pause:3.5
|
||||
)
|
||||
```
|
||||
|
||||
To run this example, simply execute the following from the root of this repository
|
||||
|
@ -39,8 +45,6 @@ python3 examples/quick-example.py
|
|||
|
||||
### Using Pip
|
||||
|
||||
> IMPORTANT: This project has not yet been released on PyPi. This will not work at this point in time. Instead follow [the local install instructions](#installation).
|
||||
|
||||
Run the following command to install the latest version of this package
|
||||
|
||||
```
|
||||
|
@ -65,4 +69,4 @@ From the root of this repository, execute
|
|||
make build
|
||||
```
|
||||
|
||||
Under the hood, this will execute `python3 -m build` and produce a `.whl` (wheel) and `.tgz` (TAR-GZip archive) file in the `dist` subdirectory. For more on this project's make recipes, see [CONTRIBUTING.md](CONTRIBUTING.md#other-make-recipes).
|
||||
Under the hood, this will execute `python3 -m build` and produce a `.whl` (wheel) and `.tgz` (TAR-GZip archive) file in the `dist` subdirectory. For more on this project's make recipes, see [CONTRIBUTING.md](https://github.com/containers/containerimage-py/blob/main/CONTRIBUTING.md#other-make-recipes).
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
twine<=6.0.1
|
|
@ -52,6 +52,14 @@ image.containerimage module
|
|||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
image.containerimageinspect module
|
||||
----------------------------------
|
||||
|
||||
.. automodule:: image.containerimageinspect
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
image.descriptor module
|
||||
-----------------------
|
||||
|
||||
|
@ -68,6 +76,14 @@ image.errors module
|
|||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
image.inspectschema module
|
||||
--------------------------
|
||||
|
||||
.. automodule:: image.inspectschema
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
image.manifest module
|
||||
---------------------
|
||||
|
||||
|
|
|
@ -37,13 +37,17 @@ Here is a quick motivating example for how you might use ``containerimage-py`` i
|
|||
my_image.get_digest(auth={}) # sha256:1ff6c18fbef2045af6b9c16bf034cc421a29027b800e4f9b68ae9b1cb3e9ae07
|
||||
)
|
||||
|
||||
# Inspect the container image image for a more consolidated summary
|
||||
print(
|
||||
f"Inspect for {str(my_image)}:\n" + \
|
||||
str(my_image.inspect(auth={})) # Same as skopeo inspect docker://registry.k8s.io/pause:3.5
|
||||
)
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
**Using Pip**
|
||||
|
||||
IMPORTANT: This project has not yet been released on PyPi. This will not work at this point in time. Instead follow the local install instructions.
|
||||
|
||||
Run the following command to install the latest version of this package using pip
|
||||
|
||||
.. code-block:: shell
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
######
|
||||
# Hack
|
||||
#
|
||||
# Make sibling modules visible to this nested executable
|
||||
import os, sys
|
||||
sys.path.insert(
|
||||
0,
|
||||
os.path.dirname(
|
||||
os.path.dirname(
|
||||
os.path.realpath(__file__)
|
||||
)
|
||||
)
|
||||
)
|
||||
# End Hack
|
||||
######
|
||||
|
||||
from image.containerimage import ContainerImage
|
||||
|
||||
# Initialize a ContainerImage given a tag reference
|
||||
my_image = ContainerImage("registry.k8s.io/pause:3.5")
|
||||
|
||||
# Display the inspect information for the container image
|
||||
my_image_inspect = my_image.inspect(auth={})
|
||||
print(
|
||||
f"Inspect of {str(my_image)}: \n" + \
|
||||
str(my_image_inspect)
|
||||
)
|
|
@ -17,7 +17,7 @@ sys.path.insert(
|
|||
from image.containerimage import ContainerImage
|
||||
|
||||
# Initialize a ContainerImage given a tag reference
|
||||
my_image = ContainerImage("registry.k8s.io/pause:3.5")
|
||||
my_image = ContainerImage("ghcr.io/matejvasek/builder-ubi8-base:latest")
|
||||
|
||||
# Display some basic information about the container image
|
||||
print(
|
||||
|
@ -28,3 +28,9 @@ print(
|
|||
f"Digest for {str(my_image)}: " + \
|
||||
my_image.get_digest(auth={}) # sha256:1ff6c18fbef2045af6b9c16bf034cc421a29027b800e4f9b68ae9b1cb3e9ae07
|
||||
)
|
||||
|
||||
# Inspect the container image image for a more consolidated summary
|
||||
print(
|
||||
f"Inspect for {str(my_image)}:\n" + \
|
||||
str(my_image.inspect(auth={})) # Same as skopeo inspect docker://registry.k8s.io/pause:3.5
|
||||
)
|
||||
|
|
|
@ -1,14 +1,21 @@
|
|||
import image.auth
|
||||
import image.byteunit
|
||||
import image.client
|
||||
import image.config
|
||||
import image.configschema
|
||||
import image.containerimage
|
||||
import image.containerimageinspect
|
||||
import image.descriptor
|
||||
import image.errors
|
||||
import image.inspectschema
|
||||
import image.manifest
|
||||
import image.manifestlistentry
|
||||
import image.manifestschema
|
||||
import image.mediatypes
|
||||
import image.oci
|
||||
import image.ocischema
|
||||
import image.platform
|
||||
import image.reference
|
||||
import image.regex
|
||||
import image.v2s2
|
||||
import image.v2s2schema
|
|
@ -139,7 +139,7 @@ class ContainerImageRegistryClient:
|
|||
response, and MUST include the www-authenticate header
|
||||
|
||||
Args:
|
||||
res (Type[requests.Response]): The response from the registry API
|
||||
res (requests.Response): The response from the registry API
|
||||
reg_auth (str): The auth retrieved for the registry
|
||||
|
||||
Returns:
|
||||
|
@ -168,6 +168,8 @@ class ContainerImageRegistryClient:
|
|||
|
||||
# Send the request to the auth service, parse the token from the
|
||||
# response
|
||||
headers = {}
|
||||
if len(reg_auth) > 0:
|
||||
headers = {
|
||||
'Authorization': f"Basic {reg_auth}"
|
||||
}
|
||||
|
@ -189,11 +191,11 @@ class ContainerImageRegistryClient:
|
|||
|
||||
Args:
|
||||
str_or_ref (Union[str, ContainerImageReference]): An image reference corresponding to the blob descriptor
|
||||
desc (Type[ContainerImageDescriptor]): A blob descriptor
|
||||
desc (ContainerImageDescriptor): A blob descriptor
|
||||
auth (Dict[str, Any]): A valid docker config JSON loaded into a dict
|
||||
|
||||
Returns:
|
||||
Type[requests.Response]: The registry API blob response
|
||||
requests.Response: The registry API blob response
|
||||
"""
|
||||
# If given a str, then load as a ref
|
||||
ref = str_or_ref
|
||||
|
@ -219,7 +221,7 @@ class ContainerImageRegistryClient:
|
|||
# Send the request to the distribution registry API
|
||||
# If it fails with a 401 response code and auth given, do OAuth dance
|
||||
res = requests.get(api_url, headers=headers)
|
||||
if res.status_code == 401 and found and \
|
||||
if res.status_code == 401 and \
|
||||
'www-authenticate' in res.headers.keys():
|
||||
# Do Oauth dance if basic auth fails
|
||||
# Ref: https://distribution.github.io/distribution/spec/auth/token/
|
||||
|
@ -264,6 +266,90 @@ class ContainerImageRegistryClient:
|
|||
config = res.json()
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
def query_tags(
|
||||
str_or_ref: Union[str, ContainerImageReference],
|
||||
auth: Dict[str, Any]
|
||||
) -> requests.Response:
|
||||
"""
|
||||
Fetches the list of tags for a reference from the registry API and
|
||||
returns as a dict
|
||||
Args:
|
||||
str_or_ref (Union[str, ContainerImageReference]): An image reference
|
||||
auth (Dict[str, Any]): A valid docker config JSON loaded into a dict
|
||||
Returns:
|
||||
requests.Response: The registry API tag list response
|
||||
"""
|
||||
# If given a str, then load as a ref
|
||||
ref = str_or_ref
|
||||
if isinstance(str_or_ref, str):
|
||||
ref = ContainerImageReference(str_or_ref)
|
||||
|
||||
# Construct the API URL for querying the image manifest
|
||||
api_base_url = ContainerImageRegistryClient.get_registry_base_url(
|
||||
ref
|
||||
)
|
||||
image_identifier = ref.get_identifier()
|
||||
api_url = f'{api_base_url}/tags/list'
|
||||
|
||||
# Construct the headers for querying the image manifest
|
||||
headers = {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
|
||||
# Get the matching auth for the image from the docker config JSON
|
||||
reg_auth, found = ContainerImageRegistryClient.get_registry_auth(
|
||||
ref,
|
||||
auth
|
||||
)
|
||||
if found:
|
||||
headers['Authorization'] = f'Basic {reg_auth}'
|
||||
|
||||
# Send the request to the distribution registry API
|
||||
# If it fails with a 401 response code and auth given, do OAuth dance
|
||||
res = requests.get(api_url, headers=headers)
|
||||
if res.status_code == 401 and \
|
||||
'www-authenticate' in res.headers.keys():
|
||||
# Do Oauth dance if basic auth fails
|
||||
# Ref: https://distribution.github.io/distribution/spec/auth/token/
|
||||
scheme, token = ContainerImageRegistryClient.get_auth_token(
|
||||
res, reg_auth
|
||||
)
|
||||
headers['Authorization'] = f'{scheme} {token}'
|
||||
res = requests.get(api_url, headers=headers)
|
||||
|
||||
# Raise exceptions on error status codes
|
||||
res.raise_for_status()
|
||||
return res
|
||||
|
||||
@staticmethod
|
||||
def list_tags(
|
||||
str_or_ref: Union[str, ContainerImageReference],
|
||||
auth: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetches the list of tags for a reference from the registry API and
|
||||
returns as a dict
|
||||
Args:
|
||||
str_or_ref (Union[str, ContainerImageReference]): An image reference
|
||||
auth (Dict[str, Any]): A valid docker config JSON loaded into a dict
|
||||
Returns:
|
||||
Dict[str, Any]: The config as a dict
|
||||
"""
|
||||
# If given a str, then load as a ref
|
||||
ref = str_or_ref
|
||||
if isinstance(str_or_ref, str):
|
||||
ref = ContainerImageReference(str_or_ref)
|
||||
|
||||
# Query the tags, get the tag list response
|
||||
res = ContainerImageRegistryClient.query_tags(
|
||||
ref, auth
|
||||
)
|
||||
|
||||
# Load the tag list into a dict and return
|
||||
tags = res.json()
|
||||
return tags
|
||||
|
||||
@staticmethod
|
||||
def query_manifest(
|
||||
str_or_ref: Union[str, ContainerImageReference],
|
||||
|
@ -308,7 +394,7 @@ class ContainerImageRegistryClient:
|
|||
# Send the request to the distribution registry API
|
||||
# If it fails with a 401 response code and auth given, do OAuth dance
|
||||
res = requests.get(api_url, headers=headers)
|
||||
if res.status_code == 401 and found and \
|
||||
if res.status_code == 401 and \
|
||||
'www-authenticate' in res.headers.keys():
|
||||
# Do Oauth dance if basic auth fails
|
||||
# Ref: https://distribution.github.io/distribution/spec/auth/token/
|
||||
|
@ -433,7 +519,7 @@ class ContainerImageRegistryClient:
|
|||
# Send the request to the distribution registry API
|
||||
# If it fails with a 401 response code and auth given, do OAuth dance
|
||||
res = requests.delete(api_url, headers=headers)
|
||||
if res.status_code == 401 and found and \
|
||||
if res.status_code == 401 and \
|
||||
'www-authenticate' in res.headers.keys():
|
||||
# Do Oauth dance if basic auth fails
|
||||
# Ref: https://distribution.github.io/distribution/spec/auth/token/
|
||||
|
|
|
@ -3,7 +3,7 @@ Contains the ContainerImageConfig class, which specifies the runtime
|
|||
configuration for a container image.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Tuple, Type, Union
|
||||
from typing import Dict, Any, Tuple, Type, Union, List
|
||||
from jsonschema import validate, ValidationError
|
||||
from image.configschema import CONTAINER_IMAGE_CONFIG_SCHEMA
|
||||
from image.platform import ContainerImagePlatform
|
||||
|
@ -106,3 +106,49 @@ class ContainerImageConfig:
|
|||
if variant != None:
|
||||
platform_dict["variant"] = variant
|
||||
return ContainerImagePlatform(platform_dict)
|
||||
|
||||
def get_labels(self) -> Dict[str, str]:
|
||||
"""
|
||||
Returns the container image labels from the config
|
||||
|
||||
Returns:
|
||||
Dict[str, str]: The labels from the config
|
||||
"""
|
||||
return self.get_runtime_config().get("Labels", {})
|
||||
|
||||
def get_created_date(self) -> str:
|
||||
"""
|
||||
Returns the created date of the container image from the config
|
||||
|
||||
Returns:
|
||||
str: The created date, as a string
|
||||
"""
|
||||
return self.config.get("created", "")
|
||||
|
||||
def get_runtime_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Returns the runtime config for the container image from its config
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: The container image runtime config
|
||||
"""
|
||||
return self.config.get("config", {})
|
||||
|
||||
def get_env(self) -> List[str]:
|
||||
"""
|
||||
Returns the list of environment variables set for the container image
|
||||
at build time from the container image runtime config
|
||||
|
||||
Returns:
|
||||
List[str]: The list of environment variables
|
||||
"""
|
||||
return self.get_runtime_config().get("Env", [])
|
||||
|
||||
def get_author(self) -> str:
|
||||
"""
|
||||
Returns the author of the container image from its config
|
||||
|
||||
Returns:
|
||||
str: The container image author
|
||||
"""
|
||||
return self.config.get("author", "")
|
||||
|
|
|
@ -14,6 +14,7 @@ from typing import List, Dict, Any, \
|
|||
from image.byteunit import ByteUnit
|
||||
from image.client import ContainerImageRegistryClient
|
||||
from image.config import ContainerImageConfig
|
||||
from image.containerimageinspect import ContainerImageInspect
|
||||
from image.errors import ContainerImageError
|
||||
from image.manifestfactory import ContainerImageManifestFactory
|
||||
from image.manifestlist import ContainerImageManifestList
|
||||
|
@ -85,6 +86,103 @@ class ContainerImage(ContainerImageReference):
|
|||
return isinstance(manifest, ContainerImageManifestOCI) or \
|
||||
isinstance(manifest, ContainerImageIndexOCI)
|
||||
|
||||
@staticmethod
|
||||
def get_host_platform_manifest_static(
|
||||
ref: ContainerImageReference,
|
||||
manifest: Union[
|
||||
ContainerImageManifestV2S2,
|
||||
ContainerImageManifestListV2S2,
|
||||
ContainerImageManifestOCI,
|
||||
ContainerImageIndexOCI
|
||||
],
|
||||
auth: Dict[str, Any]
|
||||
) -> Union[
|
||||
ContainerImageManifestV2S2,
|
||||
ContainerImageManifestOCI
|
||||
]:
|
||||
"""
|
||||
Given an image's reference and manifest, this static method checks if
|
||||
the manifest is a manifest list, and attempts to get the manifest from
|
||||
the list matching the host platform.
|
||||
|
||||
Args:
|
||||
ref (ContainerImageReference): The image reference corresponding to the manifest
|
||||
manifest (Union[ContainerImageManifestV2S2,ContainerImageManifestListV2S2,ContainerImageManifestOCI,ContainerImageIndexOCI]): The manifest object, generally from get_manifest method
|
||||
auth (Dict[str, Any]): A valid docker config JSON with auth into the ref's registry
|
||||
|
||||
Returns:
|
||||
Union[ContainerImageManifestV2S2,ContainerImageManifestOCI]: The manifest response from the registry API
|
||||
|
||||
Raises:
|
||||
ContainerImageError: Error if the image is a manifest list without a manifest matching the host platform
|
||||
"""
|
||||
host_manifest = manifest
|
||||
|
||||
# If manifest list, get the manifest matching the host platform
|
||||
if ContainerImage.is_manifest_list_static(manifest):
|
||||
found = False
|
||||
host_entry_digest = None
|
||||
host_plt = ContainerImagePlatform.get_host_platform()
|
||||
entries = manifest.get_entries()
|
||||
for entry in entries:
|
||||
if entry.get_platform() == host_plt:
|
||||
found = True
|
||||
host_entry_digest = entry.get_digest()
|
||||
if not found:
|
||||
raise ContainerImageError(
|
||||
"no image found in manifest list for platform: " + \
|
||||
f"{str(host_plt)}"
|
||||
)
|
||||
host_ref = ContainerImage(
|
||||
f"{ref.get_name()}@{host_entry_digest}"
|
||||
)
|
||||
host_manifest = host_ref.get_manifest(auth=auth)
|
||||
|
||||
# Return the manifest matching the host platform
|
||||
return host_manifest
|
||||
|
||||
@staticmethod
|
||||
def get_config_static(
|
||||
ref: ContainerImageReference,
|
||||
manifest: Union[
|
||||
ContainerImageManifestV2S2,
|
||||
ContainerImageManifestListV2S2,
|
||||
ContainerImageManifestOCI,
|
||||
ContainerImageIndexOCI
|
||||
],
|
||||
auth: Dict[str, Any]
|
||||
) -> ContainerImageConfig:
|
||||
"""
|
||||
Given an image's manifest, this static method fetches that image's
|
||||
config from the distribution registry API. If the image is a manifest
|
||||
list, then it gets the config corresponding to the manifest matching
|
||||
the host platform.
|
||||
|
||||
Args:
|
||||
ref (ContainerImageReference): The image reference corresponding to the manifest
|
||||
manifest (Union[ContainerImageManifestV2S2,ContainerImageManifestListV2S2,ContainerImageManifestOCI,ContainerImageIndexOCI]): The manifest object, generally from get_manifest method
|
||||
auth (Dict[str, Any]): A valid docker config JSON with auth into this image's registry
|
||||
|
||||
Returns:
|
||||
ContainerImageConfig: The config for this image
|
||||
|
||||
Raises:
|
||||
ContainerImageError: Error if the image is a manifest list without a manifest matching the host platform
|
||||
"""
|
||||
# If manifest list, get the manifest matching the host platform
|
||||
manifest = ContainerImage.get_host_platform_manifest_static(
|
||||
ref, manifest, auth
|
||||
)
|
||||
|
||||
# Get the image's config
|
||||
return ContainerImageConfig(
|
||||
ContainerImageRegistryClient.get_config(
|
||||
ref,
|
||||
manifest.get_config_descriptor(),
|
||||
auth=auth
|
||||
)
|
||||
)
|
||||
|
||||
def __init__(self, ref: str):
|
||||
"""
|
||||
Constructor for the ContainerImage class
|
||||
|
@ -179,6 +277,73 @@ class ContainerImage(ContainerImageReference):
|
|||
ContainerImageRegistryClient.get_manifest(self, auth)
|
||||
)
|
||||
|
||||
def get_host_platform_manifest(self, auth: Dict[str, Any]) -> Union[
|
||||
ContainerImageManifestOCI,
|
||||
ContainerImageManifestV2S2
|
||||
]:
|
||||
"""
|
||||
Fetches the manifest from the distribution registry API. If the
|
||||
manifest is a manifest list, then it attempts to fetch the manifest
|
||||
in the list matching the host platform. If not found, an exception is
|
||||
raised.
|
||||
|
||||
Args:
|
||||
auth (Dict[str, Any]): A valid docker config JSON with auth into this image's registry
|
||||
|
||||
Returns:
|
||||
Union[ContainerImageManifestV2S2,ContainerImageManifestOCI]: The manifest response from the registry API
|
||||
|
||||
Raises:
|
||||
ContainerImageError: Error if the image is a manifest list without a manifest matching the host platform
|
||||
"""
|
||||
# Get the container image's manifest
|
||||
manifest = self.get_manifest(auth=auth)
|
||||
|
||||
# Return the host platform manifest
|
||||
return ContainerImage.get_host_platform_manifest_static(
|
||||
self,
|
||||
manifest,
|
||||
auth
|
||||
)
|
||||
|
||||
def get_config(self, auth: Dict[str, Any]) -> ContainerImageConfig:
|
||||
"""
|
||||
Fetches the image's config from the distribution registry API. If the
|
||||
image is a manifest list, then it gets the config corresponding to the
|
||||
manifest matching the host platform.
|
||||
|
||||
Args:
|
||||
auth (Dict[str, Any]): A valid docker config JSON with auth into this image's registry
|
||||
|
||||
Returns:
|
||||
ContainerImageConfig: The config for this image
|
||||
|
||||
Raises:
|
||||
ContainerImageError: Error if the image is a manifest list without a manifest matching the host platform
|
||||
"""
|
||||
# Get the image's manifest
|
||||
manifest = self.get_manifest(auth=auth)
|
||||
|
||||
# Use the image's manifest to get the image's config
|
||||
config = ContainerImage.get_config_static(
|
||||
self, manifest, auth
|
||||
)
|
||||
|
||||
# Return the image's config
|
||||
return config
|
||||
|
||||
def list_tags(self, auth: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetches the list of tags for the image from the distribution registry
|
||||
API.
|
||||
Args:
|
||||
auth (Dict[str, Any]): A valid docker config JSON with auth into this image's registry
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: The tag list loaded into a dict
|
||||
"""
|
||||
return ContainerImageRegistryClient.list_tags(self, auth)
|
||||
|
||||
def exists(self, auth: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Determine if the image reference corresponds to an image in the remote
|
||||
|
@ -266,6 +431,67 @@ class ContainerImage(ContainerImageReference):
|
|||
"""
|
||||
return ByteUnit.format_size_bytes(self.get_size(auth))
|
||||
|
||||
def inspect(self, auth: Dict[str, Any]) -> ContainerImageInspect:
|
||||
"""
|
||||
Returns a collection of basic information about the image, equivalent
|
||||
to skopeo inspect.
|
||||
|
||||
Args:
|
||||
auth (Dict[str, Any]): A valid docker config JSON loaded into a dict
|
||||
|
||||
Returns:
|
||||
ContainerImageInspect: A collection of information about the image
|
||||
"""
|
||||
# Get the image's manifest
|
||||
manifest = self.get_host_platform_manifest(auth=auth)
|
||||
|
||||
# Use the image's manifest to get the image's config
|
||||
config = ContainerImage.get_config_static(
|
||||
self, manifest, auth
|
||||
)
|
||||
|
||||
# List the image's tags
|
||||
tags = self.list_tags(auth)
|
||||
|
||||
# Format the inspect dictionary
|
||||
inspect = {
|
||||
"Name": self.get_name(),
|
||||
"Digest": self.get_digest(auth=auth),
|
||||
"RepoTags": tags["tags"],
|
||||
# TODO: Implement v2s1 manifest extension - only v2s1 manifests use this value
|
||||
"DockerVersion": "",
|
||||
"Created": config.get_created_date(),
|
||||
"Labels": config.get_labels(),
|
||||
"Architecture": config.get_architecture(),
|
||||
"Os": config.get_os(),
|
||||
"Layers": [
|
||||
layer.get_digest() \
|
||||
for layer \
|
||||
in manifest.get_layer_descriptors()
|
||||
],
|
||||
"LayersData": [
|
||||
{
|
||||
"MIMEType": layer.get_media_type(),
|
||||
"Digest": layer.get_digest(),
|
||||
"Size": layer.get_size(),
|
||||
"Annotations": layer.get_annotations() or {}
|
||||
} for layer in manifest.get_layer_descriptors()
|
||||
],
|
||||
"Env": config.get_env()
|
||||
}
|
||||
|
||||
# Set the variant in the inspect dict if found
|
||||
variant = config.get_variant()
|
||||
if variant:
|
||||
inspect["Variant"] = variant
|
||||
|
||||
# Set the tag in the inspect dict
|
||||
if self.is_tag_ref():
|
||||
inspect["Tag"] = self.get_identifier()
|
||||
|
||||
# TODO: Get the RepoTags for the image
|
||||
return ContainerImageInspect(inspect)
|
||||
|
||||
def delete(self, auth: Dict[str, Any]):
|
||||
"""
|
||||
Deletes the image from the registry.
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
import json
|
||||
import re
|
||||
from image.errors import ContainerImageError
|
||||
from image.regex import ANCHORED_DIGEST, ANCHORED_NAME
|
||||
from image.inspectschema import CONTAINER_IMAGE_INSPECT_SCHEMA
|
||||
from jsonschema import validate
|
||||
from typing import Dict, Any, Tuple
|
||||
|
||||
class ContainerImageInspect:
|
||||
"""
|
||||
Represents a collection of basic informataion about a container image.
|
||||
This object is equivalent to the output of skopeo inspect.
|
||||
"""
|
||||
@staticmethod
|
||||
def validate_static(inspect: Dict[str, Any]) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate a container image inspect dict using its json schema
|
||||
|
||||
Args:
|
||||
inspect (dict): The container image inspect dict to validate
|
||||
|
||||
Returns:
|
||||
bool: Whether the container image inspect dict was valid
|
||||
str: Error message if it was invalid
|
||||
"""
|
||||
# Validate the container image inspect dict against its json schema
|
||||
try:
|
||||
validate(
|
||||
instance=inspect,
|
||||
schema=CONTAINER_IMAGE_INSPECT_SCHEMA
|
||||
)
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
# Validate the name and digest
|
||||
if len(inspect["Name"]) > 0 and not bool(re.match(ANCHORED_NAME, inspect["Name"])):
|
||||
return False, f"Invalid Name: {inspect['Name']}"
|
||||
if not bool(re.match(ANCHORED_DIGEST, inspect["Digest"])):
|
||||
return False, f"Invalid Digest: {inspect['Digest']}"
|
||||
|
||||
# Validate the layer and layersdata digests
|
||||
for digest in inspect["Layers"]:
|
||||
if not bool(re.match(ANCHORED_DIGEST, digest)):
|
||||
return False, f"Invalid digest in Layers: {digest}"
|
||||
for layerdata in inspect["LayersData"]:
|
||||
if not bool(re.match(ANCHORED_DIGEST, layerdata["Digest"])):
|
||||
return False, f"Invalid digest in LayersData: {layerdata['Digest']}"
|
||||
|
||||
# If all pass then the inspect is valid
|
||||
return True, ""
|
||||
|
||||
def __init__(self, inspect: Dict[str, Any]) -> "ContainerImageInspect":
|
||||
"""
|
||||
Constructor for the ContainerImageInspect class
|
||||
|
||||
Args:
|
||||
inspect (dict): The container image inspect dict
|
||||
|
||||
Returns:
|
||||
ContainerImageInspect: The ContainerImageInspect instance
|
||||
"""
|
||||
valid, err = ContainerImageInspect.validate_static(inspect)
|
||||
if not valid:
|
||||
raise ContainerImageError(f"Invalid inspect dictionary: {err}")
|
||||
self.inspect = inspect
|
||||
|
||||
def validate(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate a container image inspect instance
|
||||
|
||||
Returns:
|
||||
bool: Whether the container image inspect dict was valid
|
||||
str: Error message if it was invalid
|
||||
"""
|
||||
return ContainerImageInspect.validate_static(self.inspect)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
Stringifies a ContainerImageInspect instance
|
||||
|
||||
Args:
|
||||
None
|
||||
|
||||
Returns:
|
||||
str: The stringified inspect
|
||||
"""
|
||||
return json.dumps(self.inspect, indent=2, sort_keys=False)
|
||||
|
||||
def __json__(self) -> Dict[str, Any]:
|
||||
"""
|
||||
JSONifies a ContainerImageInspect instance
|
||||
|
||||
Args:
|
||||
None
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: The JSONified descriptor
|
||||
"""
|
||||
return self.inspect
|
|
@ -0,0 +1,156 @@
|
|||
CONTAINER_IMAGE_LAYER_INSPECT_SCHEMA = {
|
||||
"type": "object",
|
||||
"description": "The JSON schema for a container image layer inspect " + \
|
||||
"dictionary.",
|
||||
"required": [ "MIMEType", "Digest", "Size" ],
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"MIMEType": {
|
||||
"type": "string",
|
||||
"description": "This REQUIRED property is the MIME type, or " + \
|
||||
"media type, of this container image layer"
|
||||
},
|
||||
"Digest": {
|
||||
"type": "string",
|
||||
"description": "This REQUIRED property is the digest of this " + \
|
||||
"container image layer"
|
||||
},
|
||||
"Size": {
|
||||
"type": "integer",
|
||||
"description": "This REQUIRED property is the size of this " + \
|
||||
"container image layer measured in bytes"
|
||||
},
|
||||
"Annotations": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"description": "This OPTIONAL property is the set of " + \
|
||||
"annotations belonging to this container image",
|
||||
"patternProperties": {
|
||||
"(^.*$)": { "type": "string" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
The JSON schema for validating a container image layer inspect dictionary
|
||||
Ref: https://github.com/containers/image/blob/main/types/types.go#L491-L497
|
||||
|
||||
:meta hide-value:
|
||||
"""
|
||||
|
||||
CONTAINER_IMAGE_INSPECT_SCHEMA = {
|
||||
"type": "object",
|
||||
"description": "The JSON schema for a container image inspect dictionary",
|
||||
"required": [
|
||||
"Digest", "Created", "DockerVersion", "Labels", "Architecture", "Os",
|
||||
"Layers", "LayersData", "Env"
|
||||
],
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"Name": {
|
||||
"type": "string",
|
||||
"description": "This OPTIONAL property is the name of this " + \
|
||||
"container image"
|
||||
},
|
||||
"Digest": {
|
||||
"type": "string",
|
||||
"description": "This REQUIRED property is the digest of this " + \
|
||||
"container image"
|
||||
},
|
||||
"Tag": {
|
||||
"type": "string",
|
||||
"description": "This OPTIONAL property is the tag of this " + \
|
||||
"container image"
|
||||
},
|
||||
"RepoTags": {
|
||||
"type": "array",
|
||||
"description": "This OPTIONAL property is the list of tags " + \
|
||||
"for this container image in the remote registry",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Created": {
|
||||
"type": "string",
|
||||
"description": "This REQUIRED property is the date this " + \
|
||||
"container image was built"
|
||||
},
|
||||
"DockerVersion": {
|
||||
"type": "string",
|
||||
"description": "This REQUIRED property is the version of " + \
|
||||
"docker used to build this container image"
|
||||
},
|
||||
"Labels": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"description": "This REQUIRED property is the set of labels " + \
|
||||
"applied to this container image at build time",
|
||||
"patternProperties": {
|
||||
"(^.*$)": { "type": "string" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Architecture": {
|
||||
"type": "string",
|
||||
"description": "This REQUIRED property is the architecture " + \
|
||||
"for which this container image was built"
|
||||
},
|
||||
"Variant": {
|
||||
"type": "string",
|
||||
"description": "This OPTIONAL property is the variant of the " + \
|
||||
"OS and architecture for which this container image was built"
|
||||
},
|
||||
"Os": {
|
||||
"type": "string",
|
||||
"description": "This REQUIRED property is the operating system" + \
|
||||
"for which this container image was built"
|
||||
},
|
||||
"Layers": {
|
||||
"type": "array",
|
||||
"description": "This REQUIRED property contains information " + \
|
||||
"on the set of layers belonging to this container image",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"LayersData": {
|
||||
"type": "array",
|
||||
"description": "This REQUIRED property contains information " + \
|
||||
"on the set of layers belonging to this container image",
|
||||
"items": CONTAINER_IMAGE_LAYER_INSPECT_SCHEMA
|
||||
},
|
||||
"Env": {
|
||||
"type": "array",
|
||||
"description": "This REQUIRED property contains information " + \
|
||||
"on the set of environment variables set at build time " + \
|
||||
"in this container image",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Author": {
|
||||
"type": "string",
|
||||
"description": "This OPTIONAL property is the author who " + \
|
||||
"built the container image"
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
The JSON schema for validating a container image inspect dictionary
|
||||
Ref:
|
||||
- https://github.com/containers/image/blob/main/types/types.go#L474-L489
|
||||
- https://github.com/containers/image/blob/main/types/types.go#L474-L489
|
||||
|
||||
:meta hide-value:
|
||||
"""
|
|
@ -156,10 +156,6 @@ class ContainerImageIndexEntryOCI(ContainerImageManifestListEntry):
|
|||
if not platform_valid:
|
||||
return platform_valid, err
|
||||
|
||||
# If the mediaType is unsupported, then error
|
||||
if entry["mediaType"] in UNSUPPORTED_OCI_MANIFEST_MEDIA_TYPES:
|
||||
return False, f"Unsupported mediaType: {entry['mediaType']}"
|
||||
|
||||
# Valid if all of the above are valid
|
||||
return True, ""
|
||||
|
||||
|
|
|
@ -4,10 +4,39 @@ architecture, and optionally the variant on which the container image is built
|
|||
to run
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
from typing import Dict, Any, Tuple, Union, List
|
||||
from jsonschema import validate, ValidationError
|
||||
from image.manifestschema import IMAGE_INDEX_ENTRY_PLATFORM_SCHEMA
|
||||
|
||||
PLATFORM_ARCHITECTURE_MAP = {
|
||||
'x86_64': 'amd64',
|
||||
'amd64': 'amd64',
|
||||
'i386': '386',
|
||||
'i686': '386',
|
||||
'arm64': 'arm64',
|
||||
'aarch64': 'arm64',
|
||||
'armv7l': 'arm',
|
||||
'armv6l': 'arm',
|
||||
}
|
||||
"""
|
||||
A map used to transform the output of the platform.machine method such that it
|
||||
matches the expected value of GoLang's GOARCH environment variable
|
||||
"""
|
||||
|
||||
DEFAULT_HOST_OS = platform.system().lower()
|
||||
DEFAULT_HOST_ARCH = PLATFORM_ARCHITECTURE_MAP.get(
|
||||
platform.machine().lower(),
|
||||
platform.machine().lower()
|
||||
)
|
||||
HOST_OS = os.environ.get("HOST_OS", DEFAULT_HOST_OS)
|
||||
HOST_ARCH = os.environ.get("HOST_ARCH", DEFAULT_HOST_ARCH)
|
||||
HOST_PLATFORM = {
|
||||
"os": HOST_OS,
|
||||
"architecture": HOST_ARCH
|
||||
}
|
||||
|
||||
class ContainerImagePlatform:
|
||||
"""
|
||||
Represents platform metadata, which is generally specified in an OCI image
|
||||
|
@ -17,6 +46,16 @@ class ContainerImagePlatform:
|
|||
Note that the OCI and v2s2 specifications do not diverge in their schema for
|
||||
platform metadata, hence we reuse this class across both scenarios.
|
||||
"""
|
||||
@staticmethod
|
||||
def get_host_platform() -> "ContainerImagePlatform":
|
||||
"""
|
||||
Get the platform of the host machine
|
||||
|
||||
Returns:
|
||||
ContainerImagePlatform: The host machine platform
|
||||
"""
|
||||
return ContainerImagePlatform(HOST_PLATFORM)
|
||||
|
||||
@staticmethod
|
||||
def validate_static(platform: Dict[str, Any]) -> Tuple[bool, str]:
|
||||
"""
|
||||
|
|
|
@ -18,13 +18,23 @@ from image.v2s2schema import MANIFEST_V2_SCHEMA, \
|
|||
MANIFEST_LIST_V2_SCHEMA, \
|
||||
MANIFEST_LIST_V2_ENTRY_SCHEMA
|
||||
|
||||
# A list of mediaTypes which are not supported by the v2s2 manifest spec
|
||||
# See doc comments below
|
||||
UNSUPPORTED_V2S2_MANIFEST_MEDIA_TYPES = [
|
||||
OCI_MANIFEST_MEDIA_TYPE
|
||||
]
|
||||
"""
|
||||
A list of mediaTypes which are not supported by the v2s2 manifest spec.
|
||||
This mainly just includes the OCI manifest mediaType.
|
||||
"""
|
||||
|
||||
# See doc comments below
|
||||
UNSUPPORTED_V2S2_MANIFEST_LIST_MEDIA_TYPES = [
|
||||
OCI_INDEX_MEDIA_TYPE
|
||||
]
|
||||
"""
|
||||
A list of mediaTypes which are not supported by the v2s2 manifest list
|
||||
spec. This mainly just includes the OCI index mediaType.
|
||||
"""
|
||||
|
||||
"""
|
||||
ContainerImageManifestV2S2 class
|
||||
|
@ -146,10 +156,6 @@ class ContainerImageManifestListEntryV2S2(ContainerImageManifestListEntry):
|
|||
if not platform_valid:
|
||||
return platform_valid, err
|
||||
|
||||
# If the mediaType is unsupported, then error
|
||||
if entry["mediaType"] in UNSUPPORTED_V2S2_MANIFEST_MEDIA_TYPES:
|
||||
return False, f"Unsupported mediaType: {entry['mediaType']}"
|
||||
|
||||
# Valid if all of the above are valid
|
||||
return True, ""
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||
|
||||
[project]
|
||||
name = "containerimage-py"
|
||||
version = "0.1.0"
|
||||
version = "0.1.0-6"
|
||||
authors = [
|
||||
{name = "Ethan Balcik", email="ethanbalcik@ibm.com" }
|
||||
]
|
||||
|
@ -17,7 +17,9 @@ classifiers = [
|
|||
]
|
||||
keywords = ["container","image","py","oci","docker"]
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE"]
|
||||
license-files = [
|
||||
"LICENSE"
|
||||
]
|
||||
dependencies = [
|
||||
"requests",
|
||||
"jsonschema"
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
import copy
|
||||
import json
|
||||
import os
|
||||
from image.config import ContainerImageConfig
|
||||
from typing import Dict, Any
|
||||
|
||||
# Get the directory of this file to load the JSON schemas from files
|
||||
WORKDIR = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
def _load_json(path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Helper function for loading mock manifests
|
||||
"""
|
||||
mock_manifest_dict = {}
|
||||
with open(path) as mock_manifest_file:
|
||||
mock_manifest_dict = json.load(mock_manifest_file)
|
||||
return mock_manifest_dict
|
||||
|
||||
EXAMPLE_CONFIG = _load_json(f"{WORKDIR}/mock/configs/config.json")
|
||||
|
||||
def test_load_config():
|
||||
"""
|
||||
Ensure we can load a config from the OCI image spec
|
||||
"""
|
||||
exc = None
|
||||
try:
|
||||
inspect = ContainerImageConfig(EXAMPLE_CONFIG)
|
||||
except Exception as e:
|
||||
exc = e
|
||||
assert exc == None
|
||||
assert isinstance(inspect, ContainerImageConfig)
|
||||
|
||||
def test_load_config_no_platform():
|
||||
"""
|
||||
Ensure the config fails to load if no OS or arch given
|
||||
"""
|
||||
# No os
|
||||
conf = copy.deepcopy(EXAMPLE_CONFIG)
|
||||
conf.pop("os")
|
||||
exc = None
|
||||
try:
|
||||
config = ContainerImageConfig(conf)
|
||||
except Exception as e:
|
||||
exc = e
|
||||
assert exc != None
|
||||
|
||||
# No architecture
|
||||
conf = copy.deepcopy(EXAMPLE_CONFIG)
|
||||
conf.pop("architecture")
|
||||
exc = None
|
||||
try:
|
||||
config = ContainerImageConfig(conf)
|
||||
except Exception as e:
|
||||
exc = e
|
||||
assert exc != None
|
||||
|
||||
def test_load_config_no_rootfs():
|
||||
"""
|
||||
Ensure the config fails to load if no rootfs property given
|
||||
"""
|
||||
# No rootfs property at all
|
||||
conf = copy.deepcopy(EXAMPLE_CONFIG)
|
||||
conf.pop("rootfs")
|
||||
exc = None
|
||||
try:
|
||||
config = ContainerImageConfig(conf)
|
||||
except Exception as e:
|
||||
exc = e
|
||||
assert exc != None
|
||||
|
||||
# No type in rootfs property
|
||||
conf = copy.deepcopy(EXAMPLE_CONFIG)
|
||||
conf["rootfs"].pop("type")
|
||||
exc = None
|
||||
try:
|
||||
config = ContainerImageConfig(conf)
|
||||
except Exception as e:
|
||||
exc = e
|
||||
assert exc != None
|
||||
|
||||
# No diff_ids in rootfs property
|
||||
conf = copy.deepcopy(EXAMPLE_CONFIG)
|
||||
conf["rootfs"].pop("diff_ids")
|
||||
exc = None
|
||||
try:
|
||||
config = ContainerImageConfig(conf)
|
||||
except Exception as e:
|
||||
exc = e
|
||||
assert exc != None
|
|
@ -6,6 +6,7 @@ from image.oci import ContainerImageManifestOCI
|
|||
from image.containerimage import ContainerImage, \
|
||||
ContainerImageManifestListV2S2, \
|
||||
ContainerImageIndexOCI
|
||||
from image.containerimageinspect import ContainerImageInspect
|
||||
from tests.registryclientmock import MOCK_IMAGE_NAME, \
|
||||
MOCK_REGISTRY_CREDS, \
|
||||
REDHAT_MANIFEST_LIST_EXAMPLE, \
|
||||
|
@ -18,7 +19,10 @@ from tests.registryclientmock import MOCK_IMAGE_NAME, \
|
|||
ATTESTATION_S390X_MANIFEST, \
|
||||
ATTESTATION_AMD64_ATTESTATION_MANIFEST, \
|
||||
ATTESTATION_S390X_ATTESTATION_MANIFEST, \
|
||||
mock_get_manifest
|
||||
mock_get_manifest, \
|
||||
mock_list_tags, \
|
||||
mock_get_config, \
|
||||
mock_get_digest
|
||||
|
||||
def test_container_image_static_validation():
|
||||
# Ensure the empty string is invalid
|
||||
|
@ -250,6 +254,18 @@ def test_container_image_get_name():
|
|||
assert exc != None
|
||||
assert isinstance(exc, ContainerImageError)
|
||||
|
||||
def test_container_image_list_tags(mocker):
|
||||
mocker.patch(
|
||||
"image.containerimage.ContainerImageRegistryClient.list_tags",
|
||||
mock_list_tags
|
||||
)
|
||||
|
||||
# Ensure the tag list response matches the expected
|
||||
image = ContainerImage(f"{MOCK_IMAGE_NAME}:latest")
|
||||
tags = image.list_tags(MOCK_REGISTRY_CREDS)
|
||||
assert tags["name"] == MOCK_IMAGE_NAME
|
||||
assert tags["tags"] == [ "latest", "latest-dup", "latest-attestation" ]
|
||||
|
||||
def test_container_image_get_manifest(mocker):
|
||||
mocker.patch(
|
||||
"image.containerimage.ContainerImageRegistryClient.get_manifest",
|
||||
|
@ -290,6 +306,41 @@ def test_container_image_get_manifest(mocker):
|
|||
assert exc != None
|
||||
assert isinstance(exc, ContainerImageError)
|
||||
|
||||
def test_container_image_inspect(mocker):
|
||||
mocker.patch(
|
||||
"image.containerimage.ContainerImageRegistryClient.get_manifest",
|
||||
mock_get_manifest
|
||||
)
|
||||
mocker.patch(
|
||||
"image.containerimage.ContainerImageRegistryClient.list_tags",
|
||||
mock_list_tags
|
||||
)
|
||||
mocker.patch(
|
||||
"image.containerimage.ContainerImageRegistryClient.get_config",
|
||||
mock_get_config
|
||||
)
|
||||
mocker.patch(
|
||||
"image.containerimage.ContainerImageRegistryClient.get_digest",
|
||||
mock_get_digest
|
||||
)
|
||||
image = ContainerImage(f"{MOCK_IMAGE_NAME}:latest")
|
||||
|
||||
# Ensure the inspect response matches the expected
|
||||
exc = None
|
||||
try:
|
||||
inspect = image.inspect(MOCK_REGISTRY_CREDS)
|
||||
except Exception as e:
|
||||
exc = e
|
||||
assert exc == None
|
||||
assert isinstance(inspect, ContainerImageInspect)
|
||||
assert inspect.inspect["Digest"] == "sha256:8f74ffc756f871ee9037fb8e0c3cd9c5cb54e92e014f92d771ab8e6bf925f372"
|
||||
assert inspect.inspect["RepoTags"] == [ "latest", "latest-dup", "latest-attestation" ]
|
||||
assert inspect.inspect["Created"] == "2015-10-31T22:22:56.015925234Z"
|
||||
assert inspect.inspect["Labels"] == {
|
||||
"com.example.project.git.url": "https://example.com/project.git",
|
||||
"com.example.project.git.commit": "45a939b2999782a3f005621a8d0f29aa387e1d6b"
|
||||
}
|
||||
|
||||
def test_container_image_is_manifest_list(mocker):
|
||||
mocker.patch(
|
||||
"image.containerimage.ContainerImageRegistryClient.get_manifest",
|
||||
|
|
|
@ -0,0 +1,103 @@
|
|||
import copy
|
||||
import json
|
||||
import os
|
||||
from image.containerimageinspect import ContainerImageInspect
|
||||
from typing import Dict, Any
|
||||
|
||||
# Get the directory of this file to load the JSON schemas from files
|
||||
WORKDIR = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
def _load_json(path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Helper function for loading mock manifests
|
||||
"""
|
||||
mock_manifest_dict = {}
|
||||
with open(path) as mock_manifest_file:
|
||||
mock_manifest_dict = json.load(mock_manifest_file)
|
||||
return mock_manifest_dict
|
||||
|
||||
EXAMPLE_INSPECT = _load_json(f"{WORKDIR}/mock/inspect/inspect.json")
|
||||
|
||||
def test_load_skopeo_inspect_output():
|
||||
"""
|
||||
Ensure we can load example output from skopeo inspect
|
||||
"""
|
||||
exc = None
|
||||
try:
|
||||
inspect = ContainerImageInspect(EXAMPLE_INSPECT)
|
||||
except Exception as e:
|
||||
exc = e
|
||||
assert exc == None
|
||||
assert isinstance(inspect, ContainerImageInspect)
|
||||
|
||||
def test_load_inspect_no_labels():
|
||||
"""
|
||||
Ensure we can load an inspect output with null labels
|
||||
"""
|
||||
example = copy.deepcopy(EXAMPLE_INSPECT)
|
||||
example["Labels"] = None
|
||||
exc = None
|
||||
try:
|
||||
inspect = ContainerImageInspect(example)
|
||||
except Exception as e:
|
||||
exc = e
|
||||
assert exc == None
|
||||
assert isinstance(inspect, ContainerImageInspect)
|
||||
|
||||
def test_load_inspect_invalid_name():
|
||||
"""
|
||||
Ensure we do not load inspect output with invalid image name
|
||||
"""
|
||||
# Invalid name should not work
|
||||
example = copy.deepcopy(EXAMPLE_INSPECT)
|
||||
example["Name"] = "Not a container image"
|
||||
exc = None
|
||||
try:
|
||||
inspect = ContainerImageInspect(example)
|
||||
except Exception as e:
|
||||
exc = e
|
||||
assert exc != None
|
||||
|
||||
# Empty name should work
|
||||
example["Name"] = ""
|
||||
exc = None
|
||||
try:
|
||||
inspect = ContainerImageInspect(example)
|
||||
except Exception as e:
|
||||
exc = e
|
||||
assert exc == None
|
||||
assert isinstance(inspect, ContainerImageInspect)
|
||||
|
||||
def test_load_inspect_invalid_digest():
|
||||
"""
|
||||
Ensure we do not load inspect output with invalid digests
|
||||
"""
|
||||
# Top-level digest
|
||||
example = copy.deepcopy(EXAMPLE_INSPECT)
|
||||
example["Digest"] = "notadigest"
|
||||
exc = None
|
||||
try:
|
||||
inspect = ContainerImageInspect(example)
|
||||
except Exception as e:
|
||||
exc = e
|
||||
assert exc != None
|
||||
|
||||
# Layer digest(s)
|
||||
example = copy.deepcopy(EXAMPLE_INSPECT)
|
||||
example["Layers"][0] = "notadigest"
|
||||
exc = None
|
||||
try:
|
||||
inspect = ContainerImageInspect(example)
|
||||
except Exception as e:
|
||||
exc = e
|
||||
assert exc != None
|
||||
|
||||
# LayersData digest(s)
|
||||
example = copy.deepcopy(EXAMPLE_INSPECT)
|
||||
example["LayersData"][0]["Digest"] = "notadigest"
|
||||
exc = None
|
||||
try:
|
||||
inspect = ContainerImageInspect(example)
|
||||
except Exception as e:
|
||||
exc = e
|
||||
assert exc != None
|
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"created": "2015-10-31T22:22:56.015925234Z",
|
||||
"author": "Alyssa P. Hacker <alyspdev@example.com>",
|
||||
"architecture": "amd64",
|
||||
"os": "linux",
|
||||
"config": {
|
||||
"User": "alice",
|
||||
"ExposedPorts": {
|
||||
"8080/tcp": {}
|
||||
},
|
||||
"Env": [
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"FOO=oci_is_a",
|
||||
"BAR=well_written_spec"
|
||||
],
|
||||
"Entrypoint": [
|
||||
"/bin/my-app-binary"
|
||||
],
|
||||
"Cmd": [
|
||||
"--foreground",
|
||||
"--config",
|
||||
"/etc/my-app.d/default.cfg"
|
||||
],
|
||||
"Volumes": {
|
||||
"/var/job-result-data": {},
|
||||
"/var/log/my-app-logs": {}
|
||||
},
|
||||
"WorkingDir": "/home/alice",
|
||||
"Labels": {
|
||||
"com.example.project.git.url": "https://example.com/project.git",
|
||||
"com.example.project.git.commit": "45a939b2999782a3f005621a8d0f29aa387e1d6b"
|
||||
}
|
||||
},
|
||||
"rootfs": {
|
||||
"diff_ids": [
|
||||
"sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1",
|
||||
"sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"
|
||||
],
|
||||
"type": "layers"
|
||||
},
|
||||
"history": [
|
||||
{
|
||||
"created": "2015-10-31T22:22:54.690851953Z",
|
||||
"created_by": "/bin/sh -c #(nop) ADD file:a3bc1e842b69636f9df5256c49c5374fb4eef1e281fe3f282c65fb853ee171c5 in /"
|
||||
},
|
||||
{
|
||||
"created": "2015-10-31T22:22:55.613815829Z",
|
||||
"created_by": "/bin/sh -c #(nop) CMD [\"sh\"]",
|
||||
"empty_layer": true
|
||||
},
|
||||
{
|
||||
"created": "2015-10-31T22:22:56.329850019Z",
|
||||
"created_by": "/bin/sh -c apk add curl"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"Name": "registry.fedoraproject.org/fedora",
|
||||
"Digest": "sha256:0f65bee641e821f8118acafb44c2f8fe30c2fc6b9a2b3729c0660376391aa117",
|
||||
"RepoTags": [
|
||||
"34-aarch64",
|
||||
"34",
|
||||
"latest"
|
||||
],
|
||||
"Created": "2022-11-24T13:54:18Z",
|
||||
"DockerVersion": "1.10.1",
|
||||
"Labels": {
|
||||
"license": "MIT",
|
||||
"name": "fedora",
|
||||
"vendor": "Fedora Project",
|
||||
"version": "37"
|
||||
},
|
||||
"Architecture": "amd64",
|
||||
"Os": "linux",
|
||||
"Layers": [
|
||||
"sha256:2a0fc6bf62e155737f0ace6142ee686f3c471c1aab4241dc3128904db46288f0"
|
||||
],
|
||||
"LayersData": [
|
||||
{
|
||||
"MIMEType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
|
||||
"Digest": "sha256:2a0fc6bf62e155737f0ace6142ee686f3c471c1aab4241dc3128904db46288f0",
|
||||
"Size": 71355009,
|
||||
"Annotations": null
|
||||
}
|
||||
],
|
||||
"Env": [
|
||||
"DISTTAG=f37container",
|
||||
"FGC=f37",
|
||||
"container=oci"
|
||||
]
|
||||
}
|
|
@ -96,20 +96,6 @@ OCI_IMAGE_INDEX_EXAMPLE = {
|
|||
}
|
||||
}
|
||||
|
||||
# An example manifest list entry from the CNCF manifest v2s2 spec
|
||||
CNCF_MANIFEST_LIST_ENTRY_EXAMPLE = {
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"digest": "sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270",
|
||||
"size": 7682,
|
||||
"platform": {
|
||||
"architecture": "amd64",
|
||||
"os": "linux",
|
||||
"features": [
|
||||
"sse4"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# An example image index entry following the OCI spec
|
||||
DOCKER_BUILDX_ATTESTATION_INDEX_ENTRY = {
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
|
@ -229,14 +215,6 @@ def test_container_image_oci_image_index_entry_static_validation():
|
|||
assert isinstance(err, str)
|
||||
assert len(err) > 0
|
||||
|
||||
# CNCF (non-OCI) example should be invalid
|
||||
cncf_example_valid, err = ContainerImageIndexEntryOCI.validate_static(
|
||||
copy.deepcopy(CNCF_MANIFEST_LIST_ENTRY_EXAMPLE)
|
||||
)
|
||||
assert cncf_example_valid == False
|
||||
assert isinstance(err, str)
|
||||
assert len(err) > 0
|
||||
|
||||
def test_container_image_oci_image_index_entry_instantiation():
|
||||
# Invalid entry should raise ValidationError
|
||||
exc = None
|
||||
|
|
|
@ -3,6 +3,8 @@ import os
|
|||
from typing import Any, Dict, Type, Union
|
||||
from image.v2s2 import ContainerImageManifestV2S2
|
||||
from image.containerimage import ContainerImage
|
||||
from image.config import ContainerImageConfig
|
||||
from image.descriptor import ContainerImageDescriptor
|
||||
|
||||
"""
|
||||
ContainerImageRegistryClient mocks
|
||||
|
@ -70,9 +72,24 @@ ATTESTATION_S390X_ATTESTATION_MANIFEST = _load_mock_manifest(
|
|||
f"{WORKDIR}/mock/manifests/attestation-s390x-attestation-manifest.json"
|
||||
)
|
||||
|
||||
# An example container image config
|
||||
MOCK_CONFIG = _load_mock_manifest(
|
||||
f"{WORKDIR}/mock/configs/config.json"
|
||||
)
|
||||
|
||||
# Mock an image name
|
||||
MOCK_IMAGE_NAME = "this.is/my/registry/and-my-image"
|
||||
|
||||
# Mock an image tag list response
|
||||
MOCK_TAG_LIST_RESPONSE = {
|
||||
"name": MOCK_IMAGE_NAME,
|
||||
"tags": [
|
||||
"latest",
|
||||
"latest-dup",
|
||||
"latest-attestation"
|
||||
]
|
||||
}
|
||||
|
||||
# Mock image registry creds for the above image name mock
|
||||
MOCK_REGISTRY_CREDS = {
|
||||
"auths": {
|
||||
|
@ -135,6 +152,27 @@ def mock_get_manifest(ref_or_img: Union[str, ContainerImage], auth: Dict[str, An
|
|||
else:
|
||||
raise Exception(f"Unmocked reference: {ref_or_img}")
|
||||
|
||||
# Mock the ContainerImageRegistryClient.get_config function
|
||||
def mock_get_config(
|
||||
ref_or_img: Union[str, ContainerImage],
|
||||
config_desc: ContainerImageDescriptor,
|
||||
auth: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Mocks the ContainerImageRegistryClient.get_config function
|
||||
|
||||
Args:
|
||||
ref (Union[str, ContainerImage]): The image reference
|
||||
auth (Dict[str, Any]): The auth for the reference
|
||||
|
||||
Returns:
|
||||
ContainerImageConfig: The container image config
|
||||
"""
|
||||
if str(ref_or_img) == f"{MOCK_IMAGE_NAME}:latest":
|
||||
return MOCK_CONFIG
|
||||
else:
|
||||
raise Exception(f"Unmocked reference: {ref_or_img}")
|
||||
|
||||
def mock_get_digest(ref_or_img: Union[str, ContainerImage], auth: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Mocks the ContainerImageRegistryClient.get_digest function
|
||||
|
@ -150,3 +188,16 @@ def mock_get_digest(ref_or_img: Union[str, ContainerImage], auth: Dict[str, Any]
|
|||
return "sha256:8f74ffc756f871ee9037fb8e0c3cd9c5cb54e92e014f92d771ab8e6bf925f372"
|
||||
else:
|
||||
raise Exception(f"Unmocked reference: {ref_or_img}")
|
||||
|
||||
def mock_list_tags(ref_or_img: Union[str, ContainerImage], auth: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Mocks the ContainerImageRegistryClient.list_tags function
|
||||
|
||||
Args:
|
||||
ref (Union[str, ContainerImage]): The image reference
|
||||
auth (Dict[str, Any]): The auth for the reference
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: The tag list response
|
||||
"""
|
||||
return MOCK_TAG_LIST_RESPONSE
|
||||
|
|
Loading…
Reference in New Issue