From 639293f0ea48ca879a42ecef617e4352d65022cd Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Mon, 9 Dec 2024 13:03:23 -0500 Subject: [PATCH] feat: reporting all base images in _COLLECTED_ARTIFACTS In addition to reporting the base image of the target section, all other base images are also collected and reported in _COLLECTED_ARTIFACTS. --- src/chalk_common.nim | 1 + src/docker/build.nim | 65 ++++++++++--------- src/docker/collect.nim | 5 +- src/docker/dockerfile.nim | 17 +++++ src/docker/git.nim | 11 ++-- src/docker/scan.nim | 8 ++- src/plugins/system.nim | 6 +- tests/functional/chalk/runner.py | 32 +++++----- tests/functional/setup.cfg | 2 + tests/functional/test_docker.py | 68 +++++++++++++++++--- tests/functional/testing.c4m | 6 +- tests/functional/utils/dict.py | 103 +++++++++++++++++++++++-------- tests/functional/utils/docker.py | 6 +- 13 files changed, 238 insertions(+), 92 deletions(-) diff --git a/src/chalk_common.nim b/src/chalk_common.nim index e1bd5e95..3107f9ce 100644 --- a/src/chalk_common.nim +++ b/src/chalk_common.nim @@ -273,6 +273,7 @@ type cmd*: CmdInfo shell*: ShellInfo lastUser*: UserInfo + chalk*: ChalkObj DockerEntrypoint* = tuple entrypoint: EntryPointInfo diff --git a/src/docker/build.nim b/src/docker/build.nim index 7db23101..83d1f00a 100644 --- a/src/docker/build.nim +++ b/src/docker/build.nim @@ -333,16 +333,16 @@ proc launchDockerSubscan(ctx: DockerInvocation, result = runChalkSubScan(usableContexts, "insert").report trace("docker: subscan complete.") -proc collectBaseImage(chalk: ChalkObj, baseSection: DockerFileSection) = +proc collectBaseImage(chalk: ChalkObj, section: DockerFileSection) = trace("docker: collecting chalkmark from base image " & - $baseSection.image & " for " & $chalk.platform) + $section.image & " for " & $chalk.platform) try: - let baseChalkOpt = scanImage(baseSection.image, platform = chalk.platform) + let baseChalkOpt = scanImage(section.image, platform = chalk.platform) if baseChalkOpt.isNone(): trace("docker: base image could not be scanned") return let - baseChalk = baseChalkOpt.get(ChalkObj()) + baseChalk = baseChalkOpt.get() dict = chalk.collectedData collected = if baseChalk.collectedData != nil: @@ -352,15 +352,16 @@ proc collectBaseImage(chalk: ChalkObj, baseSection: DockerFileSection) = baseChalk.addToAllArtifacts() baseChalk.collectedData["_OP_ARTIFACT_CONTEXT"] = pack("base") chalk.baseChalk = baseChalk - if baseChalk.isMarked(): - dict.setIfNeeded("DOCKER_BASE_IMAGE_METADATA_ID", baseChalk.extract["METADATA_ID"]) - else: - trace("docker: base image is not chalked " & $baseSection.image) - if "_IMAGE_ID" in baseChalk.collectedData: - dict.setIfNeeded("DOCKER_BASE_IMAGE_ID", baseChalk.collectedData["_IMAGE_ID"]) + section.chalk = baseChalk + if not baseChalk.isMarked(): + trace("docker: base image is not chalked " & $section.image) except: trace("docker: unable to scan base image due to: " & getCurrentExceptionMsg()) +proc collectBaseImages(chalk: ChalkObj, ctx: DockerInvocation) = + for section in ctx.getBasesDockerSections(): + chalk.collectBaseImage(section) + proc collectBeforeChalkTime(chalk: ChalkObj, ctx: DockerInvocation) = let baseSection = ctx.getBaseDockerSection() @@ -368,28 +369,32 @@ proc collectBeforeChalkTime(chalk: ChalkObj, ctx: DockerInvocation) = git = getPluginByName("vctl_git") projectRootPath = git.gitFirstDir().parentDir() dockerfileRelPath = getRelativePathBetween(projectRootPath, ctx.dockerFileLoc) - chalk.collectBaseImage(baseSection) - dict.setIfNeeded("DOCKERFILE_PATH", ctx.dockerFileLoc) - dict.setIfNeeded("DOCKERFILE_PATH_WITHIN_VCTL", dockerfileRelPath) - dict.setIfNeeded("DOCKER_ADDITIONAL_CONTEXTS", ctx.foundExtraContexts) - dict.setIfNeeded("DOCKER_CONTEXT", ctx.foundContext) - dict.setIfNeeded("DOCKER_FILE", ctx.inDockerFile) - dict.setIfNeeded("DOCKER_PLATFORM", $(chalk.platform.normalize())) - dict.setIfNeeded("DOCKER_PLATFORMS", $(ctx.platforms.normalize())) - dict.setIfNeeded("DOCKER_LABELS", ctx.foundLabels) - dict.setIfNeeded("DOCKER_ANNOTATIONS", ctx.foundAnnotations) - dict.setIfNeeded("DOCKER_TAGS", ctx.foundTags.asRepoTag()) - dict.setIfNeeded("DOCKER_BASE_IMAGE", $(baseSection.image)) - dict.setIfNeeded("DOCKER_BASE_IMAGE_REPO", baseSection.image.repo) - dict.setIfNeeded("DOCKER_BASE_IMAGE_REGISTRY", baseSection.image.registry) - dict.setIfNeeded("DOCKER_BASE_IMAGE_NAME", baseSection.image.name) - dict.setIfNeeded("DOCKER_BASE_IMAGE_TAG", baseSection.image.tag) - dict.setIfNeeded("DOCKER_BASE_IMAGE_DIGEST", baseSection.image.digest) - dict.setIfNeeded("DOCKER_BASE_IMAGES", ctx.formatBaseImages()) - dict.setIfNeeded("DOCKER_COPY_IMAGES", ctx.formatCopyImages()) + chalk.collectBaseImages(ctx) + if chalk.baseChalk != nil: + if chalk.baseChalk.isMarked(): + dict.setIfNeeded("DOCKER_BASE_IMAGE_METADATA_ID", chalk.baseChalk.extract["METADATA_ID"]) + dict.setIfNeeded("DOCKER_BASE_IMAGE_ID", chalk.baseChalk.collectedData.getOrDefault("_IMAGE_ID")) + dict.setIfNeeded("DOCKERFILE_PATH", ctx.dockerFileLoc) + dict.setIfNeeded("DOCKERFILE_PATH_WITHIN_VCTL", dockerfileRelPath) + dict.setIfNeeded("DOCKER_ADDITIONAL_CONTEXTS", ctx.foundExtraContexts) + dict.setIfNeeded("DOCKER_CONTEXT", ctx.foundContext) + dict.setIfNeeded("DOCKER_FILE", ctx.inDockerFile) + dict.setIfNeeded("DOCKER_PLATFORM", $(chalk.platform.normalize())) + dict.setIfNeeded("DOCKER_PLATFORMS", $(ctx.platforms.normalize())) + dict.setIfNeeded("DOCKER_LABELS", ctx.foundLabels) + dict.setIfNeeded("DOCKER_ANNOTATIONS", ctx.foundAnnotations) + dict.setIfNeeded("DOCKER_TAGS", ctx.foundTags.asRepoTag()) + dict.setIfNeeded("DOCKER_BASE_IMAGE", $(baseSection.image)) + dict.setIfNeeded("DOCKER_BASE_IMAGE_REPO", baseSection.image.repo) + dict.setIfNeeded("DOCKER_BASE_IMAGE_REGISTRY", baseSection.image.registry) + dict.setIfNeeded("DOCKER_BASE_IMAGE_NAME", baseSection.image.name) + dict.setIfNeeded("DOCKER_BASE_IMAGE_TAG", baseSection.image.tag) + dict.setIfNeeded("DOCKER_BASE_IMAGE_DIGEST", baseSection.image.digest) + dict.setIfNeeded("DOCKER_BASE_IMAGES", ctx.formatBaseImages()) + dict.setIfNeeded("DOCKER_COPY_IMAGES", ctx.formatCopyImages()) # note this key is expected to be empty string for alias-less targets # hence setIfSubscribed vs setIfNeeded which doesnt allow to set empty strings - dict.setIfSubscribed("DOCKER_TARGET", ctx.getTargetDockerSection().alias) + dict.setIfSubscribed("DOCKER_TARGET", ctx.getTargetDockerSection().alias) proc collectBeforeBuild*(chalk: ChalkObj, ctx: DockerInvocation) = let dict = chalk.collectedData diff --git a/src/docker/collect.nim b/src/docker/collect.nim index cef27bfd..d1e439dd 100644 --- a/src/docker/collect.nim +++ b/src/docker/collect.nim @@ -227,8 +227,9 @@ proc collectImageFrom(chalk: ChalkObj, imageRepo = manifest.asImageRepo(tag = repo.tag) annotations.update(manifest.annotations) chalk.repos[repo.repo] = imageRepo + chalk.repos.getOrDefault(repo.repo) + layers = @[] for layer in manifest.layers: - layers.add(layer.digest) + layers.add(layer.digest.extractDockerHash()) except: trace("docker: " & getCurrentExceptionMsg()) continue @@ -278,7 +279,7 @@ proc collectImageFrom(chalk: ChalkObj, chalk.setIfNeeded("_IMAGE_ANNOTATIONS", annotations.nimJsonToBox()) chalk.setIfNeeded("COMMIT_ID", annotations{"org.opencontainers.image.revision"}.getStr()) let source = annotations{"org.opencontainers.image.source"}.getStr() - if isGitContext(source): + if isGitContext(source, requireExtension = false): let (remote, head, subdir) = splitContext(source) chalk.setIfNeeded("ORIGIN_URI", remote) diff --git a/src/docker/dockerfile.nim b/src/docker/dockerfile.nim index 85256268..b6f6aa2c 100644 --- a/src/docker/dockerfile.nim +++ b/src/docker/dockerfile.nim @@ -1015,6 +1015,15 @@ proc getBaseDockerSection*(ctx: DockerInvocation): DockerFileSection = for s in ctx.getBaseDockerSections(): return s +iterator getBasesDockerSections*(ctx: DockerInvocation): DockerFileSection = + ## iterator to get only bases across all sections of dockerfile + var seen = newSeq[DockerFileSection]() + for s in ctx.dfSections: + let base = ctx.getBaseDockerSection(s) + if base notin seen: + seen.add(base) + yield base + proc formatBaseImage(ctx: DockerInvocation, section: DockerFileSection): TableRef[string, string] = let base = ctx.getBaseDockerSection(section) result = newTable[string, string]() @@ -1028,6 +1037,14 @@ proc formatBaseImage(ctx: DockerInvocation, section: DockerFileSection): TableRe result["tag"] = base.image.tag if base.image.digest != "": result["digest"] = base.image.digest + if base.chalk != nil: + let + config = unpack[string](base.chalk.collectedData.getOrDefault("_IMAGE_ID", pack(""))) + metadata = unpack[string](base.chalk.collectedData.getOrDefault("_METADATA_ID", pack(""))) + if config != "": + result["config_digest"] = config + if metadata != "": + result["metadata_id"] = metadata proc formatBaseImages*(ctx: DockerInvocation): ChalkDict = result = ChalkDict() diff --git a/src/docker/git.nim b/src/docker/git.nim index 2e3963e1..01670281 100644 --- a/src/docker/git.nim +++ b/src/docker/git.nim @@ -50,10 +50,13 @@ proc createTempKnownHosts(data: string): string = let path = writeNewTempFile(data) return path -proc isHttpGitContext(context: string): bool = +proc isHttpGitContext(context: string, requireExtension = true): bool = if context.startsWith("http://") or context.startsWith("https://"): let uri = parseUri(context) - return uri.path.endsWith(".git") + if requireExtension: + return uri.path.endsWith(".git") + else: + return true return false proc isSSHGitContext(context: string): bool = @@ -64,8 +67,8 @@ proc isSSHGitContext(context: string): bool = return uri.path.endsWith(".git") return false -proc isGitContext*(context: string): bool = - return isHttpGitContext(context) or isSSHGitContext(context) +proc isGitContext*(context: string, requireExtension = true): bool = + return isHttpGitContext(context, requireExtension) or isSSHGitContext(context) proc splitContext*(context: string): (string, string, string) = let diff --git a/src/docker/scan.nim b/src/docker/scan.nim index 80fbbdb1..9720ad4e 100644 --- a/src/docker/scan.nim +++ b/src/docker/scan.nim @@ -9,7 +9,7 @@ ## ## scan - create new chalk object and collect docker info into it -import ".."/[config, plugin_api] +import ".."/[config, plugin_api, util] import "."/[collect, ids, inspect, extract] proc scanLocalImage*(item: string): Option[ChalkObj] = @@ -36,6 +36,12 @@ proc scanImage*(item: DockerImage, platform: DockerPlatform): Option[ChalkObj] = chalk.collectImage(item) except: return none(ChalkObj) + # if we already collected the same image before, return the same pointer + # so that we do not duplicate collected artifacts + for artifact in getAllChalks() & getAllArtifacts(): + if artifact.collectedData.getOrDefault("_IMAGE_ID") == chalk.collectedData["_IMAGE_ID"]: + artifact.collectedData.merge(chalk.collectedData) + chalk = artifact try: chalk.extractImage() except: diff --git a/src/plugins/system.nim b/src/plugins/system.nim index 891b762d..9562477c 100644 --- a/src/plugins/system.nim +++ b/src/plugins/system.nim @@ -215,12 +215,16 @@ proc sysGetRunTimeHostInfo*(self: Plugin, objs: seq[ChalkObj]): if len(cachedSearchPath) != 0: result.setIfNeeded("_OP_SEARCH_PATH", cachedSearchPath) + var chalks = 0 + for i in objs: + if i.isMarked(): + chalks += 1 result.setIfNeeded("_OPERATION", getBaseCommandName()) result.setIfNeeded("_EXEC_ID", execId) result.setIfNeeded("_OP_CHALKER_VERSION", getChalkExeVersion()) result.setIfNeeded("_OP_PLATFORM", getChalkPlatform()) result.setIfNeeded("_OP_CHALKER_COMMIT_ID", getChalkCommitId()) - result.setIfNeeded("_OP_CHALK_COUNT", len(getAllChalks()) - len(getUnmarked())) + result.setIfNeeded("_OP_CHALK_COUNT", chalks) result.setIfNeeded("_OP_EXE_NAME", getMyAppPath().splitPath().tail) result.setIfNeeded("_OP_EXE_PATH", getAppDir()) result.setIfNeeded("_OP_ARGV", @[getMyAppPath()] & commandLineParams()) diff --git a/tests/functional/chalk/runner.py b/tests/functional/chalk/runner.py index 51b1ff6f..5c35512c 100644 --- a/tests/functional/chalk/runner.py +++ b/tests/functional/chalk/runner.py @@ -11,7 +11,7 @@ from ..conf import MAGIC from ..utils.bin import sha256 -from ..utils.dict import ContainsMixin, MISSING, ANY, IfExists +from ..utils.dict import ContainsDict, MISSING, ANY, IfExists, ContainsList from ..utils.docker import Docker from ..utils.log import get_logger from ..utils.os import CalledProcessError, Program, run @@ -46,7 +46,7 @@ def artifact_type(path: Path) -> str: return "ELF" -class ChalkReport(ContainsMixin, dict): +class ChalkReport(ContainsDict): name = "report" def __init__(self, report: dict[str, Any]): @@ -76,16 +76,18 @@ def deterministic(self, ignore: Optional[set[str]] = None): @property def marks(self): assert len(self["_CHALKS"]) > 0 - return [ChalkMark(i, report=self) for i in self["_CHALKS"]] + return ContainsList([ChalkMark(i, report=self) for i in self["_CHALKS"]]) @property def artifacts(self): assert len(self["_COLLECTED_ARTIFACTS"]) > 0 - return [ChalkMark(i, report=self) for i in self["_COLLECTED_ARTIFACTS"]] + return ContainsList( + [ChalkMark(i, report=self) for i in self["_COLLECTED_ARTIFACTS"]] + ) @property def marks_by_path(self): - return ContainsMixin( + return ChalkMark( { i.get("PATH_WHEN_CHALKED", i.get("_OP_ARTIFACT_PATH")): i for i in self.marks @@ -122,7 +124,7 @@ def from_json(cls, data: str): return cls(info if isinstance(info, dict) else info[0]) -class ChalkMark(ContainsMixin, dict): +class ChalkMark(ContainsDict): name = "mark" @classmethod @@ -224,7 +226,7 @@ def reports(self): break else: break - return [ChalkReport(i) for i in reports] + return ContainsList([ChalkReport(i) for i in reports]) @property def report(self): @@ -263,9 +265,9 @@ def virtual_path(self): @property def vmarks(self): assert self.virtual_path.exists() - return [ - ChalkMark.from_json(i) for i in self.virtual_path.read_text().splitlines() - ] + return ContainsList( + [ChalkMark.from_json(i) for i in self.virtual_path.read_text().splitlines()] + ) @property def vmark(self): @@ -279,10 +281,12 @@ def logged_reports_path(self): @property def logged_reports(self): - return [ - ChalkReport.from_json(json.loads(i)["$message"]) - for i in self.logged_reports_path.read_text().splitlines() - ] + return ContainsList( + [ + ChalkReport.from_json(json.loads(i)["$message"]) + for i in self.logged_reports_path.read_text().splitlines() + ] + ) @property def logged_report(self): diff --git a/tests/functional/setup.cfg b/tests/functional/setup.cfg index 42e2e79e..7b139cd3 100644 --- a/tests/functional/setup.cfg +++ b/tests/functional/setup.cfg @@ -13,6 +13,8 @@ ignore = E203 # black formats indents E131 + # black formats all statements + E701 [mypy] ignore_missing_imports = true diff --git a/tests/functional/test_docker.py b/tests/functional/test_docker.py index 04498947..b02c1207 100644 --- a/tests/functional/test_docker.py +++ b/tests/functional/test_docker.py @@ -419,6 +419,7 @@ def test_base_images(chalk: Chalk, random_hex: str, tmp_data_dir: Path): ARG BASE=seven FROM alpine as one + FROM alpine as oneduplicate FROM ubuntu:24.04 as two COPY --from=docker /usr/local/bin/docker /docker @@ -441,15 +442,64 @@ def test_base_images(chalk: Chalk, random_hex: str, tmp_data_dir: Path): """ ), ) - assert result.artifact.contains( - {k: IfExists(v) for k, v in base.mark.items() if not k.startswith("_")} - ) - assert result.artifact.has( - _OP_ARTIFACT_CONTEXT="base", - _IMAGE_ID=base.mark["_IMAGE_ID"], - METADATA_ID=base.mark["METADATA_ID"], - COMMIT_ID=base.mark["COMMIT_ID"], - ORIGIN_URI=base.mark["ORIGIN_URI"], + assert result.report.has( + _OP_CHALK_COUNT=1, + # all base images should be set as unmarked + _UNMARKED=Length(len({"alpine", "ubuntu", "busybox", "nginx"}), operator.ge), + _COLLECTED_ARTIFACTS=Contains( + [ + { + **{ + k: IfExists(v) + for k, v in base.mark.items() + if not k.startswith("_") + }, + **{ + "_OP_ARTIFACT_CONTEXT": "base", + "_IMAGE_ID": base.mark["_IMAGE_ID"], + "METADATA_ID": base.mark["METADATA_ID"], + "COMMIT_ID": base.mark["COMMIT_ID"], + "ORIGIN_URI": base.mark["ORIGIN_URI"], + }, + }, + { + "_IMAGE_ID": ANY, + "METADATA_ID": MISSING, + "COMMIT_ID": ANY, + "ORIGIN_URI": "https://git.launchpad.net/cloud-images/+oci/ubuntu-base", + "_REPO_DIGESTS": { + "registry-1.docker.io": { + "library/ubuntu": ANY, + } + }, + "_REPO_TAGS": { + "registry-1.docker.io": { + "library/ubuntu": { + "24.04": ANY, + } + } + }, + }, + { + "_IMAGE_ID": ANY, + "METADATA_ID": MISSING, + "COMMIT_ID": ANY, + "ORIGIN_URI": "https://github.com/nginxinc/docker-nginx.git", + "_REPO_DIGESTS": { + "registry-1.docker.io": { + "library/nginx": ANY, + } + }, + # even though tag is specified in dockerfile, its pinning to digest + # and tag is outdated after new release + "_REPO_TAGS": IfExists( + { + "registry-1.docker.io": MISSING, + } + ), + }, + ] + ), ) assert result.mark.has( DOCKER_TARGET="", diff --git a/tests/functional/testing.c4m b/tests/functional/testing.c4m index 7b732a31..044e65a2 100644 --- a/tests/functional/testing.c4m +++ b/tests/functional/testing.c4m @@ -6,8 +6,10 @@ custom_report.terminal_other_op.enabled: false custom_report.terminal_other_op.use_when: ["extract", "delete", "exec", "env", "heartbeat"] report_template insertion_default { - key._OP_EXIT_CODE.use = true - key._CHALK_RUN_TIME.use = true + key._OP_EXIT_CODE.use = true + key._CHALK_RUN_TIME.use = true + key._IMAGE_SBOM.use = false + key._IMAGE_PROVENANCE.use = false } report_template report_default { diff --git a/tests/functional/utils/dict.py b/tests/functional/utils/dict.py index 36508f96..5b5e0857 100644 --- a/tests/functional/utils/dict.py +++ b/tests/functional/utils/dict.py @@ -5,7 +5,7 @@ import itertools import operator import re -from typing import Any, Callable, Optional +from typing import Any, Callable, Iterable, Optional, cast ANY = object() @@ -33,20 +33,35 @@ def __eq__(self, other: Any): class Contains: - def __init__(self, items: set[Any]): - self.items = items + def __init__(self, items: set[Any] | list[Any]): + self.items = ContainsList(items) def __repr__(self): return f"{self.__class__.__name__}({self.items!r})" - def __eq__(self, other: Any): - return all(i in other for i in self.items) + def __eq__(self, others: Any): + def check(expected, others): + if isinstance(expected, ContainsMixin): + errors = [] + for other in others: + try: + return SubsetCompare(expected) == other + except AssertionError as e: + errors.append(str(e)) + raise AssertionError(errors) + else: + return expected in others + + return all(check(i, others) for i in self.items) class IfExists: def __init__(self, value: Any): self.value = value + def __repr__(self): + return f"{self.__class__.__name__}({self.value!r})" + class SubsetCompare: def __init__(self, expected: Any, path: Optional[str] = None): @@ -91,76 +106,93 @@ def __eq__(self, value: Any) -> bool: assert isinstance(value, str), self._message_ne(value) assert self.expected.search(value), self._message_ne(value) elif isinstance(self.expected, (Length, Contains)): - assert self.expected == value, self._message_ne(value) + try: + eq = self.expected == value + except AssertionError as e: + raise AssertionError(self._message_why("", str(e))) from e + else: + assert eq, self._message_ne(value) else: assert value == self.expected, self._message_ne(value) - return True + def __contains__(self, item: Any) -> bool: + for i in cast(Iterable[Any], self): + if i == item: + return True + return False + -class ContainsMixin(dict): +class ContainsMixin: def has(self, **kwargs: Any): """ - >>> ContainsMixin({"foo": "bar"}).has(foo="bar") + >>> ContainsDict({"foo": "bar"}).has(foo="bar") True """ return self.contains(kwargs) def contains(self, other: dict[Any, Any]): """ - >>> ContainsMixin({"foo": "bar"}).contains({"foo": "bar"}) + >>> ContainsDict({"foo": "bar"}).contains({"foo": "bar"}) True - >>> ContainsMixin({"foo": "bar"}).contains({"foo": "baz"}) + >>> ContainsDict({"foo": "bar"}).contains({"foo": "baz"}) Traceback (most recent call last): ... AssertionError: ['foo']: 'bar' != 'baz' - >>> ContainsMixin({"foo": "baz"}).contains({"foo": re.compile(r"z$")}) + >>> ContainsDict({"foo": "baz"}).contains({"foo": re.compile(r"z$")}) True - >>> ContainsMixin({"foo": "bar"}).contains({"foo": re.compile(r"z$")}) + >>> ContainsDict({"foo": "bar"}).contains({"foo": re.compile(r"z$")}) Traceback (most recent call last): ... AssertionError: ['foo']: 'bar' != re.compile('z$') - >>> ContainsMixin({"foo": "bar"}).contains({"foo": {"bar": "baz"}}) + >>> ContainsDict({"foo": "bar"}).contains({"foo": {"bar": "baz"}}) Traceback (most recent call last): ... AssertionError: ['foo']: 'bar' != {'bar': 'baz'} - >>> ContainsMixin({"foo": [2, 1]}).contains({"foo": {1, 2}}) + >>> ContainsDict({"foo": [2, 1]}).contains({"foo": {1, 2}}) True - >>> ContainsMixin({"foo": [2, 1]}).contains({"foo": {1, 2, 3}}) + >>> ContainsDict({"foo": [2, 1]}).contains({"foo": {1, 2, 3}}) Traceback (most recent call last): ... AssertionError: ['foo']: [2, 1] != {1, 2, 3} - >>> ContainsMixin({"foo": "bar"}).contains({"foo": ANY}) + >>> ContainsDict({"foo": "bar"}).contains({"foo": ANY}) True - >>> ContainsMixin({"foo": "bar"}).contains({"bar": MISSING}) + >>> ContainsDict({"foo": "bar"}).contains({"bar": MISSING}) True - >>> ContainsMixin({"foo": ["bar"]}).contains({"foo": Length(1)}) + >>> ContainsDict({"foo": ["bar"]}).contains({"foo": Length(1)}) True - >>> ContainsMixin({"foo": ["bar"]}).contains({"foo": Length(1, operator.gt)}) + >>> ContainsDict({"foo": ["bar"]}).contains({"foo": Length(1, operator.gt)}) Traceback (most recent call last): ... AssertionError: ['foo']: ['bar'] != Length(>1) - >>> ContainsMixin({"foo": "bar"}).contains({"bar": IfExists("bar")}) + >>> ContainsDict({"foo": "bar"}).contains({"bar": IfExists("bar")}) True - >>> ContainsMixin({"foo": "bar"}).contains({"foo": IfExists("bar")}) + >>> ContainsDict({"foo": "bar"}).contains({"foo": IfExists("bar")}) True - >>> ContainsMixin({"foo": "bar"}).contains({"foo": IfExists("baz")}) + >>> ContainsDict({"foo": "bar"}).contains({"foo": IfExists("baz")}) Traceback (most recent call last): ... AssertionError: ['foo']: 'bar' != 'baz' - >>> ContainsMixin({"foo": ["bar", "baz"]}).contains({"foo": Contains({"bar"})}) + >>> ContainsDict({"foo": ["bar", "baz"]}).contains({"foo": Contains({"bar"})}) + True + >>> ContainsDict({"foo": ["bar", "baz"]}).contains({"foo": Contains({"foobar"})}) + Traceback (most recent call last): + ... + AssertionError: ['foo']: ['bar', 'baz'] != Contains(['foobar']) + + >>> ContainsDict({"foo": [{1: 2}, {1: 2, "bar": "baz"}]}).contains({"foo": Contains([{"bar": "baz"}])}) True - >>> ContainsMixin({"foo": ["bar", "baz"]}).contains({"foo": Contains({"foobar"})}) + >>> ContainsDict({"foo": [{1: 2}, {1: 2, "bar": "baz"}]}).contains({"foo": Contains([{"baz": "baz"}])}) Traceback (most recent call last): ... - AssertionError: ['foo']: ['bar', 'baz'] != Contains({'foobar'}) + AssertionError: ['foo']: ["['baz']: is missing", "['baz']: is missing"] """ return SubsetCompare(other, getattr(self, "name", None)) == self @@ -173,3 +205,22 @@ def has_if(self, condition: bool, **kwargs: Any): if condition: return self.has(**kwargs) return True + + @classmethod + def as_contains(cls, x: Any): + if isinstance(x, (ContainsDict, ContainsList)): + return x + elif isinstance(x, dict): + return ContainsDict(x) + elif isinstance(x, list): + return [cls.as_contains(i) for i in x] + else: + return x + + +class ContainsDict(ContainsMixin, dict): ... + + +class ContainsList(ContainsMixin, list): + def __init__(self, items: Optional[Iterable[Any]] = None): + super().__init__([ContainsMixin.as_contains(i) for i in items or []]) diff --git a/tests/functional/utils/docker.py b/tests/functional/utils/docker.py index 2f03bc83..fc47aa0d 100644 --- a/tests/functional/utils/docker.py +++ b/tests/functional/utils/docker.py @@ -9,7 +9,7 @@ from more_itertools import windowed -from .dict import ContainsMixin +from .dict import ContainsDict from .log import get_logger from .os import Program, run @@ -301,8 +301,8 @@ def imagetools_inspect(tag: str) -> Program: return run(["docker", "buildx", "imagetools", "inspect", "--raw", tag]) @staticmethod - def inspect(name: str) -> list[ContainsMixin]: - return [ContainsMixin(i) for i in run(["docker", "inspect", name]).json()] + def inspect(name: str) -> list[ContainsDict]: + return [ContainsDict(i) for i in run(["docker", "inspect", name]).json()] @staticmethod def all_images() -> list[str]: