From d29fc627a0f4081a0ddeaa28d2c4cba34f47585d Mon Sep 17 00:00:00 2001 From: ethanbalcik Date: Thu, 9 Oct 2025 15:27:50 -0400 Subject: [PATCH 1/4] feat(lint): wip, prototyping for container image linting Signed-off-by: ethanbalcik --- image/linter.py | 49 ++++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 ++- 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 image/linter.py diff --git a/image/linter.py b/image/linter.py new file mode 100644 index 0000000..6e6260b --- /dev/null +++ b/image/linter.py @@ -0,0 +1,49 @@ +from image.config import ContainerImageConfig +from image.containerimage import ContainerImage +from image.manifest import ContainerImageManifest +from image.manifestlist import ContainerImageManifestList +from lint.linter import Linter +from lint.result import LintResult +from lint.rule import LintRule + +class ManifestListSupportsRequiredPlatforms( + LintRule[ContainerImageManifestList] + ): + """ + A lint rule ensuring a manifest list supports the required platforms + """ + def lint(self, artifact: ContainerImageManifestList): + # TODO: Ability to accept lint rule-specific config + return LintResult(message="Not yet implemented") + +class ContainerImageManifestLinter( + Linter[ContainerImageManifest] + ): + """ + A linter for container image manifests + """ + pass + +class ContainerImageManifestListLinter( + Linter[ContainerImageManifestList] + ): + """ + A linter for container image manifest lists + """ + pass + +class ContainerImageConfigLinter( + Linter[ContainerImageConfig] + ): + """ + A linter for container image configs + """ + pass + +class ContainerImageLinter( + Linter[ContainerImage] + ): + """ + A linter for container images + """ + pass diff --git a/requirements.txt b/requirements.txt index 61f29b3..91b1f75 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ requests -jsonschema \ No newline at end of file +jsonschema +artifact-lint-py @ git+https://github.com/whatsacomputertho/artifact-lint-py@main \ No newline at end of file From f77904dc77172c96dce01e000a9a8286e74ff51b Mon Sep 17 00:00:00 2001 From: ethanbalcik Date: Thu, 9 Oct 2025 19:44:32 -0400 Subject: [PATCH 2/4] feat(lint): continue prototyping container image linting Signed-off-by: ethanbalcik --- examples/image-lint.py | 96 +++++++++++++++++++++++ image/linter.py | 171 +++++++++++++++++++++++++++++++++++++++-- image/mediatypes.py | 8 +- requirements.txt | 2 +- 4 files changed, 268 insertions(+), 9 deletions(-) create mode 100644 examples/image-lint.py diff --git a/examples/image-lint.py b/examples/image-lint.py new file mode 100644 index 0000000..aeaa19b --- /dev/null +++ b/examples/image-lint.py @@ -0,0 +1,96 @@ +###### +# 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.auth import AUTH +from image.containerimage import ContainerImage +from image.linter import * +from lint.config import LinterConfig + +# Initialize a ContainerImage given a tag reference +my_image = ContainerImage("registry.k8s.io/pause:3.5") + +# Initialize each linter +manifest_linter = ContainerImageManifestLinter( + [ + ManifestLayersSupportRequiredMediaTypes() + ] +) +manifest_list_linter = ContainerImageManifestListLinter( + [ + ManifestListSupportsRequiredPlatforms(), + ManifestListSupportsRequiredMediaTypes() + ] +) +config_linter = ContainerImageConfigLinter( + [ + ConfigIsLessThanNDaysOld() + ] +) + +# Initialize each linter config +manifest_list_linter_config = LinterConfig({ + "ManifestListSupportsRequiredPlatforms": { + "enabled": True + }, + "ManifestListSupportsRequiredMediaTypes": { + "enabled": True + } +}) +manifest_linter_config = LinterConfig({ + "ManifestLayersSupportRequiredMediaTypes": { + "enabled": True + } +}) +config_linter_config = LinterConfig({ + "ConfigIsLessThanNDaysOld": { + "enabled": True + } +}) + +# Fetch the container image manifests and config, lint them as we go +results = [] +manifest = my_image.get_manifest(auth=AUTH) +if ContainerImage.is_manifest_list_static(manifest): + results.extend( + manifest_list_linter.lint(manifest, manifest_list_linter_config) + ) + for entry in manifest.get_entries(): + arch_image = ContainerImage( + f"{my_image.get_name()}@{entry.get_digest()}" + ) + arch_manifest = arch_image.get_manifest(auth=AUTH) + results.extend( + manifest_linter.lint(arch_manifest, manifest_linter_config) + ) + arch_config = ContainerImage.get_config_static( + ref=arch_image, + manifest=arch_manifest, + auth=AUTH + ) + results.extend( + config_linter.lint(arch_config, config_linter_config) + ) +else: + results.extend(manifest_linter.lint(manifest, manifest_linter_config)) + config = ContainerImage.get_config_static( + ref=my_image, + manifest=manifest, + auth=AUTH + ) + results.extend(config_linter.lint(config, config_linter_config)) + +for result in results: + print(result) diff --git a/image/linter.py b/image/linter.py index 6e6260b..6702a5f 100644 --- a/image/linter.py +++ b/image/linter.py @@ -1,20 +1,49 @@ +from datetime import datetime, timezone from image.config import ContainerImageConfig from image.containerimage import ContainerImage from image.manifest import ContainerImageManifest from image.manifestlist import ContainerImageManifestList +from image.mediatypes import * +from lint.config import LintRuleConfig from lint.linter import Linter from lint.result import LintResult -from lint.rule import LintRule +from lint.rule import LintRule, DEFAULT_LINT_RULE_CONFIG +from lint.status import LintStatus -class ManifestListSupportsRequiredPlatforms( - LintRule[ContainerImageManifestList] +class ManifestLayersSupportRequiredMediaTypes( + LintRule[ContainerImageManifest] ): """ - A lint rule ensuring a manifest list supports the required platforms + A lint rule ensuring a manifest's layers support the required media types """ - def lint(self, artifact: ContainerImageManifestList): - # TODO: Ability to accept lint rule-specific config - return LintResult(message="Not yet implemented") + def lint( + self, + artifact: ContainerImageManifest, + config: LintRuleConfig=DEFAULT_LINT_RULE_CONFIG + ) -> LintResult: + """ + Implementation of the ManifestLayersSupportRequiredMediaTypes lint rule + """ + expected_media_types = config.config.get( + "media-types", + [ + COMPRESSED_LAYER_MEDIA_TYPE + ] + ) + for layer in artifact.get_layer_descriptors(): + layer_media_type = layer.get_media_type() + if layer_media_type not in expected_media_types: + return LintResult( + status=LintStatus.ERROR, + message=f"({self.name()}) " + \ + f" layer {layer.get_digest()} has mediaType " + \ + f"{layer_media_type}, expected one of " + \ + str(expected_media_types) + ) + return LintResult( + message="All layers support expected mediaTypes: " + \ + str(expected_media_types) + ) class ContainerImageManifestLinter( Linter[ContainerImageManifest] @@ -24,6 +53,92 @@ class ContainerImageManifestLinter( """ pass +class ManifestListSupportsRequiredPlatforms( + LintRule[ContainerImageManifestList] + ): + """ + A lint rule ensuring a manifest list supports the required platforms + """ + def lint( + self, + artifact: ContainerImageManifestList, + config: LintRuleConfig=DEFAULT_LINT_RULE_CONFIG + ) -> LintResult: + """ + Implementation of the ManifestListSupportsRequiredPlatforms lint rule + """ + required = config.config.get("platforms", [ "linux/amd64" ]) + platforms = set( + str(entry.get_platform()) for entry in artifact.get_entries() + ) + missing = [ + set(required).difference(platforms) + ] + if len(missing) > 0: + return LintResult( + status=LintStatus.ERROR, + message=f"({self.name()}) " + \ + "manifest list does not support required platforms: " + \ + str([ str(platform) for platform in missing ]) + ) + return LintResult( + status=LintStatus.INFO, + message=f"({self.name()})" + \ + "manifest list supports all required platforms: " + \ + str(required) + ) + +class ManifestListSupportsRequiredMediaTypes( + LintRule[ContainerImageManifestList] + ): + """ + A lint rule ensuring a manifest list and its manifests support the required + media types + """ + def lint( + self, + artifact: ContainerImageManifestList, + config: LintRuleConfig=DEFAULT_LINT_RULE_CONFIG + ) -> LintResult: + """ + Implementation of the ManifestListSupportsRequiredMediaTypes lint rule + """ + list_media_type = artifact.get_media_type() + expected_list_media_types = config.config.get( + "manifest-list-media-types", + [ + DOCKER_V2S2_LIST_MEDIA_TYPE, + OCI_INDEX_MEDIA_TYPE + ] + ) + if not list_media_type in expected_list_media_types: + return LintResult( + status=LintStatus.ERROR, + message=f"({self.name()}) " + \ + f"manifest list has mediaType {list_media_type}, " + \ + f"expected one of {str(expected_list_media_types)}" + ) + for entry in artifact.get_entries(): + manifest_media_type = artifact.get_media_type() + expected_media_types = config.config.get( + "manifest-media-types", + [ + DOCKER_V2S2_MEDIA_TYPE, + OCI_MANIFEST_MEDIA_TYPE + ] + ) + if not manifest_media_type in expected_media_types: + return LintResult( + status=LintStatus.ERROR, + message=f"({self.name()}) " + \ + f"manifest {entry.get_platform()} has mediaType " + \ + f"{manifest_media_type}, expected one of " + \ + str(expected_media_types) + ) + return LintResult( + message="Manifest list and manifests support expected maediaTypes" + ) + class ContainerImageManifestListLinter( Linter[ContainerImageManifestList] ): @@ -32,6 +147,48 @@ class ContainerImageManifestListLinter( """ pass +class ConfigIsLessThanNDaysOld( + LintRule[ContainerImageConfig] + ): + """ + A lint rule ensuring a container image config's created date is less than N + days old + """ + def lint( + self, + artifact: ContainerImageConfig, + config: LintRuleConfig=DEFAULT_LINT_RULE_CONFIG + ) -> LintResult: + """ + Implementation of the ConfigIsLessThanNDaysOld lint rule + """ + current_datetime = datetime.now() + + # Get the created date and convert to a backward-compatible + # python-parseable format + created_date = artifact.get_created_date() + created_date.replace('Z', '') + dt, frac = created_date.split('.') + frac = frac[:6] + iso_str = f"{dt}.{frac}" + config_created_datetime = datetime.fromisoformat(iso_str) + + diff = current_datetime - config_created_datetime + error_threshold = config.config.get("error-threshold", 60) + warning_threshold = config.config.get("warning-threshold", 30) + message = f"({self.name()}) created date is {diff.days} days old" + if diff.days > error_threshold: + return LintResult( + status=LintStatus.ERROR, + message=message + ) + elif diff.days > warning_threshold: + return LintResult( + status=LintStatus.WARNING, + message=message + ) + return LintResult(message=message) + class ContainerImageConfigLinter( Linter[ContainerImageConfig] ): diff --git a/image/mediatypes.py b/image/mediatypes.py index 3614dab..8cc0d3f 100644 --- a/image/mediatypes.py +++ b/image/mediatypes.py @@ -37,4 +37,10 @@ OCI_INDEX_MEDIA_TYPE = "application/vnd.oci.image.index.v1+json" """ The mediaType for an OCI image index, also known as a multi-arch image -""" \ No newline at end of file +""" + +# See doc comments below +COMPRESSED_LAYER_MEDIA_TYPE = "application/vnd.docker.image.rootfs.diff.tar.gzip" +""" +The mediaType for a container image layer that has been TGZ compressed +""" diff --git a/requirements.txt b/requirements.txt index 91b1f75..7606631 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ requests jsonschema -artifact-lint-py @ git+https://github.com/whatsacomputertho/artifact-lint-py@main \ No newline at end of file +artifact-lint-py @ git+https://github.com/whatsacomputertho/artifact-lint-py@rule-specific-config \ No newline at end of file From 170749d5a36c9768a42c9b5349025f86c63a4c15 Mon Sep 17 00:00:00 2001 From: ethanbalcik Date: Fri, 10 Oct 2025 18:15:46 -0400 Subject: [PATCH 3/4] feat(lint): develop container image linter, lint cli Signed-off-by: ethanbalcik --- doc/source/image/image.rst | 8 ++ examples/image-lint.py | 74 +---------- image/__init__.py | 1 + image/cli/cli.py | 144 ++++++++++++++++++++++ image/linter.py | 245 +++++++++++++++++++++++++++++++++---- pyproject.toml | 3 + 6 files changed, 382 insertions(+), 93 deletions(-) create mode 100644 image/cli/cli.py diff --git a/doc/source/image/image.rst b/doc/source/image/image.rst index ab04c1c..6cc6653 100644 --- a/doc/source/image/image.rst +++ b/doc/source/image/image.rst @@ -84,6 +84,14 @@ image.inspectschema module :undoc-members: :show-inheritance: +image.linter module +------------------- + +.. automodule:: image.linter + :members: + :undoc-members: + :show-inheritance: + image.manifest module --------------------- diff --git a/examples/image-lint.py b/examples/image-lint.py index aeaa19b..8264e1b 100644 --- a/examples/image-lint.py +++ b/examples/image-lint.py @@ -22,75 +22,11 @@ # Initialize a ContainerImage given a tag reference my_image = ContainerImage("registry.k8s.io/pause:3.5") -# Initialize each linter -manifest_linter = ContainerImageManifestLinter( - [ - ManifestLayersSupportRequiredMediaTypes() - ] -) -manifest_list_linter = ContainerImageManifestListLinter( - [ - ManifestListSupportsRequiredPlatforms(), - ManifestListSupportsRequiredMediaTypes() - ] -) -config_linter = ContainerImageConfigLinter( - [ - ConfigIsLessThanNDaysOld() - ] -) - -# Initialize each linter config -manifest_list_linter_config = LinterConfig({ - "ManifestListSupportsRequiredPlatforms": { - "enabled": True - }, - "ManifestListSupportsRequiredMediaTypes": { - "enabled": True - } -}) -manifest_linter_config = LinterConfig({ - "ManifestLayersSupportRequiredMediaTypes": { - "enabled": True - } -}) -config_linter_config = LinterConfig({ - "ConfigIsLessThanNDaysOld": { - "enabled": True - } -}) - -# Fetch the container image manifests and config, lint them as we go -results = [] -manifest = my_image.get_manifest(auth=AUTH) -if ContainerImage.is_manifest_list_static(manifest): - results.extend( - manifest_list_linter.lint(manifest, manifest_list_linter_config) - ) - for entry in manifest.get_entries(): - arch_image = ContainerImage( - f"{my_image.get_name()}@{entry.get_digest()}" - ) - arch_manifest = arch_image.get_manifest(auth=AUTH) - results.extend( - manifest_linter.lint(arch_manifest, manifest_linter_config) - ) - arch_config = ContainerImage.get_config_static( - ref=arch_image, - manifest=arch_manifest, - auth=AUTH - ) - results.extend( - config_linter.lint(arch_config, config_linter_config) - ) -else: - results.extend(manifest_linter.lint(manifest, manifest_linter_config)) - config = ContainerImage.get_config_static( - ref=my_image, - manifest=manifest, - auth=AUTH - ) - results.extend(config_linter.lint(config, config_linter_config)) +# Initialize the linter and linter config +linter = ContainerImageLinter() +config = DEFAULT_CONTAINER_IMAGE_LINTER_CONFIG +# Lint the container image and print results +results = linter.lint(my_image, config=config, auth=AUTH) for result in results: print(result) diff --git a/image/__init__.py b/image/__init__.py index 30bc47e..39ee37d 100644 --- a/image/__init__.py +++ b/image/__init__.py @@ -1,5 +1,6 @@ import image.auth import image.byteunit +import image.cli.cli import image.client import image.config import image.configschema diff --git a/image/cli/cli.py b/image/cli/cli.py new file mode 100644 index 0000000..cac0527 --- /dev/null +++ b/image/cli/cli.py @@ -0,0 +1,144 @@ +import argparse +import json +import sys +from image.auth import AUTH_FILE_PATH_DEFAULT +from image.containerimage import ContainerImage +from image.linter import ContainerImageLinter, DEFAULT_CONTAINER_IMAGE_LINTER_CONFIG +from lint.config import LinterConfig +from lint.status import LintStatus +from typing import Union + +def inspect( + ref: str, + auth: str=AUTH_FILE_PATH_DEFAULT + ): + """ + Inspect a container image + """ + try: + # Initialize the image + image = ContainerImage(ref) + + # Load the auth + img_auth = {} + with open(auth, 'r') as auth_file: + img_auth = json.load(auth_file) + + # Inspect the image + print(image.inspect(auth=img_auth)) + except Exception as e: + print( + f"{type(e).__name__} while linting image: {str(e)}" + ) + sys.exit(1) + +def lint( + ref: str, + config: Union[str, None], + auth: str=AUTH_FILE_PATH_DEFAULT + ): + """ + Lint a container image using the ContainerImageLinter + """ + try: + # Initialize the image + image = ContainerImage(ref) + + # Load the auth + img_auth = {} + with open(auth, 'r') as auth_file: + img_auth = json.load(auth_file) + + # Load the config + img_config = DEFAULT_CONTAINER_IMAGE_LINTER_CONFIG + if config is not None: + with open(config, 'r') as config_file: + img_config = LinterConfig(json.load(config_file)) + + # Initialize the linter and lint the image + linter = ContainerImageLinter() + results = linter.lint(image, config=img_config, auth=img_auth) + errors = [ + result for result in results if result.status == LintStatus.ERROR + ] + warnings = [ + result for result in results if result.status == LintStatus.WARNING + ] + + # Print the results + print(f"Encountered {len(errors)} errors and {len(warnings)} warnings") + for result in errors: + print(f"- {result}") + for result in warnings: + print(f"- {result}") + if len(errors) > 0: + sys.exit(1) + except Exception as e: + print( + f"{type(e).__name__} while linting image: {str(e)}" + ) + sys.exit(1) + +def main(argv=None): + """ + Main command executed on entry in the containerimage-py CLI + """ + # Initialize the root command and subcommand parsers + parser = argparse.ArgumentParser( + prog="containerimage-py", + description="A CLI for interacting with container images and " + \ + "container image registries based on the containerimage-py " + \ + "python library." + ) + parser.add_argument( + '--auth', + dest='auth', + required=False, + default=AUTH_FILE_PATH_DEFAULT, + help='A path to an image pull secret with access to your registry' + ) + subparsers = parser.add_subparsers(dest='command', help='Available commands') + + # Initialize the inspect subcommand + inspect_subcommand = subparsers.add_parser('inspect', help='Inspect a container image') + inspect_subcommand.add_argument( + 'image', + type=str, + help='The container image reference to inspect' + ) + + # Initialize the lint subcommand + lint_subcommand = subparsers.add_parser('lint', help='Lint a container image') + lint_subcommand.add_argument( + 'image', + type=str, + help='The container image reference to lint' + ) + lint_subcommand.add_argument( + '--config', + dest='config', + required=False, + default=None, + help='A path to a linter config file for linting the container image' + ) + + # Execute the subcommand + args = parser.parse_args(argv) + if args.command == "lint": + lint( + ref=args.image, + config=args.config, + auth=args.auth + ) + elif args.command == "inspect": + inspect( + ref=args.image, + auth=args.auth + ) + else: + print(f"Unknown command: {args.command}") + parser.print_help() + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/image/linter.py b/image/linter.py index 6702a5f..136bb01 100644 --- a/image/linter.py +++ b/image/linter.py @@ -1,48 +1,88 @@ from datetime import datetime, timezone +from image.auth import AUTH +from image.byteunit import ByteUnit from image.config import ContainerImageConfig from image.containerimage import ContainerImage from image.manifest import ContainerImageManifest from image.manifestlist import ContainerImageManifestList from image.mediatypes import * -from lint.config import LintRuleConfig +from lint.config import LinterConfig, LintRuleConfig from lint.linter import Linter from lint.result import LintResult from lint.rule import LintRule, DEFAULT_LINT_RULE_CONFIG from lint.status import LintStatus -class ManifestLayersSupportRequiredMediaTypes( +DEFAULT_CONTAINER_IMAGE_LINTER_CONFIG = LinterConfig({ + "ManifestListSupportsRequiredPlatforms": { + "enabled": True + }, + "ManifestListSupportsRequiredMediaTypes": { + "enabled": True + }, + "ManifestSupportsRequiredMediaTypes": { + "enabled": True + }, + "ConfigIsLessThanNDaysOld": { + "enabled": True + }, + "ContainerImageIsLessThanSizeLimit": { + "enabled": True + } +}) + +class ManifestSupportsRequiredMediaTypes( LintRule[ContainerImageManifest] ): """ - A lint rule ensuring a manifest's layers support the required media types + A lint rule ensuring a manifest and its layers each support the required + media types """ def lint( self, artifact: ContainerImageManifest, - config: LintRuleConfig=DEFAULT_LINT_RULE_CONFIG + config: LintRuleConfig=DEFAULT_LINT_RULE_CONFIG, + **kwargs ) -> LintResult: """ Implementation of the ManifestLayersSupportRequiredMediaTypes lint rule """ - expected_media_types = config.config.get( - "media-types", + # Validate the manifest mediaType + manifest_media_type = artifact.get_media_type() + expected_manifest_media_types = config.config.get( + "manifest-media-types", + [ + DOCKER_V2S2_MEDIA_TYPE, + OCI_MANIFEST_MEDIA_TYPE + ] + ) + if manifest_media_type not in expected_manifest_media_types: + return LintResult( + status=LintStatus.ERROR, + message=f"({self.name()})" + \ + f" manifest has mediaType {manifest_media_type}, " + \ + f"expected one of {expected_manifest_media_types}" + ) + + # Validate each layer mediaType + expected_layer_media_types = config.config.get( + "layer-media-types", [ COMPRESSED_LAYER_MEDIA_TYPE ] ) for layer in artifact.get_layer_descriptors(): layer_media_type = layer.get_media_type() - if layer_media_type not in expected_media_types: + if layer_media_type not in expected_layer_media_types: return LintResult( status=LintStatus.ERROR, message=f"({self.name()}) " + \ - f" layer {layer.get_digest()} has mediaType " + \ + f"layer {layer.get_digest()} has mediaType " + \ f"{layer_media_type}, expected one of " + \ - str(expected_media_types) + str(expected_layer_media_types) ) return LintResult( - message="All layers support expected mediaTypes: " + \ - str(expected_media_types) + message= f"({self.name()}) " + \ + "manifest and layers support expected mediaTypes" ) class ContainerImageManifestLinter( @@ -71,21 +111,18 @@ def lint( platforms = set( str(entry.get_platform()) for entry in artifact.get_entries() ) - missing = [ - set(required).difference(platforms) - ] + missing = list(set(required).difference(platforms)) if len(missing) > 0: return LintResult( status=LintStatus.ERROR, - message=f"({self.name()}) " + \ - "manifest list does not support required platforms: " + \ + message=f"({self.name()}) manifest list does not support " + \ + "the following required platforms: " + \ str([ str(platform) for platform in missing ]) ) return LintResult( status=LintStatus.INFO, - message=f"({self.name()})" + \ - "manifest list supports all required platforms: " + \ - str(required) + message=f"({self.name()}) " + \ + "manifest list supports all required platforms" ) class ManifestListSupportsRequiredMediaTypes( @@ -98,7 +135,8 @@ class ManifestListSupportsRequiredMediaTypes( def lint( self, artifact: ContainerImageManifestList, - config: LintRuleConfig=DEFAULT_LINT_RULE_CONFIG + config: LintRuleConfig=DEFAULT_LINT_RULE_CONFIG, + **kwargs ) -> LintResult: """ Implementation of the ManifestListSupportsRequiredMediaTypes lint rule @@ -119,7 +157,7 @@ def lint( f"expected one of {str(expected_list_media_types)}" ) for entry in artifact.get_entries(): - manifest_media_type = artifact.get_media_type() + manifest_media_type = entry.get_media_type() expected_media_types = config.config.get( "manifest-media-types", [ @@ -136,7 +174,8 @@ def lint( str(expected_media_types) ) return LintResult( - message="Manifest list and manifests support expected maediaTypes" + message=f"({self.name()}) " + \ + "manifest list and manifests support expected maediaTypes" ) class ContainerImageManifestListLinter( @@ -157,7 +196,8 @@ class ConfigIsLessThanNDaysOld( def lint( self, artifact: ContainerImageConfig, - config: LintRuleConfig=DEFAULT_LINT_RULE_CONFIG + config: LintRuleConfig=DEFAULT_LINT_RULE_CONFIG, + **kwargs ) -> LintResult: """ Implementation of the ConfigIsLessThanNDaysOld lint rule @@ -197,10 +237,167 @@ class ContainerImageConfigLinter( """ pass +class ContainerImageIsLessThanSizeLimit( + LintRule[ContainerImage] + ): + """ + A lint rule ensuring a container image is smaller than a given size limit + measured in bytes + """ + def lint( + self, + artifact: ContainerImage, + config: LintRuleConfig=DEFAULT_LINT_RULE_CONFIG, + **kwargs + ) -> LintResult: + """ + Implementation of the ContainerImageIsLessThanSizeLimit lint rule + """ + # Check if manifests were passed in explicitly + manifests = kwargs.get("manifests") + + # If so, calculate size using the given manifests and configs + # Otherwise calculate the size using the container image + size = 0 + if manifests is not None: + for manifest in manifests: + size += manifest.get_size() + else: + auth = kwargs.get("auth", AUTH) + size = artifact.get_size(auth=auth) + + # Compare against the size limit + error_threshold = config.config.get( + "error-threshold", + 2147483648 + ) # 2GB + warning_threshold = config.config.get( + "warning-threshold", + 1073741824 + ) # 1GB + error_threshold_formatted = ByteUnit.format_size_bytes( + error_threshold + ) + warning_threshold_formatted = ByteUnit.format_size_bytes( + warning_threshold + ) + size_formatted = ByteUnit.format_size_bytes(size) + if size > error_threshold: + return LintResult( + status=LintStatus.ERROR, + message=f"({self.name()}) " + \ + f"image is larger than {error_threshold_formatted}: " + \ + size_formatted + ) + elif size > warning_threshold: + return LintResult( + status=LintStatus.WARNING, + message=f"({self.name()}) " + \ + f"image is larger than {warning_threshold_formatted} " + \ + size_formatted + ) + return LintResult( + message=f"({self.name()}) image size is ok: {size_formatted}" + ) + class ContainerImageLinter( Linter[ContainerImage] ): """ A linter for container images """ - pass + def __init__(self): + """ + Constructor for the ContainerImageLinter class + """ + # Initialize the default sub-type linters + self.manifest_linter = ContainerImageManifestLinter( + [ + ManifestSupportsRequiredMediaTypes() + ] + ) + self.manifest_list_linter = ContainerImageManifestListLinter( + [ + ManifestListSupportsRequiredPlatforms(), + ManifestListSupportsRequiredMediaTypes() + ] + ) + self.config_linter = ContainerImageConfigLinter( + [ + ConfigIsLessThanNDaysOld() + ] + ) + + # Also include any additional rules passed in + super().__init__([ + ContainerImageIsLessThanSizeLimit() + ]) + + def lint( + self, + artifact: ContainerImage, + config: LintRuleConfig=DEFAULT_LINT_RULE_CONFIG, + **kwargs + ) -> list[LintResult]: + """ + Implementation of the container image linter + """ + results = [] + + # Execute each container image lint rule + for rule in self.rules: + results.append(rule.lint(artifact, config)) + + # Fetch the container image manifests and config, lint them as we go + auth=kwargs.get("auth", AUTH) + manifest = artifact.get_manifest(auth=auth) + manifests = [] + if ContainerImage.is_manifest_list_static(manifest): + results.extend( + self.manifest_list_linter.lint(manifest, config) + ) + for entry in manifest.get_entries(): + platform = entry.get_platform() + arch_image = ContainerImage( + f"{artifact.get_name()}@{entry.get_digest()}" + ) + arch_manifest = arch_image.get_manifest(auth=AUTH) + manifests.append(arch_manifest) + manifest_results = self.manifest_linter.lint( + arch_manifest, config + ) + for result in manifest_results: + result.message += f" for manifest {platform}" + results.extend(manifest_results) + arch_config = ContainerImage.get_config_static( + ref=arch_image, + manifest=arch_manifest, + auth=AUTH + ) + config_results = self.config_linter.lint(arch_config, config) + for result in config_results: + result.message += f" for manifest {platform}" + results.extend(config_results) + else: + manifests.append(manifest) + results.extend(self.manifest_linter.lint(manifest, config)) + config = ContainerImage.get_config_static( + ref=artifact, + manifest=manifest, + auth=AUTH + ) + results.extend(self.config_linter.lint(config, config)) + + # Even though it should always exist, this is protection against OOB + if len(self.rules) > 0: + results.append( + self.rules[0].lint( + artifact, + config.config.get( + self.rules[0].name(), + DEFAULT_LINT_RULE_CONFIG + ), + manifests=manifests + ) + ) + return results diff --git a/pyproject.toml b/pyproject.toml index 551fee7..0cfbe61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,3 +33,6 @@ Issues = "https://github.com/whatsacomputertho/containerimage-py/issues" test = ["tox","pytest","pytest-mock","pytest-cov"] build = ["build"] doc = ["sphinx","sphinx_rtd_theme"] + +[project.scripts] +containerimage-py = "image.cli.cli:main" From 34aca19e6adf74546378457167c1926820f4784f Mon Sep 17 00:00:00 2001 From: ethanbalcik Date: Fri, 10 Oct 2025 19:35:02 -0400 Subject: [PATCH 4/4] feat(lint): switch to published artifact-lint-py package Signed-off-by: ethanbalcik --- pyproject.toml | 3 ++- requirements.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0cfbe61..34e7de9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,8 @@ license-files = [ ] dependencies = [ "requests", - "jsonschema" + "jsonschema", + "artifact-lint-py" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 7606631..6090fd6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ requests jsonschema -artifact-lint-py @ git+https://github.com/whatsacomputertho/artifact-lint-py@rule-specific-config \ No newline at end of file +artifact-lint-py \ No newline at end of file