diff --git a/commodore/cli/component.py b/commodore/cli/component.py index abfa15306..9db3f2ee2 100644 --- a/commodore/cli/component.py +++ b/commodore/cli/component.py @@ -231,6 +231,12 @@ def decorator(cmd): show_default=True, help=f"{add_text} golden tests.", )(cmd) + click.option( + "--update-golden-tests/--no-update-golden-tests", + default=True, + show_default=True, + help="Whether to run `make gen-golden(-all)` after applying the template.", + )(cmd) click.option( "--pp/--no-pp", default=False if new_cmd else None, @@ -305,6 +311,7 @@ def component_new( owner: str, copyright_holder: str, golden_tests: bool, + update_golden_tests: bool, matrix_tests: bool, verbose: int, output_dir: str, @@ -331,6 +338,10 @@ def component_new( t.copyright_holder = copyright_holder t.golden_tests = golden_tests t.matrix_tests = matrix_tests + # NOTE(sg): Must be after matrix test config, because that setter adjusts + # gen_golden_target. + if not update_golden_tests: + t.gen_golden_target = None t.test_cases = ["defaults"] + list(additional_test_case) t.automerge_patch = automerge_patch t.automerge_patch_v0 = automerge_patch_v0 @@ -435,6 +446,7 @@ def component_update( copyright_holder: str, template_version: Optional[str], golden_tests: Optional[bool], + update_golden_tests: Optional[bool], matrix_tests: Optional[bool], lib: Optional[bool], pp: Optional[bool], @@ -479,6 +491,10 @@ def component_update( t.golden_tests = golden_tests if matrix_tests is not None: t.matrix_tests = matrix_tests + # NOTE(sg): Must be after matrix test config, because that setter adjusts + # gen_golden_target. + if not update_golden_tests: + t.gen_golden_target = None if lib is not None: t.library = lib if pp is not None: diff --git a/commodore/cluster.py b/commodore/cluster.py index 8900ebb20..506376010 100644 --- a/commodore/cluster.py +++ b/commodore/cluster.py @@ -5,7 +5,7 @@ import textwrap from datetime import datetime -from typing import Any, Optional, Union +from typing import Any, Iterable, Optional, Union import click @@ -200,6 +200,7 @@ def render_target( target: str, components: dict[str, Component], component: Optional[str] = None, + extra_classes: Optional[Iterable[str]] = None, ): if not component: component = target @@ -229,15 +230,27 @@ def render_target( ) classes.append(f"components.{target}") + if extra_classes: + classes.extend(extra_classes) + return generate_target(inv, target, components, classes, component) -def update_target(cfg: Config, target: str, component: Optional[str] = None): +def update_target( + cfg: Config, + target: str, + component: Optional[str] = None, + extra_classes: Optional[Iterable[str]] = None, +): click.secho(f"Updating Kapitan target for {target}...", bold=True) file = cfg.inventory.target_file(target) os.makedirs(file.parent, exist_ok=True) targetdata = render_target( - cfg.inventory, target, cfg.get_components(), component=component + cfg.inventory, + target, + cfg.get_components(), + component=component, + extra_classes=extra_classes, ) yaml_dump(targetdata, file) diff --git a/commodore/compile.py b/commodore/compile.py index 67b4f78b7..e4e89372d 100644 --- a/commodore/compile.py +++ b/commodore/compile.py @@ -25,6 +25,7 @@ verify_version_overrides, ) from .dependency_mgmt.component_library import create_component_library_aliases +from .dependency_mgmt.component_dependency import validate_catalog_dependencies from .dependency_mgmt.jsonnet_bundler import ( fetch_jsonnet_libraries, jsonnet_dependencies, @@ -241,6 +242,9 @@ def setup_compile_environment(config: Config) -> tuple[dict[str, Any], Iterable[ # Raise exception if component version override without URL is present in the # hierarchy. verify_version_overrides(cluster_parameters, config.get_component_aliases()) + # Raise exception if the catalog violates any component dependency version + # requirements. + validate_catalog_dependencies(config, inventory) for component in config.get_components().values(): ckey = component.parameters_key diff --git a/commodore/component/compile.py b/commodore/component/compile.py index c755a98d2..48242c95f 100644 --- a/commodore/component/compile.py +++ b/commodore/component/compile.py @@ -5,26 +5,35 @@ from collections.abc import Iterable from pathlib import Path as P -from textwrap import dedent -from typing import Optional +from typing import Any, Optional import click import git -from commodore.cluster import generate_target +from commodore.cluster import update_target from commodore.config import Config from commodore.component import Component +from commodore.dependency_mgmt import fetch_components, create_component_symlinks +from commodore.dependency_mgmt.component_dependency import ( + collect_catalog_dependencies, + ComponentDependency, +) from commodore.dependency_mgmt.component_library import ( validate_component_library_name, create_component_library_aliases, ) -from commodore.dependency_mgmt.jsonnet_bundler import fetch_jsonnet_libraries +from commodore.dependency_mgmt.jsonnet_bundler import ( + fetch_jsonnet_libraries, + jsonnet_dependencies, +) from commodore.helpers import kapitan_inventory, kapitan_compile, relsymlink, yaml_dump from commodore.inventory import Inventory from commodore.inventory.lint import check_removed_reclass_variables from commodore.multi_dependency import MultiDependency from commodore.postprocess import postprocess_components +_ARGOCD_REPO_URL = "https://github.com/projectsyn/component-argocd.git" + # pylint: disable=too-many-arguments disable=too-many-locals def compile_component( @@ -35,12 +44,12 @@ def compile_component( search_paths_: Iterable[str], output_path_: str, component_name: str, + discovery_iterations: int = 3, ): # Resolve all input to absolute paths to fix symlinks component_path = P(component_path_).resolve() value_files = [P(f).resolve() for f in value_files_] search_paths = [P(d).resolve() for d in search_paths_] - search_paths.append(component_path / "vendor") output_path = P(output_path_).resolve() if not component_name: @@ -59,20 +68,27 @@ def compile_component( ) temp_dir = P(tempfile.mkdtemp(prefix="component-")).resolve() + search_paths.append(temp_dir / "vendor") config.work_dir = temp_dir try: if config.debug: click.echo(f" > Created temp workspace: {config.work_dir}") inv = config.inventory inv.ensure_dirs() + inv.global_config_dir.mkdir() + yaml_dump({}, inv.global_config_dir / "commodore.yml") + search_paths.append(component_path / "vendor") search_paths.append(inv.dependencies_dir) + # search_paths.append(component_path) component = _setup_component( config, component_name, instance_name, component_path, ) - _prepare_kapitan_inventory(inv, component, value_files, instance_name) + config.register_component(component) + create_component_symlinks(config, component) + _prepare_kapitan_inventory(config, component, value_files, instance_name) # Raise error if component uses removed reclass parameters check_removed_reclass_variables( @@ -81,21 +97,25 @@ def compile_component( [component.defaults_file, component.class_file] + value_files, ) + # Fetch and install component dependencies + nodes = _fetch_component_dependencies( + config, component, instance_name, value_files, discovery_iterations + ) + cluster_parameters = nodes[inv.bootstrap_target]["parameters"] + + # Fetch Jsonnet dependencies + for component in config.get_components().values(): + ckey = component.parameters_key + component.render_jsonnetfile_json(cluster_parameters[ckey]) + + fetch_jsonnet_libraries(config.work_dir, deps=jsonnet_dependencies(config)) + # Verify component alias - nodes = kapitan_inventory(config) config.verify_component_aliases(nodes, bootstrap_target=instance_name) cluster_params = nodes[instance_name]["parameters"] create_component_library_aliases(config, cluster_params) - # Render jsonnetfile.jsonnet if necessary - component_params = nodes[instance_name]["parameters"].get( - component_name.replace("-", "_"), {} - ) - component.render_jsonnetfile_json(component_params) - # Fetch Jsonnet libs - fetch_jsonnet_libraries(component_path) - # Compile component kapitan_compile( config, @@ -111,7 +131,10 @@ def compile_component( # Change working directory for postprocessing config.work_dir = output_path - postprocess_components(config, nodes, config.get_components()) + # NOTE(sg): We prune the inventory here, since we only want to run + # postprocessing for the component that we're actually compiling. + pp_nodes = {instance_name: nodes[instance_name]} + postprocess_components(config, pp_nodes, config.get_components()) config.print_deprecation_notices() finally: if config.trace: @@ -180,14 +203,20 @@ def _setup_component( def _prepare_kapitan_inventory( - inv: Inventory, component: Component, value_files: Iterable[P], instance_name: str + config: Config, + component: Component, + value_files: Iterable[P], + instance_name: str, ): """ Setup Kapitan inventory. Create component symlinks, values file symlinks, setup params class with fake values - and Kapitan target for the component, create a fake `lib/argocd.libjsonnet`. + and Kapitan target for the component. """ + + inv = config.inventory + component_class_file = component.class_file component_defaults_file = component.defaults_file if not component_class_file.exists(): @@ -200,12 +229,14 @@ def _prepare_kapitan_inventory( ) # Create class symlink - relsymlink(component_class_file, inv.components_dir) + relsymlink( + component_class_file, inv.components_dir, dest_name=f"{instance_name}.yml" + ) # Create defaults symlink relsymlink( component_defaults_file, inv.defaults_dir, - dest_name=f"{component.name}.yml", + dest_name=f"{instance_name}.yml", ) # Create component symlink relsymlink(component.target_directory, inv.dependencies_dir, component.name) @@ -229,9 +260,6 @@ def _prepare_kapitan_inventory( "cloud": "cloudscale", "region": "rma1", }, - "argocd": { - "namespace": "test", - }, "components": { component.name: { "url": f"https://example.com/{component.name}.git", @@ -251,27 +279,87 @@ def _prepare_kapitan_inventory( # Create test target value_classes = [f"{c.stem}" for c in value_files] - classes = [ - f"params.{inv.bootstrap_target}", - f"defaults.{component.name}", - f"components.{component.name}", - ] + value_classes - yaml_dump( - generate_target( - inv, instance_name, {component.name: component}, classes, component.name - ), - inv.target_file(instance_name), + update_target(config, instance_name, component.name, value_classes) + + +def _fetch_component_dependencies( + config: Config, + component: Component, + instance_name: str, + value_files: list[P], + discovery_iterations: int, +) -> dict[str, Any]: + click.secho( + f"Discovering component dependencies for {instance_name} " + + f"(iterations={discovery_iterations})...", + bold=True, ) + inv = config.inventory - # Fake Argo CD lib - # We plug "fake" Argo CD library here because every component relies on it - # and we don't want to provide it every time when compiling a single component. - with open(inv.lib_dir / "argocd.libjsonnet", "w", encoding="utf-8") as argocd_libf: - argocd_libf.write(dedent(""" - local ArgoApp(component, namespace, project='', secrets=true, base=null) = {}; - local ArgoProject(name) = {}; - - { - App: ArgoApp, - Project: ArgoProject, - }""")) + nodes = kapitan_inventory(config) + prev_component_deps: dict[str, ComponentDependency] = {} + component_deps = _collect_component_dependencies(config, nodes, component) + i = 0 + + while ( + component_deps.keys() != prev_component_deps.keys() or i == 0 + ) and i < discovery_iterations: + _setup_dependencies(inv, component_deps) + update_target(config, inv.bootstrap_target) + + fetch_components( + config, + applications_target=inv.bootstrap_target, + prefetched_set=set(prev_component_deps.keys()), + ) + + update_target(config, inv.bootstrap_target) + for c in component_deps: + update_target(config, c) + _prepare_kapitan_inventory(config, component, value_files, instance_name) + + nodes = kapitan_inventory(config) + + prev_component_deps = component_deps + component_deps = _collect_component_dependencies(config, nodes, component) + i = i + 1 + + diff = set(component_deps.keys()) - set(prev_component_deps.keys()) + if diff: + click.secho( + f" > [WARNING] component dependency fetching didn't reach fixpoint in {discovery_iterations} iterations", + fg="yellow", + ) + + return nodes + + +def _collect_component_dependencies( + config: Config, + nodes: dict[str, Any], + c: Component, +) -> dict[str, ComponentDependency]: + component_deps = collect_catalog_dependencies(config, nodes) + + # Inject argocd as dependency, if it's not explicitly specified by the + # component, and we're not compiling component-argocd itself. + if c.repo_url == _ARGOCD_REPO_URL: + click.echo(" > Skipping component-argocd dependency injection") + return component_deps + + if "argocd" not in component_deps: + component_deps["argocd"] = ComponentDependency.parse( + c.name, "argocd", {"url": _ARGOCD_REPO_URL} + ) + + return component_deps + + +def _setup_dependencies(inv: Inventory, dependencies: dict[str, ComponentDependency]): + dependencies_yaml: dict[str, Any] = { + "applications": list(dependencies.keys()), + "parameters": { + "components": {dn: dep.component_entry for dn, dep in dependencies.items()} + }, + } + yaml_dump(dependencies_yaml, inv.global_config_dir / "commodore.yml") diff --git a/commodore/component/template.py b/commodore/component/template.py index e6241128d..caae133de 100644 --- a/commodore/component/template.py +++ b/commodore/component/template.py @@ -45,6 +45,7 @@ def __init__( self._automerge_patch_blocklist = set() self._automerge_patch_v0_allowlist = set() self._automerge_minor_allowlist = set() + self.gen_golden_target = "gen-golden" @classmethod def from_existing(cls, config: Config, path: Path): @@ -189,6 +190,10 @@ def matrix_tests(self) -> bool: @matrix_tests.setter def matrix_tests(self, matrix_tests: bool) -> None: self._matrix_tests = matrix_tests + if self.matrix_tests: + self.gen_golden_target = "gen-golden-all" + else: + self.gen_golden_target = "gen-golden" def add_automerge_patch_block_pattern(self, pattern: str): """Add pattern to the patch automerge blocklist. diff --git a/commodore/config.py b/commodore/config.py index b1b5a2030..7ab2b6e02 100644 --- a/commodore/config.py +++ b/commodore/config.py @@ -344,7 +344,7 @@ def inventory(self): def update_verbosity(self, verbose): self._verbose += verbose - def get_components(self): + def get_components(self) -> dict[str, Component]: return self._components def register_component(self, component: Component): @@ -396,7 +396,7 @@ def register_dependency_repo(self, repo_url: str) -> MultiDependency: dep.url = repo_url return dep - def get_component_aliases(self): + def get_component_aliases(self) -> dict[str, str]: return self._component_aliases def register_component_aliases(self, aliases: dict[str, str]): diff --git a/commodore/dependency_mgmt/__init__.py b/commodore/dependency_mgmt/__init__.py index e303dbe88..71e5357d0 100644 --- a/commodore/dependency_mgmt/__init__.py +++ b/commodore/dependency_mgmt/__init__.py @@ -2,7 +2,7 @@ import itertools from concurrent.futures import ThreadPoolExecutor -from typing import Callable, Iterable +from typing import Callable, Iterable, Optional import click from click import ClickException @@ -77,7 +77,11 @@ def create_package_symlink(cfg, pname: str, package: Package): relsymlink(package.target_dir, cfg.inventory.classes_dir, dest_name=pname) -def fetch_components(cfg: Config): +def fetch_components( + cfg: Config, + applications_target: Optional[str] = None, + prefetched_set: Optional[set[str]] = None, +): """ Download all components required by target. @@ -87,14 +91,17 @@ def fetch_components(cfg: Config): click.secho("Discovering components...", bold=True) cfg.inventory.ensure_dirs() - component_names, component_aliases = _discover_components(cfg) + component_names, component_aliases = _discover_components( + cfg, applications_target=applications_target + ) click.secho("Registering component aliases...", bold=True) cfg.register_component_aliases(component_aliases) cspecs = _read_components(cfg, component_aliases) click.secho("Fetching components...", bold=True) deps: dict[str, list] = {} - for cn in component_names: + prefetched = prefetched_set or set() + for cn in set(component_names) - prefetched: cspec = cspecs[cn] if cfg.debug: click.echo(f" > Fetching component {cn}...") diff --git a/commodore/dependency_mgmt/component_dependency.py b/commodore/dependency_mgmt/component_dependency.py new file mode 100644 index 000000000..632fe88b2 --- /dev/null +++ b/commodore/dependency_mgmt/component_dependency.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional + +import click +import semver + +from cel_expr_python import cel # type: ignore + +from commodore.config import Config +from commodore.component import component_parameters_key + + +class ComponentDependencyParseError(ValueError): + field: str + reason: Optional[str] + + def __init__(self, field: str, reason: Optional[str] = ""): + msg = "Error parsing dependency specification" + if reason: + msg += f": {reason}" + super().__init__(msg) + self.field = field + self.reason = reason + + +@dataclass +class ComponentDependency: + """Class for parsed component dependency specification""" + + name: str + instances: list[str] + url: str + path: Optional[str] + minverspec: Optional[str] + minversion: Optional[semver.Version] + testversion: Optional[str] + mandatory: bool + requiredif: list[str] + + @classmethod + def parse( + cls, cname: str, depname: str, depspec: dict[str, str] + ) -> ComponentDependency: + if "url" not in depspec: + raise ComponentDependencyParseError("url", "field 'url' missing") + url = depspec["url"] + minverspec = None + if "minversion" in depspec: + minverspec = depspec["minversion"] + try: + if minverspec.startswith("v"): + minversion = semver.Version.parse(minverspec[1:]) + else: + minversion = semver.Version.parse(minverspec) + except ValueError as e: + raise ComponentDependencyParseError("minversion", str(e)) + else: + minversion = None + + # NOTE(sg): if requiredif isn't set, the dependency is mandatory + mandatory = "requiredif" not in depspec + requiredif = [] + if not mandatory: + requiredif = [depspec["requiredif"]] + + return ComponentDependency( + depname, + [cname], + url, + depspec.get("path"), + minverspec, + minversion, + depspec.get("test_version"), + mandatory, + requiredif, + ) + + def update(self, other: ComponentDependency): + if self.name != other.name: + raise ValueError( + f"Cannot merge ComponentDependency objects with different names: {self.name}, {other.name}" + ) + if self.url != other.url: + raise ValueError( + f"Cannot merge ComponentDependency objects with same name but different URLs: {self.url}, {other.url}" + ) + + if self.path != other.path: + raise ValueError( + "Cannot merge ComponentDependency objects with same name and URL but different sub-paths: " + + f"{self.path}, {other.path}" + ) + + if self.minversion and other.minversion: + cur_self = self.minversion + self.minversion = max(self.minversion, other.minversion) + if cur_self != self.minversion: + self.minverspec = other.minverspec + elif other.minversion: + self.minversion = other.minversion + self.minverspec = other.minverspec + + self.mandatory = self.mandatory or other.mandatory + self.requiredif.extend(other.requiredif) + self.instances.extend(other.instances) + + def _error_helper(self) -> tuple[str, str]: + if len(self.instances) == 1: + instances = "instance" + require = "requires" + else: + instances = "instances" + require = "require" + + return (instances, require) + + def missing_dependency_error(self) -> str: + instances, require = self._error_helper() + instances_list = ", ".join(map(lambda i: f"'{i}'", self.instances)) + return ( + f"Component {instances} {instances_list} {require} dependency " + + f"'{self.name}' which isn't present in catalog" + ) + + def not_minversion_error(self, cv: str) -> str: + instances, require = self._error_helper() + instances_list = ", ".join(map(lambda i: f"'{i}'", self.instances)) + return ( + f"Component {instances} {instances_list} {require} dependency '{self.name}' " + + f"in a version '>= {self.minverspec}': catalog has '{cv}'" + ) + + def required_for_catalog( + self, config: Config, cparams: dict[str, Any], facts: dict[str, Any] + ) -> bool: + if self.mandatory: + return True + + required = False + cel_env = cel.NewEnv(variables={"config": cel.Type.MAP, "facts": cel.Type.MAP}) + for expr in self.requiredif: + if config.debug: + click.echo(f" > Evaluating CEL expression: {expr}") + cel_expr = cel_env.compile(expr) + res = cel_expr.eval(data={"config": cparams, "facts": facts}) + if res.type() == cel.Type.ERROR: + raise ValueError( + f"Evaluation failed for `requiredif` CEL expression: {res.value()}" + ) + if res.type() != cel.Type.BOOL: + raise ValueError( + "Component dependency `requiredif` CEL expression must evaluate to a boolean" + ) + resval = res.value() + required = required or resval + + return required + + @property + def component_entry(self) -> dict[str, str]: + entry = { + "url": self.url, + "version": self.testversion or self.minverspec or "master", + } + if self.path: + entry["path"] = self.path + return entry + + +def collect_catalog_dependencies( + config: Config, inventory: dict[str, Any] +) -> dict[str, ComponentDependency]: + catalog_deps: dict[str, ComponentDependency] = {} + for instance, cn in config.get_component_aliases().items(): + if config.debug: + click.echo(f" > Collecting dependencies for component instance {instance}") + params = inventory[instance]["parameters"] + deps = map( + lambda d: ComponentDependency.parse(instance, *d), + params.get("commodore", {}).get("dependencies", {}).items(), + ) + + for dep in deps: + if not dep.required_for_catalog( + config, params[component_parameters_key(cn)], params["facts"] + ): + if config.debug: + click.echo(f" > Dependency {dep.name} not required for catalog") + continue + + if dep.name in catalog_deps: + catalog_deps[dep.name].update(dep) + else: + catalog_deps[dep.name] = dep + + return catalog_deps + + +def validate_catalog_dependencies(config: Config, inventory: dict[str, Any]): + click.secho("Validating component dependencies...", bold=True) + catalog_deps = collect_catalog_dependencies(config, inventory) + deperrs = [] + for dn, dep in catalog_deps.items(): + if config.verbose: + click.echo(f" > Validating dependency {dn}") + d = config.get_components().get(dn) + if not d: + deperrs.append(dep.missing_dependency_error()) + continue + + if dep.minversion: + try: + if not d.version: + raise ValueError("component instance {dn} missing version") + + if d.version.startswith("v"): + dv = semver.Version.parse(d.version[1:]) + else: + dv = semver.Version.parse(d.version) + except ValueError: + if config.verbose: + click.echo( + f" > Dependency '{dn}' present in catalog with version '{d.version}' " + + f"which doesn't parse as SemVer: assuming '{d.version} >= {dep.minversion}'" + ) + continue + if dep.minversion > dv: + deperrs.append(dep.not_minversion_error(d.version)) + + if len(deperrs) > 0: + deperrs_str = "\n * ".join(deperrs) + raise click.ClickException( + f"catalog dependency validation failed:\n * {deperrs_str}" + ) diff --git a/commodore/dependency_mgmt/discovery.py b/commodore/dependency_mgmt/discovery.py index 39e530166..fa779f03a 100644 --- a/commodore/dependency_mgmt/discovery.py +++ b/commodore/dependency_mgmt/discovery.py @@ -2,6 +2,7 @@ import re from collections.abc import Iterable +from typing import Optional import click @@ -49,7 +50,9 @@ def _extract_component_aliases( return components, all_component_aliases -def _discover_components(cfg) -> tuple[list[str], dict[str, str]]: +def _discover_components( + cfg, applications_target: Optional[str] = None +) -> tuple[list[str], dict[str, str]]: """ Discover components used by the current cluster by extracting all entries from the reclass applications dictionary. @@ -57,10 +60,15 @@ def _discover_components(cfg) -> tuple[list[str], dict[str, str]]: The function also verifies the extracted entries, and raises an exception if any invalid aliases are found. """ - kapitan_applications = kapitan_inventory(cfg, key="applications") + if not applications_target: + kapitan_applications = kapitan_inventory(cfg, key="applications").keys() + else: + kapitan_applications = kapitan_inventory(cfg)[applications_target][ + "applications" + ] components, all_component_aliases = _extract_component_aliases( - cfg, kapitan_applications.keys() + cfg, kapitan_applications ) component_aliases: dict[str, str] = {} diff --git a/commodore/dependency_templater.py b/commodore/dependency_templater.py index 3ea6c7f96..8de2677be 100644 --- a/commodore/dependency_templater.py +++ b/commodore/dependency_templater.py @@ -5,7 +5,9 @@ import json import re import tempfile +import os import shutil +import subprocess # nosec import textwrap from abc import ABC, abstractmethod @@ -90,6 +92,7 @@ class Templater(ABC): copyright_holder: str copyright_year: Optional[str] = None golden_tests: bool + gen_golden_target: Optional[str] today: datetime.date output_dir: Optional[Path] = None _target_dir: Optional[Path] = None @@ -112,6 +115,7 @@ def __init__( self.slug = slug self._name = name self.today = datetime.date.today() + self.gen_golden_target = None if output_dir != "": odir = Path(output_dir) if not odir.is_dir(): @@ -341,6 +345,13 @@ def create(self) -> None: Path(tmpdir) / self.slug, self.target_dir, dirs_exist_ok=True ) + if self.golden_tests and self.gen_golden_target: + env = os.environ + env["COMMODORE_CMD"] = "commodore" + subprocess.call( + ["make", self.gen_golden_target, "-j1"], cwd=self.target_dir, env=env + ) + self.commit("Initial commit", amend=want_worktree) click.secho( f"{self.deptype.capitalize()} {self.name} successfully added 🎉", bold=True diff --git a/poetry.lock b/poetry.lock index ddac8eed4..ab161b31a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -252,6 +252,36 @@ files = [ {file = "cachetools-7.1.7.tar.gz", hash = "sha256:a3e2a00b14d8f8a6b70c1dae7b4685e7ad3bc965c5b42124a2d6ce895da6cf50"}, ] +[[package]] +name = "cel-expr-python" +version = "0.1.3" +description = "The CEL Python runtime" +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "cel_expr_python-0.1.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:5b2ecfc4f4e8120928b446262b13977ee3326b535c1684bdc3eb2fb990298dba"}, + {file = "cel_expr_python-0.1.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11e4cf0a3b2bf7b4231df4ed1f359de006001f2680676c99631df86744f9c1f2"}, + {file = "cel_expr_python-0.1.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc1d80c53affcaf7f31b09dd3e10d87add48833b1de51f037b8fcfb549e7362f"}, + {file = "cel_expr_python-0.1.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:292ae505727bfcfc1986c8bf653c2580faa06f46af56b9087060eafdf2a08652"}, + {file = "cel_expr_python-0.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:8b56ebf586df6ec4dae0449d90bd9f6da0027bef407f2ffbb92701a9886ee43c"}, + {file = "cel_expr_python-0.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab17a336e7f01e3868fc454630c05596fb8a16293833fa34a7a2f946faa835f"}, + {file = "cel_expr_python-0.1.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4e2694afe68a0484ee7c99a79887309511f6738e562a5d2c4ff566dc791e7ec6"}, + {file = "cel_expr_python-0.1.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4170d1e6a3cf359b0ec7c1fb50317daa3e3e3eb12b61311260714a1e02313fbe"}, + {file = "cel_expr_python-0.1.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab3b0a418b3911177a77ee69fef1f4199b4a053e2ddd1c339119e58c9cd72824"}, + {file = "cel_expr_python-0.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:925bbf323fdf2743491ddf411e40b5c4a12ab102e3bc7e0bfec0fed1c6defa3b"}, + {file = "cel_expr_python-0.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ac60fdb981d1435faadc536c0582ada321a3a392c18ba70e94e3f3caa0f37437"}, + {file = "cel_expr_python-0.1.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8eb8d3c92b11e99fffddb846ba68f8b1ba12a3bf1bc0ccdd8c2914c7458f3a4"}, + {file = "cel_expr_python-0.1.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea4679e74567c145c824e1ef049ae340af5b2a1a4451324d215e58ed5db20969"}, + {file = "cel_expr_python-0.1.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa80370ed20cee201a7671634f9c58dc17fd93f15addf680d7e0393969bc51b3"}, + {file = "cel_expr_python-0.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:cd6ff40b4a89af6b62ca24d431893d8fcce5cca6149f06926a0e88211a370645"}, + {file = "cel_expr_python-0.1.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:665fbb261c4733e69a4022763c16f7655cfe6e057cd394ad9f36dfee92b9df8c"}, + {file = "cel_expr_python-0.1.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b3e1ba34748796624b2cf8c9bc664b79baccc0662c97299a1991278ef45d3aed"}, + {file = "cel_expr_python-0.1.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d2035831cfb9c0ae36c91e3de421828f8fa27173680e86676a7d6e0f68300a0"}, + {file = "cel_expr_python-0.1.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b36c7a36ca865b147e1fc68f3f6faaae5b113e9de4d77e7eb751c95e1547a99"}, + {file = "cel_expr_python-0.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:4711f59f0dd3fcabf68a617685ab2857ed931cc10e2f1595abe7d4d99f921bba"}, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -3063,6 +3093,18 @@ botocore = ">=1.37.4,<2.0a0" [package.extras] crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] +[[package]] +name = "semver" +version = "3.0.4" +description = "Python helper for Semantic Versioning (https://semver.org)" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746"}, + {file = "semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602"}, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -3349,4 +3391,4 @@ dev = ["doc8", "flake8", "flake8-import-order", "rstcheck[sphinx]", "ruff", "sph [metadata] lock-version = "2.1" python-versions = ">=3.11, <3.15" -content-hash = "8fb1d1b206459e1f562ae6572aeb070f7f9bae2c495caf0fe5a612ccb5606250" +content-hash = "e7bc865a6915a91f9ce8a1eaabf8c4338fc5349a8bc1417c61b56a364af0de2e" diff --git a/pyproject.toml b/pyproject.toml index e419ccc61..c14ddd743 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,8 @@ PyGithub = "2.10.0" reclass-rs = "0.11.0" gojsonnet = "0.22.0" pygorpmrustinfo = "0.1.5" +semver = "3.0.4" +cel-expr-python = "^0.1.3" [tool.poetry.group.dev.dependencies] tox = "3.28.0" diff --git a/tests/test_component_template.py b/tests/test_component_template.py index e94fe0ba2..65cbcf14e 100644 --- a/tests/test_component_template.py +++ b/tests/test_component_template.py @@ -36,6 +36,7 @@ def call_component_new( automerge_patch="--no-automerge-patch", automerge_patch_v0="--no-automerge-patch-v0", autorelease="--no-autorelease", + update_golden="--no-update-golden-tests", output_dir="", extra_args: list[str] = [], ): @@ -52,6 +53,7 @@ def call_component_new( automerge_patch, automerge_patch_v0, autorelease, + update_golden, ] ) args.extend(extra_args) @@ -482,7 +484,8 @@ def test_run_component_new_command_with_name(tmp_path: P): cruftjson_path = tmp_path / "dependencies" / component_slug / ".cruft.json" exit_status = call( - f"commodore -d {tmp_path} -vvv component new --name '{component_name}' {component_slug}", + f"commodore -d {tmp_path} -vvv component new " + + f"--no-update-golden-tests --name '{component_name}' {component_slug}", shell=True, ) @@ -915,7 +918,7 @@ def test_check_golden_diff(tmp_path: P): component_name = "test-component" exit_status = call( - f"commodore -d {tmp_path} -vvv component new {component_name}", + f"commodore -d {tmp_path} -vvv component new --update-golden-tests {component_name}", shell=True, ) assert exit_status == 0 @@ -967,6 +970,7 @@ def test_component_update_bool_flags( "--no-lib", "--no-pp", "--no-golden-tests", + "--no-update-golden-tests", "--no-matrix-tests", "--no-automerge-patch", "--no-automerge-patch-v0", @@ -1004,6 +1008,7 @@ def test_component_update_bool_flags( str(tmp_path), "component", "update", + "--no-update-golden-tests", f"{tmp_path}/dependencies/{component_name}", ] has_lib = "--lib" in update_args diff --git a/tests/test_dependency_mgmt_component_dependency.py b/tests/test_dependency_mgmt_component_dependency.py new file mode 100644 index 000000000..f6ef247bd --- /dev/null +++ b/tests/test_dependency_mgmt_component_dependency.py @@ -0,0 +1,547 @@ +import copy + +from pathlib import Path +from typing import Optional + +import click +import pytest +import semver + +from commodore.config import Config +from commodore.component import Component +from commodore.multi_dependency import MultiDependency + +from commodore.dependency_mgmt import component_dependency + + +def _make_dep( + name: str, + url: str, + instances: list[str] = ["test-component"], + path: Optional[str] = None, + minverspec: Optional[str] = None, + requiredif: Optional[list[str]] = None, +): + mandatory = False + if not requiredif: + mandatory = True + minversion = None + if minverspec: + try: + minversion = semver.Version.parse(minverspec) + except ValueError: + minversion = semver.Version.parse(minverspec[1:]) + + return component_dependency.ComponentDependency( + name, + instances, + url, + path, + minverspec, + minversion, + None, + mandatory, + requiredif or [], + ) + + +@pytest.mark.parametrize( + "depname,depspec,expected", + [ + ( + "argocd", + {"url": "https://github.com/projectsyn/component-argocd.git"}, + _make_dep("argocd", "https://github.com/projectsyn/component-argocd.git"), + ), + ( + "argocd", + { + "url": "https://github.com/projectsyn/component-argocd.git", + "minversion": "v1.0.0", + }, + _make_dep( + "argocd", + "https://github.com/projectsyn/component-argocd.git", + minverspec="v1.0.0", + ), + ), + ( + "argocd", + { + "url": "https://github.com/projectsyn/component-argocd.git", + "requiredif": "facts.distribution == 'openshift4'", + }, + _make_dep( + "argocd", + "https://github.com/projectsyn/component-argocd.git", + requiredif=["facts.distribution == 'openshift4'"], + ), + ), + ], +) +def test_component_dependency_parse( + depname: str, + depspec: dict[str, str], + expected: component_dependency.ComponentDependency, +): + dep = component_dependency.ComponentDependency.parse( + "test-component", depname, depspec + ) + assert dep == expected + + +@pytest.mark.parametrize( + "depname,depspec,expected_error", + [ + ("argocd", {}, "Error parsing dependency specification: field 'url' missing"), + ( + "argocd", + { + "url": "https://github.com/projectsyn/component-argocd.git", + "minversion": "foo", + }, + "Error parsing dependency specification: foo is not valid SemVer string", + ), + ], +) +def test_component_dependency_parse_error( + depname: str, depspec: dict[str, str], expected_error: str +): + + with pytest.raises(component_dependency.ComponentDependencyParseError) as e: + component_dependency.ComponentDependency.parse( + "test-component", depname, depspec + ) + + assert expected_error in str(e) + + +@pytest.mark.parametrize( + "d1_min,d2_min,expected_min", + [ + (None, None, None), + (None, "v1.0.0", "v1.0.0"), + ("v1.2.3", "v1.0.0", "v1.2.3"), + ("v1.0.0", "v1.2.3", "v1.2.3"), + ], +) +def test_component_dependency_update( + d1_min: Optional[str], d2_min: Optional[str], expected_min: Optional[str] +): + d1 = _make_dep( + "argocd", + "https://github.com/projectsyn/component-argocd.git", + instances=["cilium"], + minverspec=d1_min, + ) + d2 = _make_dep( + "argocd", + "https://github.com/projectsyn/component-argocd.git", + instances=["rook-ceph"], + minverspec=d2_min, + ) + d1.update(d2) + assert d1.minverspec == expected_min + if expected_min: + assert d1.minversion == semver.Version.parse(expected_min[1:]) + assert d1.instances == ["cilium", "rook-ceph"] + + +@pytest.mark.parametrize( + "d1,d2,expected_error", + [ + ( + {"name": "argocd"}, + {"name": "foo"}, + "Cannot merge ComponentDependency objects with different names: argocd, foo", + ), + ( + { + "name": "argocd", + "url": "https://github.com/projectsyn/component-argocd.git", + }, + { + "name": "argocd", + "url": "https://github.com/projectsyn/component-argocd", + }, + "Cannot merge ComponentDependency objects with same name but different URLs: " + + "https://github.com/projectsyn/component-argocd.git, https://github.com/projectsyn/component-argocd", + ), + ( + { + "name": "argocd", + "url": "https://github.com/projectsyn/component-argocd.git", + }, + { + "name": "argocd", + "url": "https://github.com/projectsyn/component-argocd.git", + "path": "foobar", + }, + "Cannot merge ComponentDependency objects with same name and URL but different sub-paths: " + + "None, foobar", + ), + ], +) +def test_component_dependency_update_error( + d1: dict[str, str], d2: dict[str, str], expected_error: str +): + d1 = _make_dep( + d1["name"], d1.get("url", ""), instances=["cilium"], path=d1.get("path") + ) + d2 = _make_dep( + d2["name"], d2.get("url", ""), instances=["rook-ceph"], path=d2.get("path") + ) + + with pytest.raises(ValueError) as e: + d1.update(d2) + + assert expected_error in str(e) + + +def test_component_dependency_error_helpers_single_instance(): + d = _make_dep( + "argocd", + "https://github.com/projectsyn/component-argocd.git", + minverspec="v1.0.0", + ) + assert ( + d.missing_dependency_error() + == "Component instance 'test-component' requires dependency 'argocd' which isn't present in catalog" + ) + assert ( + d.not_minversion_error("v0.8.1") + == "Component instance 'test-component' requires dependency 'argocd' in a version '>= v1.0.0': " + + "catalog has 'v0.8.1'" + ) + + +def test_component_dependency_error_helpers_multiple_instances(): + d = _make_dep( + "argocd", + "https://github.com/projectsyn/component-argocd.git", + minverspec="v1.0.0", + instances=["test-component-1", "test-component-2"], + ) + assert ( + d.missing_dependency_error() + == "Component instances 'test-component-1', 'test-component-2' require dependency 'argocd' " + + "which isn't present in catalog" + ) + assert ( + d.not_minversion_error("v0.8.1") + == "Component instances 'test-component-1', 'test-component-2' require dependency 'argocd' " + + "in a version '>= v1.0.0': catalog has 'v0.8.1'" + ) + + +def test_component_dependency_mandatory_required_for_catalog(config: Config): + d = _make_dep( + "argocd", + "https://github.com/projectsyn/component-argocd.git", + ) + assert d.required_for_catalog(config, {}, {}) + + +@pytest.mark.parametrize( + "requiredif,required", + [ + (["facts.distribution=='openshift4'"], False), + (["facts.distribution=='talos'"], True), + ( + [ + "facts.distribution=='talos' && (config.foo=='bar' || config.param == 'temporary')" + ], + True, + ), + ], +) +def test_component_dependency_required_for_catalog( + config: Config, requiredif: list[str], required: bool +): + d = _make_dep( + "argocd", + "https://github.com/projectsyn/component-argocd.git", + requiredif=requiredif, + ) + + facts = { + "distribution": "talos", + } + cparams = { + "foo": "bar", + "param": "value", + } + + assert d.required_for_catalog(config, cparams, facts) == required + + +@pytest.mark.parametrize( + "requiredif,expected_error", + [ + ( + ["facts.distribution"], + "Component dependency `requiredif` CEL expression must evaluate to a boolean", + ), + ( + ["facts.cloud == 'cloudscale'"], + 'Evaluation failed for `requiredif` CEL expression: NOT_FOUND: Key not found in map : "cloud"\'', + ), + ], +) +def test_component_dependency_required_for_catalog_errors( + config: Config, requiredif: list[str], expected_error: str +): + d = _make_dep( + "argocd", + "https://github.com/projectsyn/component-argocd.git", + requiredif=requiredif, + ) + + facts = { + "distribution": "talos", + } + cparams = { + "foo": "bar", + "param": "value", + } + + with pytest.raises(ValueError) as e: + d.required_for_catalog(config, cparams, facts) + + assert expected_error in str(e) + + +@pytest.mark.parametrize( + "dep,expected", + [ + ( + _make_dep( + "argocd", + "https://github.com/projectsyn/component-argocd.git", + ), + { + "url": "https://github.com/projectsyn/component-argocd.git", + "version": "master", + }, + ), + ( + _make_dep( + "argocd", + "https://github.com/projectsyn/component-argocd.git", + minverspec="v1.0.0", + ), + { + "url": "https://github.com/projectsyn/component-argocd.git", + "version": "v1.0.0", + }, + ), + ( + _make_dep( + "argocd", + "https://github.com/projectsyn/component-argocd.git", + path="foobar", + ), + { + "url": "https://github.com/projectsyn/component-argocd.git", + "version": "master", + "path": "foobar", + }, + ), + ], +) +def test_component_dependency_component_entry(dep, expected): + assert dep.component_entry == expected + + +def _make_inv(config: Config, tmp_path: Path, inject_errors: bool = False): + cluster = { + "parameters": { + # NOTE(sg): because we don't use fetch_components() in the tests, this isn't read + # directly. Instead we directly read the version field below when we create the + # `Component` objects. + "components": { + "test-component-1": { + "url": "https://github.com/projectsyn/component-tc1.git", + "version": "v1.0.1", + }, + "test-component-2": { + "url": "https://github.com/projectsyn/component-tc2.git", + "version": "v1.0.2", + }, + "test-component-3": { + "url": "https://github.com/projectsyn/component-tc3.git", + "version": "v1.0.3", + }, + "test-component-4": { + "url": "https://github.com/projectsyn/component-tc4.git", + "version": "v1.0.4", + }, + "test-component-5": { + "url": "https://github.com/projectsyn/component-tc5.git", + "version": "v1.0.0" if inject_errors else "v1.0.5", + }, + "test-component-6": { + "url": "https://github.com/projectsyn/component-tc6.git", + "version": "feat/test", + }, + }, + "facts": { + "distribution": "talos", + "cloud": "cloudscale", + }, + "test_component_1": {}, + "test_component_2": {}, + "test_component_3": { + "tc4_enabled": True, + }, + "test_component_4": {}, + "test_component_5": {}, + "test_component_6": {}, + } + } + + tc1 = copy.deepcopy(cluster) + tc1["parameters"]["commodore"] = { + "dependencies": { + "test-component-2": { + "url": "https://github.com/projectsyn/component-tc2.git", + }, + "test-component-3": { + "url": "https://github.com/projectsyn/component-tc2.git", + "minversion": "v1.1.0", + "requiredif": "facts.distribution == 'openshift4'", + }, + } + } + tc2 = copy.deepcopy(cluster) + if inject_errors: + tc2["parameters"]["commodore"] = { + "dependencies": { + "fake-component-1": { + "url": "https://github.com/projectsyn/component-fc1.git", + } + } + } + tc3 = copy.deepcopy(cluster) + tc3["parameters"]["commodore"] = { + "dependencies": { + "test-component-4": { + "url": "https://github.com/projectsyn/component-tc4.git", + "requiredif": "facts.distribution == 'talos' && config.tc4_enabled", + }, + } + } + tc4 = copy.deepcopy(cluster) + tc4["parameters"]["commodore"] = { + "dependencies": { + "test-component-5": { + "url": "https://github.com/projectsyn/component-tc5.git", + "minversion": "v1.0.1", + } + } + } + tc5 = copy.deepcopy(cluster) + if inject_errors: + tc5["parameters"]["commodore"] = { + "dependencies": { + "test-component-2": { + "url": "https://github.com/projectsyn/component-tc2.git", + }, + "test-component-6": { + "url": "https://github.com/projectsyn/component-tc6.git", + "minversion": "v1.0.0", + }, + } + } + tc6 = copy.deepcopy(cluster) + + inv = { + "cluster": cluster, + "test-component-1": tc1, + "test-component-2": tc2, + "test-component-3": tc3, + "test-component-4": tc4, + "test-component-5": tc5, + "test-component-6": tc6, + } + for i in range(1, 7): + cdep = MultiDependency( + f"https://github.com/projectsyn/component-tc{i}.git", + tmp_path / "dependencies", + ) + c = Component( + f"test-component-{i}", + dependency=cdep, + work_dir=tmp_path, + version=cluster["parameters"]["components"][f"test-component-{i}"][ + "version" + ], + ) + config.register_component(c) + config.register_component_aliases( + { + "test-component-1": "test-component-1", + "test-component-2": "test-component-2", + "test-component-3": "test-component-3", + "test-component-4": "test-component-4", + "test-component-5": "test-component-5", + "test-component-6": "test-component-6", + } + ) + return inv + + +def test_collect_catalog_dependencies(tmp_path: Path, config: Config): + inv = _make_inv(config, tmp_path) + + deps = component_dependency.collect_catalog_dependencies(config, inv) + + expected_deps = { + "test-component-2": _make_dep( + "test-component-2", + "https://github.com/projectsyn/component-tc2.git", + instances=["test-component-1"], + ), + "test-component-4": _make_dep( + "test-component-4", + "https://github.com/projectsyn/component-tc4.git", + instances=["test-component-3"], + requiredif=["facts.distribution == 'talos' && config.tc4_enabled"], + ), + "test-component-5": _make_dep( + "test-component-5", + "https://github.com/projectsyn/component-tc5.git", + instances=["test-component-4"], + minverspec="v1.0.1", + ), + } + + assert deps == expected_deps + + +def test_validate_catalog_dependencies(config: Config, tmp_path: Path): + inv = _make_inv(config, tmp_path) + component_dependency.validate_catalog_dependencies(config, inv) + + +def test_validate_catalog_dependencies_errors(config: Config, tmp_path: Path): + inv = _make_inv(config, tmp_path, inject_errors=True) + config.update_verbosity(3) + + with pytest.raises(click.ClickException) as e: + component_dependency.validate_catalog_dependencies(config, inv) + + elines = e.value.message.split("\n") + + assert len(elines) == 3 + assert elines[0] == "catalog dependency validation failed:" + assert ( + elines[1] + == " * Component instance 'test-component-2' requires dependency 'fake-component-1' " + + "which isn't present in catalog" + ) + assert ( + elines[2] + == " * Component instance 'test-component-4' requires dependency 'test-component-5' " + + "in a version '>= v1.0.1': catalog has 'v1.0.0'" + ) diff --git a/tests/test_dependency_mgmt_jsonnet_bundler.py b/tests/test_dependency_mgmt_jsonnet_bundler.py index 1f61771af..e84111096 100644 --- a/tests/test_dependency_mgmt_jsonnet_bundler.py +++ b/tests/test_dependency_mgmt_jsonnet_bundler.py @@ -1,6 +1,9 @@ import json from pathlib import Path +import click +import pytest + from commodore.component import Component from commodore.config import Config @@ -88,3 +91,18 @@ def test_clear_jsonnet_lock_file(tmp_path: Path): data["dependencies"][0]["version"] != "57b4365eacda291b82e0d55ba7eec573a8198dda" ) + + +def test_fetch_jsonnet_libraries_no_jsonnetfile(tmp_path: Path): + jsonnet_bundler.fetch_jsonnet_libraries(tmp_path, []) + + +def test_fetch_jsonnet_libraries_jb_error(tmp_path: Path): + missing_path = tmp_path / "foobar" + with pytest.raises(click.ClickException) as e: + jsonnet_bundler.fetch_jsonnet_libraries( + tmp_path, + [{"source": {"local": {"directory": str(missing_path)}}}], + ) + + assert e.value.message == "jsonnet-bundler exited with error" diff --git a/tox.ini b/tox.ini index 210c37214..bd83b9160 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,7 @@ deps = !bench: pytest-xdist bench: pytest-benchmark commands = \ - pytest {toxinidir}/tests \ + pytest --durations=10 {toxinidir}/tests \ bench: -m bench \ !bench: -m "not bench and not integration" -n auto --dist worksteal \ {posargs}