From 5959e198ac82704c3a2273147bc1a1c2aad14942 Mon Sep 17 00:00:00 2001 From: Murilo Pereira Date: Thu, 29 Oct 2026 16:12:51 +0100 Subject: [PATCH 1/4] feat(e2e): add a chaos experiment harness Add e2e/chaos, a harness that runs declarative resilience experiments against an OpenShell gateway and decides a verdict from recorded evidence: PASS, XFAIL, FAIL or ERROR. An experiment is one YAML file under e2e/chaos/experiments/ (apiVersion chaos/v1alpha1): the workloads to run as sandboxes, the operations to apply (kill, restart, pause, stop and start, policy changes), a flow that orders them in time, the metrics it is about, and per-instance and experiment-level checks, including bounds on scraped metrics. An expectations block ties checks to a GitHub issue, so a known bug ends the run in XFAIL and its fix shows up as XPASS. An adapter is a second YAML file under e2e/chaos/adapters/ that binds the harness to one system on one backend: the hooks that create, read, collect and delete a sandbox, the processes and containers or pods the built-in faults act on, adapter operations with their revert hooks, and what to observe. openshell-docker runs under e2e/with-docker-gateway.sh and is the one every result below came from. openshell-kubernetes declares the operations it cannot do and validates against every experiment, but has not been run against a cluster. A run writes e2e/chaos/runs/// with every input, a timeline of state changes and operations, gateway and sandbox logs, scraped gateway metrics, the files each sandbox wrote and the verdict. From a run, chaos report draws an HTML report, chaos share writes a Markdown page with Mermaid charts for an issue or a post, chaos export writes the metrics as OpenMetrics, chaos dashboard builds the experiment's Grafana dashboard, and chaos evaluate decides the verdict again offline. grafana/ holds the Prometheus and Grafana compose stack. Nine experiments are included, all run on a local Docker gateway: - hello-world, smoke: harness sanity checks. PASS. - gateway-graceful-restart: SIGTERM the gateway and start it; every sandbox restarts and each counter resumes from its saved tick. PASS. - supervisor-pause: SIGSTOP every supervisor for 10 s; workloads with no network use run through untouched. PASS. - gateway-kill-keeps-state, policy-tighten-survives-gateway-restart: SIGKILL the gateway; the running sandboxes stop and never record an exit code. XFAIL, NVIDIA/OpenShell#4910. - sandbox-container-restart: docker restart one sandbox container; the sandbox never returns to Ready. XFAIL, NVIDIA/OpenShell#4828. - sandbox-stop-start: stop and start sandboxes for 25 minutes, timed against reconcile sweeps; one start ends StartFailed. XFAIL, NVIDIA/OpenShell#4981. - supervisor-kill: SIGKILL two supervisors; the sandboxes go to Error and stop, as documented. FAIL until the intended end state is settled. Outside e2e/chaos: e2e/with-docker-gateway.sh gains a metrics port and an opt-in OTLP endpoint (OPENSHELL_E2E_GATEWAY_OTLP=1), tasks/test.toml gains the e2e:chaos, e2e:chaos:preflight, e2e:chaos:check, e2e:chaos:share and e2e:chaos:grafana tasks, and CONTRIBUTING.md links the directory. Nothing runs in CI; e2e:chaos:check runs hello-world and smoke against a Docker gateway and fails unless both pass. Docs: e2e/chaos/README.md (overview, walkthrough, the run lifecycle, results, how to write an experiment), REFERENCE.md (the experiment and adapter schemas, CLI, run directory, timeline events, verdicts) and ADAPTER.md (how to write an adapter), with report, share and Grafana screenshots under images/. Signed-off-by: Murilo Pereira --- CONTRIBUTING.md | 1 + e2e/chaos/.gitignore | 2 + e2e/chaos/ADAPTER.md | 417 +++++ e2e/chaos/README.md | 1380 +++++++++++++++++ e2e/chaos/REFERENCE.md | 646 ++++++++ e2e/chaos/adapters/openshell-docker.yaml | 540 +++++++ e2e/chaos/adapters/openshell-kubernetes.yaml | 491 ++++++ .../experiments/gateway-graceful-restart.yaml | 120 ++ .../experiments/gateway-kill-keeps-state.yaml | 146 ++ e2e/chaos/experiments/hello-world.yaml | 49 + ...licy-tighten-survives-gateway-restart.yaml | 214 +++ .../sandbox-container-restart.yaml | 142 ++ e2e/chaos/experiments/sandbox-stop-start.yaml | 165 ++ e2e/chaos/experiments/smoke.yaml | 77 + e2e/chaos/experiments/supervisor-kill.yaml | 112 ++ e2e/chaos/experiments/supervisor-pause.yaml | 111 ++ e2e/chaos/grafana/compose.yaml | 55 + e2e/chaos/grafana/dashboards/chaos-run.json | 401 +++++ e2e/chaos/grafana/prometheus.yml | 5 + .../provisioning/dashboards/chaos.yaml | 14 + .../provisioning/datasources/prometheus.yaml | 13 + e2e/chaos/grafana/view.sh | 49 + .../grafana-gateway-kill-keeps-state.png | Bin 0 -> 612202 bytes .../report-gateway-kill-keeps-state.png | Bin 0 -> 373165 bytes .../images/share-gateway-graceful-restart.png | Bin 0 -> 74863 bytes .../images/share-gateway-kill-keeps-state.png | Bin 0 -> 109896 bytes e2e/chaos/images/share-hello-world.png | Bin 0 -> 30345 bytes ...olicy-tighten-survives-gateway-restart.png | Bin 0 -> 64592 bytes .../share-sandbox-container-restart.png | Bin 0 -> 66065 bytes e2e/chaos/images/share-sandbox-stop-start.png | Bin 0 -> 87779 bytes e2e/chaos/images/share-smoke.png | Bin 0 -> 45026 bytes e2e/chaos/images/share-supervisor-kill.png | Bin 0 -> 73395 bytes e2e/chaos/images/share-supervisor-pause.png | Bin 0 -> 61943 bytes e2e/chaos/pyproject.toml | 40 + e2e/chaos/scripts/check.sh | 38 + e2e/chaos/scripts/kubernetes-env.sh | 78 + .../scripts/shellcheck-embedded-scripts.py | 137 ++ e2e/chaos/scripts/test-and-lint.sh | 31 + e2e/chaos/src/chaos/__init__.py | 4 + e2e/chaos/src/chaos/adapter.py | 558 +++++++ e2e/chaos/src/chaos/cli.py | 657 ++++++++ e2e/chaos/src/chaos/dashboard.py | 441 ++++++ e2e/chaos/src/chaos/envvars.py | 169 ++ e2e/chaos/src/chaos/evaluate.py | 548 +++++++ e2e/chaos/src/chaos/export.py | 210 +++ e2e/chaos/src/chaos/flow.py | 234 +++ e2e/chaos/src/chaos/inject/__init__.py | 48 + e2e/chaos/src/chaos/inject/base.py | 196 +++ e2e/chaos/src/chaos/inject/docker.py | 254 +++ e2e/chaos/src/chaos/inject/kubernetes.py | 263 ++++ e2e/chaos/src/chaos/instances.py | 343 ++++ e2e/chaos/src/chaos/model.py | 900 +++++++++++ e2e/chaos/src/chaos/observe.py | 241 +++ e2e/chaos/src/chaos/operations.py | 243 +++ e2e/chaos/src/chaos/report.py | 574 +++++++ e2e/chaos/src/chaos/report_template.html | 523 +++++++ e2e/chaos/src/chaos/rundir.py | 33 + e2e/chaos/src/chaos/runner.py | 442 ++++++ e2e/chaos/src/chaos/share.py | 424 +++++ e2e/chaos/src/chaos/signals.py | 43 + e2e/chaos/src/chaos/timeline.py | 46 + e2e/chaos/tests/conftest.py | 55 + .../tests/fixtures/invalid-api-version.yaml | 19 + .../tests/fixtures/invalid-chaos-env.yaml | 20 + .../tests/fixtures/invalid-data-name.yaml | 25 + ...expectation-affected-experiment-check.yaml | 25 + ...valid-expectation-affected-untargeted.yaml | 21 + .../fixtures/invalid-expectation-issue.yaml | 21 + .../invalid-expectation-shared-check.yaml | 24 + .../invalid-expectation-unknown-check.yaml | 22 + .../fixtures/invalid-flow-missing-name.yaml | 21 + .../fixtures/invalid-flow-repeated-name.yaml | 19 + .../invalid-metric-check-no-bound.yaml | 22 + .../invalid-metric-check-undeclared.yaml | 22 + .../fixtures/invalid-metric-entry-both.yaml | 21 + .../tests/fixtures/invalid-metric-name.yaml | 20 + .../fixtures/invalid-missing-description.yaml | 18 + .../fixtures/invalid-no-api-version.yaml | 18 + .../tests/fixtures/invalid-no-checks.yaml | 19 + .../invalid-operation-global-select.yaml | 24 + .../invalid-operation-name-clash.yaml | 24 + .../invalid-operation-no-targeting.yaml | 24 + .../fixtures/invalid-operation-no-wait.yaml | 24 + .../fixtures/invalid-outage-no-duration.yaml | 21 + .../tests/fixtures/invalid-repeat-shape.yaml | 19 + .../fixtures/invalid-repeat-workload.yaml | 19 + .../fixtures/invalid-restart-duration.yaml | 21 + .../fixtures/invalid-role-fault-no-wait.yaml | 21 + .../invalid-role-fault-wait-after.yaml | 21 + .../tests/fixtures/invalid-unknown-key.yaml | 20 + .../invalid-wait-outside-parallel.yaml | 19 + .../tests/fixtures/valid-expectations.yaml | 26 + e2e/chaos/tests/fixtures/valid-freeze.yaml | 21 + e2e/chaos/tests/fixtures/valid-minimal.yaml | 19 + .../tests/fixtures/valid-operations.yaml | 25 + .../tests/fixtures/valid-role-fault.yaml | 21 + e2e/chaos/tests/test_adapter.py | 195 +++ e2e/chaos/tests/test_cli.py | 59 + e2e/chaos/tests/test_dashboard.py | 87 ++ e2e/chaos/tests/test_envvars.py | 139 ++ e2e/chaos/tests/test_evaluate.py | 568 +++++++ e2e/chaos/tests/test_kubernetes.py | 262 ++++ e2e/chaos/tests/test_model.py | 410 +++++ e2e/chaos/tests/test_observe.py | 99 ++ e2e/chaos/tests/test_recovery.py | 88 ++ e2e/chaos/tests/test_report.py | 467 ++++++ e2e/chaos/tests/test_runner.py | 873 +++++++++++ e2e/chaos/tests/test_select.py | 89 ++ e2e/chaos/tests/test_share.py | 307 ++++ e2e/chaos/tests/test_signals.py | 104 ++ e2e/chaos/uv.lock | 349 +++++ e2e/with-docker-gateway.sh | 26 +- tasks/test.toml | 25 + 113 files changed, 17792 insertions(+), 4 deletions(-) create mode 100644 e2e/chaos/.gitignore create mode 100644 e2e/chaos/ADAPTER.md create mode 100644 e2e/chaos/README.md create mode 100644 e2e/chaos/REFERENCE.md create mode 100644 e2e/chaos/adapters/openshell-docker.yaml create mode 100644 e2e/chaos/adapters/openshell-kubernetes.yaml create mode 100644 e2e/chaos/experiments/gateway-graceful-restart.yaml create mode 100644 e2e/chaos/experiments/gateway-kill-keeps-state.yaml create mode 100644 e2e/chaos/experiments/hello-world.yaml create mode 100644 e2e/chaos/experiments/policy-tighten-survives-gateway-restart.yaml create mode 100644 e2e/chaos/experiments/sandbox-container-restart.yaml create mode 100644 e2e/chaos/experiments/sandbox-stop-start.yaml create mode 100644 e2e/chaos/experiments/smoke.yaml create mode 100644 e2e/chaos/experiments/supervisor-kill.yaml create mode 100644 e2e/chaos/experiments/supervisor-pause.yaml create mode 100644 e2e/chaos/grafana/compose.yaml create mode 100644 e2e/chaos/grafana/dashboards/chaos-run.json create mode 100644 e2e/chaos/grafana/prometheus.yml create mode 100644 e2e/chaos/grafana/provisioning/dashboards/chaos.yaml create mode 100644 e2e/chaos/grafana/provisioning/datasources/prometheus.yaml create mode 100755 e2e/chaos/grafana/view.sh create mode 100644 e2e/chaos/images/grafana-gateway-kill-keeps-state.png create mode 100644 e2e/chaos/images/report-gateway-kill-keeps-state.png create mode 100644 e2e/chaos/images/share-gateway-graceful-restart.png create mode 100644 e2e/chaos/images/share-gateway-kill-keeps-state.png create mode 100644 e2e/chaos/images/share-hello-world.png create mode 100644 e2e/chaos/images/share-policy-tighten-survives-gateway-restart.png create mode 100644 e2e/chaos/images/share-sandbox-container-restart.png create mode 100644 e2e/chaos/images/share-sandbox-stop-start.png create mode 100644 e2e/chaos/images/share-smoke.png create mode 100644 e2e/chaos/images/share-supervisor-kill.png create mode 100644 e2e/chaos/images/share-supervisor-pause.png create mode 100644 e2e/chaos/pyproject.toml create mode 100755 e2e/chaos/scripts/check.sh create mode 100755 e2e/chaos/scripts/kubernetes-env.sh create mode 100644 e2e/chaos/scripts/shellcheck-embedded-scripts.py create mode 100755 e2e/chaos/scripts/test-and-lint.sh create mode 100644 e2e/chaos/src/chaos/__init__.py create mode 100644 e2e/chaos/src/chaos/adapter.py create mode 100644 e2e/chaos/src/chaos/cli.py create mode 100644 e2e/chaos/src/chaos/dashboard.py create mode 100644 e2e/chaos/src/chaos/envvars.py create mode 100644 e2e/chaos/src/chaos/evaluate.py create mode 100644 e2e/chaos/src/chaos/export.py create mode 100644 e2e/chaos/src/chaos/flow.py create mode 100644 e2e/chaos/src/chaos/inject/__init__.py create mode 100644 e2e/chaos/src/chaos/inject/base.py create mode 100644 e2e/chaos/src/chaos/inject/docker.py create mode 100644 e2e/chaos/src/chaos/inject/kubernetes.py create mode 100644 e2e/chaos/src/chaos/instances.py create mode 100644 e2e/chaos/src/chaos/model.py create mode 100644 e2e/chaos/src/chaos/observe.py create mode 100644 e2e/chaos/src/chaos/operations.py create mode 100644 e2e/chaos/src/chaos/report.py create mode 100644 e2e/chaos/src/chaos/report_template.html create mode 100644 e2e/chaos/src/chaos/rundir.py create mode 100644 e2e/chaos/src/chaos/runner.py create mode 100644 e2e/chaos/src/chaos/share.py create mode 100644 e2e/chaos/src/chaos/signals.py create mode 100644 e2e/chaos/src/chaos/timeline.py create mode 100644 e2e/chaos/tests/conftest.py create mode 100644 e2e/chaos/tests/fixtures/invalid-api-version.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-chaos-env.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-data-name.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-expectation-affected-experiment-check.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-expectation-affected-untargeted.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-expectation-issue.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-expectation-shared-check.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-expectation-unknown-check.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-flow-missing-name.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-flow-repeated-name.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-metric-check-no-bound.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-metric-check-undeclared.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-metric-entry-both.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-metric-name.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-missing-description.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-no-api-version.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-no-checks.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-operation-global-select.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-operation-name-clash.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-operation-no-targeting.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-operation-no-wait.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-outage-no-duration.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-repeat-shape.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-repeat-workload.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-restart-duration.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-role-fault-no-wait.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-role-fault-wait-after.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-unknown-key.yaml create mode 100644 e2e/chaos/tests/fixtures/invalid-wait-outside-parallel.yaml create mode 100644 e2e/chaos/tests/fixtures/valid-expectations.yaml create mode 100644 e2e/chaos/tests/fixtures/valid-freeze.yaml create mode 100644 e2e/chaos/tests/fixtures/valid-minimal.yaml create mode 100644 e2e/chaos/tests/fixtures/valid-operations.yaml create mode 100644 e2e/chaos/tests/fixtures/valid-role-fault.yaml create mode 100644 e2e/chaos/tests/test_adapter.py create mode 100644 e2e/chaos/tests/test_cli.py create mode 100644 e2e/chaos/tests/test_dashboard.py create mode 100644 e2e/chaos/tests/test_envvars.py create mode 100644 e2e/chaos/tests/test_evaluate.py create mode 100644 e2e/chaos/tests/test_kubernetes.py create mode 100644 e2e/chaos/tests/test_model.py create mode 100644 e2e/chaos/tests/test_observe.py create mode 100644 e2e/chaos/tests/test_recovery.py create mode 100644 e2e/chaos/tests/test_report.py create mode 100644 e2e/chaos/tests/test_runner.py create mode 100644 e2e/chaos/tests/test_select.py create mode 100644 e2e/chaos/tests/test_share.py create mode 100644 e2e/chaos/tests/test_signals.py create mode 100644 e2e/chaos/uv.lock diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b1034dd23f..e80b56a778 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -386,6 +386,7 @@ These are the primary `mise` tasks for day-to-day development: | `proto/` | Protocol buffer definitions and [public API conventions](proto/README.md) | | `tasks/` | `mise` task definitions and build scripts | | `tests/testkit/` | Support library for SDK-driven end-to-end suites | +| `e2e/chaos/` | [Chaos experiments](e2e/chaos/README.md) against e2e gateways | | `deploy/` | Dockerfiles, Helm chart, Kubernetes manifests | | `docs/` | Published Fern docs source, navigation, and content assets | | `fern/` | Fern site config, components, and theme assets | diff --git a/e2e/chaos/.gitignore b/e2e/chaos/.gitignore new file mode 100644 index 0000000000..8df7f27872 --- /dev/null +++ b/e2e/chaos/.gitignore @@ -0,0 +1,2 @@ +runs/ +.venv/ diff --git a/e2e/chaos/ADAPTER.md b/e2e/chaos/ADAPTER.md new file mode 100644 index 0000000000..9353c24db3 --- /dev/null +++ b/e2e/chaos/ADAPTER.md @@ -0,0 +1,417 @@ + + + +# Adapters + +An adapter maps the harness onto one system on one backend: how to create an +instance, read its state, collect its files and delete it; which processes, +containers, workloads or pods the built-in faults can act on; which +operations experiments can use; and what to observe. `adapters/.yaml` +holds one adapter. An experiment names the one it runs on with +`default-adapter: `, and `--adapter ` runs it on another, so the same +experiment manifest runs on every adapter that has what it uses. Two adapters +exist: `adapters/openshell-docker.yaml`, OpenShell on the Docker compute +driver, and `adapters/openshell-kubernetes.yaml`, OpenShell on the Kubernetes +compute driver. + +The harness knows nothing about OpenShell. Everything OpenShell-specific is in +the adapter's hooks. + +- [Where things run](#where-things-run) +- [Fields](#fields) +- [Hooks](#hooks) +- [Instance hooks](#instance-hooks) +- [Targets](#targets) +- [Operations](#operations) +- [Observers](#observers) +- [openshell-docker](#openshell-docker) +- [openshell-kubernetes](#openshell-kubernetes) +- [Write an adapter](#write-an-adapter) + +## Where things run + +Code runs in three places. + +```mermaid +flowchart LR + host["Harness host
the machine that runs chaos
hooks, checks, built-in faults,
metrics scrapes, watch commands"] + system["System under test
gateway, Docker daemon or cluster
nothing runs here"] + sandbox["Sandbox
the workload's run script"] + host -- "openshell, docker and kubectl CLIs,
signals to process targets" --> system + system -- "openshell sandbox create" --> sandbox +``` + +- The harness host, the machine that runs `chaos`. Every adapter hook runs + here as a host subprocess, `bash -c '\n + + + diff --git a/e2e/chaos/src/chaos/rundir.py b/e2e/chaos/src/chaos/rundir.py new file mode 100644 index 0000000000..cc208c2c3e --- /dev/null +++ b/e2e/chaos/src/chaos/rundir.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""The run dir's manifest and its schema version.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from chaos.model import ExperimentError + +# The layout of the run dir and its files, written to manifest.json as +# `schema_version`. Every reader of a run dir refuses any other value; there +# is no compatibility code. +SCHEMA_VERSION = "v1alpha1" + + +def read_manifest(run_dir: Path) -> dict[str, Any]: + """The manifest of a run dir, or ExperimentError if it is missing or was + written under another schema version.""" + path = run_dir / "manifest.json" + if not path.is_file(): + raise ExperimentError(f"{run_dir}: no manifest.json; not a run dir") + manifest = json.loads(path.read_text()) + version = manifest.get("schema_version") + if version != SCHEMA_VERSION: + raise ExperimentError( + f"{path}: schema_version {version!r} is not {SCHEMA_VERSION!r}; " + "this harness cannot read it" + ) + return manifest diff --git a/e2e/chaos/src/chaos/runner.py b/e2e/chaos/src/chaos/runner.py new file mode 100644 index 0000000000..2a18b1a637 --- /dev/null +++ b/e2e/chaos/src/chaos/runner.py @@ -0,0 +1,442 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""One run: the run directory, the flow under a timeout, and the abort path. + +Abort path, in order: undo applied faults; restart process targets that are +down; collect and delete every instance still present; run the adapter +`collect`; run every check; write the verdict, forced to ERROR. A second +SIGINT/SIGTERM skips to deleting instances and exits 2. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import hashlib +import importlib.metadata +import json +import logging +import os +import platform as host +import secrets +import signal +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import yaml + +from chaos import envvars +from chaos.adapter import Adapter, run_hook +from chaos.envvars import RunInfo +from chaos.evaluate import ( + EXIT_CODES, + Result, + append_index, + format_verdict, + run_checks, + run_preflight, + write_verdict, +) +from chaos.flow import Engine +from chaos.inject.base import Injector +from chaos.inject.docker import DockerInjector +from chaos.inject.kubernetes import KubernetesInjector +from chaos.instances import InstanceManager, write_data +from chaos.model import Experiment, RunError, parse_duration +from chaos.observe import Observers +from chaos.operations import Operations +from chaos.report import write_report +from chaos.rundir import SCHEMA_VERSION +from chaos.signals import Signals +from chaos.timeline import Timeline, utc_now + +log = logging.getLogger("chaos") + +PROFILE_COLLECT_TIMEOUT = 300.0 +FORCED_RESTORE_TIMEOUT = 120.0 +INJECTORS: dict[str, type[Injector]] = { + "docker": DockerInjector, + "kubernetes": KubernetesInjector, +} +# Reverting operations, collecting and deleting after an error, all together. +CLEANUP_TIMEOUT = 600.0 + + +def new_run_id() -> str: + return f"{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}-{secrets.token_hex(2)}" + + +def environment() -> dict[str, Any]: + """The harness and its host. `system` is what the adapter's `describe` + hook prints about the system under test, filled in after preflight.""" + return { + "harness": importlib.metadata.version("openshell-chaos"), + "os": host.system(), + "arch": host.machine(), + "python": host.python_version(), + "system": None, + } + + +def descriptions(exp: Experiment) -> dict[str, Any]: + return { + "experiment": exp.description, + "workloads": { + w.name: { + "description": w.description, + "checks": {c.name: c.description for c in w.checks}, + } + for w in exp.workloads + }, + "operations": {o.name: o.description for o in exp.operations}, + "checks": {c.name: c.description for c in exp.checks}, + } + + +def _setup_logging(run_dir: Path) -> logging.Handler: + handler = logging.FileHandler(run_dir / "logs" / "harness.log") + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) + log.addHandler(handler) + return handler + + +class Run: + def __init__( + self, + exp: Experiment, + raw: dict[str, Any], + experiment_path: Path, + adapter: Adapter, + seed: int, + overrides: list[str], + runs_dir: Path, + environ: dict[str, str], + ) -> None: + self.exp = exp + self.adapter = adapter + self.runs_dir = runs_dir + run_id = new_run_id() + run_dir = runs_dir / exp.id / run_id + (run_dir / "logs").mkdir(parents=True) + (run_dir / "system").mkdir() + (run_dir / "workloads").mkdir() + self.run_dir = run_dir + self.info = RunInfo( + experiment_id=exp.id, + experiment_version=exp.version, + adapter=adapter.id, + run_id=run_id, + seed=seed, + run_dir=run_dir, + ) + (run_dir / "experiment.yaml").write_text(yaml.safe_dump(raw, sort_keys=False)) + # The adapter as it ran, so `evaluate` needs nothing outside the run dir. + (run_dir / "adapter.yaml").write_bytes(adapter.path.read_bytes()) + for name, value in exp.data.items(): + write_data(run_dir, name, value) + # The sha256 is of the archived copy, the experiment after --set, so + # an evaluation can check that the copy it reads is what ran. + archived = (run_dir / "experiment.yaml").read_bytes() + self.manifest: dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "run_id": run_id, + "experiment": { + "id": exp.id, + "version": exp.version, + "file": str(experiment_path), + "sha256": hashlib.sha256(archived).hexdigest(), + }, + "adapter": { + "id": adapter.id, + "file": str(adapter.path), + "sha256": adapter.sha256, + "backend": adapter.backend, + }, + "seed": seed, + "overrides": overrides, + "environment": environment(), + "started_at": utc_now(), + "finished_at": None, + "descriptions": descriptions(exp), + } + self._write_manifest() + self.timeline = Timeline(run_dir / "timeline.jsonl") + self.instances = InstanceManager(self.info, adapter, self.timeline) + self.injector = INJECTORS[adapter.backend]( + adapter, self.info, self.timeline, self.instances + ) + self.operations = Operations( + adapter, self.info, self.timeline, self.instances, self.injector + ) + self.signal_events = Signals() + self.observers = Observers( + adapter, + self.timeline, + run_dir, + envvars.run_hook_env(self.info), + self.signal_events, + ) + self.errors: list[str] = [] + self.preflight: list[Result] = [] + self.signals = 0 + self.flow_task: asyncio.Task[None] | None = None + self.main_task: asyncio.Task[int] | None = None + + def _write_manifest(self) -> None: + (self.run_dir / "manifest.json").write_text( + json.dumps(self.manifest, indent=2) + "\n" + ) + + def _on_signal(self, sig: signal.Signals) -> None: + self.signals += 1 + if self.signals == 1: + log.warning( + "%s: aborting; send again to skip collection and exit", sig.name + ) + self.errors.append(f"aborted by {sig.name}") + if self.flow_task is not None and not self.flow_task.done(): + self.flow_task.cancel() + elif self.signals == 2 and self.main_task is not None: + log.warning("%s: deleting instances; send again to exit at once", sig.name) + self.main_task.cancel() + else: + log.warning("%s: exiting now; faults and instances are left", sig.name) + for proc in self.observers.procs: + with contextlib.suppress(ProcessLookupError): + os.killpg(proc.pid, signal.SIGKILL) + os._exit(2) + + def execute(self) -> int: + """Run to completion and return the exit code.""" + handler = _setup_logging(self.run_dir) + try: + return asyncio.run(self._outer()) + finally: + log.removeHandler(handler) + self.timeline.close() + + async def _outer(self) -> int: + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, self._on_signal, sig) + self.main_task = asyncio.create_task(self._main()) + try: + return await self.main_task + except asyncio.CancelledError: + return await self._forced_exit( + "forced exit: instances deleted without collection; checks not run" + ) + except Exception as err: # noqa: BLE001 (a harness bug is still an ERROR verdict) + log.exception("harness error") + self.errors.append(f"harness error: {err!r}") + return await self._forced_exit( + "instances deleted without collection after a harness error" + ) + + async def _main(self) -> int: + log.info("run %s -> %s", self.info.run_id, self.run_dir) + self.timeline.emit( + "run.started", + experiment=self.exp.id, + version=self.exp.version, + seed=self.info.seed, + adapter=self.info.adapter, + ) + self.preflight = await run_preflight(self.exp, self.adapter, self.info) + failed = [r.check for r in self.preflight if not r.ok] + if failed: + self.timeline.emit("preflight.failed", checks=failed) + self.errors.append(f"preflight failed: {', '.join(failed)}") + return await self._evaluate(checks=False) + await self._describe() + await self._setup() + # A signal before the flow exists only records an error; honor it here. + if not self.errors and not self.signals: + await self.observers.start() + await self._flow() + if not self.errors: + # Adapter operations still applied are put back, newest first, + # before the instances they may act on are gone. + self.errors.extend(await self.injector.undo_all(abort=False)) + if self.errors: + await self._abort() + await self._collect_run() + await self._stop_observers() + return await self._evaluate() + + async def _stop_observers(self) -> None: + """Observers run until adapter collection ends. Their failures are + recorded but never change the verdict.""" + await self.observers.stop() + for error in self.observers.errors: + self.timeline.emit("observer.failed", error=error) + self.observers.errors = [] + + async def _describe(self) -> None: + """Record what the adapter's `describe` hook says about the system + under test, verbatim, as manifest.json `environment.system`.""" + result = await run_hook( + self.adapter, + "describe", + self.adapter.describe, + envvars.run_hook_env(self.info), + ) + if not result.ok: + self.errors.append( + f"describe failed (exit {result.code}): {result.stderr.strip()[-2000:]}" + ) + return + try: + system = json.loads(result.stdout) + except ValueError as err: + self.errors.append(f"describe printed no JSON object: {err}") + return + if not isinstance(system, dict): + self.errors.append( + f"describe printed a JSON {type(system).__name__}, not an object" + ) + return + self.manifest["environment"]["system"] = system + self._write_manifest() + + async def _setup(self) -> None: + if self.adapter.setup is None: + return + result = await run_hook( + self.adapter, "setup", self.adapter.setup, envvars.run_hook_env(self.info) + ) + if not result.ok: + self.errors.append( + f"setup failed (exit {result.code}): {result.stderr.strip()[-2000:]}" + ) + + async def _flow(self) -> None: + engine = Engine( + self.exp, + self.timeline, + self.instances, + self.operations, + self.signal_events, + ) + self.flow_task = asyncio.create_task( + asyncio.wait_for(engine.run(), parse_duration(self.exp.timeout)) + ) + try: + await self.flow_task + except asyncio.CancelledError: + current = asyncio.current_task() + if current is not None and current.cancelling(): + raise + # Cancelled by the first signal; the error is already recorded. + except TimeoutError: + self.errors.append(f"experiment timeout {self.exp.timeout} expired") + except RunError as err: + self.errors.append(str(err)) + except Exception as err: # noqa: BLE001 (any harness bug is an ERROR verdict) + log.exception("harness error") + self.errors.append(f"harness error: {err!r}") + + async def _abort(self) -> None: + """Revert operations, then collect and delete every instance, within + CLEANUP_TIMEOUT. What is left at the deadline is deleted uncollected.""" + self.timeline.emit("abort", errors=self.errors) + loop = asyncio.get_running_loop() + self.instances.deadline = loop.time() + CLEANUP_TIMEOUT + try: + async with asyncio.timeout_at(self.instances.deadline): + await self._cleanup() + except TimeoutError: + self.errors.append( + f"cleanup did not finish in {CLEANUP_TIMEOUT:g}s; " + "instances left were deleted without collection" + ) + self.instances.deadline = loop.time() + FORCED_RESTORE_TIMEOUT + await self._delete_all() + finally: + self.instances.deadline = None + + async def _delete_all(self) -> None: + """One delete attempt per instance left. A failure is an error with + the instance's id, and the verdict says the cleanup is incomplete.""" + results = await asyncio.gather( + *(self.instances.delete_only(i) for i in self.instances.all()), + return_exceptions=True, + ) + failed = [str(r) for r in results if isinstance(r, BaseException)] + if failed: + self.errors.extend(failed) + self.errors.append( + f"cleanup incomplete: {len(failed)} instance(s) not deleted" + ) + + async def _cleanup(self) -> None: + self.errors.extend(await self.injector.undo_all()) + try: + await self.injector.ensure_up() + except Exception as err: # noqa: BLE001 (keep going to delete instances) + self.errors.append(f"restart targets: {err}") + results = await asyncio.gather( + *(self.instances.finish(i) for i in self.instances.all() if i.present), + return_exceptions=True, + ) + self.errors.extend(str(r) for r in results if isinstance(r, BaseException)) + + async def _collect_run(self) -> None: + if self.adapter.collect is None: + return + result = await run_hook( + self.adapter, + "collect", + self.adapter.collect, + envvars.run_hook_env(self.info), + timeout=PROFILE_COLLECT_TIMEOUT, + ) + (self.run_dir / "logs" / "collect.log").write_text( + result.stdout + result.stderr + ) + if not result.ok: + self.errors.append(f"adapter collect failed (exit {result.code})") + + async def _evaluate(self, checks: bool = True) -> int: + results = list(self.preflight) + if checks: + results += await run_checks( + self.exp, self.adapter, self.info, self.timeline + ) + verdict = write_verdict(self.run_dir, results, self.errors) + # The gateway processes seen across restarts, when a fault recorded any. + extra = {"processes": self.injector.pids} if self.injector.pids else {} + self._finish(verdict, **extra) + print(format_verdict(verdict)) + print(self.run_dir) + return EXIT_CODES[verdict["verdict"]] + + def _finish(self, verdict: dict[str, Any], **extra: Any) -> None: + self.timeline.emit("run.finished", verdict=verdict["verdict"], **extra) + self.manifest["finished_at"] = utc_now() + self._write_manifest() + append_index( + self.runs_dir / "index.jsonl", self.manifest, verdict, self.run_dir + ) + try: + write_report(self.run_dir) + except Exception as err: # noqa: BLE001 (`chaos report` can retry it) + log.warning("report.html not written: %r", err) + + async def _forced_exit(self, reason: str) -> int: + """Second signal or harness error: best-effort revert operations and delete + what is left, write an ERROR verdict, exit 2.""" + self.timeline.emit("abort", forced=True) + await self._stop_observers() + try: + async with asyncio.timeout(FORCED_RESTORE_TIMEOUT): + self.errors.extend(await self.injector.undo_all()) + await self.injector.ensure_up() + except Exception as err: # noqa: BLE001 (deleting instances matters more) + self.errors.append(f"restore operations: {err!r}") + await self._delete_all() + self.errors.append(reason) + self._finish(write_verdict(self.run_dir, list(self.preflight), self.errors)) + return 2 diff --git a/e2e/chaos/src/chaos/share.py b/e2e/chaos/src/chaos/share.py new file mode 100644 index 0000000000..26d09e36be --- /dev/null +++ b/e2e/chaos/src/chaos/share.py @@ -0,0 +1,424 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""`chaos share RUN_DIR`: one Markdown page of a run to paste into an issue +comment or a post. It holds the verdict and the failed checks, a Mermaid +gantt of the flow (workloads, waits, operations, faults) over the instance +states, and the metrics the experiment lists under `metrics:` as a table of +their check results and one Mermaid chart each. GitHub draws the Mermaid +blocks; `--png` renders them with mermaid-cli for places that do not. + +Everything comes from `chaos.report.build`, so the page agrees with +`report.html` and works on a copied run dir. +""" + +from __future__ import annotations + +import math +import re +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import yaml + +from chaos.report import build + +# Gantt rows before the instance sections are left out: Mermaid draws one +# row per task, and GitHub refuses large diagrams. +ROW_BUDGET = 40 +# A node or fault with the same name more often than this is one row. +COLLAPSE_AFTER = 3 +# Points per chart; longer series are averaged into this many buckets. +MAX_POINTS = 60 +# Charts shown before the rest fold. +CHARTS_SHOWN = 2 +# Mermaid gantt tags by instance state; the others take the default look. +TAG = { + "Running": "active", + "Succeeded": "done", + "Unknown": "crit", + "Failed": "crit", + "Error": "crit", +} +# Instances kept first when the chart cannot hold them all: a check failed +# on them, or they ended badly, then those that went through Unknown. +ENDED_BADLY = {"Failed", "Error"} +# The default xychart line is nearly white on Mermaid's default theme. +CHART_INIT = ( + '%%{init: {"theme": "base", "themeVariables": ' + '{"xyChart": {"plotColorPalette": "#1f3fb5"}}}}%%' +) + + +class ShareError(Exception): + pass + + +def _clock(t: float, hours: bool) -> str: + t = max(0, round(t)) + h, rest = divmod(t, 3600) + m, s = divmod(rest, 60) + return f"{h:02d}:{m:02d}:{s:02d}" if hours else f"{m:02d}:{s:02d}" + + +def _label(text: str) -> str: + """Text safe inside a gantt task or section line.""" + return " ".join(re.sub(r"[:;,#%]+", " ", str(text)).split()) or "-" + + +def _task(label: str, tags: list[str], start: float, end: float, hours: bool) -> str: + a, b = _clock(start, hours), _clock(end, hours) + # A task shorter than a second has no width; a milestone has a diamond. + tags = ["milestone", *tags] if a == b else tags + return f" {_label(label)} :{', '.join([*tags, a, b])}" + + +def flow_spans(timeline: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Every flow node that started, by name: a workload or operation, a + `wait` or a `delay`. A node still open at the end of the timeline ends + there. A node id that starts again (under `repeat`) is a new span.""" + end = max((e["t"] for e in timeline), default=0.0) + open_: dict[str, dict[str, Any]] = {} + pairs: list[tuple[dict[str, Any], float]] = [] + for e in timeline: + if e["event"] == "node.started": + open_[e["node"]] = e + elif e["event"] == "node.finished" and e["node"] in open_: + pairs.append((open_.pop(e["node"]), e["t"])) + pairs.extend((s, end) for s in open_.values()) + spans = [] + for s, to in pairs: + if "name" in s: + kind, name = "node", str(s["name"]) + elif "wait" in s: + kind, name = "wait", f"wait {s['wait']} {s.get('condition', 'started')}" + elif "delay" in s: + kind, name = "delay", f"delay {s['delay']}" + else: + continue + spans.append({"kind": kind, "name": name, "from": s["t"], "to": to}) + return sorted(spans, key=lambda s: (s["from"], s["to"])) + + +def _collapse(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Rows with the same name, more than COLLAPSE_AFTER of them, become one + row from the first start to the last end, named `name ×N`.""" + groups: dict[str, list[dict[str, Any]]] = {} + for row in rows: + groups.setdefault(row["name"], []).append(row) + out = [] + for name, group in groups.items(): + if len(group) > COLLAPSE_AFTER: + out.append( + { + **group[0], + "name": f"{name} ×{len(group)}", + "from": min(r["from"] for r in group), + "to": max(r["to"] for r in group), + } + ) + else: + out.extend(group) + return sorted(out, key=lambda r: (r["from"], r["to"])) + + +def gantt(data: dict[str, Any]) -> tuple[str, str]: + """The gantt text and a note on what it leaves out ('' when nothing).""" + hours = data["end"] >= 3600 + title = " · ".join( + str(x) for x in (data["experiment"]["id"], data["run_id"], data["verdict"]) if x + ) + lines = [ + "gantt", + f" title {_label(title)}", + f" dateFormat {'HH:mm:ss' if hours else 'mm:ss'}", + f" axisFormat {'%H:%M:%S' if hours else '%M:%S'}", + " todayMarker off", + " section flow", + ] + # A built-in operation shows as its fault window, not as its flow node. + fault_names = {f["name"] for f in data["faults"]} + nodes = [s for s in flow_spans(data["timeline"]) if s["name"] not in fault_names] + faults = [ + { + "name": f"fault {f['name']} ({f['uses']})", + "from": f["from"], + "to": f["to"], + "tags": ["crit"], + } + for f in data["faults"] + ] + flow = _collapse([{**n, "tags": []} for n in nodes] + faults) + for row in flow: + lines.append(_task(row["name"], row["tags"], row["from"], row["to"], hours)) + # Instance sections, failed ones first, until the row budget is spent. + budget = ROW_BUDGET - len(flow) + failed = { + r.get("instance") + for r in data["results"] + if r.get("instance") and (not r["ok"] or r.get("error")) + } + lanes = sorted(data["lanes"], key=lambda ln: _lane_rank(ln, failed)) + left_out: list[dict[str, Any]] = [] + for ln in lanes: + if left_out or len(ln["segments"]) > budget: + left_out.append(ln) + continue + budget -= len(ln["segments"]) + lines.append(f" section {_label(ln['name'])}") + for seg in ln["segments"]: + tag = TAG.get(seg["state"]) + lines.append( + _task(seg["state"], [tag] if tag else [], seg["from"], seg["to"], hours) + ) + note = "" + if left_out: + changes = sum(len(ln["segments"]) for ln in left_out) + which = ( + "The instance states" + if len(left_out) == len(lanes) + else (f"{len(left_out)} of {len(lanes)} instances") + ) + note = ( + f"{which} ({changes} state changes) would not fit the chart; " + "`report.html` in the run directory has them." + ) + return "\n".join(lines) + "\n", note + + +def _lane_rank(lane: dict[str, Any], failed: set[str]) -> int: + states = {seg["state"] for seg in lane["segments"]} + if lane["name"] in failed or states & ENDED_BADLY: + return 0 + return 1 if "Unknown" in states else 2 + + +def _bucket(points: list[list[float | None]]) -> list[list[float | None]]: + if len(points) <= MAX_POINTS: + return points + size = math.ceil(len(points) / MAX_POINTS) + out = [] + for i in range(0, len(points), size): + chunk = points[i : i + size] + values = [v for _, v in chunk if v is not None] + out.append([chunk[0][0], sum(values) / len(values) if values else None]) + return out + + +def _num(v: float) -> str: + return f"{float(f'{v:.4g}'):g}" + + +def xychart(chart: dict[str, Any]) -> tuple[str, str]: + """A Mermaid xychart of one report chart and a note under it ('' when + nothing to say). Scrapes without an answer are drawn at 0, since the + chart has no gaps; leading ones (a counter has no rate at its first + scrape) are dropped instead.""" + points = list(chart["points"]) + while points and points[0][1] is None: + points = points[1:] + if not points: + return "", "no scrapes with a value" + scrapes = len(points) + points = _bucket(points) + values = [0.0 if v is None else v for _, v in points] + low, high = min(0.0, min(values)), max(values) + high = high + (high - low) * 0.1 if high > low else low + 1 + x0, x1 = int(points[0][0]), int(points[-1][0]) + text = "\n".join( + [ + CHART_INIT, + "xychart-beta", + f' title "{chart["title"].replace(chr(34), " ")}"', + f' x-axis "seconds since the run started" {x0} --> {max(x1, x0 + 1)}', + f' y-axis "{chart["subtitle"].replace(chr(34), " ")}" ' + f"{_num(low)} --> {_num(high)}", + f" line [{', '.join(_num(v) for v in values)}]", + ] + ) + notes = [] + if any(v is None for _, v in points): + notes.append("scrapes the endpoint did not answer are drawn at 0") + if len(points) < scrapes: + notes.append(f"{scrapes} scrapes averaged into {len(points)} points") + return text + "\n", "; ".join(notes) + + +def _metric_checks(run_dir: Path, families: list[str]) -> dict[str, list[str]]: + """The names of the experiment's `metric` checks by declared family.""" + path = run_dir / "experiment.yaml" + raw = yaml.safe_load(path.read_text()) if path.is_file() else None + checks = (raw or {}).get("checks") or [] + out: dict[str, list[str]] = {f: [] for f in families} + for check in checks: + if not isinstance(check, dict) or check.get("kind") != "metric": + continue + metric = str(check.get("metric", "")) + for family in families: + if metric == family or ( + metric.startswith(family + "_") + and metric[len(family) :] in ("_count", "_sum", "_bucket") + ): + out[family].append(str(check.get("name"))) + return out + + +def _cell(text: str) -> str: + return " ".join(str(text).split()).replace("|", "\\|") + + +def _status(result: dict[str, Any]) -> str: + if result.get("error"): + return "ERROR" + if result["ok"]: + return "ok" + return "FAILED" + + +def metrics_table(run_dir: Path, data: dict[str, Any]) -> str: + families = [m["family"] for m in data["metrics"]] + checks = _metric_checks(run_dir, families) + results = {r["check"]: r for r in data["results"] if r["scope"] == "experiment"} + lines = ["| Metric | Why it is here | Checks |", "|---|---|---|"] + for m in data["metrics"]: + cells = [] + for name in checks[m["family"]]: + r = results.get(name) + if r is None: + cells.append(f"`{name}`: not run") + continue + # The output starts with the value and the bound; the series + # count after the semicolon is for the report. + summary = r["output"].split(";")[0] + cells.append(f"`{name}` {_status(r)}: {_cell(summary)}") + lines.append( + f"| `{m['family']}` | {_cell(m['description'])} | " + f"{'
'.join(cells) or 'none'} |" + ) + return "\n".join(lines) + "\n" + + +def _failures(results: list[dict[str, Any]]) -> list[str]: + out = [] + for r in results: + if r["ok"] and not r.get("error"): + continue + where = f" on {r['instance']}" if r.get("instance") else "" + verb = "could not decide" if r.get("error") else "failed" + known = f", known {r['issue']}" if r.get("issue") else "" + output = str(r.get("output", "")).strip().splitlines() + detail = f": {_cell(output[0])}" if output else "" + out.append(f"- `{r['check']}`{where} {verb}{known}{detail}") + return out + + +def details_table(data: dict[str, Any]) -> str: + """The report's run details as a two-column table. Digests and image + refs are left out; the report has them.""" + lines = ["| | |", "|---|---|"] + for row in data["details"]: + if row["more"]: + continue + value = f"`{row['value']}`" if row["code"] else row["value"] + note = f", {row['note']}" if row["note"] else "" + lines.append(f"| {_cell(row['key'])} | {_cell(value + note)} |") + return "\n".join(lines) + "\n" + + +def share_markdown(run_dir: Path) -> str: + data = build(run_dir) + exp = data["experiment"] + heading = " · ".join( + str(x) for x in (exp["id"], data["run_id"], data["verdict"]) if x + ) + results = [r for r in data["results"] if r["scope"] != "preflight"] + passed = sum(1 for r in results if r["ok"] and not r.get("error")) + failed = sum(1 for r in results if not r["ok"] and not r.get("error")) + errors = sum(1 for r in results if r.get("error")) + known = sum(1 for r in results if not r["ok"] and r.get("issue")) + counts = [f"{passed} passed"] + if failed: + counts.append(f"{failed} failed" + (f" ({known} known)" if known else "")) + if errors: + counts.append(f"{errors} could not decide") + parts = [f"## {heading}\n"] + if exp["title"]: + parts.append(f"**{_cell(exp['title'])}**\n") + if exp["hypothesis"]: + parts.append(f"Hypothesis: {_cell(exp['hypothesis'])}\n") + parts.append(details_table(data)) + parts.append(f"**Checks:** {', '.join(counts)}.\n") + failures = _failures(results) + if failures: + parts.append("\n".join(failures) + "\n") + for error in data["errors"]: + parts.append(f"Harness error: {_cell(error)}\n") + chart, note = gantt(data) + parts.append("### Flow\n") + parts.append(f"```mermaid\n{chart}```\n") + if note: + parts.append(note + "\n") + if data["metrics"]: + parts.append("### Metrics\n") + parts.append(metrics_table(run_dir, data)) + descriptions = {m["family"]: m["description"] for m in data["metrics"]} + blocks = [] + for c in data["charts"]: + if not c["declared"]: + continue + text, chart_note = xychart(c) + block = [f"#### {_cell(c['title'])}\n"] + if text: + block.append(f"```mermaid\n{text}```\n") + caption = [descriptions.get(c["family"], "")] + if chart_note: + caption.append(chart_note[0].upper() + chart_note[1:] + ".") + if any(caption): + block.append(" ".join(_cell(c) for c in caption if c) + "\n") + blocks.append("\n".join(block)) + parts.extend(blocks[:CHARTS_SHOWN]) + if len(blocks) > CHARTS_SHOWN: + rest = len(blocks) - CHARTS_SHOWN + parts.append( + f"
\n{rest} more chart{'s' if rest > 1 else ''}" + "\n\n" + "\n".join(blocks[CHARTS_SHOWN:]) + "\n
\n" + ) + return "\n".join(parts) + + +def write_share(run_dir: Path, out: Path | None = None) -> Path: + path = out or run_dir / "share.md" + path.write_text(share_markdown(run_dir)) + return path + + +def render_png(markdown: Path) -> Path: + """`markdown` with every Mermaid block replaced by a PNG, written next + to the images under `/`, through mermaid-cli (`mmdc`).""" + out_dir = markdown.with_suffix("") + out = out_dir / markdown.name + command = [ + "mmdc", + "-i", + str(markdown), + "-o", + str(out), + "-e", + "png", + "-s", + "2", + "-b", + "white", + ] + if shutil.which("mmdc") is None: + raise ShareError( + "mermaid-cli is not on PATH (npm install -g @mermaid-js/mermaid-cli); " + "with it, run: " + " ".join(command) + ) + out_dir.mkdir(parents=True, exist_ok=True) + done = subprocess.run(command, capture_output=True, text=True, check=False) + if done.returncode != 0: + raise ShareError(f"mmdc failed ({done.returncode}): {done.stderr.strip()}") + return out diff --git a/e2e/chaos/src/chaos/signals.py b/e2e/chaos/src/chaos/signals.py new file mode 100644 index 0000000000..d323533c34 --- /dev/null +++ b/e2e/chaos/src/chaos/signals.py @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Signal events: the arrival times of watch lines that match an adapter signal. + +Times are `time.monotonic()` at the moment the harness read the line, not the +time the system under test produced it. +""" + +from __future__ import annotations + +import asyncio +import time +from collections import defaultdict + + +class Signals: + def __init__(self) -> None: + self.events: dict[str, list[float]] = defaultdict(list) + self._changed = asyncio.Event() + + def add(self, name: str, at: float | None = None) -> None: + self.events[name].append(time.monotonic() if at is None else at) + self._changed.set() + self._changed = asyncio.Event() + + async def next( + self, name: str, after: float | None, not_before: float, deadline: float + ) -> float: + """Return the first event of `name` newer than `after` and not before + `not_before`, waiting for one until `deadline`. Raises TimeoutError.""" + while True: + for at in self.events[name]: + if (after is None or at > after) and at >= not_before: + return at + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError + changed = self._changed + try: + await asyncio.wait_for(changed.wait(), remaining) + except TimeoutError: + continue diff --git a/e2e/chaos/src/chaos/timeline.py b/e2e/chaos/src/chaos/timeline.py new file mode 100644 index 0000000000..45218d47ac --- /dev/null +++ b/e2e/chaos/src/chaos/timeline.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""The run timeline: one JSON object per line in `timeline.jsonl`.""" + +from __future__ import annotations + +import json +import logging +import time +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +log = logging.getLogger("chaos") + + +def utc_now() -> str: + return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ") + + +class Timeline: + def __init__(self, path: Path) -> None: + self.path = path + self.start = time.monotonic() + self._file = path.open("a") + + def emit(self, event: str, **fields: Any) -> None: + record = { + "t": round(time.monotonic() - self.start, 3), + "ts": utc_now(), + "event": event, + **fields, + } + self._file.write(json.dumps(record, sort_keys=False) + "\n") + self._file.flush() + log.info("%s %s", event, json.dumps(fields) if fields else "") + + def close(self) -> None: + self._file.close() + + +def read_timeline(path: Path) -> list[dict[str, Any]]: + if not path.is_file(): + return [] + return [json.loads(line) for line in path.read_text().splitlines() if line.strip()] diff --git a/e2e/chaos/tests/conftest.py b/e2e/chaos/tests/conftest.py new file mode 100644 index 0000000000..cef70b5de4 --- /dev/null +++ b/e2e/chaos/tests/conftest.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +FIXTURES = Path(__file__).resolve().parent / "fixtures" +EXPERIMENTS = PACKAGE_DIR / "experiments" +ADAPTERS = PACKAGE_DIR / "adapters" + + +def minimal_adapter(**extra) -> dict: + """A Docker adapter with no hooks that are ever run.""" + adapter = { + "apiVersion": "chaos/v1alpha1", + "id": "openshell-docker", + "backend": "docker", + "limits": {"env-value-bytes": 8192, "run-script-bytes": 28672}, + "describe": 'echo \'{"openshell": "test"}\'', + "instance": { + "output-dir": "/out", + "create": "true", + "status": "true", + "collect": "true", + "delete": "true", + }, + "targets": { + "gateway": {"type": "process", "pid-file": "/pid", "start": "true"}, + "roles": { + "sandbox": {"type": "container", "labels": {"role": "sandbox"}}, + "supervisor": {"type": "container", "labels": {"role": "supervisor"}}, + }, + }, + } + adapter.update(extra) + return adapter + + +@pytest.fixture +def adapters_dir(tmp_path: Path): + """Write an adapter into a temporary adapters dir: `adapters_dir(**extra)`.""" + directory = tmp_path / "adapters" + directory.mkdir() + + def write(**extra) -> Path: + path = directory / "openshell-docker.yaml" + path.write_text(yaml.safe_dump(minimal_adapter(**extra))) + return directory + + return write diff --git a/e2e/chaos/tests/fixtures/invalid-api-version.yaml b/e2e/chaos/tests/fixtures/invalid-api-version.yaml new file mode 100644 index 0000000000..c104041f58 --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-api-version.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v2 +id: invalid-api-version +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: w diff --git a/e2e/chaos/tests/fixtures/invalid-chaos-env.yaml b/e2e/chaos/tests/fixtures/invalid-chaos-env.yaml new file mode 100644 index 0000000000..8b64d40807 --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-chaos-env.yaml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-chaos-env +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + env: {CHAOS_X: "1"} + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: w diff --git a/e2e/chaos/tests/fixtures/invalid-data-name.yaml b/e2e/chaos/tests/fixtures/invalid-data-name.yaml new file mode 100644 index 0000000000..ee2adafb8e --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-data-name.yaml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-data-name +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +data: + p: {version: 1} + Bad_Name: text +workloads: + - name: w + description: d + kind: shell + instances: 2 + timeout: 1m + options: {policy: p} + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +operations: + - {name: g, description: d, uses: openshell-policy-set, options: {policy: p}, scope: global} +flow: {serial: [w, g]} diff --git a/e2e/chaos/tests/fixtures/invalid-expectation-affected-experiment-check.yaml b/e2e/chaos/tests/fixtures/invalid-expectation-affected-experiment-check.yaml new file mode 100644 index 0000000000..bdcf4a3b7b --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-expectation-affected-experiment-check.yaml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-expectation-affected-experiment-check +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +expectations: + - {issue: NVIDIA/OpenShell#1, checks: [experiment/e], instances: affected} +workloads: + - name: w + description: d + kind: shell + instances: 2 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +checks: + - {name: e, description: d, kind: shell, run: "true"} +operations: + - {name: a, description: d, uses: openshell-sandbox-restart, target: {workload: w}, select: one} +flow: {parallel: [w, {serial: [{wait: {workload: w, condition: started}}, a]}]} diff --git a/e2e/chaos/tests/fixtures/invalid-expectation-affected-untargeted.yaml b/e2e/chaos/tests/fixtures/invalid-expectation-affected-untargeted.yaml new file mode 100644 index 0000000000..b9cc4ec834 --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-expectation-affected-untargeted.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-expectation-affected-untargeted +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +expectations: + - {issue: NVIDIA/OpenShell#1, checks: [w/ok], instances: affected} +workloads: + - name: w + description: d + kind: shell + instances: 2 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: w diff --git a/e2e/chaos/tests/fixtures/invalid-expectation-issue.yaml b/e2e/chaos/tests/fixtures/invalid-expectation-issue.yaml new file mode 100644 index 0000000000..3ec53564cc --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-expectation-issue.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-expectation-issue +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +expectations: + - issue: "4910" +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: w diff --git a/e2e/chaos/tests/fixtures/invalid-expectation-shared-check.yaml b/e2e/chaos/tests/fixtures/invalid-expectation-shared-check.yaml new file mode 100644 index 0000000000..7419a470b9 --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-expectation-shared-check.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-expectation-shared-check +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +expectations: + - issue: NVIDIA/OpenShell#1 + checks: [w/ok] + - issue: NVIDIA/OpenShell#2 + checks: [w/ok] +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: w diff --git a/e2e/chaos/tests/fixtures/invalid-expectation-unknown-check.yaml b/e2e/chaos/tests/fixtures/invalid-expectation-unknown-check.yaml new file mode 100644 index 0000000000..6366a73bbd --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-expectation-unknown-check.yaml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-expectation-unknown-check +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +expectations: + - issue: NVIDIA/OpenShell#1 + checks: [w/exited-ok] +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: w diff --git a/e2e/chaos/tests/fixtures/invalid-flow-missing-name.yaml b/e2e/chaos/tests/fixtures/invalid-flow-missing-name.yaml new file mode 100644 index 0000000000..14ea4aa509 --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-flow-missing-name.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-flow-missing-name +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +operations: + - {name: f, description: d, uses: restart, target: gateway, select: one} +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: w diff --git a/e2e/chaos/tests/fixtures/invalid-flow-repeated-name.yaml b/e2e/chaos/tests/fixtures/invalid-flow-repeated-name.yaml new file mode 100644 index 0000000000..ae9af952cd --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-flow-repeated-name.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-flow-repeated-name +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: {serial: [w, w]} diff --git a/e2e/chaos/tests/fixtures/invalid-metric-check-no-bound.yaml b/e2e/chaos/tests/fixtures/invalid-metric-check-no-bound.yaml new file mode 100644 index 0000000000..d94b19df4b --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-metric-check-no-bound.yaml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-metric-check-no-bound +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: w +metrics: [up_gauge] +checks: + - {name: m, description: d, kind: metric, metric: up_gauge} diff --git a/e2e/chaos/tests/fixtures/invalid-metric-check-undeclared.yaml b/e2e/chaos/tests/fixtures/invalid-metric-check-undeclared.yaml new file mode 100644 index 0000000000..98e773915b --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-metric-check-undeclared.yaml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-metric-check-undeclared +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: w +metrics: [up_gauge] +checks: + - {name: m, description: d, kind: metric, metric: other_total, value: increase, at-most: 0} diff --git a/e2e/chaos/tests/fixtures/invalid-metric-entry-both.yaml b/e2e/chaos/tests/fixtures/invalid-metric-entry-both.yaml new file mode 100644 index 0000000000..a3d56e224f --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-metric-entry-both.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-metric-entry-both +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: w +metrics: + - {family: up_gauge, title: Up, expr: "sum(up_gauge)"} diff --git a/e2e/chaos/tests/fixtures/invalid-metric-name.yaml b/e2e/chaos/tests/fixtures/invalid-metric-name.yaml new file mode 100644 index 0000000000..96b007d4d5 --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-metric-name.yaml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-metric-name +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: w +metrics: ["rate(x[1m])"] diff --git a/e2e/chaos/tests/fixtures/invalid-missing-description.yaml b/e2e/chaos/tests/fixtures/invalid-missing-description.yaml new file mode 100644 index 0000000000..f64341f902 --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-missing-description.yaml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-missing-description +version: 1 +title: t +hypothesis: h +default-adapter: openshell-docker +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: w diff --git a/e2e/chaos/tests/fixtures/invalid-no-api-version.yaml b/e2e/chaos/tests/fixtures/invalid-no-api-version.yaml new file mode 100644 index 0000000000..0e509d47fe --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-no-api-version.yaml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: invalid-no-api-version +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: w diff --git a/e2e/chaos/tests/fixtures/invalid-no-checks.yaml b/e2e/chaos/tests/fixtures/invalid-no-checks.yaml new file mode 100644 index 0000000000..943622d79e --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-no-checks.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-no-checks +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [] +flow: w diff --git a/e2e/chaos/tests/fixtures/invalid-operation-global-select.yaml b/e2e/chaos/tests/fixtures/invalid-operation-global-select.yaml new file mode 100644 index 0000000000..77cfbd07bb --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-operation-global-select.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-operation-global-select +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +data: + p: {version: 1} +workloads: + - name: w + description: d + kind: shell + instances: 2 + timeout: 1m + options: {policy: p} + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +operations: + - {name: g, description: d, uses: openshell-policy-set, options: {policy: p}, scope: global, select: all} +flow: {serial: [w, g]} diff --git a/e2e/chaos/tests/fixtures/invalid-operation-name-clash.yaml b/e2e/chaos/tests/fixtures/invalid-operation-name-clash.yaml new file mode 100644 index 0000000000..bcd7896400 --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-operation-name-clash.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-operation-name-clash +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +data: + p: {version: 1} +workloads: + - name: w + description: d + kind: shell + instances: 2 + timeout: 1m + options: {policy: p} + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +operations: + - {name: w, description: d, uses: openshell-policy-set, options: {policy: p}, scope: global} +flow: {serial: [w]} diff --git a/e2e/chaos/tests/fixtures/invalid-operation-no-targeting.yaml b/e2e/chaos/tests/fixtures/invalid-operation-no-targeting.yaml new file mode 100644 index 0000000000..6a5bf90199 --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-operation-no-targeting.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-operation-no-targeting +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +data: + p: {version: 1} +workloads: + - name: w + description: d + kind: shell + instances: 2 + timeout: 1m + options: {policy: p} + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +operations: + - {name: g, description: d, uses: openshell-policy-set, options: {policy: p}} +flow: {serial: [w, g]} diff --git a/e2e/chaos/tests/fixtures/invalid-operation-no-wait.yaml b/e2e/chaos/tests/fixtures/invalid-operation-no-wait.yaml new file mode 100644 index 0000000000..39f0d8331f --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-operation-no-wait.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-operation-no-wait +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +data: + p: {version: 1} +workloads: + - name: w + description: d + kind: shell + instances: 2 + timeout: 1m + options: {policy: p} + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +operations: + - {name: a, description: d, uses: openshell-policy-set, options: {policy: p}, target: {workload: w}, select: all} +flow: {parallel: [w, a]} diff --git a/e2e/chaos/tests/fixtures/invalid-outage-no-duration.yaml b/e2e/chaos/tests/fixtures/invalid-outage-no-duration.yaml new file mode 100644 index 0000000000..6d1b7f077b --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-outage-no-duration.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-outage-no-duration +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +operations: + - {name: f, description: d, uses: outage, target: gateway, select: one} +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: {serial: [w, f]} diff --git a/e2e/chaos/tests/fixtures/invalid-repeat-shape.yaml b/e2e/chaos/tests/fixtures/invalid-repeat-shape.yaml new file mode 100644 index 0000000000..b9d72177c8 --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-repeat-shape.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-repeat-shape +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: {serial: [w, {repeat: {times: 2, for: 1m, serial: [{delay: 1s}]}}]} diff --git a/e2e/chaos/tests/fixtures/invalid-repeat-workload.yaml b/e2e/chaos/tests/fixtures/invalid-repeat-workload.yaml new file mode 100644 index 0000000000..4dfe0ee169 --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-repeat-workload.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-repeat-workload +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: {repeat: {times: 2, serial: [w]}} diff --git a/e2e/chaos/tests/fixtures/invalid-restart-duration.yaml b/e2e/chaos/tests/fixtures/invalid-restart-duration.yaml new file mode 100644 index 0000000000..63dbc07ba1 --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-restart-duration.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-restart-duration +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +operations: + - {name: f, description: d, uses: restart, target: gateway, select: one, duration: 10s} +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: {serial: [w, f]} diff --git a/e2e/chaos/tests/fixtures/invalid-role-fault-no-wait.yaml b/e2e/chaos/tests/fixtures/invalid-role-fault-no-wait.yaml new file mode 100644 index 0000000000..6e75fc8e22 --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-role-fault-no-wait.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-role-fault-no-wait +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +operations: + - {name: f, description: d, uses: kill, target: {workload: w, role: supervisor}, select: one} +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: {parallel: [w, f]} diff --git a/e2e/chaos/tests/fixtures/invalid-role-fault-wait-after.yaml b/e2e/chaos/tests/fixtures/invalid-role-fault-wait-after.yaml new file mode 100644 index 0000000000..d081400538 --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-role-fault-wait-after.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-role-fault-wait-after +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +operations: + - {name: f, description: d, uses: kill, target: {workload: w, role: supervisor}, select: one} +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: {parallel: [w, {serial: [f, {wait: {workload: w, condition: started}}]}]} diff --git a/e2e/chaos/tests/fixtures/invalid-unknown-key.yaml b/e2e/chaos/tests/fixtures/invalid-unknown-key.yaml new file mode 100644 index 0000000000..d67ae8903c --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-unknown-key.yaml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-unknown-key +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +colour: red +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: w diff --git a/e2e/chaos/tests/fixtures/invalid-wait-outside-parallel.yaml b/e2e/chaos/tests/fixtures/invalid-wait-outside-parallel.yaml new file mode 100644 index 0000000000..12a1732606 --- /dev/null +++ b/e2e/chaos/tests/fixtures/invalid-wait-outside-parallel.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: invalid-wait-outside-parallel +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: {serial: [w, {wait: {workload: w, condition: started}}]} diff --git a/e2e/chaos/tests/fixtures/valid-expectations.yaml b/e2e/chaos/tests/fixtures/valid-expectations.yaml new file mode 100644 index 0000000000..e3ff23fce4 --- /dev/null +++ b/e2e/chaos/tests/fixtures/valid-expectations.yaml @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: valid-expectations +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +expectations: + - {issue: NVIDIA/OpenShell#1, checks: [w/ok], instances: affected, kind: flaky} + - {issue: NVIDIA/OpenShell#2, checks: [experiment/e]} +workloads: + - name: w + description: d + kind: shell + instances: 2 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +checks: + - {name: e, description: d, kind: shell, run: "true"} +operations: + - {name: a, description: d, uses: openshell-sandbox-restart, target: {workload: w}, select: one} +flow: {parallel: [w, {serial: [{wait: {workload: w, condition: started}}, a]}]} diff --git a/e2e/chaos/tests/fixtures/valid-freeze.yaml b/e2e/chaos/tests/fixtures/valid-freeze.yaml new file mode 100644 index 0000000000..8327ae24f5 --- /dev/null +++ b/e2e/chaos/tests/fixtures/valid-freeze.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: valid-freeze +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +operations: + - {name: f, description: d, uses: freeze, target: {workload: w, role: supervisor}, select: all, duration: 5s} +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: {parallel: [w, {serial: [{wait: {workload: w, condition: started}}, f]}]} diff --git a/e2e/chaos/tests/fixtures/valid-minimal.yaml b/e2e/chaos/tests/fixtures/valid-minimal.yaml new file mode 100644 index 0000000000..c4b97b5762 --- /dev/null +++ b/e2e/chaos/tests/fixtures/valid-minimal.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: valid-minimal +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: w diff --git a/e2e/chaos/tests/fixtures/valid-operations.yaml b/e2e/chaos/tests/fixtures/valid-operations.yaml new file mode 100644 index 0000000000..928403c1b0 --- /dev/null +++ b/e2e/chaos/tests/fixtures/valid-operations.yaml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: valid-operations +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +data: + p: {version: 1} +workloads: + - name: w + description: d + kind: shell + instances: 2 + timeout: 1m + options: {policy: p} + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +operations: + - {name: a, description: d, uses: openshell-policy-set, options: {policy: p}, target: {workload: w}, select: all} + - {name: g, description: d, uses: openshell-policy-set, options: {policy: p}, scope: global} +flow: {parallel: [w, {serial: [{wait: {workload: w, condition: started}}, a, g]}]} diff --git a/e2e/chaos/tests/fixtures/valid-role-fault.yaml b/e2e/chaos/tests/fixtures/valid-role-fault.yaml new file mode 100644 index 0000000000..620113e1b7 --- /dev/null +++ b/e2e/chaos/tests/fixtures/valid-role-fault.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: valid-role-fault +version: 1 +title: t +hypothesis: h +description: d +default-adapter: openshell-docker +operations: + - {name: f, description: d, uses: kill, target: {workload: w, role: supervisor}, select: one} +workloads: + - name: w + description: d + kind: shell + instances: 1 + timeout: 1m + run: "true" + checks: [{name: ok, description: d, kind: exit-code, equals: 0}] +flow: {parallel: [w, {serial: [{wait: {workload: w, condition: started}}, f]}]} diff --git a/e2e/chaos/tests/test_adapter.py b/e2e/chaos/tests/test_adapter.py new file mode 100644 index 0000000000..c0961b57d4 --- /dev/null +++ b/e2e/chaos/tests/test_adapter.py @@ -0,0 +1,195 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +import re +import shutil +import subprocess + +import pytest +from conftest import ADAPTERS + +from chaos.adapter import expand, hook_script, load_adapter, missing_env, run_hook +from chaos.model import ExperimentError + + +@pytest.fixture +def docker_adapter(): + return load_adapter(ADAPTERS, "openshell-docker") + + +def test_openshell_docker_adapter_loads(docker_adapter): + assert docker_adapter.id == "openshell-docker" + assert docker_adapter.backend == "docker" + assert docker_adapter.target("gateway").type == "process" + assert set(docker_adapter.targets.roles) == {"sandbox", "supervisor"} + assert {c.name for c in docker_adapter.checks} == { + "no-leaked-containers", + "nothing-paused", + "gateway-healthy", + } + assert [c.name for c in docker_adapter.preflight] == [ + "docker-reachable", + "pull-otel-collector-image", + "disk-space", + "images-present", + "cli-runs", + "jq-installed", + "gateway-up", + ] + assert [(s.name, s.watch) for s in docker_adapter.signals] == [ + ("reconcile-sweep", "otel") + ] + (sweep,) = docker_adapter.signals + assert re.search(sweep.match, " Name : reconcile.sandboxes") + assert not re.search(sweep.match, " Name : reconcile.sandboxes.one") + assert len(docker_adapter.sha256) == 64 + + +def _hooks(adapter) -> dict[str, str]: + hooks = { + f"instance.{k}": getattr(adapter.instance, k) + for k in ("create", "status", "collect", "delete") + } + hooks["setup"] = adapter.setup + hooks["collect"] = adapter.collect + for name, target in adapter.targets.named.items(): + hooks[f"targets.{name}.start"] = target.start + for name, spec in adapter.operations.items(): + hooks[f"operations.{name}"] = spec.run + if spec.reverts: + hooks[f"operations.{name}.revert"] = spec.revert + return {k: v for k, v in hooks.items() if v} + + +@pytest.mark.skipif(shutil.which("shellcheck") is None, reason="needs shellcheck") +@pytest.mark.parametrize( + ("adapter_id", "count"), [("openshell-docker", 9), ("openshell-kubernetes", 10)] +) +def test_adapter_hooks_pass_shellcheck(adapter_id, count): + adapter = load_adapter(ADAPTERS, adapter_id) + hooks = _hooks(adapter) + assert len(hooks) == count + for name, script in hooks.items(): + result = subprocess.run( + # A hook uses only some of the prelude's helpers. + ["shellcheck", "--shell=bash", "--exclude=SC2317,SC2329", "-"], + input=hook_script(adapter, script), + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, f"{name}:\n{result.stdout}" + + +def test_hook_runs_after_the_prelude_with_its_name(adapters_dir): + adapter = load_adapter( + adapters_dir(prelude='greet() { echo "hi from $0"; }\n'), + "openshell-docker", + ) + result = asyncio.run(run_hook(adapter, "instance.create", "greet", {})) + assert result.ok, result.stderr + assert result.stdout == "hi from instance.create\n" + + +def test_options_defaults_and_overrides(docker_adapter): + assert docker_adapter.resolve_options({}, "w") == { + "image": "", + "policy": "", + "restart-policy": "never", + } + resolved = docker_adapter.resolve_options({"restart-policy": "on-failure"}, "w") + assert resolved["restart-policy"] == "on-failure" + + +def test_option_not_in_enum(docker_adapter): + with pytest.raises( + ExperimentError, match=r"w: option restart-policy='sometimes' not in" + ): + docker_adapter.resolve_options({"restart-policy": "sometimes"}, "w") + + +def test_unknown_option(docker_adapter): + with pytest.raises(ExperimentError, match="w: unknown option 'gpu'"): + docker_adapter.resolve_options({"gpu": "1"}, "w") + + +def test_unknown_target_and_role(docker_adapter): + with pytest.raises(ExperimentError, match="no target 'db'"): + docker_adapter.target("db") + with pytest.raises(ExperimentError, match="no role 'proxy'"): + docker_adapter.role("proxy") + + +def test_missing_env(docker_adapter): + env = dict.fromkeys(docker_adapter.requires_env, "x") + assert missing_env(docker_adapter, env) == [] + env["OPENSHELL_BIN"] = "" + del env["OPENSHELL_GATEWAY"] + assert missing_env(docker_adapter, env) == ["OPENSHELL_BIN", "OPENSHELL_GATEWAY"] + + +def test_missing_adapter(tmp_path): + with pytest.raises(ExperimentError, match="no adapter openshell-kubernetes"): + load_adapter(tmp_path, "openshell-kubernetes") + + +def test_adapter_id_must_match_file_name(adapters_dir): + directory = adapters_dir() + (directory / "openshell-docker.yaml").rename(directory / "other.yaml") + with pytest.raises(ExperimentError, match="does not match the file name"): + load_adapter(directory, "other") + + +def test_backend_must_be_supported(adapters_dir): + with pytest.raises( + ExperimentError, match=r"backend: Input should be 'docker' or 'kubernetes'" + ): + load_adapter(adapters_dir(backend="podman"), "openshell-docker") + + +def test_process_target_needs_pid_file_and_start(adapters_dir): + directory = adapters_dir(targets={"gateway": {"type": "process"}}) + with pytest.raises( + ExperimentError, match="a process target needs pid-file and start" + ): + load_adapter(directory, "openshell-docker") + + +def test_unknown_adapter_key(adapters_dir): + with pytest.raises(ExperimentError, match="colour: Extra inputs are not permitted"): + load_adapter(adapters_dir(colour="red"), "openshell-docker") + + +@pytest.mark.parametrize( + ("operation", "error"), + [ + ({"run": "true"}, r"operations\.op\.revert: Field required"), + ({"run": "true", "revert": "none"}, "revert: none needs a reason"), + ( + {"run": "true", "revert": "true", "reason": "r"}, + "reason goes with revert: none", + ), + ], +) +def test_operation_reverts_or_says_why_not(adapters_dir, operation, error): + directory = adapters_dir(operations={"op": operation}) + with pytest.raises(ExperimentError, match=error): + load_adapter(directory, "openshell-docker") + + +def test_adapter_check_is_shell_only(adapters_dir): + check = {"name": "e", "description": "d", "kind": "exit-code", "equals": 0} + with pytest.raises( + ExperimentError, match=r"checks\.0\.kind: Input should be 'shell'" + ): + load_adapter(adapters_dir(checks=[check]), "openshell-docker") + + +def test_expand(): + assert expand("a-${X}-${Y}", {"X": "1", "Y": "2"}) == "a-1-2" + assert expand("$X", {}) == "$X" + with pytest.raises(ExperimentError, match=r"\$\{MISSING\} is not set"): + expand("${MISSING}", {}) diff --git a/e2e/chaos/tests/test_cli.py b/e2e/chaos/tests/test_cli.py new file mode 100644 index 0000000000..9a0ef0e7c5 --- /dev/null +++ b/e2e/chaos/tests/test_cli.py @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""`--adapter` picks the adapter instead of the experiment's `default-adapter`.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import yaml +from conftest import ADAPTERS, PACKAGE_DIR, minimal_adapter + +from chaos.cli import main + +EXPERIMENTS = PACKAGE_DIR / "experiments" + + +def adapters(tmp_path: Path) -> Path: + """The real adapters, and `bare-docker`: a Docker adapter with no + operations, options or signals.""" + directory = tmp_path / "adapters" + shutil.copytree(ADAPTERS, directory) + bare = minimal_adapter(id="bare-docker") + (directory / "bare-docker.yaml").write_text(yaml.safe_dump(bare)) + return directory + + +def test_validate_uses_default_adapter(tmp_path, capsys): + directory = adapters(tmp_path) + path = EXPERIMENTS / "hello-world.yaml" + assert main(["--adapters-dir", str(directory), "validate", str(path)]) == 0 + assert "ok on openshell-docker:" in capsys.readouterr().out + + +def test_validate_with_another_adapter(tmp_path, capsys): + directory = adapters(tmp_path) + path = EXPERIMENTS / "hello-world.yaml" + argv = ["--adapters-dir", str(directory), "validate", "--adapter", "bare-docker"] + assert main([*argv, str(path)]) == 0 + assert "ok on bare-docker:" in capsys.readouterr().out + + +def test_validate_names_what_the_adapter_lacks(tmp_path, capsys): + directory = adapters(tmp_path) + path = EXPERIMENTS / "policy-tighten-survives-gateway-restart.yaml" + argv = ["--adapters-dir", str(directory), "validate", "--adapter", "bare-docker"] + assert main([*argv, str(path)]) == 1 + err = capsys.readouterr().err + assert err.startswith("invalid on bare-docker: ") + assert "unknown option 'policy' for bare-docker" in err + + +def test_unknown_adapter(tmp_path, capsys): + directory = adapters(tmp_path) + path = EXPERIMENTS / "hello-world.yaml" + argv = ["--adapters-dir", str(directory), "preflight", "--adapter", "nope"] + assert main([*argv, str(path)]) == 2 + assert "no adapter nope" in capsys.readouterr().err diff --git a/e2e/chaos/tests/test_dashboard.py b/e2e/chaos/tests/test_dashboard.py new file mode 100644 index 0000000000..4a376dd34e --- /dev/null +++ b/e2e/chaos/tests/test_dashboard.py @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Per-experiment Grafana dashboards.""" + +from __future__ import annotations + +import json + +import pytest +from conftest import EXPERIMENTS + +from chaos.cli import main +from chaos.dashboard import UID_MAX, build_dashboard, dashboard_uid +from chaos.model import load_experiment, parse_experiment, read_yaml + + +def experiment(**extra): + raw = read_yaml(EXPERIMENTS / "supervisor-kill.yaml") + raw.update(extra) + return parse_experiment(raw, "test") + + +def by_title(dashboard): + return {p["title"]: p for p in dashboard["panels"]} + + +def test_panels_follow_the_experiment(): + exp = experiment(metrics=[], checks=[]) + dash = build_dashboard(exp) + assert dash["uid"] == "exp-supervisor-kill" + panels = by_title(dash) + assert exp.hypothesis.split()[0] in panels[exp.title]["options"]["content"] + for w in exp.workloads: + (target,) = panels[f"{w.name} instances"]["targets"] + assert f'workload="{w.name}"' in target["expr"] + assert "Faults" in panels + assert "chaos_check_result" in panels["Check results"]["targets"][0]["expr"] + run, chart = dash["templating"]["list"] + assert f'experiment="{exp.id}"' in run["query"]["query"] + # No `metrics:`: every chart of the run, repeated over `$chart`. + assert panels["$chart"]["repeat"] == chart["name"] == "chart" + + +def test_listed_metrics_replace_the_chart_repeat(): + dash = build_dashboard( + experiment( + checks=[], + metrics=[ + "openshell_gateway_sessions", + {"title": "Up", "expr": 'up{chaos_run_id="$run"}'}, + ], + ) + ) + panels = by_title(dash) + assert "$chart" not in panels + variable = dash["templating"]["list"][1] + assert variable["name"] == "metric0" and variable["hide"] == 2 + assert 'family="openshell_gateway_sessions"' in variable["query"]["query"] + assert panels["$metric0"]["repeat"] == "metric0" + assert panels["Up"]["targets"][0]["expr"] == 'up{chaos_run_id="$run"}' + + +def test_layout_does_not_overlap(): + cells = set() + for p in build_dashboard(experiment())["panels"]: + g = p["gridPos"] + assert g["x"] + g["w"] <= 24 + for x in range(g["x"], g["x"] + g["w"]): + for y in range(g["y"], g["y"] + g["h"]): + assert (x, y) not in cells, p["title"] + cells.add((x, y)) + + +def test_long_ids_get_a_short_unique_uid(): + a, b = dashboard_uid("a" * 50), dashboard_uid("a" * 49 + "b") + assert len(a) <= UID_MAX and len(b) <= UID_MAX and a != b + + +@pytest.mark.parametrize( + "path", sorted(EXPERIMENTS.glob("*.yaml")), ids=lambda p: p.stem +) +def test_every_shipped_experiment_has_a_dashboard(path, tmp_path): + exp, _ = load_experiment(path) + assert main(["dashboard", str(path), "--dir", str(tmp_path)]) == 0 + dash = json.loads((tmp_path / f"{exp.id}.json").read_text()) + assert dash["uid"] == dashboard_uid(exp.id) diff --git a/e2e/chaos/tests/test_envvars.py b/e2e/chaos/tests/test_envvars.py new file mode 100644 index 0000000000..7abc4fb8ed --- /dev/null +++ b/e2e/chaos/tests/test_envvars.py @@ -0,0 +1,139 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""The REFERENCE.md variable tables are the oracle for the `envvars` builders.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from conftest import PACKAGE_DIR + +from chaos import envvars +from chaos.envvars import InstanceInfo, RunInfo + +ROW_RE = re.compile(r"^\| `(CHAOS_[A-Z_]+)` \|(.*)\|$") +COLUMNS = ["workload_run", "workload_check", "experiment_check"] + +RUN = RunInfo( + "exp", 3, "openshell-docker", "20260101T000000Z-abcd", 42, Path("/runs/exp/r") +) +INST = InstanceInfo( + "counter", + 2, + 1, + "counter-1", + Path("/runs/exp/r/workloads/counter/instances/counter-1"), + "/out", +) + + +def readme_table() -> dict[str, set[str]]: + """Map each column of the variable table to the variables marked in it.""" + table: dict[str, set[str]] = {c: set() for c in COLUMNS} + for line in (PACKAGE_DIR / "REFERENCE.md").read_text().splitlines(): + match = ROW_RE.match(line) + if not match: + continue + cells = [c.strip() for c in match.group(2).split("|")] + assert len(cells) == len(COLUMNS), line + for column, cell in zip(COLUMNS, cells, strict=True): + if cell.startswith("✓"): + table[column].add(match.group(1)) + assert all(table.values()), "REFERENCE.md variable table not found" + return table + + +def builders(exit_code: int | None) -> dict[str, dict[str, str]]: + return { + "workload_run": envvars.workload_run_env(RUN, INST), + "workload_check": envvars.workload_check_env(RUN, INST, "c", exit_code), + "experiment_check": envvars.experiment_check_env(RUN, "c"), + } + + +@pytest.mark.parametrize("column", COLUMNS) +def test_scope_matches_reference(column): + assert set(builders(0)[column]) == readme_table()[column] + + +def test_exit_code_unset_when_none_recorded(): + env = builders(None)["workload_check"] + assert "CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_EXIT_CODE" not in env + assert set(env) == readme_table()["workload_check"] - { + "CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_EXIT_CODE" + } + + +def test_output_dir_is_the_only_value_that_differs(): + run, check = builders(0)["workload_run"], builders(0)["workload_check"] + differ = {k for k in run if run[k] != check[k]} + assert differ == {"CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_OUTPUT_DIR"} + assert run["CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_OUTPUT_DIR"] == "/out" + assert check["CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_OUTPUT_DIR"] == str( + INST.dir / "output" + ) + + +def test_values(): + env = builders(3)["workload_check"] + assert env["CHAOS_EXPERIMENT_VERSION"] == "3" + assert env["CHAOS_EXPERIMENT_RUN_SEED"] == "42" + assert env["CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_COUNT"] == "2" + assert env["CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_INDEX"] == "1" + assert env["CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_EXIT_CODE"] == "3" + assert env["CHAOS_EXPERIMENT_RUN_DIR"] == "/runs/exp/r" + + +def test_instance_hook_env(): + env = envvars.instance_hook_env( + RUN, + INST, + {"restart-policy": "never"}, + Path("/run.sh"), + Path("/wrap.sh"), + Path("/env"), + None, + ) + expected = readme_table()["workload_run"] | { + "CHAOS_EXPERIMENT_RUN_DIR", + "CHAOS_EXPERIMENT_DATA_DIR", + "CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_DIR", + "CHAOS_EXPERIMENT_WORKLOAD_RUN_FILE", + "CHAOS_EXPERIMENT_WORKLOAD_WRAPPER_FILE", + "CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_ENV_FILE", + "CHAOS_EXPERIMENT_WORKLOAD_OPTION_RESTART_POLICY", + } + assert set(env) == expected + with_id = envvars.instance_hook_env( + RUN, INST, {}, Path("/run.sh"), Path("/wrap.sh"), Path("/env"), "sb-1" + ) + assert with_id["CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_SYSTEM_ID"] == "sb-1" + + +def test_operation_env_matches_reference(): + reference = (PACKAGE_DIR / "REFERENCE.md").read_text() + documented = set( + re.findall(r"^- `(CHAOS_EXPERIMENT_OPERATION_[A-Z_]+)`", reference, re.M) + ) + # The runner adds the outcome file and the state dir to both. + documented -= { + "CHAOS_EXPERIMENT_OPERATION_OUTCOME_FILE", + "CHAOS_EXPERIMENT_OPERATION_STATE_DIR", + } + options = {"policy": "/run/data/p"} + targeted = envvars.operation_env(RUN, "a", "openshell-policy-set", options) + option = {"CHAOS_EXPERIMENT_OPERATION_OPTION_POLICY"} + assert ( + set(targeted) + == documented - {"CHAOS_EXPERIMENT_OPERATION_INSTANCES_FILE"} | option + ) + assert targeted["CHAOS_EXPERIMENT_OPERATION_SCOPE"] == "instances" + assert targeted["CHAOS_EXPERIMENT_OPERATION_OPTION_POLICY"] == "/run/data/p" + glob = envvars.operation_env(RUN, "a", "openshell-policy-set", options, Path("/i")) + operations = {k for k in glob if k.startswith("CHAOS_EXPERIMENT_OPERATION_")} + assert operations == documented | option + assert glob["CHAOS_EXPERIMENT_OPERATION_SCOPE"] == "global" + assert glob["CHAOS_EXPERIMENT_RUN_DIR"] == str(RUN.run_dir) diff --git a/e2e/chaos/tests/test_evaluate.py b/e2e/chaos/tests/test_evaluate.py new file mode 100644 index 0000000000..95adbb9fbf --- /dev/null +++ b/e2e/chaos/tests/test_evaluate.py @@ -0,0 +1,568 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Checks, verdicts and `chaos evaluate` against a hand-made run directory.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from pathlib import Path + +import pytest +import yaml +from conftest import FIXTURES + +from chaos import cli +from chaos.adapter import load_adapter +from chaos.envvars import RunInfo +from chaos.evaluate import ( + Result, + format_verdict, + read_verdict, + run_checks, + selected_instances, + verdict_of, + write_verdict, +) +from chaos.model import load_experiment + +RUN_ID = "20260101T000000Z-abcd" +EXPERIMENT = { + "apiVersion": "chaos/v1alpha1", + "id": "evaluated", + "version": 1, + "title": "t", + "hypothesis": "h", + "description": "d", + "default-adapter": "openshell-docker", + "workloads": [ + { + "name": "w", + "description": "d", + "kind": "shell", + "instances": 2, + "timeout": "1m", + "env": {"WANT": "done"}, + "run": "true", + "checks": [ + { + "name": "exited-ok", + "description": "d", + "kind": "exit-code", + "equals": 0, + }, + { + "name": "wrote-done", + "description": "d", + "kind": "shell", + "run": 'grep -qx "$WANT" "$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_OUTPUT_DIR/result"' + ' || { echo "$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_NAME missing"; exit 1; }', + }, + ], + } + ], + "flow": "w", + "checks": [ + { + "name": "timeline-written", + "description": "d", + "kind": "shell", + "run": 'test -f "$CHAOS_EXPERIMENT_RUN_DIR/timeline.jsonl"' + ' && test "$CHAOS_EXPERIMENT_CHECK_NAME" = timeline-written', + } + ], +} + + +def instance_output(run_dir: Path, name: str) -> Path: + path = run_dir / "workloads" / "w" / "instances" / name / "output" + (path / ".chaos").mkdir(parents=True) + return path + + +@pytest.fixture +def run_dir(tmp_path, adapters_dir): + """A finished run: w-0 succeeded, w-1 exited 1 without writing its result.""" + directory = tmp_path / "runs" / "evaluated" / RUN_ID + directory.mkdir(parents=True) + (directory / "timeline.jsonl").write_text( + json.dumps({"event": "run.finished", "verdict": "FAIL"}) + "\n" + ) + (directory / "experiment.yaml").write_text(yaml.safe_dump(EXPERIMENT)) + (directory / "manifest.json").write_text( + json.dumps( + { + "schema_version": "v1alpha1", + "run_id": RUN_ID, + "experiment": {"id": "evaluated", "version": 1}, + "seed": 0, + "adapter": {"id": "openshell-docker"}, + "overrides": [], + "environment": {"system": {"git_sha": "abc"}}, + } + ) + ) + ok = instance_output(directory, "w-0") + (ok / ".chaos" / "exit_code").write_text("0\n") + (ok / "result").write_text("done\n") + bad = instance_output(directory, "w-1") + (bad / ".chaos" / "exit_code").write_text("1\n") + adapters_dir() + return directory + + +def evaluate(run_dir: Path, *extra: str) -> int: + adapters = run_dir.parents[2] / "adapters" + return cli.main(["--adapters-dir", str(adapters), "evaluate", str(run_dir), *extra]) + + +def test_run_dir_commands_refuse_another_schema_version(run_dir, capsys): + manifest = json.loads((run_dir / "manifest.json").read_text()) + manifest["schema_version"] = "v0" + (run_dir / "manifest.json").write_text(json.dumps(manifest)) + assert evaluate(run_dir) == 2 + assert cli.main(["report", str(run_dir)]) == 2 + assert cli.main(["dashboard", str(run_dir)]) == 2 + err = capsys.readouterr().err + assert ( + err.count("schema_version 'v0' is not 'v1alpha1'; this harness cannot read it") + == 3 + ) + assert not (run_dir / "evaluations").exists() + + +def test_evaluate_reports_each_check(run_dir, capsys): + assert evaluate(run_dir) == 1 + verdict = read_verdict(run_dir) + assert verdict["verdict"] == "FAIL" + results = {(r["scope"], r["instance"], r["check"]): r for r in verdict["results"]} + assert set(results) == { + ("workload", "w-0", "exited-ok"), + ("workload", "w-0", "wrote-done"), + ("workload", "w-1", "exited-ok"), + ("workload", "w-1", "wrote-done"), + ("experiment", None, "timeline-written"), + } + assert results["workload", "w-0", "exited-ok"]["ok"] + assert results["workload", "w-0", "wrote-done"]["ok"] + assert results["workload", "w-1", "exited-ok"]["output"] == "exit code 1, want 0" + assert "w-1 missing" in results["workload", "w-1", "wrote-done"]["output"] + assert results["experiment", None, "timeline-written"]["ok"] + *_, last_verdict, path = capsys.readouterr().out.strip().splitlines() + assert last_verdict == "FAIL" + assert Path(path).parent == run_dir / "evaluations" + + +def test_evaluate_is_reproducible_and_appends_index(run_dir): + (run_dir / "verdict.json").write_text(json.dumps({"verdict": "PASS"})) + index = run_dir.parents[1] / "index.jsonl" + evaluate(run_dir, "--index", str(index)) + first = read_verdict(run_dir) + evaluate(run_dir, "--index", str(index)) + assert read_verdict(run_dir)["results"] == first["results"] + assert len(list((run_dir / "evaluations").glob("*.json"))) == 2 + # An evaluation never replaces the verdict the run wrote. + assert json.loads((run_dir / "verdict.json").read_text()) == {"verdict": "PASS"} + lines = [json.loads(line) for line in index.read_text().splitlines()] + assert [line["verdict"] for line in lines] == ["FAIL", "FAIL"] + assert lines[0]["system"] == {"git_sha": "abc"} + assert lines[0]["adapter"] == "openshell-docker" + assert lines[0]["flaky_missed"] == [] + # Paths are relative to the index's directory. + assert lines[0]["path"] == f"evaluated/{RUN_ID}" + assert lines[0]["evaluation"].startswith(f"evaluated/{RUN_ID}/evaluations/") + # Each evaluation says what it read, and the printed verdict starts with it. + archived = run_dir / "experiment.yaml" + assert first["read"] == { + "experiment": { + "file": str(archived), + "sha256": hashlib.sha256(archived.read_bytes()).hexdigest(), + }, + "adapter": { + "id": "openshell-docker", + "file": str(run_dir.parents[2] / "adapters" / "openshell-docker.yaml"), + }, + } + assert format_verdict(first).startswith(f" evaluated with {archived} (sha256 ") + + +def test_evaluate_keeps_run_errors(run_dir): + (run_dir / "verdict.json").write_text(json.dumps({"errors": ["aborted by SIGINT"]})) + assert evaluate(run_dir) == 2 + verdict = read_verdict(run_dir) + assert verdict["verdict"] == "ERROR" + assert verdict["errors"] == ["aborted by SIGINT"] + + +def test_evaluate_of_unfinished_run_is_error(run_dir): + (run_dir / "timeline.jsonl").write_text(json.dumps({"event": "run.started"}) + "\n") + assert evaluate(run_dir) == 2 + assert read_verdict(run_dir)["errors"] == ["run did not finish"] + # The run wrote the same error: it is not repeated. + (run_dir / "verdict.json").write_text( + json.dumps({"errors": ["run did not finish"]}) + ) + evaluate(run_dir) + assert read_verdict(run_dir)["errors"] == ["run did not finish"] + + +def test_evaluate_with_edited_experiment(run_dir, tmp_path): + edited = json.loads(json.dumps(EXPERIMENT)) + edited["workloads"][0]["checks"] = edited["workloads"][0]["checks"][1:] + path = tmp_path / "evaluated.yaml" + path.write_text(yaml.safe_dump(edited)) + ( + run_dir / "workloads" / "w" / "instances" / "w-1" / "output" / "result" + ).write_text("done\n") + assert evaluate(run_dir, "--experiment", str(path)) == 0 + + +CHECKS = ["w/exited-ok", "w/wrote-done"] + + +def test_evaluate_known_failures_are_xfail(run_dir, tmp_path): + path = _edited(tmp_path, expectations=[{"issue": "o/r#1", "checks": CHECKS}]) + assert evaluate(run_dir, "--experiment", path) == 0 + verdict = read_verdict(run_dir) + assert verdict["verdict"] == "XFAIL" + issues = {(r["instance"], r["check"]): r["issue"] for r in verdict["results"]} + assert issues["w-1", "exited-ok"] == issues["w-1", "wrote-done"] == "o/r#1" + # w-0 passed checks that w-1 failed: a race, not a fix. + assert issues["w-0", "exited-ok"] is None and issues["w-0", "wrote-done"] is None + + +def test_evaluate_unexpected_pass_fails(run_dir, tmp_path): + expectations = [ + {"issue": "o/r#1", "checks": CHECKS}, + {"issue": "o/r#2", "checks": ["experiment/timeline-written"]}, + ] + path = _edited(tmp_path, expectations=expectations) + assert evaluate(run_dir, "--experiment", path) == 1 + verdict = read_verdict(run_dir) + assert verdict["verdict"] == "FAIL" + xpass = [r for r in verdict["results"] if r["ok"] and r["issue"]] + assert [(r["check"], r["issue"]) for r in xpass] == [("timeline-written", "o/r#2")] + + +def test_missing_exit_code(run_dir, adapters_dir): + ( + run_dir + / "workloads" + / "w" + / "instances" + / "w-1" + / "output" + / ".chaos" + / "exit_code" + ).unlink() + exp, _ = load_experiment(_write(run_dir.parent, EXPERIMENT)) + adapter = load_adapter(adapters_dir(), "openshell-docker") + info = RunInfo("evaluated", 1, "openshell-docker", RUN_ID, 0, run_dir) + results = asyncio.run(run_checks(exp, adapter, info)) + missing = next(r for r in results if r.instance == "w-1" and r.check == "exited-ok") + assert not missing.ok and missing.output == "no exit code recorded" + + +def _write(directory: Path, raw: dict) -> Path: + path = directory / f"{raw['id']}.yaml" + path.write_text(yaml.safe_dump(raw)) + return path + + +def test_verdict_of(): + ok = Result("workload", "w", "w-0", "c", "d", True, "") + bad = Result("workload", "w", "w-1", "c", "d", False, "boom") + assert verdict_of([ok], []) == "PASS" + assert verdict_of([ok, bad], []) == "FAIL" + assert verdict_of([ok], ["timeout"]) == "ERROR" + assert verdict_of([], []) == "PASS" + known = Result("workload", "w", "w-1", "c", "d", False, "boom", issue="o/r#1") + fixed = Result("workload", "w", "w-0", "c", "d", True, "", issue="o/r#1") + assert verdict_of([ok, known], []) == "XFAIL" + assert verdict_of([known, bad], []) == "FAIL" + assert verdict_of([fixed], []) == "FAIL" + assert verdict_of([known], ["timeout"]) == "ERROR" + + +def test_format_verdict(tmp_path): + bad = Result( + "workload", "w", "w-1", "c", "d", False, "\n".join(f"l{i}" for i in range(9)) + ) + verdict = write_verdict(tmp_path, [bad], ["timeout"]) + text = format_verdict(verdict) + assert " FAIL w-1/c\n" in text and "l8" in text and "l3" not in text + assert text.splitlines()[-2:] == [" error: timeout", "ERROR"] + + +def test_format_verdict_marks_known_failures(tmp_path): + known = Result("workload", "w", "w-1", "c", "d", False, "boom", issue="o/r#1") + fixed = Result("experiment", None, None, "e", "d", True, "", issue="o/r#2") + text = format_verdict(write_verdict(tmp_path, [known, fixed], [])) + assert " xfail w-1/c (known: o/r#1)" in text + assert " XPASS experiment/e (expected to fail: o/r#2; fixed?)" in text + + +def test_validate_command(capsys): + assert cli.main(["validate", str(FIXTURES / "valid-minimal.yaml")]) == 0 + assert cli.main(["validate", str(FIXTURES / "invalid-no-checks.yaml")]) == 1 + assert "invalid-no-checks.yaml" in capsys.readouterr().err + + +def test_list_and_show(capsys): + assert cli.main(["list"]) == 0 + assert "gateway-kill-keeps-state (v" in capsys.readouterr().out + assert cli.main(["show", "smoke"]) == 0 + out = capsys.readouterr().out + assert "check journal-complete:" in out and "hypothesis:" in out + assert cli.main(["list", "--issue", "#4910"]) == 0 + out = capsys.readouterr().out + assert "gateway-kill-keeps-state (v" in out and "smoke (v" not in out + assert cli.main(["show", "gateway-kill-keeps-state"]) == 0 + assert ( + "NVIDIA/OpenShell#4910, known to fail:\n counter/exited-ok\n" + in capsys.readouterr().out + ) + + +def test_list_and_show_fit_80_columns(capsys): + cli.main(["list"]) + ids = [line for line in capsys.readouterr().out.splitlines() if " (v" in line] + assert ids + for line in ids: + cli.main(["show", line.split(" (v")[0]]) + cli.main(["list"]) + lines = capsys.readouterr().out.splitlines() + assert [line for line in lines if len(line) > 80] == [] + + +def test_run_refuses_without_wrapper_env(monkeypatch, tmp_path, capsys): + adapter = load_adapter(cli.DEFAULT_ADAPTERS_DIR, "openshell-docker") + for name in adapter.requires_env: + monkeypatch.delenv(name, raising=False) + smoke = cli.DEFAULT_EXPERIMENTS_DIR / "smoke.yaml" + code = cli.main(["run", str(smoke), "--runs-dir", str(tmp_path)]) + assert code == 2 + assert "needs these environment variables:\n OPENSHELL_BIN\n" in ( + capsys.readouterr().err + ) + assert not any(tmp_path.iterdir()) + + +def _edited(tmp_path: Path, **changes) -> str: + """The experiment with `changes`.""" + edited = {**json.loads(json.dumps(EXPERIMENT)), **changes} + path = tmp_path / "evaluated.yaml" + path.write_text(yaml.safe_dump(edited)) + return str(path) + + +def _results(run_dir: Path) -> dict[tuple[str | None, str], dict]: + return {(r["instance"], r["check"]): r for r in read_verdict(run_dir)["results"]} + + +OPERATION = { + "name": "f", + "description": "d", + "uses": "kill", + "target": {"workload": "w", "role": "supervisor"}, + "select": "one", +} + + +@pytest.mark.parametrize( + ("selected", "code", "verdict"), + [(["w-1"], 0, "XFAIL"), (["w-0"], 1, "FAIL")], +) +def test_evaluate_expectation_on_affected_instances( + run_dir, tmp_path, selected, code, verdict +): + with (run_dir / "timeline.jsonl").open("w") as f: + for event in ( + { + "event": "operation.started", + "operation": "f", + "scope": "instances", + "instances": selected, + }, + {"event": "run.finished", "verdict": "FAIL"}, + ): + f.write(json.dumps(event) + "\n") + started = {"wait": {"workload": "w", "condition": "started"}} + path = _edited( + tmp_path, + operations=[OPERATION], + flow={"parallel": ["w", {"serial": [started, "f"]}]}, + expectations=[{"issue": "o/r#1", "checks": CHECKS, "instances": "affected"}], + ) + assert evaluate(run_dir, "--experiment", path) == code + assert read_verdict(run_dir)["verdict"] == verdict + # Only the selected instance is covered; the other one is the control. + results = _results(run_dir) + assert {i for (i, _), r in results.items() if r["issue"]} == set(selected) + + +def test_evaluate_flaky_expectation(run_dir, tmp_path): + expectation = {"issue": "o/r#1", "checks": CHECKS, "kind": "flaky"} + path = _edited(tmp_path, expectations=[expectation]) + assert evaluate(run_dir, "--experiment", path) == 0 + assert read_verdict(run_dir)["verdict"] == "XFAIL" + # The bug did not trigger anywhere: still a pass, not an XPASS. + w1 = run_dir / "workloads" / "w" / "instances" / "w-1" / "output" + (w1 / ".chaos" / "exit_code").write_text("0\n") + (w1 / "result").write_text("done\n") + index = run_dir.parents[1] / "index.jsonl" + assert evaluate(run_dir, "--experiment", path, "--index", str(index)) == 0 + assert read_verdict(run_dir)["verdict"] == "PASS" + assert all(r["flaky"] for r in _results(run_dir).values() if r["issue"]) + # The index says which flaky known failures did not trigger. + assert read_verdict(run_dir)["read"]["experiment"]["file"] == path + (line,) = [json.loads(line) for line in index.read_text().splitlines()] + assert line["flaky_missed"] == CHECKS + strict = _edited(tmp_path, expectations=[{"issue": "o/r#1", "checks": CHECKS}]) + assert evaluate(run_dir, "--experiment", strict) == 1 + + +@pytest.mark.parametrize( + ("run", "timeout"), [("no-such-command-xyz", "1m"), ("sleep 5", "1s")] +) +def test_evaluate_checker_error_is_error(run_dir, tmp_path, run, timeout): + workloads = json.loads(json.dumps(EXPERIMENT["workloads"])) + workloads[0]["checks"][1].update(run=run, timeout=timeout) + # The expectation does not excuse a check that could not decide. + path = _edited( + tmp_path, + workloads=workloads, + expectations=[{"issue": "o/r#1", "checks": CHECKS}], + ) + assert evaluate(run_dir, "--experiment", path) == 2 + assert read_verdict(run_dir)["verdict"] == "ERROR" + errored = _results(run_dir)["w-0", "wrote-done"] + assert errored["error"] and errored["issue"] is None + + +def test_selected_instances(): + events = [ + { + "event": "operation.started", + "operation": "f", + "scope": "instances", + "instances": ["w-0"], + }, + { + "event": "operation.started", + "operation": "a", + "scope": "instances", + "instances": ["w-1", "w-2"], + }, + {"event": "operation.started", "operation": "g", "scope": "global"}, + {"event": "operation.completed", "operation": "f", "outcome": "ok"}, + ] + assert selected_instances(events) == {"w-0", "w-1", "w-2"} + + +def test_verdict_and_format_of_errors_and_flaky(tmp_path): + error = Result("workload", "w", "w-0", "c", "d", False, "x", error=True) + flaky_ok = Result( + "workload", "w", "w-0", "f", "d", True, "", issue="o/r#1", flaky=True + ) + flaky_bad = Result( + "workload", "w", "w-1", "f", "d", False, "x", issue="o/r#1", flaky=True + ) + assert verdict_of([error], []) == "ERROR" + assert verdict_of([flaky_ok], []) == "PASS" + assert verdict_of([flaky_ok, flaky_bad], []) == "XFAIL" + text = format_verdict(write_verdict(tmp_path, [error, flaky_ok, flaky_bad], [])) + assert " ERROR w-0/c (the check could not decide)" in text + assert " ok w-0/f (flaky: o/r#1 did not trigger)" in text + assert " xfail w-1/f (known, flaky: o/r#1)" in text + + +def _scrapes( + run_dir: Path, samples: list[dict[str, float]], source: str = "gw" +) -> None: + directory = run_dir / "system" / "metrics" + directory.mkdir(parents=True, exist_ok=True) + (directory / f"{source}.jsonl").write_text( + "".join( + json.dumps({"t": float(t), "up": 1, "samples": s}) + "\n" + for t, s in enumerate(samples) + ) + + json.dumps({"t": float(len(samples)), "up": 0, "samples": {}}) + + "\n" + ) + + +def _metric_check(name: str, **fields) -> dict: + fields = {k.replace("_", "-"): v for k, v in fields.items()} + return {"name": name, "description": "d", "kind": "metric", **fields} + + +def test_metric_checks_read_the_recorded_scrapes(run_dir, tmp_path): + _scrapes( + run_dir, + [ + {'req_total{m="a"}': 10, 'req_total{m="b"}': 1, "sessions": 1}, + {'req_total{m="a"}': 16, 'req_total{m="b"}': 1, "sessions": 3}, + # A counter reset: the increase counts 2, not -14. + {'req_total{m="a"}': 2, 'req_total{m="b"}': 0, "sessions": 0}, + ], + ) + ( + run_dir / "workloads" / "w" / "instances" / "w-1" / "output" / "result" + ).write_text("done\n") + path = _edited( + tmp_path, + metrics=["req_total", "sessions", "lat_seconds"], + checks=[ + _metric_check("peak", metric="sessions", value="max", at_least=3), + _metric_check("last", metric="sessions", equals=0), + _metric_check("grew", metric="req_total", value="increase", equals=8), + _metric_check( + "a-only", metric="req_total", labels={"m": "a"}, value="min", at_least=2 + ), + _metric_check("never", metric="lat_seconds_count", at_least=1), + _metric_check("elsewhere", metric="sessions", source="other", equals=0), + ], + ) + # One exit code is 1, so the verdict is FAIL whatever the metric checks say. + assert evaluate(run_dir, "--experiment", path) == 2 + results = _results(run_dir) + assert [ + (results[None, n]["ok"], results[None, n]["error"]) + for n in ("peak", "last", "grew", "a-only", "never", "elsewhere") + ] == [(True, False)] * 4 + [(False, False), (False, True)] + assert results[None, "grew"]["output"] == ( + "increase = 8 (want equals 8); 2 series of req_total summed over 3 scrapes of gw" + ) + assert results[None, "a-only"]["output"].startswith( + 'min = 2 (want at-least 2); 1 series of req_total{m="a"}' + ) + assert results[None, "never"]["output"] == ( + "no series lat_seconds_count in any scrape (gw)" + ) + assert results[None, "elsewhere"]["output"] == ( + "no scrapes of metrics source 'other' in system/metrics" + ) + + +def test_metric_check_needs_one_source(run_dir, tmp_path): + _scrapes(run_dir, [{"sessions": 1}], source="gw") + _scrapes(run_dir, [{"sessions": 2}], source="gw2") + path = _edited( + tmp_path, + metrics=["sessions"], + checks=[ + _metric_check("either", metric="sessions", at_least=1), + _metric_check("second", metric="sessions", source="gw2", equals=2), + ], + ) + evaluate(run_dir, "--experiment", path) + results = _results(run_dir) + assert results[None, "either"]["error"] + assert ( + "more than one source (gw, gw2); set source" + in results[None, "either"]["output"] + ) + assert results[None, "second"]["ok"] diff --git a/e2e/chaos/tests/test_kubernetes.py b/e2e/chaos/tests/test_kubernetes.py new file mode 100644 index 0000000000..82957b4e83 --- /dev/null +++ b/e2e/chaos/tests/test_kubernetes.py @@ -0,0 +1,262 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""The kubernetes backend: adapter rules and which experiments validate on +openshell-kubernetes. The adapter has not been run against a cluster, so +nothing here exercises the injector's kubectl calls.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest +import yaml +from conftest import ADAPTERS, EXPERIMENTS, minimal_adapter + +from chaos.adapter import load_adapter +from chaos.cli import main +from chaos.model import ExperimentError + + +def kubernetes_adapter(**extra) -> dict: + """A Kubernetes adapter whose hooks only record that they ran.""" + adapter = minimal_adapter( + backend="kubernetes", + kubernetes={"context": "${CHAOS_TEST_CONTEXT}"}, + targets={ + "gateway": { + "type": "workload", + "namespace": "openshell", + "resource": "statefulset/openshell", + "start": 'echo started >> "$CHAOS_TEST_LOG"', + }, + "roles": { + "sandbox": { + "type": "pod", + "namespace": "sandboxes", + "labels": { + "pair": "${CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_SYSTEM_ID}", + "role": "workload", + }, + }, + "supervisor": { + "type": "pod", + "namespace": "sandboxes", + "labels": { + "pair": "${CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_SYSTEM_ID}", + "role": "supervisor", + }, + }, + }, + }, + ) + adapter.update(extra) + return adapter + + +def write_adapter(tmp_path: Path, adapter: dict) -> Path: + directory = tmp_path / "adapters" + directory.mkdir(exist_ok=True) + (directory / f"{adapter['id']}.yaml").write_text(yaml.safe_dump(adapter)) + return directory + + +def load(tmp_path: Path, **extra): + return load_adapter( + write_adapter(tmp_path, kubernetes_adapter(**extra)), "openshell-docker" + ) + + +# Adapter rules. + + +def test_openshell_kubernetes_adapter_loads(): + adapter = load_adapter(ADAPTERS, "openshell-kubernetes") + assert adapter.backend == "kubernetes" + assert adapter.kubernetes is not None + assert adapter.kubernetes.context == "${OPENSHELL_E2E_KUBE_CONTEXT}" + assert adapter.target("gateway").type == "workload" + assert {r.type for r in adapter.targets.roles.values()} == {"pod"} + assert {c.name for c in adapter.checks} == {"no-leaked-pods", "gateway-healthy"} + assert adapter.setup + + +def test_kubernetes_needs_a_context(tmp_path): + adapter = kubernetes_adapter() + del adapter["kubernetes"] + with pytest.raises( + ExperimentError, match="the kubernetes backend needs kubernetes.context" + ): + load_adapter(write_adapter(tmp_path, adapter), "openshell-docker") + + +def test_docker_takes_no_kubernetes_block(tmp_path): + adapter = minimal_adapter(kubernetes={"context": "kind-chaos"}) + with pytest.raises(ExperimentError, match="kubernetes"): + load_adapter(write_adapter(tmp_path, adapter), "openshell-docker") + + +def test_workload_target_needs_namespace_resource_and_start(tmp_path): + with pytest.raises( + ExperimentError, match="a workload target needs namespace, resource and start" + ): + load( + tmp_path, + targets={"gateway": {"type": "workload", "namespace": "openshell"}}, + ) + + +def test_pod_target_needs_a_namespace(tmp_path): + targets = {"roles": {"sandbox": {"type": "pod", "labels": {"role": "workload"}}}} + with pytest.raises(ExperimentError, match="a pod target needs a namespace"): + load(tmp_path, targets=targets) + + +def test_process_target_is_not_available_on_kubernetes(tmp_path): + targets = {"gateway": {"type": "process", "pid-file": "/pid", "start": "true"}} + with pytest.raises( + ExperimentError, match="a process target is not available on kubernetes" + ): + load(tmp_path, targets=targets) + + +def test_pod_target_is_not_available_on_docker(tmp_path): + targets = { + "roles": { + "sandbox": { + "type": "pod", + "namespace": "ns", + "labels": {"role": "workload"}, + } + } + } + adapter = minimal_adapter(targets=targets) + with pytest.raises( + ExperimentError, match="a pod target is not available on docker" + ): + load_adapter(write_adapter(tmp_path, adapter), "openshell-docker") + + +# Which shipped experiments validate on openshell-kubernetes. + + +def _validate(tmp_path, capsys, experiment: str) -> tuple[int, str]: + directory = tmp_path / "adapters" + shutil.copytree(ADAPTERS, directory) + argv = [ + "--adapters-dir", + str(directory), + "validate", + "--adapter", + "openshell-kubernetes", + ] + code = main([*argv, str(EXPERIMENTS / f"{experiment}.yaml")]) + out = capsys.readouterr() + return code, out.out + out.err + + +@pytest.mark.parametrize( + "experiment", + [ + "hello-world", + "smoke", + "gateway-graceful-restart", + "gateway-kill-keeps-state", + "policy-tighten-survives-gateway-restart", + "supervisor-kill", + ], +) +def test_experiment_validates_on_kubernetes(tmp_path, capsys, experiment): + code, out = _validate(tmp_path, capsys, experiment) + assert code == 0, out + assert "ok on openshell-kubernetes:" in out + + +@pytest.mark.parametrize( + ("experiment", "reason"), + [ + ( + "supervisor-pause", + "freeze on a pod target (role supervisor) is not supported on " + "kubernetes: freezing a pod's processes needs node-level cgroup access", + ), + ( + "sandbox-container-restart", + "restart-in-place on a pod target (role sandbox) is not supported on " + "kubernetes: the Kubernetes driver runs sandbox pods with " + "restartPolicy: Never", + ), + ("sandbox-stop-start", "openshell-kubernetes has no signal 'reconcile-sweep'"), + ], +) +def test_experiment_is_not_applicable_on_kubernetes( + tmp_path, capsys, experiment, reason +): + code, out = _validate(tmp_path, capsys, experiment) + assert code == 0, out + assert "n/a on openshell-kubernetes:" in out + assert reason in out + + +def test_run_of_a_not_applicable_experiment_exits_0_without_a_run(tmp_path, capsys): + runs = tmp_path / "runs" + code = main( + [ + "run", + "--adapter", + "openshell-kubernetes", + "--runs-dir", + str(runs), + str(EXPERIMENTS / "supervisor-pause.yaml"), + ] + ) + assert code == 0 + assert "n/a on openshell-kubernetes:" in capsys.readouterr().out + assert not runs.exists() + + +def test_not_applicable_on_the_default_adapter_is_invalid(tmp_path, capsys): + raw = yaml.safe_load((EXPERIMENTS / "supervisor-pause.yaml").read_text()) + raw["default-adapter"] = "openshell-kubernetes" + path = tmp_path / "supervisor-pause.yaml" + path.write_text(yaml.safe_dump(raw, sort_keys=False)) + code = main(["validate", str(path)]) + out = capsys.readouterr() + assert code == 1 + assert "invalid on openshell-kubernetes:" in out.err + assert "(on its default-adapter)" in out.err + + +def test_validate_all_adapters_prints_the_matrix(capsys): + code = main(["validate", "--all-adapters"]) + out = capsys.readouterr().out + assert code == 0, out + table, _, reasons = out.partition("\n\n") + lines = table.splitlines() + assert lines[0].split() == [ + "experiment", + "openshell-docker", + "openshell-kubernetes", + ] + rows = {line.split()[0]: line.split()[1:] for line in lines[1:]} + assert rows["supervisor-kill"] == ["ok", "ok"] + assert rows["supervisor-pause"] == ["ok", "n/a"] + assert rows["sandbox-container-restart"] == ["ok", "n/a"] + assert "sandbox-container-restart on openshell-kubernetes: n/a:" in reasons + + +def test_validate_takes_adapter_or_all_adapters_not_both(capsys): + with pytest.raises(SystemExit): + main(["validate", "--adapter", "openshell-docker", "--all-adapters"]) + capsys.readouterr() + + +def test_validate_needs_files_without_all_adapters(capsys): + assert main(["validate"]) == 2 + assert "give experiment files" in capsys.readouterr().err + + +def test_unavailable_names_a_builtin(tmp_path): + with pytest.raises(ExperimentError, match="unavailable.pause: not a built-in"): + load(tmp_path, unavailable={"pause": "no"}) diff --git a/e2e/chaos/tests/test_model.py b/e2e/chaos/tests/test_model.py new file mode 100644 index 0000000000..b71219bb81 --- /dev/null +++ b/e2e/chaos/tests/test_model.py @@ -0,0 +1,410 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from conftest import ADAPTERS, EXPERIMENTS, FIXTURES + +from chaos.adapter import load_adapter, validate_adapter +from chaos.model import ( + ExperimentError, + Operation, + Parallel, + Ref, + Repeat, + Serial, + Wait, + apply_overrides, + load_experiment, + parse_duration, + parse_experiment, + read_yaml, +) + +# Each invalid fixture and a pattern its error must contain; the pattern names +# the offending field or flow node. +INVALID = { + "invalid-api-version": r"apiVersion: Input should be 'chaos/v1alpha1'", + "invalid-no-api-version": r"apiVersion: Field required", + "invalid-unknown-key": r"colour: Extra inputs are not permitted", + "invalid-missing-description": r"description: Field required", + "invalid-chaos-env": r"workloads\.0\.env: .*'CHAOS_X' must not start with CHAOS_", + "invalid-flow-missing-name": r"flow: 'f' is never used", + "invalid-flow-repeated-name": r"flow\.serial\[1\]: 'w' appears more than once", + "invalid-metric-name": r"metrics\.0: .*'rate\(x\[1m\]\)' is not a metric family name", + "invalid-metric-entry-both": r"metrics\.0: .*set exactly one of family and expr", + "invalid-metric-check-no-bound": r"checks\.0\.metric: .*set equals, at-least or at-most", + "invalid-metric-check-undeclared": r"checks\.0\.metric: 'other_total' is not a family under metrics:", + "invalid-wait-outside-parallel": r"flow\.serial\[1\]\.wait: wait on 'w' must be in a sibling branch", + "invalid-no-checks": r"workloads\.0\.checks: List should have at least 1 item", + "invalid-outage-no-duration": r"operations\.0: .*outage requires duration", + "invalid-restart-duration": r"operations\.0: .*restart is one-shot and takes no duration", + "invalid-role-fault-no-wait": r"operations\.f: must come after wait: \{workload: w, condition: started\}", + "invalid-role-fault-wait-after": r"operations\.f: must come after wait: \{workload: w, condition: started\}", + "invalid-data-name": r"data\.Bad_Name: want lowercase letters, digits, '-'", + "invalid-operation-no-wait": r"operations\.a: must come after wait: \{workload: w, condition: started\}", + "invalid-operation-global-select": r"operations\.0: .*scope global takes no select", + "invalid-operation-no-targeting": r"operations\.0: .*set exactly one of target and scope", + "invalid-operation-name-clash": r"duplicate workload/operation name 'w'", + "invalid-expectation-issue": r"expectations\.0\.issue: .*must be a URL or look like /#", + "invalid-expectation-unknown-check": r"expectations\.0\.checks: no check 'w/exited-ok'", + "invalid-expectation-shared-check": r"expectations\.1\.checks: 'w/ok' is in more than one expectation", + "invalid-expectation-affected-experiment-check": r"expectations\.0: only workload checks run per instance", + "invalid-expectation-affected-untargeted": r"expectations\.0\.instances: no operation targets w instances", + "invalid-repeat-workload": r"flow\.repeat\.serial\[0\]: only adapter operations can be in a repeat, not 'w'", + "invalid-repeat-shape": r"flow\.serial\[1\]\.repeat: want \{times: , serial: \[\.\.\.\]\}", +} + + +def test_every_invalid_fixture_is_covered(): + assert {p.stem for p in FIXTURES.glob("invalid-*.yaml")} == set(INVALID) + + +@pytest.mark.parametrize("name", sorted(INVALID)) +def test_invalid_fixture_is_rejected(name): + with pytest.raises(ExperimentError, match=INVALID[name]): + load_experiment(FIXTURES / f"{name}.yaml") + + +@pytest.mark.parametrize( + "path", sorted(FIXTURES.glob("valid-*.yaml")), ids=lambda p: p.stem +) +def test_valid_fixture_loads(path): + exp, _ = load_experiment(path) + assert exp.id == path.stem + + +@pytest.mark.parametrize( + "path", sorted(EXPERIMENTS.glob("*.yaml")), ids=lambda p: p.stem +) +def test_shipped_experiment_validates_against_its_adapter(path): + exp, _ = load_experiment(path) + validate_adapter(exp, load_adapter(ADAPTERS, exp.default_adapter)) + + +def test_id_must_equal_file_name(tmp_path): + path = tmp_path / "other-name.yaml" + path.write_text((FIXTURES / "valid-minimal.yaml").read_text()) + with pytest.raises(ExperimentError, match="must equal the file name 'other-name'"): + load_experiment(path) + + +def test_exit_code_check_only_in_workloads(tmp_path): + raw = read_yaml(FIXTURES / "valid-minimal.yaml") + raw["id"] = "exit-code-outside" + raw["checks"] = [ + {"name": "e", "description": "d", "kind": "exit-code", "equals": 0} + ] + path = tmp_path / "exit-code-outside.yaml" + path.write_text(yaml.safe_dump(raw)) + with pytest.raises( + ExperimentError, match=r"checks\.0: Input tag 'exit-code' .* 'shell', 'metric'" + ): + load_experiment(path) + + +def test_keys_are_kebab_case(tmp_path): + raw = read_yaml(FIXTURES / "valid-minimal.yaml") + raw["id"] = "snake-case-key" + raw["requires_env"] = [] + path = tmp_path / "snake-case-key.yaml" + path.write_text(yaml.safe_dump(raw)) + with pytest.raises( + ExperimentError, match=r"requires_env: Extra inputs are not permitted" + ): + load_experiment(path) + + +def test_operation_must_suit_the_target_type(tmp_path, adapters_dir): + exp, _ = load_experiment(FIXTURES / "valid-freeze.yaml") + validate_adapter(exp, load_adapter(adapters_dir(), "openshell-docker")) + text = (FIXTURES / "valid-role-fault.yaml").read_text() + path = tmp_path / "valid-role-fault.yaml" + path.write_text(text.replace("kill", "restart")) + exp, _ = load_experiment(path) + with pytest.raises( + ExperimentError, + match=r"operations\.f: restart on a container target " + r"\(role supervisor\) is not supported on docker", + ): + validate_adapter(exp, load_adapter(adapters_dir(), "openshell-docker")) + + +def test_flow_tree_has_paths(): + exp, _ = load_experiment(FIXTURES / "valid-role-fault.yaml") + assert isinstance(exp.flow, Parallel) + workload, branch = exp.flow.children + assert isinstance(workload, Ref) and workload.path == "flow.parallel[0]" + assert isinstance(branch, Serial) + assert isinstance(branch.children[0], Wait) and branch.children[0].workload == "w" + assert branch.children[1].path == "flow.parallel[1].serial[1]" + + +def test_repeat_parses_times_and_for(tmp_path): + raw = read_yaml(FIXTURES / "valid-operations.yaml") + raw["id"] = "rep" + raw["flow"] = { + "parallel": [ + "w", + { + "serial": [ + {"wait": {"workload": "w", "condition": "started"}}, + {"repeat": {"for": "1m", "serial": ["a", {"delay": "1s"}]}}, + {"repeat": {"times": 2, "serial": ["g"]}}, + ] + }, + ] + } + path = tmp_path / "rep.yaml" + path.write_text(yaml.safe_dump(raw)) + exp, _ = load_experiment(path) + _, branch = exp.flow.children + timed, counted = branch.children[1:] + assert isinstance(timed, Repeat) and (timed.seconds, timed.times) == (60, None) + assert timed.children[0].path == "flow.parallel[1].serial[1].repeat.serial[0]" + assert isinstance(counted, Repeat) and (counted.seconds, counted.times) == (None, 2) + + +@pytest.mark.parametrize( + ("value", "seconds"), [("500ms", 0.5), ("10s", 10), ("6m", 360), ("1h", 3600)] +) +def test_parse_duration(value, seconds): + assert parse_duration(value) == seconds + + +@pytest.mark.parametrize("value", ["10", "1.5s", "10 s", "1d", ""]) +def test_parse_duration_rejects(value): + with pytest.raises(ValueError, match="invalid duration"): + parse_duration(value) + + +# --set overrides. + + +def test_override_addresses_list_items_by_name(): + exp, raw = load_experiment( + EXPERIMENTS / "smoke.yaml", + [ + "workloads.counter.instances=3", + "workloads.counter.env.TICKS=7", + "timeout=20m", + ], + ) + assert exp.workload("counter").instances == 3 + assert exp.workload("counter").env["TICKS"] == "7" + assert exp.timeout == "20m" + assert raw["workloads"][0]["instances"] == 3 + + +def test_override_does_not_mutate_input(): + raw = {"workloads": [{"name": "w", "instances": 1}]} + apply_overrides(raw, ["workloads.w.instances=2"]) + assert raw["workloads"][0]["instances"] == 1 + + +def test_override_creates_missing_mappings(): + data = apply_overrides( + {"workloads": [{"name": "w"}]}, ["workloads.w.options.image=x"] + ) + assert data["workloads"][0]["options"] == {"image": "x"} + + +def test_override_empty_value_is_empty_string(): + assert apply_overrides({"a": "b"}, ["a="]) == {"a": ""} + + +@pytest.mark.parametrize( + ("override", "message"), + [ + ("timeout", "want path=value"), + ("=1", "want path=value"), + ("workloads.nope.instances=2", "no item named 'nope'"), + ("workloads.counter=2", "cannot replace a whole list item"), + ("timeout.minutes=2", "timeout is not a mapping or list"), + ], +) +def test_override_errors(override, message): + with pytest.raises(ExperimentError, match=message): + load_experiment(EXPERIMENTS / "smoke.yaml", [override]) + + +def test_override_result_is_validated(): + with pytest.raises(ExperimentError, match=r"workloads\.0\.instances"): + load_experiment(EXPERIMENTS / "smoke.yaml", ["workloads.counter.instances=0"]) + + +def _with_preflight(tmp_path, names: list[str]): + raw = read_yaml(FIXTURES / "valid-minimal.yaml") + raw["id"] = "pf" + raw["preflight"] = [ + {"name": n, "description": "d", "kind": "shell", "run": "true"} for n in names + ] + path = tmp_path / "pf.yaml" + path.write_text(yaml.safe_dump(raw)) + return path + + +def test_preflight_names(tmp_path): + with pytest.raises(ExperimentError, match="duplicate preflight check"): + load_experiment(_with_preflight(tmp_path, ["a", "a"])) + exp, _ = load_experiment(_with_preflight(tmp_path, ["docker-reachable"])) + with pytest.raises(ExperimentError, match="already has a preflight check"): + validate_adapter(exp, load_adapter(ADAPTERS, exp.default_adapter)) + + +# Data and adapter operations. + + +def _operations_fixture(tmp_path, edit) -> Path: + raw = read_yaml(FIXTURES / "valid-operations.yaml") + raw["id"] = "act" + edit(raw) + path = tmp_path / "act.yaml" + path.write_text(yaml.safe_dump(raw)) + return path + + +@pytest.mark.parametrize( + ("edit", "error"), + [ + ( + lambda raw: raw["workloads"][0]["options"].update(policy="nope"), + r"workloads\.w\.options\.policy: no data entry named 'nope'", + ), + ( + lambda raw: raw["operations"][1]["options"].update(policy="nope"), + r"operations\.g\.options\.policy: no data entry named 'nope'", + ), + ( + lambda raw: raw["operations"][1].update(options={}), + r"operations\.g\.options: option policy is required", + ), + ( + lambda raw: raw["operations"][1]["options"].update(colour="red"), + r"operations\.g\.options: unknown option 'colour' for openshell-policy-set", + ), + ], +) +def test_options_are_checked_against_the_adapter(tmp_path, edit, error): + exp, _ = load_experiment(_operations_fixture(tmp_path, edit)) + with pytest.raises(ExperimentError, match=error): + validate_adapter(exp, load_adapter(ADAPTERS, exp.default_adapter)) + + +def test_adapter_needs_the_version_marker(adapters_dir): + directory = adapters_dir() + path = directory / "openshell-docker.yaml" + path.write_text(path.read_text().replace("apiVersion: chaos/v1alpha1", "")) + with pytest.raises(ExperimentError, match=r"apiVersion: Field required"): + load_adapter(directory, "openshell-docker") + + +def test_limits_are_the_adapters(tmp_path, adapters_dir): + """A `run` script or an `env` value over the adapter's limits is rejected + against that adapter, with the limit named; the harness has no limit.""" + raw = read_yaml(FIXTURES / "valid-minimal.yaml") + raw["workloads"][0]["run"] = "x" * 100 + raw["workloads"][0]["env"] = {"BIG": "y" * 50} + path = tmp_path / "valid-minimal.yaml" + path.write_text(yaml.safe_dump(raw)) + exp, _ = load_experiment(path) + validate_adapter(exp, load_adapter(adapters_dir(), "openshell-docker")) + limits = {"env-value-bytes": 50, "run-script-bytes": 99} + adapter = load_adapter(adapters_dir(limits=limits), "openshell-docker") + with pytest.raises( + ExperimentError, + match=r"workloads\.w\.run: 100 bytes is over limits\.run-script-bytes " + r"\(99\) of openshell-docker", + ): + validate_adapter(exp, adapter) + limits = {"env-value-bytes": 49, "run-script-bytes": 100} + adapter = load_adapter(adapters_dir(limits=limits), "openshell-docker") + with pytest.raises( + ExperimentError, + match=r"workloads\.w\.env\.BIG: 50 bytes is over limits\.env-value-bytes " + r"\(49\) of openshell-docker", + ): + validate_adapter(exp, adapter) + + +def test_operation_must_be_in_the_adapter(adapters_dir): + exp, _ = load_experiment(FIXTURES / "valid-operations.yaml") + validate_adapter(exp, load_adapter(ADAPTERS, exp.default_adapter)) + options = {"policy": {"default": "", "data": True}} + adapter = load_adapter(adapters_dir(options=options), "openshell-docker") + with pytest.raises( + ExperimentError, + match=r"operations\.a: openshell-docker has no operation 'openshell-policy-set'", + ): + validate_adapter(exp, adapter) + + +def test_container_restart_is_one_shot(tmp_path, adapters_dir): + text = (FIXTURES / "valid-role-fault.yaml").read_text() + path = tmp_path / "valid-role-fault.yaml" + path.write_text(text.replace("kill", "restart-in-place")) + exp, _ = load_experiment(path) + validate_adapter(exp, load_adapter(adapters_dir(), "openshell-docker")) + + path.write_text( + text.replace("kill", "restart-in-place").replace( + "select: one}", "select: one, duration: 10s}" + ) + ) + with pytest.raises(ExperimentError, match="restart-in-place is one-shot"): + load_experiment(path) + + +@pytest.mark.parametrize( + ("legacy", "current"), + [ + ("process-restart", "restart"), + ("process-outage", "outage"), + ("container-kill", "kill"), + ("container-restart", "restart-in-place"), + ("container-pause", "freeze"), + ], +) +def test_legacy_operation_names_load_as_their_current_name(legacy, current): + op = Operation.model_validate( + { + "name": "f", + "description": "d", + "uses": legacy, + "target": "gateway", + "select": "all", + } + | ({"duration": "1s"} if current in {"outage", "freeze"} else {}) + ) + assert op.uses == current + + +def test_metrics_entries_and_metric_checks(tmp_path): + raw = read_yaml(FIXTURES / "valid-minimal.yaml") + raw["id"] = "metrics" + raw["metrics"] = [ + "up_gauge", + {"family": "lat_seconds", "description": "d"}, + {"title": "Up", "expr": "sum(up_gauge)"}, + ] + raw["checks"] = [ + { + "name": "slow", + "description": "d", + "kind": "metric", + "metric": "lat_seconds_count", + "value": "increase", + "at-least": 1, + "at-most": 9, + } + ] + exp = parse_experiment(raw, "test") + assert exp.metric_families == ["up_gauge", "lat_seconds"] + assert [m.title for m in exp.metrics if m.expr] == ["Up"] + (check,) = exp.checks + assert check.wants() == "at-least 1 and at-most 9" + assert check.holds(9) and not check.holds(9.5) diff --git a/e2e/chaos/tests/test_observe.py b/e2e/chaos/tests/test_observe.py new file mode 100644 index 0000000000..6ee1a3eda3 --- /dev/null +++ b/e2e/chaos/tests/test_observe.py @@ -0,0 +1,99 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Metrics scrapes and watch commands, against files and shell commands.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +from conftest import minimal_adapter + +from chaos.adapter import Adapter +from chaos.observe import Observers, parse_prometheus +from chaos.timeline import Timeline + +EXPOSITION = """\ +# HELP openshell_server_grpc_requests_total gRPC requests. +# TYPE openshell_server_grpc_requests_total counter +openshell_server_grpc_requests_total{method="Health",code="0"} 3 +openshell_server_grpc_requests_total{method="a b",note="x}y \\"q\\""} 1 1700000000000 +# TYPE openshell_server_supervisor_sessions gauge +openshell_server_supervisor_sessions 2 +broken_sample not-a-number +""" + + +def test_parse_prometheus(): + samples, types = parse_prometheus(EXPOSITION) + assert samples == { + 'openshell_server_grpc_requests_total{method="Health",code="0"}': 3.0, + 'openshell_server_grpc_requests_total{method="a b",note="x}y \\"q\\""}': 1.0, + "openshell_server_supervisor_sessions": 2.0, + } + assert types == { + "openshell_server_grpc_requests_total": "counter", + "openshell_server_supervisor_sessions": "gauge", + } + + +def records(path: Path) -> list[dict]: + return [json.loads(line) for line in path.read_text().splitlines()] + + +def observe(tmp_path: Path, seconds: float, **extra) -> Path: + run_dir = tmp_path / "run" + (run_dir / "logs").mkdir(parents=True) + (run_dir / "system").mkdir() + adapter = Adapter.model_validate(minimal_adapter(**extra)) + timeline = Timeline(run_dir / "timeline.jsonl") + + async def go() -> list[str]: + observers = Observers(adapter, timeline, run_dir, {"RUN": str(run_dir)}) + await observers.start() + await asyncio.sleep(seconds) + await observers.stop() + return observers.errors + + try: + assert asyncio.run(go()) == [] + finally: + timeline.close() + return run_dir + + +def test_scrapes_up_and_down(tmp_path): + exposition = tmp_path / "metrics.txt" + exposition.write_text(EXPOSITION) + run_dir = observe( + tmp_path, + 0.35, + metrics=[ + {"name": "up", "url": f"file://{exposition}", "interval": "100ms"}, + {"name": "down", "url": f"file://{tmp_path}/missing", "interval": "1s"}, + ], + ) + up = records(run_dir / "system" / "metrics" / "up.jsonl") + assert len(up) >= 2 + assert all(r["up"] == 1 for r in up) + assert up[0]["samples"]["openshell_server_supervisor_sessions"] == 2.0 + assert [r["t"] for r in up] == sorted(r["t"] for r in up) + types = json.loads((run_dir / "system" / "metrics" / "up.types.json").read_text()) + assert types["openshell_server_supervisor_sessions"] == "gauge" + (down,) = records(run_dir / "system" / "metrics" / "down.jsonl") + assert down["up"] == 0 and down["error"] + + +def test_watch_records_lines_and_stops(tmp_path): + run_dir = observe( + tmp_path, + 0.5, + watch=[ + {"name": "events", "run": 'echo "{\\"a\\": 1}"; echo "$RUN" >&2; sleep 60'}, + ], + ) + (record,) = records(run_dir / "system" / "watch" / "events.jsonl") + assert json.loads(record["line"]) == {"a": 1} + assert (run_dir / "logs" / "watch-events.log").read_text().strip() == str(run_dir) diff --git a/e2e/chaos/tests/test_recovery.py b/e2e/chaos/tests/test_recovery.py new file mode 100644 index 0000000000..83b8906ca8 --- /dev/null +++ b/e2e/chaos/tests/test_recovery.py @@ -0,0 +1,88 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""State that must survive a retry: the wrapper's exit code and the undo stack.""" + +from __future__ import annotations + +import asyncio +import os +import subprocess +from typing import cast + +from chaos.inject.base import Undo +from chaos.inject.docker import DockerInjector +from chaos.instances import WRAPPER +from chaos.model import RunError +from chaos.timeline import Timeline + + +def test_wrapper_clears_previous_attempts_exit_code(tmp_path): + chaos = tmp_path / ".chaos" + chaos.mkdir() + (chaos / "exit_code").write_text("0\n") + env = {**os.environ, "CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_OUTPUT_DIR": str(tmp_path)} + # The script sees whether the previous attempt's exit code is still there. + script = f'test ! -e "{chaos}/exit_code"' + result = subprocess.run(["sh", "-c", WRAPPER, "chaos", script], env=env) + assert result.returncode == 0 + assert (chaos / "exit_code").read_text() == "0\n" + assert len((chaos / "attempts").read_text().splitlines()) == 1 + + +class _Timeline: + def __init__(self) -> None: + self.events: list[tuple[str, dict]] = [] + + def emit(self, event: str, **fields) -> None: + self.events.append((event, fields)) + + +def _injector() -> tuple[DockerInjector, _Timeline]: + injector = object.__new__(DockerInjector) + injector.undo_stack = [] + timeline = _Timeline() + injector.timeline = cast(Timeline, timeline) + return injector, timeline + + +def test_cancelled_undo_stays_on_the_stack(): + injector, _ = _injector() + started = asyncio.Event() + + async def hang() -> None: + started.set() + await asyncio.sleep(60) + + entry = Undo("pause", "unpause", hang) + injector.undo_stack.append(entry) + + async def go() -> None: + task = asyncio.create_task(injector.undo_all()) + await started.wait() + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + asyncio.run(go()) + assert injector.undo_stack == [entry] + + +def test_failed_undo_is_recorded_and_dropped(): + injector, timeline = _injector() + ran = [] + + async def ok() -> None: + ran.append("older") + + async def fail() -> None: + raise RunError("docker unpause: no such container") + + injector.undo_stack = [Undo("a", "older", ok), Undo("b", "newer", fail)] + errors = asyncio.run(injector.undo_all()) + assert errors == ["undo b (newer): docker unpause: no such container"] + assert ran == ["older"] + assert injector.undo_stack == [] + assert [e for e, _ in timeline.events] == [ + "operation.error", + "operation.reverted", + ] diff --git a/e2e/chaos/tests/test_report.py b/e2e/chaos/tests/test_report.py new file mode 100644 index 0000000000..1ccdaf15f0 --- /dev/null +++ b/e2e/chaos/tests/test_report.py @@ -0,0 +1,467 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""The HTML report, built from a synthetic run dir.""" + +from __future__ import annotations + +import itertools +import json +import shutil +from pathlib import Path + +from chaos.export import export +from chaos.report import build, write_report + +TIMELINE = [ + {"t": 0.0, "event": "run.started"}, + { + "t": 1.0, + "event": "instance.created", + "workload": "counter", + "instance": "counter-0", + "system_id": "sb-0", + }, + { + "t": 1.5, + "event": "instance.state", + "workload": "counter", + "instance": "counter-0", + "state": "Running", + }, + { + "t": 4.0, + "event": "operation.started", + "operation": "gateway-down", + "uses": "outage", + "run": 1, + "target": "gateway", + "scope": "target", + "instances": [], + "ids": [], + }, + { + "t": 4.0, + "event": "operation.applied", + "operation": "gateway-down", + "uses": "outage", + }, + { + "t": 9.0, + "event": "operation.reverted", + "operation": "gateway-down", + "uses": "outage", + }, + { + "t": 10.0, + "event": "operation.applied", + "operation": "kill-one", + "uses": "kill", + }, + { + "t": 12.0, + "event": "instance.state", + "workload": "counter", + "instance": "counter-0", + "state": "Succeeded", + }, + { + "t": 12.5, + "event": "instance.collected", + "workload": "counter", + "instance": "counter-0", + "exit_code": 0, + }, + { + "t": 13.0, + "event": "instance.deleted", + "workload": "counter", + "instance": "counter-0", + }, + {"t": 14.0, "event": "run.finished", "verdict": "PASS"}, +] + + +def jsonl(path: Path, records: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("".join(json.dumps(r) + "\n" for r in records)) + + +def run_dir(tmp_path: Path) -> Path: + run = tmp_path / "run" + jsonl( + run / "timeline.jsonl", [{"ts": "2026-10-27T00:00:00Z", **e} for e in TIMELINE] + ) + (run / "manifest.json").write_text( + json.dumps( + { + "schema_version": "v1alpha1", + "run_id": "r1", + "seed": 7, + "adapter": {"id": "openshell-docker"}, + "environment": {"system": {"openshell": "1.2.3"}}, + } + ) + ) + (run / "verdict.json").write_text( + json.dumps({"verdict": "PASS", "errors": [], "results": []}) + ) + (run / "experiment.yaml").write_text( + "id: demo\ntitle: Demo \nhypothesis: |\n It\n holds.\n" + ) + metrics = run / "system" / "metrics" + jsonl( + metrics / "gateway.jsonl", + [ + { + "t": 0.0, + "up": 1, + "samples": { + 'req_total{m="a"}': 10, + 'req_total{m="b"}': 0, + "sessions": 1, + "lat_count": 0, + "lat_sum": 0, + 'lat_bucket{le="1"}': 0, + "idle": 0, + }, + }, + { + "t": 2.0, + "up": 1, + "samples": { + 'req_total{m="a"}': 14, + 'req_total{m="b"}': 2, + "sessions": 2, + "lat_count": 4, + "lat_sum": 2, + 'lat_bucket{le="1"}': 4, + "idle": 0, + }, + }, + {"t": 4.0, "up": 0, "error": "refused"}, + {"t": 6.0, "up": 0, "error": "refused"}, + { + "t": 8.0, + "up": 1, + "samples": { + 'req_total{m="a"}': 1, + "sessions": 1, + "lat_count": 6, + "lat_sum": 3, + "idle": 0, + }, + }, + ], + ) + (metrics / "gateway.types.json").write_text( + json.dumps( + { + "req_total": "counter", + "sessions": "gauge", + "lat": "histogram", + "idle": "gauge", + } + ) + ) + line = { + "Action": "die", + "Actor": { + "Attributes": { + "openshell.ai/sandbox-id": "sb-0", + "openshell.ai/isolation-role": "supervisor", + "exitCode": "137", + } + }, + } + other = { + "Action": "die", + "Actor": {"Attributes": {"openshell.ai/sandbox-id": "elsewhere"}}, + } + jsonl( + run / "system" / "watch" / "docker-events.jsonl", + [ + {"t": 10.1, "line": json.dumps(line)}, + {"t": 10.2, "line": json.dumps(other)}, + {"t": 10.3, "line": "not json"}, + ], + ) + return run + + +def test_lanes_faults_and_marks(tmp_path): + data = build(run_dir(tmp_path)) + (lane,) = data["lanes"] + assert [(s["state"], s["from"], s["to"]) for s in lane["segments"]] == [ + ("Created", 1.0, 1.5), + ("Running", 1.5, 12.0), + ("Succeeded", 12.0, 13.0), + ] + labels = [m["label"] for m in lane["marks"]] + assert "supervisor die (exit 137)" in labels + assert len(labels) == 3 # collected, deleted, one docker event for this run + assert [(f["name"], f["from"], f["to"], f["ended_by"]) for f in data["faults"]] == [ + ("gateway-down", 4.0, 9.0, "operation.reverted"), + ("kill-one", 10.0, 10.0, "operation.applied"), + ] + assert data["experiment"]["hypothesis"] == "It holds." + assert [e["t"] for e in data["events"]] == [0.0, 14.0] + + +def test_metric_charts(tmp_path): + data = build(run_dir(tmp_path)) + charts = {c["title"]: c for c in data["charts"]} + assert set(charts) == { + "req_total", + "sessions", + "lat (observations)", + "lat (mean per observation)", + } + # Counter rate: none at the first scrape, (16 - 10) / 2s, a gap while + # down, then a reset (16 -> 1) read as 1 since the last answer at t=2. + rates = charts["req_total"]["points"] + assert rates == [[0.0, None], [2.0, 3.0], [4.0, None], [6.0, None], [8.0, 0.166667]] + assert charts["req_total"]["subtitle"] == "per second, sum of 2 series" + assert charts["sessions"]["points"][0] == [0.0, 1.0] + assert charts["lat (mean per observation)"]["points"] == [ + [0.0, None], + [2.0, 0.5], + [4.0, None], + [6.0, None], + [8.0, None], + ] + assert data["down"] == [{"source": "gateway", "from": 4.0, "to": 8.0}] + + +def test_listed_metrics_come_first_and_keep_zero_charts(tmp_path): + run = run_dir(tmp_path) + (run / "experiment.yaml").write_text( + "id: demo\ntitle: Demo\nhypothesis: h\n" + "metrics:\n" + " - family: lat\n description: |\n Why it\n matters.\n" + " - sessions\n" + " - family: absent_total\n" + " - {title: Up, expr: 'sum(up)'}\n" + ) + metrics = run / "system" / "metrics" + scrapes = [ + json.loads(line) + for line in (metrics / "gateway.jsonl").read_text().splitlines() + ] + for s in scrapes: + if s["up"]: + s["samples"]["zero_total"] = 0 + jsonl(metrics / "gateway.jsonl", scrapes) + (metrics / "gateway.types.json").write_text( + json.dumps( + {"req_total": "counter", "lat": "histogram", "zero_total": "counter"} + ) + ) + data = build(run) + assert data["metrics"] == [ + {"family": "lat", "description": "Why it matters."}, + {"family": "sessions", "description": ""}, + {"family": "absent_total", "description": ""}, + ] + assert [(c["title"], c["declared"]) for c in data["charts"]] == [ + ("lat (observations)", True), + ("lat (mean per observation)", True), + ("sessions", True), + ("req_total", False), + ] + # An all-zero family is charted only when it is listed. + (run / "experiment.yaml").write_text( + "id: demo\ntitle: Demo\nhypothesis: h\nmetrics: [zero_total]\n" + ) + data = build(run) + assert [(c["title"], c["declared"]) for c in data["charts"]][:1] == [ + ("zero_total", True) + ] + assert not any(v for _, v in data["charts"][0]["points"]) + + +def test_summary_quantiles_not_charted(tmp_path): + run = run_dir(tmp_path) + metrics = run / "system" / "metrics" + jsonl( + metrics / "gateway.jsonl", + [ + { + "t": float(t), + "up": 1, + "samples": { + 'dur{quantile="0.5"}': 0.1, + 'dur{quantile="0.99"}': 0.9, + "dur_count": 2 * t, + "dur_sum": t, + }, + } + for t in range(3) + ], + ) + (metrics / "gateway.types.json").write_text(json.dumps({"dur": "summary"})) + titles = [c["title"] for c in build(run)["charts"]] + assert titles == ["dur (observations)", "dur (mean per observation)"] + + +def test_run_details_table(tmp_path): + run = run_dir(tmp_path) + manifest = json.loads((run / "manifest.json").read_text()) + manifest["experiment"] = {"id": "demo", "version": 2} + manifest["started_at"] = "2026-10-27T00:00:00.123456Z" + manifest["adapter"]["backend"] = "docker" + manifest["environment"]["system"] = { + "openshell": "openshell 1.2.3", + "docker": "29.8.2", + "gateway_sha256": "ab" * 32, + "git_sha": "c0ffee", + "git_dirty": True, + "images": {"supervisor_image": "openshell/supervisor:dev@sha256:00"}, + } + (run / "manifest.json").write_text(json.dumps(manifest)) + (run / "experiment.yaml").write_text( + "id: demo\noperations:\n" + "- {name: gateway-down, uses: outage, target: gateway, select: one}\n" + "- {name: crash, uses: kill-all, scope: global}\n" + ) + # Under `//` the directory names both. + copy = tmp_path / "runs" / "demo" / "r1" + shutil.copytree(run, copy) + rows = build(copy)["details"] + table = [ + (r["key"], r["value"], r["note"], r["code"]) for r in rows if not r["more"] + ] + assert table == [ + ("Experiment", "demo", "version 2", True), + ("Run", "r1", "", True), + ("Run directory", "demo/r1", "", True), + ("Started", "2026-10-27 00:00:00 UTC", "", False), + ("Length", "14s", "", False), + ( + "Seed", + "7", + "picks the instances that gateway-down acts on; " + "the same seed and instance counts pick the same ones", + True, + ), + ("Adapter", "openshell-docker", "docker backend", True), + ("OpenShell", "1.2.3", "", True), + ("Docker", "29.8.2", "", True), + ("Git", "c0ffee", "dirty", True), + ] + # Digests and image refs are folded under the table. + assert [(r["key"], r["value"]) for r in rows if r["more"]] == [ + ("Gateway sha256", "ab" * 32), + ("Images: supervisor image", "openshell/supervisor:dev@sha256:00"), + ] + # A run dir copied elsewhere shows its own name only. + assert build(run)["details"][2]["value"] == "run" + # The page draws the table from the embedded rows. + html = write_report(copy, tmp_path / "page.html").read_text() + data = html.split('")[0])["details"] == rows + assert 'html("table", "", "details")' in html + + +def test_seed_note_without_selecting_operations(tmp_path): + rows = {r["key"]: r for r in build(run_dir(tmp_path))["details"]} + assert rows["Seed"]["note"] == ( + "no operation selects instances; scripts get it as CHAOS_EXPERIMENT_RUN_SEED" + ) + assert "Started" not in rows and "Git" not in rows + + +def test_write_report_embeds_data_safely(tmp_path): + run = run_dir(tmp_path) + hostile = "--> & \u2028\u2029 \u00e9" + (run / "experiment.yaml").write_text( + f"id: demo\ntitle: {json.dumps(hostile)}\nhypothesis: {json.dumps(hostile)}\n" + ) + path = write_report(run) + html = path.read_text() + assert path.name == "report.html" + assert "__DATA__" not in html and "__TITLE__" not in html + assert "demo r1" in html + # The data block is the only script that closes, no HTML tag or comment + # delimiter from the data reaches the parser, and the data round-trips. + assert html.count("") == 2 + data = html.split('" + )[0] + assert not any(c in data for c in "<>&\u2028\u2029") + assert json.loads(data)["experiment"]["title"] == hostile + assert json.loads(data)["experiment"]["hypothesis"] == " ".join(hostile.split()) + + +def test_export_openmetrics(tmp_path): + out = tmp_path / "run.om" + directory = run_dir(tmp_path) + result = {"scope": "workload", "workload": "counter", "instance": "counter-0"} + (directory / "verdict.json").write_text( + json.dumps( + { + "verdict": "FAIL", + "errors": [], + "results": [ + {**result, "check": "a", "ok": True}, + {**result, "check": "b", "ok": False}, + ], + } + ) + ) + run = export(directory, out) + assert (run.run_id, run.experiment) == ("r1", "demo") + assert run.end - run.start == 14.0 + lines = out.read_text().splitlines() + assert lines[-1] == "# EOF" + names = [ln.split("{")[0] for ln in lines if not ln.startswith("#")] + # Each family is contiguous, as OpenMetrics requires. + runs = [name for name, _ in itertools.groupby(names)] + assert len(runs) == len(set(runs)) + raw = [ln for ln in lines if ln.startswith("req_total")] + assert raw[0].startswith( + 'req_total{m="a",chaos_run_id="r1",chaos_source="gateway"} 10 ' + ) + # Gaps are NaN, so Prometheus does not hold the last value across them. + rate = [ln.split()[-2] for ln in lines if 'title="req_total"' in ln] + assert rate == ["NaN", "3.0", "NaN", "NaN", "0.166667", "NaN"] + state = [ln for ln in lines if ln.startswith("chaos_instance_state")] + assert {ln.split()[1] for ln in state} == {"0", "2", "4"} + fault = [ln for ln in lines if 'operation="gateway-down"' in ln] + assert fault[-1].split()[1] == "0" + checks = { + ln.split('check="')[1].split('"')[0]: ln.split()[1] + for ln in lines + if ln.startswith("chaos_check_result") + } + assert checks == {"a": "1", "b": "0"} + assert 'family="lat",title="lat (observations)"' in out.read_text() + (die,) = [ln for ln in lines if "exit 137" in ln] + value, ts = die.split()[-2:] + assert int(value) == round(float(ts) * 1000) + + +def test_fault_windows_end_per_operation(): + from chaos.report import faults + + def ev(t, event, op, uses): + return {"t": t, "event": event, "operation": op, "uses": uses} + + events = [ + ev(1.0, "operation.applied", "bounce", "restart"), + # A revert of another operation does not end the restart window. + ev(2.0, "operation.reverted", "bounce", "restart"), + ev(3.0, "target.restarted", "bounce", "restart"), + ev(4.0, "operation.applied", "in-place", "restart-in-place"), + ev(5.0, "operation.applied", "ice", "freeze"), + ev(6.0, "operation.applied", "down", "outage"), + ev(7.0, "operation.error", "down", "outage"), + ] + assert [ + (f["name"], f["from"], f["to"], f["ended_by"]) for f in faults(events, 9.0) + ] == [ + ("bounce", 1.0, 3.0, "target.restarted"), + ("in-place", 4.0, 4.0, "operation.applied"), + ("ice", 5.0, 9.0, None), + ("down", 6.0, 7.0, "operation.error"), + ] diff --git a/e2e/chaos/tests/test_runner.py b/e2e/chaos/tests/test_runner.py new file mode 100644 index 0000000000..481774f4bf --- /dev/null +++ b/e2e/chaos/tests/test_runner.py @@ -0,0 +1,873 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Whole runs against a fake docker adapter: bash hooks, no Docker, no gateway.""" + +from __future__ import annotations + +import asyncio +import contextlib +import hashlib +import json +import os +import signal +import time +from pathlib import Path + +import pytest +import yaml +from conftest import minimal_adapter + +from chaos import cli, flow, instances, runner +from chaos.adapter import run_shell +from chaos.evaluate import read_verdict +from chaos.inject.docker import DockerInjector +from chaos.runner import Run + +HOOKS = { + # Runs the workload on the host, writing to the instance's output dir. + "create": """ +name=$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_NAME +echo "$name" >> "$FAKE_STATE/created" +[ "${FAKE_CREATE_FAIL:-}" = "$name" ] && exit 1 +while IFS= read -r line; do export "${line?}"; done < "$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_ENV_FILE" +export CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_OUTPUT_DIR="$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_DIR/output" +echo "$name ${CHAOS_EXPERIMENT_WORKLOAD_OPTION_POLICY:-}" >> "$FAKE_STATE/options" +if [ -n "${FAKE_BACKGROUND:-}" ]; then + sh -c "$(cat "$CHAOS_EXPERIMENT_WORKLOAD_WRAPPER_FILE")" chaos \\ + "$(cat "$CHAOS_EXPERIMENT_WORKLOAD_RUN_FILE")" > /dev/null 2>&1 & +else + sh -c "$(cat "$CHAOS_EXPERIMENT_WORKLOAD_WRAPPER_FILE")" chaos \\ + "$(cat "$CHAOS_EXPERIMENT_WORKLOAD_RUN_FILE")" > /dev/null 2>&1 +fi +echo "id-$name" +""", + "status": """ +out=$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_DIR/output/.chaos +if [ -f "$out/exit_code" ]; then echo Succeeded +elif [ -f "$out/attempts" ]; then echo Running +else echo Pending; fi +""", + # Records the call, then releases each instance it applies to. + "openshell-policy-set": """ +case "${FAKE_OPERATION_FAIL:-}" in + 1|"$CHAOS_EXPERIMENT_OPERATION_NAME") echo "policy rejected" >&2; exit 3;; +esac +cp "$CHAOS_EXPERIMENT_OPERATION_OPTION_POLICY" "$FAKE_STATE/applied-policy.yaml" +echo "$CHAOS_EXPERIMENT_OPERATION_SCOPE" > "$CHAOS_EXPERIMENT_OPERATION_STATE_DIR/scope" +echo ok > "$CHAOS_EXPERIMENT_OPERATION_OUTCOME_FILE" +release() { + echo "$CHAOS_EXPERIMENT_OPERATION_NAME $CHAOS_EXPERIMENT_OPERATION_SCOPE $1" >> "$FAKE_STATE/operations" + touch "$CHAOS_EXPERIMENT_RUN_DIR/workloads/w/instances/$1/output/go" +} +if [ "$CHAOS_EXPERIMENT_OPERATION_SCOPE" = instances ]; then + release "$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_NAME" +else + while IFS= read -r name; do release "$name"; done < "$CHAOS_EXPERIMENT_OPERATION_INSTANCES_FILE" +fi +""", + "collect": """ +name=$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_NAME +echo "$name $CHAOS_EXPERIMENT_HOOK_TIMEOUT" >> "$FAKE_STATE/collected" +[ "${FAKE_COLLECT_FAIL:-}" = "$name" ] && exit 1 +[ -n "${FAKE_COLLECT_SLOW:-}" ] && sleep 30 +true +""", + "delete": """ +[ "${FAKE_DELETE_FAIL:-}" = "$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_NAME" ] && exit 5 +echo "$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_NAME" >> "$FAKE_STATE/deleted" +""", + "start": """ +nohup sleep 300 > /dev/null 2>&1 & +echo $! > "$FAKE_STATE/gateway.pid" +""", +} + +EXPERIMENT = { + "apiVersion": "chaos/v1alpha1", + "id": "fake", + "version": 1, + "title": "t", + "hypothesis": "h", + "description": "d", + "default-adapter": "openshell-docker", + "workloads": [ + { + "name": "w", + "description": "d", + "kind": "shell", + "instances": 2, + "timeout": "1m", + "run": 'echo ok > "$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_OUTPUT_DIR/result"', + "checks": [ + {"name": "ok", "description": "d", "kind": "exit-code", "equals": 0} + ], + } + ], + "flow": "w", +} + + +@pytest.fixture +def fake(tmp_path, monkeypatch): + """A fake docker adapter. Returns the state dir that hooks record into.""" + state = tmp_path / "state" + state.mkdir() + adapters = tmp_path / "adapters" + adapters.mkdir() + adapter = minimal_adapter(prelude="set -u\n") + adapter["instance"] = { + **adapter["instance"], + **{k: HOOKS[k] for k in ("create", "status", "collect", "delete")}, + } + adapter["targets"]["gateway"] = { + "type": "process", + "pid-file": "${FAKE_STATE}/gateway.pid", + "start": HOOKS["start"], + } + (adapters / "openshell-docker.yaml").write_text(yaml.safe_dump(adapter)) + (tmp_path / "fake.yaml").write_text(yaml.safe_dump(EXPERIMENT)) + monkeypatch.setenv("FAKE_STATE", str(state)) + monkeypatch.setattr(instances, "POLL_INTERVAL", 0.05) + monkeypatch.setattr(flow, "POLL_INTERVAL", 0.05) + monkeypatch.setattr(instances, "RETRY_WINDOW", 0.5) + yield state + pid_file = state / "gateway.pid" + if pid_file.exists(): + with contextlib.suppress(ProcessLookupError): + os.kill(int(pid_file.read_text()), signal.SIGKILL) + + +def run(fake: Path) -> tuple[int, dict]: + root = fake.parent + code = cli.main( + [ + "--adapters-dir", + str(root / "adapters"), + "run", + str(root / "fake.yaml"), + "--runs-dir", + str(root / "runs"), + ] + ) + (run_dir,) = (root / "runs" / "fake").iterdir() + return code, json.loads((run_dir / "verdict.json").read_text()) + + +def lines(path: Path) -> list[str]: + return sorted(path.read_text().split()) if path.exists() else [] + + +def index(fake: Path) -> list[str]: + path = fake.parent / "runs" / "index.jsonl" + return [json.loads(line)["verdict"] for line in path.read_text().splitlines()] + + +def test_run_passes_and_deletes_every_instance(fake): + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (0, "PASS"), verdict + assert len(verdict["results"]) == 2 + assert lines(fake / "deleted") == lines(fake / "created") == ["w-0", "w-1"] + assert index(fake) == ["PASS"] + # The run's checks are on the timeline; no gateway fault ran, so the + # `run.finished` event names no processes. + checks = [(e["event"], e["instance"], e.get("ok")) for e in _events(fake, "check.")] + assert checks == [ + ("check.started", "w-0", None), + ("check.finished", "w-0", True), + ("check.started", "w-1", None), + ("check.finished", "w-1", True), + ] + (finished,) = _events(fake, "run.finished") + assert finished == { + "t": finished["t"], + "ts": finished["ts"], + "event": "run.finished", + "verdict": "PASS", + } + (path,) = (fake.parent / "runs" / "fake").iterdir() + (line,) = _index_lines(fake) + assert (line["path"], line["flaky_missed"]) == (f"fake/{path.name}", []) + + +def _index_lines(fake: Path) -> list[dict]: + path = fake.parent / "runs" / "index.jsonl" + return [json.loads(line) for line in path.read_text().splitlines()] + + +def test_state_reason_comes_from_the_status_hook(fake, monkeypatch): + """A second word on the status hook's line is the state's reason.""" + adapters = fake.parent / "adapters" + path = adapters / "openshell-docker.yaml" + adapter = yaml.safe_load(path.read_text()) + adapter["instance"]["status"] = HOOKS["status"].replace( + "echo Succeeded", 'echo "Succeeded MainProcessExited"' + ) + path.write_text(yaml.safe_dump(adapter)) + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (0, "PASS"), verdict + states = [ + (e["instance"], e["state"], e.get("reason")) + for e in _events(fake, "instance.state") + ] + assert ("w-0", "Succeeded", "MainProcessExited") in states + assert all(reason is None for _, state, reason in states if state != "Succeeded") + + +def test_unparsed_status_is_logged_once_per_instance(fake): + """A status hook output that is no state reads as Unknown and is logged + the first time, with the output, so a hook bug is visible without a + debugger.""" + adapters = fake.parent / "adapters" + path = adapters / "openshell-docker.yaml" + adapter = yaml.safe_load(path.read_text()) + adapter["instance"]["status"] = ( + "polls=$FAKE_STATE/polls-$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_NAME\n" + 'n=$(cat "$polls" 2>/dev/null || echo 0); echo $((n + 1)) > "$polls"\n' + 'if [ "$n" -lt 2 ]; then echo "Bogus state"; exit 0; fi\n' + ) + HOOKS["status"] + path.write_text(yaml.safe_dump(adapter)) + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (0, "PASS"), verdict + log = next((fake.parent / "runs" / "fake").glob("*/logs/harness.log")) + lines = [line for line in log.read_text().splitlines() if "read as Unknown" in line] + assert len(lines) == 2, lines + assert sorted( + line.split("status hook for ")[1].split(" ")[0] for line in lines + ) == [ + "w-0", + "w-1", + ] + assert all("stdout 'Bogus state'" in line for line in lines), lines + + +def test_observers_run_beside_the_flow(fake): + path = fake.parent / "adapters" / "openshell-docker.yaml" + adapter = yaml.safe_load(path.read_text()) + (fake / "metrics.txt").write_text("up_gauge 1\n") + adapter["metrics"] = [ + {"name": "m", "url": "file://${FAKE_STATE}/metrics.txt", "interval": "50ms"} + ] + adapter["watch"] = [{"name": "w", "run": "echo started; sleep 60"}] + path.write_text(yaml.safe_dump(adapter)) + code, _ = run(fake) + assert code == 0 + (run_dir,) = (fake.parent / "runs" / "fake").iterdir() + scrapes = (run_dir / "system" / "metrics" / "m.jsonl").read_text().splitlines() + assert json.loads(scrapes[0])["samples"] == {"up_gauge": 1.0} + (line,) = (run_dir / "system" / "watch" / "w.jsonl").read_text().splitlines() + assert json.loads(line)["line"] == "started" + assert '"title": "up_gauge"' in (run_dir / "report.html").read_text() + + +def _with_metrics(fake: Path, families: list[str], text: str | None = "up_gauge 1\n"): + """The experiment lists `families`; the adapter scrapes a file with `text` + (no metrics source when None).""" + path = fake.parent / "adapters" / "openshell-docker.yaml" + adapter = yaml.safe_load(path.read_text()) + if text is not None: + (fake / "metrics.txt").write_text(text) + adapter["metrics"] = [ + {"name": "m", "url": "file://${FAKE_STATE}/metrics.txt", "interval": "50ms"} + ] + path.write_text(yaml.safe_dump(adapter)) + (fake.parent / "fake.yaml").write_text( + yaml.safe_dump({**EXPERIMENT, "metrics": families}) + ) + + +def test_listed_metrics_must_be_exported(fake): + _with_metrics( + fake, + ["up_gauge", "lat_seconds", "gone_total"], + "up_gauge 1\nlat_seconds_count 2\n", + ) + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (2, "ERROR") + assert verdict["errors"] == ["preflight failed: metrics-exported"] + (result,) = verdict["results"] + assert (result["scope"], result["check"], result["ok"]) == ( + "preflight", + "metrics-exported", + False, + ) + assert result["output"] == "m: 2 families, 2 listed\nmissing: gone_total" + assert not (fake / "created").exists() + + +def test_listed_metrics_without_a_source_fail_preflight(fake): + _with_metrics(fake, ["up_gauge"], None) + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (2, "ERROR") + assert verdict["results"][0]["output"] == ( + "adapter openshell-docker has no metrics sources to scrape\nmissing: up_gauge" + ) + + +def test_listed_metrics_are_checked_and_reported(fake): + _with_metrics(fake, ["up_gauge", "zero_total"], "up_gauge 1\nzero_total 0\n") + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (0, "PASS"), verdict + assert [(r["scope"], r["check"]) for r in verdict["results"]][:1] == [ + ("preflight", "metrics-exported") + ] + (run_dir,) = (fake.parent / "runs" / "fake").iterdir() + report = (run_dir / "report.html").read_text() + # A listed family is charted even when it stays at zero. + assert '"title": "zero_total"' in report + assert '"declared": true' in report + + +def test_failed_create_is_still_deleted(fake, monkeypatch): + monkeypatch.setenv("FAKE_CREATE_FAIL", "w-1") + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (2, "ERROR") + assert any("create w-1 failed" in e for e in verdict["errors"]) + assert "w-1" in lines(fake / "deleted") + assert lines(fake / "deleted") == lines(fake / "created") + + +POLICY = {"version": 1, "network_policies": {}} +STARTED = {"wait": {"workload": "w", "condition": "started"}} + + +def _with_operation(fake: Path, monkeypatch, operation: dict) -> None: + """Instances that wait for the operation to release them, then exit 0.""" + root = fake.parent + path = root / "adapters" / "openshell-docker.yaml" + adapter = yaml.safe_load(path.read_text()) + adapter["options"] = {"policy": {"default": "", "data": True}} + adapter["operations"] = { + "openshell-policy-set": { + "run": HOOKS["openshell-policy-set"], + # Records what it was asked to put back and what `run` left behind. + "revert": 'echo "$CHAOS_EXPERIMENT_OPERATION_NAME ' + "$CHAOS_EXPERIMENT_OPERATION_SCOPE " + "${CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_NAME:-} " + '$(cat "$CHAOS_EXPERIMENT_OPERATION_STATE_DIR/scope")" ' + '>> "$FAKE_STATE/reverted"\n' + '[ -z "${FAKE_REVERT_FAIL:-}" ] || { echo "cannot" >&2; exit 4; }\n', + "options": {"policy": {"data": True, "required": True}}, + } + } + path.write_text(yaml.safe_dump(adapter)) + exp = json.loads(json.dumps(EXPERIMENT)) + exp["data"] = {"p": POLICY, "note": "free text\n", "limit": 3} + exp["operations"] = [ + { + "name": "a", + "description": "d", + "uses": "openshell-policy-set", + "options": {"policy": "p"}, + **operation, + } + ] + exp["workloads"][0]["options"] = {"policy": "p"} + exp["workloads"][0]["run"] = ( + 'i=0; until [ -f "$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_OUTPUT_DIR/go" ]; do ' + "i=$((i + 1)); [ $i -gt 100 ] && exit 1; sleep 0.05; done" + ) + exp["flow"] = {"parallel": ["w", {"serial": [STARTED, "a"]}]} + (root / "fake.yaml").write_text(yaml.safe_dump(exp)) + monkeypatch.setenv("FAKE_BACKGROUND", "1") + + +def _events(fake: Path, prefix: str) -> list[dict]: + (run_dir,) = (fake.parent / "runs" / "fake").iterdir() + timeline = (run_dir / "timeline.jsonl").read_text().splitlines() + return [e for e in map(json.loads, timeline) if e["event"].startswith(prefix)] + + +def test_targeted_operation_runs_once_per_instance(fake, monkeypatch): + _with_operation(fake, monkeypatch, {"target": {"workload": "w"}, "select": "all"}) + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (0, "PASS"), verdict + assert sorted((fake / "operations").read_text().splitlines()) == [ + "a instances w-0", + "a instances w-1", + ] + assert yaml.safe_load((fake / "applied-policy.yaml").read_text()) == POLICY + # The workload option resolves to the data file the runner wrote. + for line in (fake / "options").read_text().splitlines(): + assert line.split()[1].endswith("/data/p") + (run_dir,) = (fake.parent / "runs" / "fake").iterdir() + assert (run_dir / "data" / "note").read_text() == "free text\n" + assert (run_dir / "data" / "limit").read_text() == "3\n" + started, *rest = _events(fake, "operation.") + assert started["event"] == "operation.started" + assert started["options"] == {"policy": "p"} + assert (started["scope"], started["instances"]) == ("instances", ["w-0", "w-1"]) + completed = [e for e in rest if e["event"] == "operation.completed"] + assert sorted((e["instance"], e["outcome"]) for e in completed) == [ + ("w-0", "ok"), + ("w-1", "ok"), + ] + # The instances exited and were deleted before the end of the flow, so + # there is nothing to put the policy back on: each revert is skipped. + reverted = [e for e in rest if e["event"] == "operation.reverted"] + assert sorted((e["abort"], e["skipped"]) for e in reverted) == [ + (False, "w-0 is deleted"), + (False, "w-1 is deleted"), + ] + assert not (fake / "reverted").exists() + + +def test_global_operation_runs_once_for_running_instances(fake, monkeypatch): + _with_operation(fake, monkeypatch, {"scope": "global"}) + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (0, "PASS"), verdict + assert sorted((fake / "operations").read_text().splitlines()) == [ + "a global w-0", + "a global w-1", + ] + started, completed, reverted = _events(fake, "operation.") + assert (started["scope"], started["instances"]) == ("global", ["w-0", "w-1"]) + assert (completed["event"], completed["outcome"]) == ("operation.completed", "ok") + assert (reverted["event"], reverted["abort"]) == ("operation.reverted", False) + assert reverted["undo"] == "revert a (run 1)" + assert (fake / "reverted").read_text() == "a global global\n" + + +def test_operation_hook_must_report_an_outcome(fake, monkeypatch): + _with_operation(fake, monkeypatch, {"scope": "global"}) + path = fake.parent / "adapters" / "openshell-docker.yaml" + adapter = yaml.safe_load(path.read_text()) + adapter["operations"]["openshell-policy-set"]["run"] = "true\n" + path.write_text(yaml.safe_dump(adapter)) + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (2, "ERROR") + assert any("a: hook wrote no outcome" in e for e in verdict["errors"]), verdict + assert not (fake / "reverted").exists() + + +def test_operations_are_reverted_on_abort_while_instances_are_up(fake, monkeypatch): + """`a` is applied to each instance, then `b` fails: the abort reverts + `a` before the instances are collected and deleted.""" + _with_operation(fake, monkeypatch, {"target": {"workload": "w"}, "select": "all"}) + exp = yaml.safe_load((fake.parent / "fake.yaml").read_text()) + exp["operations"].append({**exp["operations"][0], "name": "b", "scope": "global"}) + del exp["operations"][1]["target"], exp["operations"][1]["select"] + exp["flow"] = {"parallel": ["w", {"serial": [STARTED, "a", "b"]}]} + # Released by `a`, the instances then wait for a second release that + # never comes, so they are still up when `b` fails. + exp["workloads"][0]["run"] += ( + '; until [ -f "$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_OUTPUT_DIR/go2" ]; do ' + "i=$((i + 1)); [ $i -gt 100 ] && exit 1; sleep 0.05; done" + ) + (fake.parent / "fake.yaml").write_text(yaml.safe_dump(exp)) + monkeypatch.setenv("FAKE_OPERATION_FAIL", "b") + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (2, "ERROR") + assert any("operation b: " in e for e in verdict["errors"]), verdict + reverted = _events(fake, "operation.reverted") + assert [(e["operation"], e["abort"], e.get("skipped")) for e in reverted] == [ + ("a", True, None), + ("a", True, None), + ] + assert sorted((fake / "reverted").read_text().splitlines()) == [ + "a instances w-0 instances", + "a instances w-1 instances", + ] + assert lines(fake / "deleted") == lines(fake / "created") == ["w-0", "w-1"] + + +def test_failed_revert_is_an_error(fake, monkeypatch): + _with_operation(fake, monkeypatch, {"scope": "global"}) + monkeypatch.setenv("FAKE_REVERT_FAIL", "1") + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (2, "ERROR") + assert any( + e == "undo a (revert a (run 1)): revert a (run 1): " + "operations.openshell-policy-set.revert exited 4: cannot" + for e in verdict["errors"] + ), verdict + assert [e["event"] for e in _events(fake, "operation.")] == [ + "operation.started", + "operation.completed", + "operation.error", + ] + assert lines(fake / "deleted") == lines(fake / "created") == ["w-0", "w-1"] + + +def test_failed_operation_is_an_error_verdict(fake, monkeypatch): + _with_operation(fake, monkeypatch, {"target": {"workload": "w"}, "select": "one"}) + monkeypatch.setenv("FAKE_OPERATION_FAIL", "1") + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (2, "ERROR") + assert any("exited 3: policy rejected" in e for e in verdict["errors"]), verdict + assert [e["event"] for e in _events(fake, "operation.")] == [ + "operation.started", + "operation.error", + ] + assert lines(fake / "deleted") == lines(fake / "created") == ["w-0", "w-1"] + + +def test_failed_collect_is_still_deleted(fake, monkeypatch): + monkeypatch.setenv("FAKE_COLLECT_FAIL", "w-0") + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (2, "ERROR") + assert any("collect w-0 failed" in e for e in verdict["errors"]), verdict + assert lines(fake / "deleted") == lines(fake / "created") == ["w-0", "w-1"] + + +def test_cleanup_is_bounded_and_hooks_get_the_budget(fake, monkeypatch): + _with_operation(fake, monkeypatch, {"target": {"workload": "w"}, "select": "one"}) + monkeypatch.setenv("FAKE_OPERATION_FAIL", "1") + monkeypatch.setenv("FAKE_COLLECT_SLOW", "1") + monkeypatch.setattr(runner, "CLEANUP_TIMEOUT", 1.0) + start = time.monotonic() + code, verdict = run(fake) + assert time.monotonic() - start < 10 + assert (code, verdict["verdict"]) == (2, "ERROR") + assert any(e.startswith("cleanup did not finish in 1s") for e in verdict["errors"]) + assert lines(fake / "deleted") == lines(fake / "created") == ["w-0", "w-1"] + # Each collect was told it had at most the 1s left of the cleanup. + collected = (fake / "collected").read_text().splitlines() + assert {line.split()[1] for line in collected} == {"1"} + + +def _with_hook(fake: Path, hook: str) -> None: + """Replaces the operation hook of the adapter `_with_operation` wrote. The + hook reports `ok`, as every hook must report something.""" + path = fake.parent / "adapters" / "openshell-docker.yaml" + adapter = yaml.safe_load(path.read_text()) + adapter["operations"]["openshell-policy-set"]["run"] = ( + hook + 'echo ok > "$CHAOS_EXPERIMENT_OPERATION_OUTCOME_FILE"\n' + ) + path.write_text(yaml.safe_dump(adapter)) + + +def test_repeat_runs_an_operation_each_iteration(fake, monkeypatch): + _with_operation(fake, monkeypatch, {"target": {"workload": "w"}, "select": "all"}) + path = fake.parent / "fake.yaml" + exp = yaml.safe_load(path.read_text()) + exp["workloads"][0]["run"] = ( + "out=$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_OUTPUT_DIR; i=0; " + 'until [ "$(wc -l < "$out/go" 2>/dev/null || echo 0)" -ge 3 ]; do ' + "i=$((i + 1)); [ $i -gt 200 ] && exit 1; sleep 0.05; done" + ) + exp["flow"] = { + "parallel": [ + "w", + { + "serial": [ + STARTED, + {"repeat": {"times": 3, "serial": ["a"]}}, + ] + }, + ] + } + path.write_text(yaml.safe_dump(exp)) + _with_hook( + fake, + 'echo "run" >> "$CHAOS_EXPERIMENT_RUN_DIR/workloads/w/instances/' + '$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_NAME/output/go"\n' + 'echo "applied $CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_NAME"\n', + ) + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (0, "PASS"), verdict + assert [e["iteration"] for e in _events(fake, "repeat.")] == [1, 2, 3] + completed = _events(fake, "operation.completed") + assert [e["run"] for e in completed if e["instance"] == "w-0"] == [1, 2, 3] + (run_dir,) = (fake.parent / "runs" / "fake").iterdir() + log = (run_dir / "logs" / "operations" / "a.w-0.log").read_text() + assert log == "".join(f"--- run {n}\napplied w-0\n" for n in (1, 2, 3)) + + +def test_held_instance_is_not_finished_on_a_terminal_state(fake, monkeypatch): + """An operation that makes the instance read as Succeeded for a while, as a + stop does, does not get it collected and deleted mid-operation.""" + _with_operation(fake, monkeypatch, {"target": {"workload": "w"}, "select": "all"}) + _with_hook( + fake, + "out=$CHAOS_EXPERIMENT_RUN_DIR/workloads/w/instances/" + "$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_NAME/output\n" + 'echo 0 > "$out/.chaos/exit_code"; sleep 0.5; rm "$out/.chaos/exit_code"\n' + 'touch "$out/go"\n', + ) + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (0, "PASS"), verdict + events = _events(fake, "") + names = [(e["event"], e.get("instance")) for e in events] + completed = names.index(("operation.completed", "w-0")) + # Seen Succeeded while held, deleted only after the operation finished. + assert names.index(("instance.state", "w-0")) < completed + assert any( + e["event"] == "instance.state" and e["state"] == "Succeeded" + for e in events[:completed] + ) + assert names.index(("instance.deleted", "w-0")) > completed + + +def _add_preflight(fake: Path, adapter_checks: list, exp_checks: list) -> None: + root = fake.parent + path = root / "adapters" / "openshell-docker.yaml" + adapter = yaml.safe_load(path.read_text()) + adapter["preflight"] = adapter_checks + path.write_text(yaml.safe_dump(adapter)) + (root / "fake.yaml").write_text( + yaml.safe_dump({**EXPERIMENT, "preflight": exp_checks}) + ) + + +def _check(name: str, run: str) -> dict: + return {"name": name, "description": "d", "kind": "shell", "run": run} + + +def test_failed_preflight_creates_nothing(fake): + _add_preflight( + fake, + [_check("up", "true"), _check("disk", "echo full; exit 1")], + [_check("mine", 'echo "$CHAOS_EXPERIMENT_CHECK_NAME"')], + ) + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (2, "ERROR") + assert verdict["errors"] == ["preflight failed: disk"] + assert [(r["scope"], r["check"], r["ok"]) for r in verdict["results"]] == [ + ("preflight", "up", True), + ("preflight", "disk", False), + ("preflight", "mine", True), + ] + assert not (fake / "created").exists() + (run_dir,) = (fake.parent / "runs" / "fake").iterdir() + events = [ + json.loads(line)["event"] + for line in (run_dir / "timeline.jsonl").read_text().splitlines() + ] + assert events == ["run.started", "preflight.failed", "run.finished"] + # Evaluating again keeps the preflight results and runs no other check. + adapters = str(fake.parent / "adapters") + assert cli.main(["--adapters-dir", adapters, "evaluate", str(run_dir)]) == 2 + assert read_verdict(run_dir)["results"] == verdict["results"] + + +def test_passed_preflight_is_part_of_the_verdict(fake): + _add_preflight(fake, [_check("up", "true")], []) + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (0, "PASS"), verdict + assert [r["scope"] for r in verdict["results"]] == [ + "preflight", + "workload", + "workload", + ] + + +def test_preflight_command(fake, capsys): + root = fake.parent + args = [ + "--adapters-dir", + str(root / "adapters"), + "preflight", + str(root / "fake.yaml"), + "--runs-dir", + str(root / "runs"), + ] + _add_preflight( + fake, [_check("up", 'test -f "$CHAOS_EXPERIMENT_RUN_DIR/experiment.yaml"')], [] + ) + assert cli.main(args) == 0 + assert "ok preflight/up" in capsys.readouterr().out + _add_preflight(fake, [_check("up", "exit 1")], []) + assert cli.main(args) == 2 + assert capsys.readouterr().out.splitlines()[-1] == "ERROR" + # Nothing is created and no run dir is left. + assert not (fake / "created").exists() + assert list((root / "runs").iterdir()) == [] + + +def test_describe_output_is_the_manifests_system(fake): + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (0, "PASS"), verdict + (run_dir,) = (fake.parent / "runs" / "fake").iterdir() + manifest = json.loads((run_dir / "manifest.json").read_text()) + assert manifest["schema_version"] == "v1alpha1" + assert manifest["environment"]["system"] == {"openshell": "test"} + assert set(manifest["environment"]) == {"harness", "os", "arch", "python", "system"} + archived = (run_dir / "experiment.yaml").read_bytes() + assert manifest["experiment"]["sha256"] == hashlib.sha256(archived).hexdigest() + + +@pytest.mark.parametrize( + ("describe", "error"), + [ + ("echo nope >&2; exit 3", "describe failed (exit 3): nope"), + ("echo not json", "describe printed no JSON object"), + ("echo '[1]'", "describe printed a JSON list, not an object"), + ], +) +def test_failed_describe_is_an_error_verdict(fake, describe, error): + path = fake.parent / "adapters" / "openshell-docker.yaml" + raw = yaml.safe_load(path.read_text()) + raw["describe"] = describe + path.write_text(yaml.safe_dump(raw)) + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (2, "ERROR") + assert any(error in e for e in verdict["errors"]), verdict["errors"] + assert lines(fake / "created") == [] + + +def test_harness_error_is_an_error_verdict(fake, monkeypatch): + async def broken(self): + raise OSError("disk full") + + monkeypatch.setattr(Run, "_collect_run", broken) + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (2, "ERROR") + assert any("harness error: OSError('disk full')" in e for e in verdict["errors"]) + assert index(fake) == ["ERROR"] + + +def test_forced_exit_reports_an_instance_it_could_not_delete(fake, monkeypatch): + async def broken(self): + raise OSError("disk full") + + monkeypatch.setattr(Run, "_collect_run", broken) + monkeypatch.setenv("FAKE_DELETE_FAIL", "w-1") + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (2, "ERROR") + assert any( + e.startswith("delete w-1 (id-w-1) failed (exit 5)") for e in verdict["errors"] + ), verdict + assert "cleanup incomplete: 1 instance(s) not deleted" in verdict["errors"] + assert lines(fake / "deleted") == ["w-0"] + deleted = _events(fake, "instance.deleted") + assert [e["instance"] for e in deleted] == ["w-0"] + + +def test_forced_restore_is_bounded(fake, monkeypatch): + async def broken(self): + raise OSError("disk full") + + async def stuck(self): + await asyncio.sleep(60) + return [] + + monkeypatch.setattr(Run, "_collect_run", broken) + monkeypatch.setattr(DockerInjector, "undo_all", stuck) + monkeypatch.setattr(runner, "FORCED_RESTORE_TIMEOUT", 0.2) + start = time.monotonic() + code, verdict = run(fake) + assert time.monotonic() - start < 10 + assert (code, verdict["verdict"]) == (2, "ERROR") + assert any( + e.startswith("restore operations: TimeoutError") for e in verdict["errors"] + ) + assert lines(fake / "deleted") == lines(fake / "created") + + +def _alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + return True + + +def test_hook_timeout_kills_its_children(tmp_path): + pid_file = tmp_path / "pid" + start = time.monotonic() + result = asyncio.run( + run_shell(["bash", "-c", f"sleep 30 & echo $! > {pid_file}; wait"], {}, 0.5) + ) + assert result.code == 124 + assert time.monotonic() - start < 5 + assert not _alive(int(pid_file.read_text())) + + +def test_cancelled_hook_kills_its_children(tmp_path): + pid_file = tmp_path / "pid" + + async def cancel_hook() -> None: + task = asyncio.create_task( + run_shell(["bash", "-c", f"sleep 30 & echo $! > {pid_file}; wait"], {}, 60) + ) + while not pid_file.exists() or not pid_file.read_text().strip(): + await asyncio.sleep(0.02) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + asyncio.run(cancel_hook()) + assert not _alive(int(pid_file.read_text())) + + +def _with_signal_wait(fake: Path, monkeypatch, watch: str, wait: dict) -> None: + """An operation after two signal waits in a repeat, on a `sweep` signal.""" + _with_operation(fake, monkeypatch, {"target": {"workload": "w"}, "select": "all"}) + path = fake.parent / "adapters" / "openshell-docker.yaml" + adapter = yaml.safe_load(path.read_text()) + adapter["watch"] = [{"name": "otel", "run": watch}] + adapter["signals"] = [ + {"name": "sweep", "watch": "otel", "match": "^Name +: sweep$"} + ] + path.write_text(yaml.safe_dump(adapter)) + exp_path = fake.parent / "fake.yaml" + exp = yaml.safe_load(exp_path.read_text()) + exp["workloads"][0]["run"] = ( + "out=$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_OUTPUT_DIR; i=0; " + 'until [ "$(wc -l < "$out/go" 2>/dev/null || echo 0)" -ge 2 ]; do ' + "i=$((i + 1)); [ $i -gt 200 ] && exit 1; sleep 0.05; done" + ) + exp["flow"] = { + "parallel": [ + "w", + { + "serial": [ + STARTED, + {"repeat": {"times": 2, "serial": [{"wait": wait}, "a"]}}, + ] + }, + ] + } + exp_path.write_text(yaml.safe_dump(exp)) + _with_hook( + fake, + 'echo "run" >> "$CHAOS_EXPERIMENT_RUN_DIR/workloads/w/instances/' + '$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_NAME/output/go"\n', + ) + + +def test_signal_wait_returns_after_each_new_event(fake, monkeypatch): + _with_signal_wait( + fake, + monkeypatch, + "while true; do echo 'Name : sweep'; echo other; sleep 0.3; done", + {"signal": "sweep", "delay": "100ms", "timeout": "5s"}, + ) + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (0, "PASS"), verdict + waited = _events(fake, "signal.waited") + assert len(waited) == 2 + assert waited[0]["signal_t"] < waited[1]["signal_t"] + # Timeline times are rounded to the millisecond. + for e in waited: + assert e["t"] >= e["signal_t"] + 0.1 - 0.001 + received = _events(fake, "signal.received") + assert received and all(e["signal"] == "sweep" for e in received) + + +def test_signal_wait_timeout_is_an_error(fake, monkeypatch): + _with_signal_wait( + fake, monkeypatch, "sleep 60", {"signal": "sweep", "timeout": "300ms"} + ) + code, verdict = run(fake) + assert (code, verdict["verdict"]) == (2, "ERROR"), verdict + assert any("no sweep signal within 300ms" in e for e in verdict["errors"]) + (timeout,) = _events(fake, "signal.timeout") + assert (timeout["signal"], timeout["timeout"], timeout["last_t"]) == ( + "sweep", + "300ms", + None, + ) + # The workload the wait ran beside was cancelled, and its node says so. + finished = {e["node"]: e.get("status") for e in _events(fake, "node.finished")} + assert finished["flow.parallel[0]"] == "cancelled" + assert "flow.parallel[1].serial[1].repeat[0].wait" not in finished + + +def test_signal_must_exist_in_the_adapter(fake, monkeypatch, capsys): + _with_signal_wait(fake, monkeypatch, "sleep 60", {"signal": "other"}) + argv = ["--adapters-dir", str(fake.parent / "adapters"), "validate"] + code = cli.main([*argv, str(fake.parent / "fake.yaml")]) + assert code != 0 + assert "has no signal 'other'" in capsys.readouterr().err diff --git a/e2e/chaos/tests/test_select.py b/e2e/chaos/tests/test_select.py new file mode 100644 index 0000000000..c6e831f92b --- /dev/null +++ b/e2e/chaos/tests/test_select.py @@ -0,0 +1,89 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import random + +import pytest + +from chaos.inject import Candidate, select +from chaos.model import Operation, RunError + +CANDIDATES = [ + Candidate(f"counter-{i}", i, "supervisor", f"cid{9 - i}") for i in range(6) +] + + +def fault(select_: str | dict, name: str = "f") -> Operation: + return Operation( + name=name, + description="d", + uses="kill", + target={"workload": "counter", "role": "supervisor"}, + select=select_, + ) + + +def names(picks: list[Candidate]) -> list[str]: + return [c.instance for c in picks] + + +@pytest.mark.parametrize("select_", ["one", {"count": 3}]) +def test_same_seed_same_picks_in_any_order(select_): + expected = names(select(fault(select_), 42, CANDIDATES)) + for shuffle_seed in range(10): + shuffled = CANDIDATES[:] + random.Random(shuffle_seed).shuffle(shuffled) + # New container ids, as in a second run: picks follow instance names. + renamed = [ + Candidate(c.instance, c.index, c.role, f"new-{c.id}") for c in shuffled + ] + assert names(select(fault(select_), 42, renamed)) == expected + + +def test_seed_and_fault_name_change_picks(): + picks = { + tuple(names(select(fault({"count": 2}, name), seed, CANDIDATES))) + for seed in range(20) + for name in ("a", "b") + } + assert len(picks) > 1 + + +def test_select_all_returns_every_candidate_sorted(): + assert names(select(fault("all"), 0, list(reversed(CANDIDATES)))) == names( + CANDIDATES + ) + + +def test_select_count_picks_distinct_sorted(): + picks = names(select(fault({"count": 4}), 7, CANDIDATES)) + assert len(picks) == len(set(picks)) == 4 + assert picks == sorted(picks, key=lambda n: int(n.rsplit("-", 1)[1])) + + +def test_select_count_needs_enough_candidates(): + with pytest.raises(RunError, match="wants 7 targets, only 6 found"): + select(fault({"count": 7}), 0, CANDIDATES) + + +def test_no_candidates(): + with pytest.raises(RunError, match="no candidates"): + select(fault("one"), 0, []) + + +def test_adapter_operation_picks_like_a_fault_and_names_itself_in_errors(): + operation = Operation( + name="f", + description="d", + uses="openshell-policy-set", + options={"policy": "p"}, + target={"workload": "counter"}, + select={"count": 2}, + ) + assert names(select(operation, 42, CANDIDATES)) == names( + select(fault({"count": 2}), 42, CANDIDATES) + ) + with pytest.raises(RunError, match="^operation f: no candidates"): + select(operation, 0, []) diff --git a/e2e/chaos/tests/test_share.py b/e2e/chaos/tests/test_share.py new file mode 100644 index 0000000000..49f517eb3d --- /dev/null +++ b/e2e/chaos/tests/test_share.py @@ -0,0 +1,307 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""`chaos share`: the Markdown page, built from a synthetic run dir.""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +from test_report import TIMELINE, jsonl, run_dir + +from chaos.cli import main +from chaos.share import MAX_POINTS, ROW_BUDGET, flow_spans, gantt, xychart + +NODES = [ + {"t": 0.5, "event": "node.started", "node": "flow.serial"}, + {"t": 0.5, "event": "node.started", "node": "flow.serial[0]", "name": "counter"}, + { + "t": 1.0, + "event": "node.started", + "node": "flow.serial[1].wait", + "wait": "counter", + "condition": "started", + }, + { + "t": 1.5, + "event": "node.finished", + "node": "flow.serial[1].wait", + "wait": "counter", + "condition": "started", + }, + {"t": 1.5, "event": "node.started", "node": "flow.serial[2].delay", "delay": "2s"}, + {"t": 3.5, "event": "node.finished", "node": "flow.serial[2].delay", "delay": "2s"}, + { + "t": 4.0, + "event": "node.started", + "node": "flow.serial[3]", + "name": "gateway-down", + }, + { + "t": 9.0, + "event": "node.finished", + "node": "flow.serial[3]", + "name": "gateway-down", + }, + {"t": 13.0, "event": "node.finished", "node": "flow.serial[0]", "name": "counter"}, +] + +RESULTS = [ + { + "scope": "preflight", + "workload": None, + "instance": None, + "check": "gateway-up", + "description": "", + "ok": True, + "output": "", + "issue": None, + "flaky": False, + "error": None, + }, + { + "scope": "workload", + "workload": "counter", + "instance": "counter-0", + "check": "exited-ok", + "description": "", + "ok": True, + "output": "exit code 0", + "issue": None, + "flaky": False, + "error": None, + }, + { + "scope": "workload", + "workload": "counter", + "instance": "counter-0", + "check": "journal-complete", + "description": "", + "ok": False, + "output": "12,60d11\nmore", + "issue": "NVIDIA/OpenShell#1", + "flaky": False, + "error": None, + }, + { + "scope": "experiment", + "workload": None, + "instance": None, + "check": "sessions-max", + "description": "", + "ok": True, + "output": "max = 2 (want at-least 2); 1 series of sessions summed over " + "3 scrapes of gateway", + "issue": None, + "flaky": False, + "error": None, + }, +] + + +def share_run(tmp_path: Path) -> Path: + run = run_dir(tmp_path) + events = sorted(TIMELINE + NODES, key=lambda e: e["t"]) + jsonl(run / "timeline.jsonl", [{"ts": "2026-10-27T00:00:00Z", **e} for e in events]) + (run / "verdict.json").write_text( + json.dumps( + {"verdict": "XFAIL", "errors": ["adapter said no"], "results": RESULTS} + ) + ) + (run / "experiment.yaml").write_text( + "id: demo\ntitle: Demo\nhypothesis: |\n It\n holds.\n" + "metrics:\n" + " - family: sessions\n description: Sessions on the gateway.\n" + " - family: lat\n description: Request latency.\n" + " - req_total\n" + "checks:\n" + " - name: sessions-max\n kind: metric\n metric: sessions\n" + " value: max\n at-least: 2\n" + " - name: exited-ok\n kind: exit-code\n equals: 0\n" + ) + return run + + +def test_share_page(tmp_path): + run = share_run(tmp_path) + out = tmp_path / "page.md" + assert main(["share", str(run), "-o", str(out)]) == 0 + text = out.read_text() + assert text.startswith("## demo · r1 · XFAIL\n") + assert "Hypothesis: It holds.\n" in text + assert ( + "| | |\n|---|---|\n| Experiment | `demo` |\n| Run | `r1` |\n" + "| Run directory | `run` |\n| Length | 14s |\n" + "| Seed | `7`, no operation selects instances; scripts get it as " + "CHAOS_EXPERIMENT_RUN_SEED |\n" + "| Adapter | `openshell-docker` |\n| OpenShell | `1.2.3` |\n" + ) in text + # Preflight checks are not counted; the failed one is listed with its issue. + assert "**Checks:** 2 passed, 1 failed (1 known)." in text + assert ( + "- `journal-complete` on counter-0 failed, known NVIDIA/OpenShell#1: 12,60d11\n" + in text + ) + assert "Harness error: adapter said no" in text + chart = text.split("```mermaid\n")[1].split("```")[0] + assert chart.startswith("gantt\n title demo · r1 · XFAIL\n dateFormat mm:ss\n") + assert " section flow\n counter :00:00, 00:13\n" in chart + assert " wait counter started :00:01, 00:02\n" in chart + assert " delay 2s :00:02, 00:04\n" in chart + # A built-in operation is its fault window, not a second row for its node. + assert " fault gateway-down (outage) :crit, 00:04, 00:09\n" in chart + assert "\n gateway-down :" not in chart + assert " fault kill-one (kill) :milestone, crit, 00:10, 00:10\n" in chart + assert " section counter-0\n" in chart + assert " Running :active, 00:02, 00:12\n" in chart + assert " Succeeded :done, 00:12, 00:13\n" in chart + assert "would not fit" not in text + # The metrics table joins the experiment's metric checks to their family. + assert ( + "| `sessions` | Sessions on the gateway. | " + "`sessions-max` ok: max = 2 (want at-least 2) |\n" in text + ) + assert "| `lat` | Request latency. | none |\n" in text + assert "| `req_total` | | none |\n" in text + # Charts in the declared order; after the first two they fold. + heads = [line for line in text.splitlines() if line.startswith("#### ")] + assert heads == [ + "#### sessions", + "#### lat (observations)", + "#### lat (mean per observation)", + "#### req_total", + ] + assert "2 more charts" in text + assert text.index("#### lat (observations)") < text.index("
") + assert text.index("
") < text.index("#### lat (mean per observation)") + sessions = text.split("#### sessions\n")[1].split("####")[0] + assert ' x-axis "seconds since the run started" 0 --> 8\n' in sessions + assert " line [1, 2, 0, 0, 1]\n" in sessions + assert ( + "Sessions on the gateway. Scrapes the endpoint did not answer are drawn at 0." + in sessions + ) + + +def test_default_output_is_next_to_the_report(tmp_path, capsys): + run = share_run(tmp_path) + assert main(["share", str(run)]) == 0 + assert capsys.readouterr().out.strip() == str(run / "share.md") + assert (run / "share.md").is_file() + + +def test_run_dir_without_manifest_is_refused(tmp_path, capsys): + assert main(["share", str(tmp_path)]) == 2 + assert "manifest" in capsys.readouterr().err + + +def test_png_needs_mermaid_cli(tmp_path, capsys, monkeypatch): + run = share_run(tmp_path) + monkeypatch.setattr(shutil, "which", lambda name: None) + assert main(["share", str(run), "--png"]) == 2 + err = capsys.readouterr().err + assert "mermaid-cli" in err and "mmdc -i" in err + assert (run / "share.md").is_file() + + +def test_repeated_nodes_collapse_into_one_row(): + timeline = [] + for i in range(4): + node = f"flow.repeat[{i}]" + timeline.append( + {"t": i * 10.0, "event": "node.started", "node": node, "name": "restart"} + ) + timeline.append( + { + "t": i * 10.0 + 2, + "event": "node.finished", + "node": node, + "name": "restart", + } + ) + timeline.append( + {"t": 40.0, "event": "node.started", "node": "flow.open", "name": "tail"} + ) + spans = flow_spans(timeline) + assert [s["name"] for s in spans] == ["restart"] * 4 + ["tail"] + # A node still open when the timeline ends runs to its last event. + assert spans[-1]["to"] == 40.0 + data = { + "experiment": {"id": "x"}, + "run_id": "r", + "verdict": "PASS", + "end": 40.0, + "timeline": timeline, + "faults": [], + "lanes": [], + "results": [], + } + chart, note = gantt(data) + assert " restart ×4 :00:00, 00:32\n" in chart + assert "\n restart :" not in chart + assert note == "" + + +def lane(name: str, states: list[str]) -> dict: + return { + "name": name, + "segments": [ + {"from": float(i), "to": float(i + 1), "state": s} + for i, s in enumerate(states) + ], + } + + +def test_instances_beyond_the_row_budget_are_left_out(): + per_lane = ROW_BUDGET // 4 + data = { + "experiment": {"id": "x"}, + "run_id": "r", + "verdict": "FAIL", + "end": 100.0, + "timeline": [], + "faults": [{"name": "f", "uses": "kill", "from": 1.0, "to": 2.0}], + "lanes": [lane(f"c-{i}", ["Running"] * per_lane) for i in range(5)] + + [lane("c-5", ["Running"] * (per_lane - 1) + ["Unknown"])], + "results": [ + {"scope": "workload", "instance": "c-3", "check": "k", "ok": False}, + ], + } + chart, note = gantt(data) + sections = [line.split()[1] for line in chart.splitlines() if "section" in line] + # The instance with a failed check, then the one that went Unknown, then + # the rest in run order, up to the budget. + assert sections == ["flow", "c-3", "c-5", "c-0"] + assert chart.count("\n ") == 1 + 3 * per_lane + assert note.startswith( + f"3 of 6 instances ({3 * per_lane} state changes) would not fit" + ) + data["lanes"] = data["lanes"][:1] + chart, note = gantt(data) + assert note == "" + data["lanes"][0]["segments"] *= 5 + chart, note = gantt(data) + assert "section c-0" not in chart + assert note.startswith(f"The instance states ({5 * per_lane} state changes)") + + +def test_xychart_buckets_long_series_and_drops_leading_gaps(): + points = [ + [float(t), None if t < 2 else float(t % 7)] for t in range(3 * MAX_POINTS) + ] + text, note = xychart( + {"title": 'a "b"', "subtitle": "per second", "points": points, "family": "a"} + ) + assert ' title "a b "\n' in text + values = text.split("line [")[1].split("]")[0].split(", ") + assert len(values) <= MAX_POINTS + assert note == f"{3 * MAX_POINTS - 2} scrapes averaged into {len(values)} points" + assert ' x-axis "seconds since the run started" 2 --> ' in text + assert xychart( + {"title": "t", "subtitle": "s", "points": [[0, None]], "family": "a"} + ) == ( + "", + "no scrapes with a value", + ) diff --git a/e2e/chaos/tests/test_signals.py b/e2e/chaos/tests/test_signals.py new file mode 100644 index 0000000000..45885cf126 --- /dev/null +++ b/e2e/chaos/tests/test_signals.py @@ -0,0 +1,104 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Adapter signals and the flow's `wait: {signal: ...}` node.""" + +from __future__ import annotations + +import asyncio +import time + +import pytest +from conftest import minimal_adapter +from pydantic import ValidationError + +from chaos.adapter import Adapter +from chaos.model import SignalWait, parse_flow +from chaos.signals import Signals + + +def test_signal_wait_parses(): + node = parse_flow({"wait": {"signal": "sweep", "delay": "55s", "timeout": "3m"}}) + assert node == SignalWait("sweep", delay="55s", timeout="3m", path="flow.wait") + assert (node.delay_seconds, node.timeout_seconds) == (55, 180) + bare = parse_flow({"wait": {"signal": "sweep"}}) + assert (bare.delay_seconds, bare.timeout) == (0, "5m") + + +@pytest.mark.parametrize( + ("value", "error"), + [ + ({"signal": "sweep", "every": "1s"}, r"unknown keys \['every'\]"), + ({"delay": "1s"}, r"want \{workload: , condition: started\} or"), + ({"signal": 1}, r"flow\.wait\.signal: want a signal name"), + ( + {"workload": "w", "condition": "ready"}, + r"flow\.wait\.condition: want started", + ), + ( + {"workload": "w", "condition": "started", "for": "1s"}, + r"unknown keys \['for'\]", + ), + ({"signal": "sweep", "delay": "soon"}, r"flow\.wait\.delay: invalid duration"), + ({"signal": "sweep", "timeout": "3"}, r"flow\.wait\.timeout: invalid duration"), + ("w.running", r"want \{workload: , condition: started\} or"), + ], +) +def test_signal_wait_rejects(value, error): + with pytest.raises(ValueError, match=error): + parse_flow({"wait": value}) + + +def adapter(**extra) -> Adapter: + return Adapter.model_validate( + minimal_adapter(watch=[{"name": "otel", "run": "true"}], **extra) + ) + + +def test_signal_names_a_watch_and_a_regex(): + (signal,) = adapter(signals=[{"name": "s", "watch": "otel", "match": "a+"}]).signals + assert signal.watch == "otel" + with pytest.raises(ValidationError, match="no watch 'logs'"): + adapter(signals=[{"name": "s", "watch": "logs", "match": "a"}]) + with pytest.raises(ValidationError, match="invalid regex"): + adapter(signals=[{"name": "s", "watch": "otel", "match": "("}]) + with pytest.raises(ValidationError, match="signals names must be unique"): + adapter(signals=[{"name": "s", "watch": "otel", "match": "a"}] * 2) + + +def next_event(signals: Signals, after, not_before, timeout=0.5) -> float: + async def go() -> float: + return await signals.next( + "s", after, not_before, deadline=time.monotonic() + timeout + ) + + return asyncio.run(go()) + + +def test_next_skips_old_events(): + now = time.monotonic() + signals = Signals() + for at in (now - 30, now - 10, now - 5): + signals.add("s", at) + # A delay of 20s still reaches the event 10s ago, not the one 30s ago. + assert next_event(signals, None, now - 20) == now - 10 + # The node already returned for it: the next one. + assert next_event(signals, now - 10, now - 20) == now - 5 + with pytest.raises(TimeoutError): + next_event(signals, now - 5, now - 20, timeout=0.1) + + +def test_next_waits_for_a_new_event(): + signals = Signals() + signals.add("s", time.monotonic() - 1) + + async def go() -> tuple[float, float]: + began = time.monotonic() + later = asyncio.get_running_loop().call_later(0.1, signals.add, "s") + try: + return began, await signals.next("s", None, began, began + 2) + finally: + later.cancel() + + began, at = asyncio.run(go()) + assert at >= began + 0.1 diff --git a/e2e/chaos/uv.lock b/e2e/chaos/uv.lock new file mode 100644 index 0000000000..0aa1a8aa0b --- /dev/null +++ b/e2e/chaos/uv.lock @@ -0,0 +1,349 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "openshell-chaos" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pydantic" }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.7" }, + { name = "pyyaml", specifier = ">=6.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.3" }, + { name = "ruff", specifier = ">=0.4" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "pydantic" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/1b/ca81811da3d2aea724fb3c4ba6b83f50ab8f210576306ccd601973142d35/pydantic_core-2.50.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c531166c42ea7bdfecc8c50049581f05dd1993b09cc7c52bb36a14e96deaec7d", size = 2152145, upload-time = "2026-10-11T18:31:58.046Z" }, + { url = "https://files.pythonhosted.org/packages/59/20/938ef7e0541af0a314e2f9d63848dbd1656ba2ebfe5ca0469f7ebee00d5e/pydantic_core-2.50.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6d0c2183008c188e19f4906d426b293bdc4f67ab17df8e180fe16cda208fa71", size = 2045761, upload-time = "2026-10-11T18:31:59.35Z" }, + { url = "https://files.pythonhosted.org/packages/2f/4b/456497fe2affa18f49b226e89bde31e8a204751ee343dd9beaac5438123e/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94be440c03fede26969a5ce75468e0e6a9927a1b46d9b679ee8adc1b057b0350", size = 2049557, upload-time = "2026-10-11T18:32:01.121Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/cd5dfe13928f55e236dff3a1bee06721d318ca6c955d9ca993b0a5d09f39/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:36c426eac0af8d1529ff8467e612b933346caec1fdc0d774f78f67a1a11e16c1", size = 2188153, upload-time = "2026-10-11T18:32:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/d0b25bc484f8d9136ea76ece69e0b2d24ab370cc2e7bbd1b79e61723b763/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028e2f212273d4a39b1ec1e0de8166b1165a65fc0f1111452a9d94fc7c625c63", size = 2388308, upload-time = "2026-10-11T18:32:03.767Z" }, + { url = "https://files.pythonhosted.org/packages/c5/64/6513cac7c9f78492abafa01d57a69de1d246bf3085fd7b0330bf14b025b3/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6f0cc1bb9900dc558960894adeb30b0c083366fc1d69b856209fb2ca5c36fe5", size = 2431187, upload-time = "2026-10-11T18:32:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/ed/60/7f2ae89c37e69a3dc0da9ff57b0388c285542c688d05f36767770429de85/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8812592c85d0edf423f10eadcef42716d71e8219085ad9e85b775057b7306133", size = 2157623, upload-time = "2026-10-11T18:32:06.616Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/e3a71373b85a7973099b9a229a4302c7792262717e0b4997e41c01f7dd16/pydantic_core-2.50.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:bbce99252ba3167b2b6277f1829d5bf4b43b754524bddf7f944707c3db7d2253", size = 2254539, upload-time = "2026-10-11T18:32:07.968Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d5/5b620aaf40dc631d911565b64d49d3fa8422027c046a7a96dc4e4921ae98/pydantic_core-2.50.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:476f6ed8e43cd1e0b460920e23571700872b284e77331cb30c4faf459cf48a4b", size = 2267344, upload-time = "2026-10-11T18:32:09.399Z" }, + { url = "https://files.pythonhosted.org/packages/e0/48/f46baf4580d8a56ca4782aa65842e16d3cf39736b29fcce8e9c0521b3422/pydantic_core-2.50.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2cbd1b75b09e976ed0d6b6ca297675632ca35df86130088457cdc60ef36970ae", size = 2300877, upload-time = "2026-10-11T18:32:10.903Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9d/e7d28505e6092c1fe075838a4f793ccbe6dd676e44ab16175815ee06cfe0/pydantic_core-2.50.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:5958c72adb417c39b12ac87525ac60b0d73315fcdc59e21f44ee4a5e2512c9ef", size = 2464270, upload-time = "2026-10-11T18:32:12.329Z" }, + { url = "https://files.pythonhosted.org/packages/22/b1/3bf63b82efd93ebc9968f9c12fc46037a28bcb452c88e4688f33e5ff7dca/pydantic_core-2.50.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d8f9e8a6c4ab04b78d61f78627370d834eb004b2869dcb28cfffa647b4ea1980", size = 2478653, upload-time = "2026-10-11T18:32:13.735Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/26d1e4b9c2ba6a92b7dc75bbcb99ef44456b7e3af1bced868403667ea7d3/pydantic_core-2.50.1-cp311-cp311-win32.whl", hash = "sha256:4be846f55c9477f5f3ddde8f2ce941137e16862a56d018ed885d422bb6ae02f2", size = 2120246, upload-time = "2026-10-11T18:32:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/24/65/3e875e7991eaecd4d70686293ff0d8a99aa8f93dc295e6a21c3d702912fb/pydantic_core-2.50.1-cp311-cp311-win_amd64.whl", hash = "sha256:0048b6dddc8ef4b64fccaad878bd143b0c3882ea9936279dc11d613f6b7dd1bc", size = 2176332, upload-time = "2026-10-11T18:32:17.153Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5c/58dbd17c61f209d91c70f549eb7a3bbec7023e89ea12dead091462d6d658/pydantic_core-2.50.1-cp311-cp311-win_arm64.whl", hash = "sha256:6a733778df2f7087ec1100ed0b41533e4f3001976e99570fa34f57c66e7f8e3e", size = 2182471, upload-time = "2026-10-11T18:32:18.49Z" }, + { url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" }, + { url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" }, + { url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" }, + { url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" }, + { url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" }, + { url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" }, + { url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" }, + { url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" }, + { url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" }, + { url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" }, + { url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" }, + { url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" }, + { url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" }, + { url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" }, + { url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" }, + { url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" }, + { url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" }, + { url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" }, + { url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" }, + { url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" }, + { url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" }, + { url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" }, + { url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" }, + { url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" }, + { url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" }, + { url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" }, + { url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" }, + { url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" }, + { url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" }, + { url = "https://files.pythonhosted.org/packages/e7/73/a23a327237d9bb985298ce47702e196f995ce850224c12c70886bd66bb5c/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:062e891facce5ca296a1c37098e5e466780457f86413894b399f0cf22934f769", size = 2162560, upload-time = "2026-10-11T18:35:06.274Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/33b37b5e82408f402a5e82649a43e7c95cd5dab7beaeef2c35cedbfd18f2/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:49c2cbb2397fe4d0987e84606e691af6cb87bc0ee1bd3e7b737f7e10b4c142f9", size = 2075250, upload-time = "2026-10-11T18:35:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/04/62/c7d5b9249466ccab90f3ce4790c2399dbfb8172ae9e74a7753dd295fe65a/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e4472072de0137ee0d8e72d6620e85939c271d2f90f6bbb4b15c24638b79f92", size = 2107891, upload-time = "2026-10-11T18:35:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7e/28d29bc1657195933147f772dee0558d9e0f045bbc11db7f992f40c37bb5/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ed4f3cef55164b026fefb41341b7754cc6b624c75dfe7142d2ecceb5ad21c87", size = 2268026, upload-time = "2026-10-11T18:35:13.336Z" }, + { url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" }, + { url = "https://files.pythonhosted.org/packages/bd/35/002a378302ff91d2a7f149bc8d22363faf581ba40ac39d0123444315a32d/pydantic_core-2.50.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7456d699b13954e9c0164dcb267250a10ae0dfb03e6e26d6796ab0d46e189c84", size = 2154076, upload-time = "2026-10-11T18:35:24.743Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5c/7a034330082c00f3c9795052bd9b161aaaf6e1951e897ac00a094823ef7e/pydantic_core-2.50.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0d955195bbbe489ad343fcc956eacea9357b79cb22192c66cacdefcbc14b32f", size = 2085850, upload-time = "2026-10-11T18:35:27.009Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/d5b44a7d3d4eceed6ef8713a0003af56fefde6ec75915513bcdd5d730cd9/pydantic_core-2.50.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2c634642694e6a0dad2ab1d375589fa671fd442edd5caf7d9737b8f6ca22906", size = 2259754, upload-time = "2026-10-11T18:35:29.763Z" }, + { url = "https://files.pythonhosted.org/packages/b6/88/e01dd37301710bd8b3b2daf34ec9e2a6e83a333130831c0d468b0292a48c/pydantic_core-2.50.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6db2fbed51a7991302e8fac498cd67e336246026d0dfa84cf5166ce1412760", size = 2268712, upload-time = "2026-10-11T18:35:32.654Z" }, + { url = "https://files.pythonhosted.org/packages/3e/43/5bb80d4a4d2b2d84612a30205662cc521a20d209c94abc079ea48b4e0df2/pydantic_core-2.50.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:79490e33c4c0fcb933bbbcfc3a62184d8803b99f535863dfbb925e1bcb6945ad", size = 2305049, upload-time = "2026-10-11T18:35:35.392Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7b/e3ef76b269cfe4a5bd3bdcf5d7124cf05e5a8909d443b91639307116bdae/pydantic_core-2.50.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:48569b0ade9edfbe065cad1d700175546592aebbb42f02adcebcc26e75b896fe", size = 2466001, upload-time = "2026-10-11T18:35:37.791Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7a/ea0947aadc1d9f72d4be00d2e19939611ec17363bcb5e6eb9ec7d8221c34/pydantic_core-2.50.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5f3cae32fc46121f787cb2486de9cf95a8bf72aec5cc78f64c606fa1735a6ef5", size = 2481777, upload-time = "2026-10-11T18:35:40.287Z" }, + { url = "https://files.pythonhosted.org/packages/24/f3/d15bc0b1fb0c4f1e07b3326ffb7489f7ae70da2b56a0884d22641b0eb47c/pydantic_core-2.50.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7f476456ac2bb0d937f75191494a09c83a30765fea4f70f3b404942fe25f6cdf", size = 2336787, upload-time = "2026-10-11T18:35:42.558Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" }, + { url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" }, + { url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" }, + { url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" }, + { url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" }, + { url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" }, + { url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" }, + { url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] diff --git a/e2e/with-docker-gateway.sh b/e2e/with-docker-gateway.sh index e97d7f961e..fd17b69fb2 100755 --- a/e2e/with-docker-gateway.sh +++ b/e2e/with-docker-gateway.sh @@ -574,6 +574,8 @@ fi HOST_PORT=$(e2e_pick_port) HEALTH_PORT=$(e2e_pick_port) +# Prometheus /metrics, scraped by the chaos harness. +METRICS_PORT=$(e2e_pick_port) STATE_DIR="${XDG_STATE_HOME}" mkdir -p "${STATE_DIR}" JWT_DIR="${STATE_DIR}/jwt" @@ -588,6 +590,16 @@ ensure_e2e_docker_network "${DOCKER_NETWORK_NAME}" export OPENSHELL_E2E_DOCKER_NETWORK_NAME="${DOCKER_NETWORK_NAME}" export OPENSHELL_E2E_NETWORK_NAME="${DOCKER_NETWORK_NAME}" export OPENSHELL_E2E_SANDBOX_NAMESPACE="${E2E_NAMESPACE}" +export OPENSHELL_E2E_METRICS_PORT="${METRICS_PORT}" +# Opt-in OTLP/gRPC trace export to 127.0.0.1:OPENSHELL_E2E_OTLP_PORT. The chaos +# harness runs the collector there and waits on the gateway's spans. +OTLP_PORT="" +if [ "${OPENSHELL_E2E_GATEWAY_OTLP:-0}" = "1" ]; then + OTLP_PORT=$(e2e_pick_port) + export OPENSHELL_E2E_OTLP_PORT="${OTLP_PORT}" + # Export finished spans within ~200ms instead of the SDK's 5s default. + export OTEL_BSP_SCHEDULE_DELAY="${OTEL_BSP_SCHEDULE_DELAY:-200}" +fi export OPENSHELL_E2E_DRIVER="docker" if [ "${ADDITIONAL_CA_MODE}" = "1" ]; then export OPENSHELL_E2E_ADDITIONAL_CA_ARTIFACT="${XDG_STATE_HOME}/openshell/network-supervisor" @@ -650,10 +662,15 @@ GATEWAY_CONFIG="${STATE_DIR}/gateway.toml" printf 'additional_ca_bundle = %s\n\n' \ "$(toml_string "${ADDITIONAL_CA_DIR}/ca.crt")" fi - # Opt-in OTLP export so the otlp_relay e2e can bind a collector stub on - # the referenced port. Supervisors connect lazily, so the gateway may - # start before the stub exists. - if [ -n "${OPENSHELL_E2E_OTLP_ENDPOINT:-}" ]; then + # Opt-in OTLP export. The chaos harness (OPENSHELL_E2E_GATEWAY_OTLP=1) + # runs a collector on the picked port and waits on the gateway's spans. + # Otherwise OPENSHELL_E2E_OTLP_ENDPOINT lets the otlp_relay e2e bind a + # collector stub on the referenced port. Supervisors connect lazily, so + # the gateway may start before the stub exists. + if [ -n "${OTLP_PORT}" ]; then + printf '[openshell.gateway.otlp]\nendpoint = %s\n\n' \ + "$(toml_string "http://127.0.0.1:${OTLP_PORT}")" + elif [ -n "${OPENSHELL_E2E_OTLP_ENDPOINT:-}" ]; then printf '[openshell.gateway.otlp]\n' printf 'endpoint = %s\n\n' "$(toml_string "${OPENSHELL_E2E_OTLP_ENDPOINT}")" # The agent trace relay is opt-in; OPENSHELL_E2E_OTLP_AGENT=0 runs the @@ -710,6 +727,7 @@ GATEWAY_ARGS=( --bind-address "${GATEWAY_BIND_IP}" --port "${HOST_PORT}" --health-port "${HEALTH_PORT}" + --metrics-port "${METRICS_PORT}" --compute-driver docker --tls-cert "${PKI_DIR}/server/tls.crt" --tls-key "${PKI_DIR}/server/tls.key" diff --git a/tasks/test.toml b/tasks/test.toml index 30e8982599..68f1fb4d72 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -194,6 +194,31 @@ run = [ "OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE=openshell/e2e-python:dev OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE_PULL_POLICY=never e2e/with-docker-gateway.sh uv run pytest -o python_files='test_*.py *_test.py' -m 'not gpu' -n ${E2E_PARALLEL:-5} e2e/python", ] +["e2e:chaos"] +description = "Run one chaos experiment against a Docker-backed gateway (pass the file: mise run e2e:chaos e2e/chaos/experiments/smoke.yaml)" +env = { UV_PYTHON_INSTALL_DIR = "{{env.HOME}}/.local/share/uv/python", OPENSHELL_E2E_GATEWAY_OTLP = "1" } +run = "e2e/with-docker-gateway.sh uv run --project e2e/chaos chaos run" + +["e2e:chaos:preflight"] +description = "Run only an experiment's preflight checks against a Docker-backed gateway (pass the file)" +env = { UV_PYTHON_INSTALL_DIR = "{{env.HOME}}/.local/share/uv/python", OPENSHELL_E2E_GATEWAY_OTLP = "1" } +run = "e2e/with-docker-gateway.sh uv run --project e2e/chaos chaos preflight" + +["e2e:chaos:check"] +description = "Run the hello-world and smoke chaos experiments against a Docker-backed gateway and fail unless both verdicts are PASS" +env = { UV_PYTHON_INSTALL_DIR = "{{env.HOME}}/.local/share/uv/python", OPENSHELL_E2E_GATEWAY_OTLP = "1" } +run = "e2e/with-docker-gateway.sh e2e/chaos/scripts/check.sh" + +["e2e:chaos:share"] +description = "Write a Markdown page of a chaos run with Mermaid charts, to paste into an issue or a post (pass the run dir; --png also renders the charts with mermaid-cli)" +env = { UV_PYTHON_INSTALL_DIR = "{{env.HOME}}/.local/share/uv/python" } +run = "uv run --project e2e/chaos chaos share" + +["e2e:chaos:grafana"] +description = "Load chaos runs into a local Prometheus and Grafana (pass run dirs: mise run e2e:chaos:grafana e2e/chaos/runs/smoke/; --down stops it)" +env = { UV_PYTHON_INSTALL_DIR = "{{env.HOME}}/.local/share/uv/python" } +run = "e2e/chaos/grafana/view.sh" + ["e2e:podman"] description = "Run Rust CLI e2e tests against a Podman-backed gateway" run = "e2e/rust/e2e-podman.sh" From 07f2a4b56e98cfa8c93a3b83d5c3d2d500a39e0b Mon Sep 17 00:00:00 2001 From: Murilo Pereira Date: Fri, 30 Oct 2026 10:47:33 +0100 Subject: [PATCH 2/4] fix(e2e): format xychart values in plain decimal for Mermaid `chaos share` wrote xychart values with Python's `g` format, which prints small values such as per-tick lock waits in exponent form (`9e-05`). Mermaid's xychart parser rejects that, so `--png` failed on those runs and GitHub would not draw the chart. Format every xychart value and axis bound with four significant digits in plain decimal, and cover it with a share-page test whose series holds values like 0.00009. Signed-off-by: Murilo Pereira --- e2e/chaos/src/chaos/share.py | 9 ++++++++- e2e/chaos/tests/test_share.py | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/e2e/chaos/src/chaos/share.py b/e2e/chaos/src/chaos/share.py index 26d09e36be..becb55bb64 100644 --- a/e2e/chaos/src/chaos/share.py +++ b/e2e/chaos/src/chaos/share.py @@ -18,6 +18,7 @@ import re import shutil import subprocess +from decimal import Decimal from pathlib import Path from typing import Any @@ -208,7 +209,13 @@ def _bucket(points: list[list[float | None]]) -> list[list[float | None]]: def _num(v: float) -> str: - return f"{float(f'{v:.4g}'):g}" + """`v` to four significant digits in plain decimal. Mermaid's xychart + parser rejects exponent form (`9e-05`, `1.235e+06`), which `g` and + `repr` print for small and large values.""" + v = float(v) + if v == 0: + return "0" + return format(Decimal(f"{v:.4g}"), "f") def xychart(chart: dict[str, Any]) -> tuple[str, str]: diff --git a/e2e/chaos/tests/test_share.py b/e2e/chaos/tests/test_share.py index 49f517eb3d..9e501f7354 100644 --- a/e2e/chaos/tests/test_share.py +++ b/e2e/chaos/tests/test_share.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +import re import shutil from pathlib import Path @@ -305,3 +306,26 @@ def test_xychart_buckets_long_series_and_drops_leading_gaps(): "", "no scrapes with a value", ) + + +def test_mermaid_numbers_are_plain_decimals(tmp_path): + """Mermaid's xychart parser rejects exponent form, which Python prints + for small per-tick latencies (`9e-05`) and for large values.""" + run = share_run(tmp_path) + jsonl( + run / "system" / "metrics" / "gateway.jsonl", + [ + {"t": float(t), "up": 1, "samples": {"sessions": v}} + for t, v in enumerate([0.000235, 0, 0.00009, 0.0001855, 0, 123456.7]) + ], + ) + out = tmp_path / "page.md" + assert main(["share", str(run), "-o", str(out)]) == 0 + text = out.read_text() + fences = [block.split("```")[0] for block in text.split("```mermaid\n")[1:]] + assert any("xychart-beta" in fence for fence in fences) + for fence in fences: + assert not re.search(r"\d[eE][+-]?\d", fence), fence + sessions = text.split("#### sessions\n")[1].split("####")[0] + assert " line [0.000235, 0, 0.00009, 0.0001855, 0, 123500]\n" in sessions + assert '" 0 --> 135800\n line [' in sessions From 42e362b1a8847f3908d404734da6fdf2ce3eb42d Mon Sep 17 00:00:00 2001 From: Murilo Pereira Date: Fri, 30 Oct 2026 16:18:30 +0100 Subject: [PATCH 3/4] feat(e2e): add a chaos experiment for SIGTERM during gateway recovery gateway-sigterm-during-recovery restarts the gateway gracefully, then sends a second SIGTERM while the restarted gateway's startup recovery sweep is starting the stopped sandboxes. On main the sweep runs before the SIGTERM handler is installed, so the gateway exits without a graceful shutdown and the next gateway retains the interrupted start's pending driver operation, which blocks starting and deleting that sandbox (NVIDIA/OpenShell#4409). Four runs on main all reproduced it. Signed-off-by: Murilo Pereira --- e2e/chaos/README.md | 85 +++++++++++++- .../gateway-sigterm-during-recovery.yaml | 111 ++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 e2e/chaos/experiments/gateway-sigterm-during-recovery.yaml diff --git a/e2e/chaos/README.md b/e2e/chaos/README.md index c20008d022..d66a04eebc 100644 --- a/e2e/chaos/README.md +++ b/e2e/chaos/README.md @@ -861,11 +861,12 @@ different writes the shell script and does not wait. | `gateway-kill-keeps-state` | Sandboxes survive a hard gateway kill. | `outage` | NVIDIA/OpenShell#4910 | | `policy-tighten-survives-gateway-restart` | A tightened policy stays in effect across a gateway restart. | `openshell-policy-set`, `outage` | NVIDIA/OpenShell#4910 | | `sandbox-stop-start` | A stopped sandbox starts again every time. | `openshell-sandbox-restart` | NVIDIA/OpenShell#4981, flaky | +| `gateway-sigterm-during-recovery` | A SIGTERM during the gateway's startup recovery stops it gracefully. | `restart` | NVIDIA/OpenShell#4409, flaky | `chaos list` prints each experiment's id, version, title and issues, and `chaos show ID` describes one. [Demo](#demo) runs `hello-world` and [Write an -experiment](#write-an-experiment) builds `sandbox-container-restart`. Four +experiment](#write-an-experiment) builds `sandbox-container-restart`. Five more follow, each as the part of its manifest that shapes the run, with the `description` fields left out, and a chart of one run on `openshell-docker`, drawn from the run's `timeline.jsonl` in minutes and seconds since @@ -1123,6 +1124,88 @@ which is when the workload node ended: the sweep wrote `Error (ContainerExited)` during a start and the start failed even though the new supervisor connected. +### gateway-sigterm-during-recovery + +```yaml +expectations: + - issue: NVIDIA/OpenShell#4409 + checks: + - experiment/every-sigterm-logged + - experiment/no-pending-operation-retained + - counter/exited-ok + kind: flaky + +operations: + - name: gateway-restart + uses: restart + target: gateway + select: one + grace-period: 60s + - name: gateway-sigterm-in-recovery + uses: restart + target: gateway + select: one + grace-period: 60s + +flow: + parallel: + - counter + - serial: + - {wait: {workload: counter, condition: started}} + - {delay: 15s} + - gateway-restart + - {delay: 1s} + - gateway-sigterm-in-recovery +``` + +Eight counter instances run. The first restart stops the gateway gracefully, +which stops every sandbox. The restarted gateway serves `/healthz` before its +recovery sweep starts the eight sandboxes again, one at a time, so the second +SIGTERM lands while the sweep is running. The sweep runs before the gateway +installs its SIGTERM handler, so the process exits without logging +`Shutdown signal received; stopping gateway`, and the sandbox whose start it +had claimed keeps its pending driver operation. The next gateway logs +`Retaining pending driver operation during gateway recovery`, and from then +on refuses to start or delete that sandbox: `previous driver operation is +still pending; retry and deletion are blocked`. The two `experiment` checks +read the gateway log for both signs. Because the harness cannot delete the +instance, the run ends `ERROR` with the instance's container left running, +not `XFAIL`. A fixed gateway logs both shutdowns and the run ends `PASS`. + +Four runs on main at 3a5dfcd2f all ended this way, each with a different +instance stuck. In each, the gateway after the kill also failed to start one +or two of the sandboxes the interrupted sweep had already started again: +`sandbox runtime is already bound to another supervisor process`. The chart +shows the stuck instance of the first run: + +```mermaid +gantt + dateFormat mm:ss + axisFormat %M:%S + section flow + workload counter :crit, 00:01, 06:04 + wait counter started :done, 00:01, 00:04 + delay 15s :done, 00:04, 00:19 + gateway-restart :done, 00:19, 00:23 + delay 1s :done, 00:23, 00:24 + gateway-sigterm-in-recovery :crit, 00:24, 00:27 + abort, delete refused :milestone, crit, 06:04, 06:04 + section counter-5 + Running :active, 00:04, 00:21 + Unknown :crit, 00:21, 00:23 + Pending :00:23, 00:25 + Unknown :crit, 00:25, 00:27 + Running :active, 00:27, 01:52 + Pending :01:52, 04:02 + timed out, collected :milestone, crit, 04:02, 04:02 +``` + +`counter-5` reported `Running` after the second restart, but its supervisor +never connected to the new gateway, and from 01:52 it was `Pending` with +`SupervisorNotConnected` until its workload timeout. Every delete from 04:02 +was refused, and at 06:04 the harness aborted. The five instances the sweeps +started cleanly reached `Succeeded` between 02:04 and 02:06. + ## Adapters An adapter is the only place that knows the system. It gives the harness: diff --git a/e2e/chaos/experiments/gateway-sigterm-during-recovery.yaml b/e2e/chaos/experiments/gateway-sigterm-during-recovery.yaml new file mode 100644 index 0000000000..971b9d836e --- /dev/null +++ b/e2e/chaos/experiments/gateway-sigterm-during-recovery.yaml @@ -0,0 +1,111 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: chaos/v1alpha1 +id: gateway-sigterm-during-recovery +version: 1 +title: A SIGTERM during the gateway's startup recovery stops it gracefully +hypothesis: > + When the gateway gets SIGTERM while its startup recovery sweep is starting + the persisted sandboxes again, it shuts down as it does after startup: it + logs the shutdown, leaves no driver operation pending, and the gateway that + starts next can start and delete every sandbox. +description: > + Eight counter instances run. 15s after all have been observed running, + gateway-restart stops the gateway gracefully, which stops every sandbox, + and starts it again. The restarted gateway serves /healthz before its + recovery sweep, which starts the eight sandboxes one at a time, so 1s + after it is healthy gateway-sigterm-in-recovery sends SIGTERM while the + sweep is still running. On main the recovery sweep runs before the + SIGTERM handler is installed (crates/openshell-server/src/lib.rs), so + the process exits at once and the sandbox whose StartSandbox it had + claimed keeps driver_operation_pending. The next gateway retains that + operation, and the sandbox can then be neither started nor deleted, so + the run also records a failed delete and ends in ERROR. +default-adapter: openshell-docker +timeout: 15m + +workloads: + - name: counter + description: > + Counts from 1 to $TICKS, one tick every $INTERVAL seconds, saving the + tick so that a script started again resumes after it. + kind: shell + instances: 8 + timeout: 4m + env: {TICKS: "90", INTERVAL: "1"} + run: | + out=$CHAOS_EXPERIMENT_WORKLOAD_INSTANCE_OUTPUT_DIR + start=$(cat "$out/counter" 2>/dev/null || echo 0) + for i in $(seq $((start + 1)) "$TICKS"); do + echo "$i $(date +%s)" >> "$out/journal" + echo "$i" > "$out/counter.tmp" && mv "$out/counter.tmp" "$out/counter" + sleep "$INTERVAL" + done + checks: + - name: exited-ok + description: > + For each counter instance: the last attempt of the script exited + with code 0, so the sandbox was started again after both restarts. + kind: exit-code + equals: 0 + +operations: + - name: gateway-restart + description: > + Sends SIGTERM to the gateway process and, if it has not exited after + 60s, SIGKILL. Then runs the adapter's start hook, which waits until the + gateway's /healthz returns 200. + uses: restart + target: gateway + select: one + grace-period: 60s + - name: gateway-sigterm-in-recovery + description: > + The same restart, sent while the restarted gateway's recovery sweep is + starting the stopped sandboxes. + uses: restart + target: gateway + select: one + grace-period: 60s + +flow: + parallel: + - counter + - serial: + - {wait: {workload: counter, condition: started}} + - {delay: 15s} + - gateway-restart + - {delay: 1s} + - gateway-sigterm-in-recovery + +checks: + - name: every-sigterm-logged + description: > + The gateway log has "Shutdown signal received; stopping gateway" at + least twice, once for each SIGTERM. A gateway that the default SIGTERM + disposition kills exits without logging it. + kind: shell + run: | + n=$(grep -c 'Shutdown signal received; stopping gateway' "$CHAOS_EXPERIMENT_RUN_DIR/logs/gateway.log" || true) + [ "$n" -ge 2 ] || { echo "want >= 2 graceful shutdowns, got $n"; exit 1; } + - name: no-pending-operation-retained + description: > + No gateway start retained a pending driver operation from the gateway + before it ("Retaining pending driver operation during gateway + recovery"). A retained operation blocks the sandbox's start, stop and + delete. + kind: shell + run: | + log=$CHAOS_EXPERIMENT_RUN_DIR/logs/gateway.log + if grep 'Retaining pending driver operation' "$log"; then + exit 1 + fi + +expectations: + - issue: NVIDIA/OpenShell#4409 + checks: + - experiment/every-sigterm-logged + - experiment/no-pending-operation-retained + - counter/exited-ok + kind: flaky From ce9f0c1c9f985a39acc71f346eb06f7364e09055 Mon Sep 17 00:00:00 2001 From: Murilo Pereira Date: Fri, 30 Oct 2026 16:25:45 +0100 Subject: [PATCH 4/4] docs(e2e): add the share chart of a gateway-sigterm-during-recovery run Render the Mermaid gantt of the latest gateway-sigterm-during-recovery run with chaos share --png, as the other experiments have, for the results table that links each experiment to its chart. Signed-off-by: Murilo Pereira --- .../share-gateway-sigterm-during-recovery.png | Bin 0 -> 135329 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 e2e/chaos/images/share-gateway-sigterm-during-recovery.png diff --git a/e2e/chaos/images/share-gateway-sigterm-during-recovery.png b/e2e/chaos/images/share-gateway-sigterm-during-recovery.png new file mode 100644 index 0000000000000000000000000000000000000000..185fbeabd60544651c152af2dc2cb0d25a085811 GIT binary patch literal 135329 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Å_d(z(D{RH`dn= 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 na9dlr_D58}l9_tKWn}nM>U#r@FYhRI?dfP