From 7928fa30fe7aa201410b52b515b288f00ee2109d Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Tue, 28 Jul 2026 08:12:22 +0300 Subject: [PATCH 01/27] feat(ci): select Molecule tests by changed component Signed-off-by: Tadas Sutkaitis --- .zuul.yaml | 90 +- atmosphere/ci/__init__.py | 4 + atmosphere/ci/molecule_plan.py | 817 ++++++++++++++++++ .../0002-mount-only-ca-certificate.patch | 13 + .../tempest/0003-use-ca-for-python-ssl.patch | 12 + .../0004-support-ca-only-identity-trust.patch | 23 + charts/tempest/templates/job-ks-user.yaml | 2 +- charts/tempest/templates/job-run-tests.yaml | 4 +- charts/tempest/values.yaml | 1 + ci/molecule-plan.yaml | 721 ++++++++++++++++ doc/source/deploy/index.rst | 1 + doc/source/deploy/selective-ci.rst | 88 ++ molecule/aio/converge.yml | 8 + molecule/aio/prepare.yml | 10 + molecule/aio/verify.yml | 10 + playbooks/tempest.yml | 7 +- pyproject.toml | 2 + ...elective-molecule-ci-3233e7ee8348d528.yaml | 7 + roles/keycloak/defaults/main.yml | 5 + roles/keycloak/tasks/main.yml | 2 +- roles/neutron/defaults/main.yml | 5 + roles/neutron/tasks/main.yml | 1 + roles/nova/defaults/main.yml | 5 + roles/nova/tasks/main.yml | 1 + roles/octavia/defaults/main.yml | 5 + roles/octavia/tasks/main.yml | 1 + roles/tempest/defaults/main.yml | 60 ++ roles/tempest/tasks/main.yml | 42 +- roles/tempest/vars/main.yml | 63 +- test-playbooks/molecule/poll-chunk.yml | 10 + test-playbooks/molecule/poll-command.yml | 105 +++ test-playbooks/molecule/post.yml | 29 + test-playbooks/molecule/prepare.yml | 45 + test-playbooks/molecule/run-command.yml | 110 +++ test-playbooks/molecule/selective-run.yml | 161 ++++ tests/unit/ci/__init__.py | 2 + tests/unit/ci/test_molecule_plan.py | 404 +++++++++ uv.lock | 97 +++ 38 files changed, 2956 insertions(+), 17 deletions(-) create mode 100644 atmosphere/ci/__init__.py create mode 100644 atmosphere/ci/molecule_plan.py create mode 100644 charts/patches/tempest/0002-mount-only-ca-certificate.patch create mode 100644 charts/patches/tempest/0003-use-ca-for-python-ssl.patch create mode 100644 charts/patches/tempest/0004-support-ca-only-identity-trust.patch create mode 100644 ci/molecule-plan.yaml create mode 100644 doc/source/deploy/selective-ci.rst create mode 100644 releasenotes/notes/selective-molecule-ci-3233e7ee8348d528.yaml create mode 100644 test-playbooks/molecule/poll-chunk.yml create mode 100644 test-playbooks/molecule/poll-command.yml create mode 100644 test-playbooks/molecule/post.yml create mode 100644 test-playbooks/molecule/prepare.yml create mode 100644 test-playbooks/molecule/run-command.yml create mode 100644 test-playbooks/molecule/selective-run.yml create mode 100644 tests/unit/ci/__init__.py create mode 100644 tests/unit/ci/test_molecule_plan.py diff --git a/.zuul.yaml b/.zuul.yaml index d736ba5f07..a748e17b64 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -1,3 +1,89 @@ +- job: + name: atmosphere-molecule-aio-openvswitch-selective + parent: atmosphere-molecule-aio-openvswitch + pre-run: + - test-playbooks/molecule/prepare.yml + run: + - test-playbooks/molecule/selective-run.yml + post-run: + - test-playbooks/molecule/post.yml + vars: + atmosphere_ci_job: aio-openvswitch + csi_driver: rbd + keycloak_helm_timeout: 15m0s + neutron_helm_timeout: 10m0s + nova_helm_timeout: 10m0s + octavia_helm_timeout: 10m0s + +- job: + name: atmosphere-molecule-aio-ovn-selective + parent: atmosphere-molecule-aio-ovn + pre-run: + - test-playbooks/molecule/prepare.yml + run: + - test-playbooks/molecule/selective-run.yml + post-run: + - test-playbooks/molecule/post.yml + vars: + atmosphere_ci_job: aio-ovn + csi_driver: rbd + keycloak_helm_timeout: 15m0s + neutron_helm_timeout: 10m0s + nova_helm_timeout: 10m0s + octavia_helm_timeout: 10m0s + +- job: + name: atmosphere-molecule-csi-local-path-provisioner-selective + parent: atmosphere-molecule-csi-local-path-provisioner + pre-run: + - test-playbooks/molecule/prepare.yml + run: + - test-playbooks/molecule/selective-run.yml + post-run: + - test-playbooks/molecule/post.yml + vars: + atmosphere_ci_job: csi-local-path-provisioner + +- job: + name: atmosphere-molecule-csi-rbd-selective + parent: atmosphere-molecule-csi-rbd + pre-run: + - test-playbooks/molecule/prepare.yml + run: + - test-playbooks/molecule/selective-run.yml + post-run: + - test-playbooks/molecule/post.yml + vars: + atmosphere_ci_job: csi-rbd + +- job: + name: atmosphere-molecule-keycloak-selective + parent: atmosphere-molecule-keycloak + timeout: 3600 + pre-run: + - test-playbooks/molecule/prepare.yml + run: + - test-playbooks/molecule/selective-run.yml + post-run: + - test-playbooks/molecule/post.yml + vars: + atmosphere_ci_job: keycloak + - project: - templates: - - atmosphere-molecule-jobs + vars: + atmosphere_ci_plan_base: "origin/{{ zuul.branch }}" + atmosphere_ci_plan_head: "{{ zuul.patchset | default('HEAD') }}" + check: + jobs: + - atmosphere-molecule-aio-openvswitch-selective + - atmosphere-molecule-aio-ovn-selective + - atmosphere-molecule-csi-local-path-provisioner-selective + - atmosphere-molecule-csi-rbd-selective + - atmosphere-molecule-keycloak-selective + gate: + jobs: + - atmosphere-molecule-aio-openvswitch-selective + - atmosphere-molecule-aio-ovn-selective + - atmosphere-molecule-csi-local-path-provisioner-selective + - atmosphere-molecule-csi-rbd-selective + - atmosphere-molecule-keycloak-selective diff --git a/atmosphere/ci/__init__.py b/atmosphere/ci/__init__.py new file mode 100644 index 0000000000..d53bfadc87 --- /dev/null +++ b/atmosphere/ci/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2026 VEXXHOST, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Continuous integration helpers for Atmosphere.""" diff --git a/atmosphere/ci/molecule_plan.py b/atmosphere/ci/molecule_plan.py new file mode 100644 index 0000000000..11e344ca48 --- /dev/null +++ b/atmosphere/ci/molecule_plan.py @@ -0,0 +1,817 @@ +# Copyright (c) 2026 VEXXHOST, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Plan the smallest safe set of Molecule jobs for a repository change.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from dataclasses import asdict, dataclass +from pathlib import Path, PurePosixPath +from typing import Any, Iterable, Sequence + +import yaml + + +class PolicyError(ValueError): + """Raised when the selective CI policy is invalid.""" + + +@dataclass(frozen=True) +class Change: + """One path reported by ``git diff --name-status``.""" + + status: str + path: str + previous_path: str | None = None + + +def _strings(value: Any, field: str) -> list[str]: + if value is None: + return [] + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise PolicyError(f"{field} must be a list of strings") + duplicates = sorted({item for item in value if value.count(item) > 1}) + if duplicates: + raise PolicyError(f"{field} contains duplicate values: {', '.join(duplicates)}") + return list(value) + + +def _mapping(value: Any, field: str) -> dict[str, Any]: + if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): + raise PolicyError(f"{field} must be a mapping with string keys") + return value + + +def _normalize_path(value: str) -> str: + normalized = value.replace("\\", "/").removeprefix("./") + path = PurePosixPath(normalized) + if not normalized or path.is_absolute() or ".." in path.parts: + raise PolicyError(f"repository path {value!r} is invalid") + return str(path) + + +def _compile_glob(pattern: str) -> re.Pattern[str]: + """Compile a repository glob where ``*`` never crosses a slash.""" + + output = ["^"] + index = 0 + while index < len(pattern): + character = pattern[index] + if character == "*": + if index + 1 < len(pattern) and pattern[index + 1] == "*": + output.append(".*") + index += 2 + continue + output.append("[^/]*") + elif character == "?": + output.append("[^/]") + else: + output.append(re.escape(character)) + index += 1 + output.append("$") + return re.compile("".join(output)) + + +def parse_changes(lines: Iterable[str]) -> list[Change]: + """Parse paths or ``git diff --name-status`` records.""" + + changes: list[Change] = [] + for line_number, raw_line in enumerate(lines, start=1): + line = raw_line.rstrip("\n") + if not line: + continue + fields = line.split("\t") + if len(fields) == 1: + changes.append(Change(status="M", path=_normalize_path(fields[0]))) + elif len(fields) == 2 and all(fields): + changes.append(Change(status=fields[0], path=_normalize_path(fields[1]))) + elif len(fields) == 3 and fields[0].startswith(("R", "C")) and all(fields): + changes.append( + Change( + status=fields[0], + previous_path=_normalize_path(fields[1]), + path=_normalize_path(fields[2]), + ) + ) + else: + raise PolicyError( + f"invalid changed-file record on line {line_number}: {line!r}" + ) + return changes + + +def git_changes(base: str, head: str) -> list[Change]: + """Return changes belonging to ``base...head``.""" + + if not base.strip() or not head.strip(): + raise PolicyError("both base and head revisions are required") + result = subprocess.run( + [ + "git", + "diff", + "--name-status", + "--find-renames", + f"{base}...{head}", + ], + check=True, + capture_output=True, + text=True, + ) + return parse_changes(result.stdout.splitlines()) + + +class Planner: + """Map changed files to static Zuul jobs and sequential Ansible tags.""" + + def __init__(self, policy: dict[str, Any]) -> None: + self.policy = policy + self.jobs = _mapping(policy.get("jobs"), "jobs") + self.components = _mapping(policy.get("components"), "components") + rules = policy.get("rules") + if not isinstance(rules, list): + raise PolicyError("rules must be a list") + self.rules: list[dict[str, Any]] = [] + self._compiled_rule_paths: dict[str, list[re.Pattern[str]]] = {} + self._compiled_component_paths: dict[str, list[re.Pattern[str]]] = {} + self._validate(rules) + + @classmethod + def load(cls, path: str | Path) -> "Planner": + with Path(path).open(encoding="utf-8") as stream: + policy = yaml.safe_load(stream) + if not isinstance(policy, dict): + raise PolicyError("policy document must be a mapping") + return cls(policy) + + def _validate(self, rules: list[Any]) -> None: + if self.policy.get("version") != 1: + raise PolicyError("policy version must be 1") + if not self.jobs: + raise PolicyError("at least one job must be configured") + if not self.components: + raise PolicyError("at least one component must be configured") + + for job_name, job_value in self.jobs.items(): + job = _mapping(job_value, f"jobs.{job_name}") + if not isinstance(job.get("scenario"), str) or not job["scenario"]: + raise PolicyError(f"jobs.{job_name}.scenario must be a string") + backend = job.get("network_backend") + if backend is not None and not isinstance(backend, str): + raise PolicyError(f"jobs.{job_name}.network_backend must be a string") + + full_jobs = _strings(self.policy.get("full_jobs"), "full_jobs") + if not full_jobs: + raise PolicyError("full_jobs must not be empty") + self._check_jobs(full_jobs, "full_jobs") + + owners: dict[tuple[str, str], str] = {} + for component_name, component_value in self.components.items(): + component = _mapping(component_value, f"components.{component_name}") + requires = _strings( + component.get("requires"), f"components.{component_name}.requires" + ) + test_requires = _strings( + component.get("test_requires"), + f"components.{component_name}.test_requires", + ) + for dependency in requires + test_requires: + if dependency not in self.components: + raise PolicyError( + f"component {component_name!r} requires unknown " + f"component {dependency!r}" + ) + backend_requires = _mapping( + component.get("backend_requires", {}), + f"components.{component_name}.backend_requires", + ) + for backend, dependencies in backend_requires.items(): + for dependency in _strings( + dependencies, + f"components.{component_name}.backend_requires.{backend}", + ): + if dependency not in self.components: + raise PolicyError( + f"component {component_name!r} requires unknown " + f"component {dependency!r} for backend {backend!r}" + ) + component_jobs = _strings( + component.get("jobs"), f"components.{component_name}.jobs" + ) + if not component_jobs: + raise PolicyError(f"component {component_name!r} has no jobs") + self._check_jobs(component_jobs, f"components.{component_name}.jobs") + _strings( + component.get("verification_profiles"), + f"components.{component_name}.verification_profiles", + ) + tempest_tests = _strings( + component.get("tempest_tests"), + f"components.{component_name}.tempest_tests", + ) + for index, pattern in enumerate(tempest_tests): + try: + re.compile(pattern) + except re.error as error: + raise PolicyError( + f"components.{component_name}.tempest_tests[{index}] " + f"is not a valid regular expression: {error}" + ) from error + + for owner_type, values in ( + ( + "role", + _strings( + component.get("roles", [component_name.replace("-", "_")]), + f"components.{component_name}.roles", + ), + ), + ( + "chart", + _strings( + component.get("charts", [component_name]), + f"components.{component_name}.charts", + ), + ), + ): + for value in values: + key = (owner_type, value) + if key in owners: + raise PolicyError( + f"{owner_type} {value!r} is owned by both " + f"{owners[key]!r} and {component_name!r}" + ) + owners[key] = component_name + + component_paths = _strings( + component.get("paths"), f"components.{component_name}.paths" + ) + self._compiled_component_paths[component_name] = [ + _compile_glob(pattern) for pattern in component_paths + ] + + seen_rules: set[str] = set() + for index, rule_value in enumerate(rules): + rule = _mapping(rule_value, f"rules[{index}]") + name = rule.get("name") + if not isinstance(name, str) or not name: + raise PolicyError(f"rules[{index}].name must be a string") + if name in seen_rules: + raise PolicyError(f"rule name {name!r} is duplicated") + seen_rules.add(name) + action = rule.get("action") + if action not in {"full", "ignore", "targets"}: + raise PolicyError(f"rule {name!r} has unsupported action {action!r}") + paths = _strings(rule.get("paths"), f"rules.{name}.paths") + if not paths: + raise PolicyError(f"rule {name!r} has no paths") + targets = _strings(rule.get("targets"), f"rules.{name}.targets") + for target in targets: + if target not in self.components: + raise PolicyError( + f"rule {name!r} targets unknown component {target!r}" + ) + rule_jobs = _strings(rule.get("jobs"), f"rules.{name}.jobs") + self._check_jobs(rule_jobs, f"rules.{name}.jobs") + if action == "targets" and not targets: + raise PolicyError(f"target rule {name!r} has no targets") + self.rules.append(rule) + self._compiled_rule_paths[name] = [ + _compile_glob(pattern) for pattern in paths + ] + + self._validate_cycles() + + def _check_jobs(self, jobs: Iterable[str], field: str) -> None: + for job in jobs: + if job not in self.jobs: + raise PolicyError(f"{field} references unknown job {job!r}") + + def _validate_cycles(self) -> None: + temporary: set[str] = set() + permanent: set[str] = set() + + def visit(name: str) -> None: + if name in permanent: + return + if name in temporary: + raise PolicyError( + f"component dependency graph contains a cycle at {name!r}" + ) + temporary.add(name) + component = self.components[name] + dependencies = _strings( + component.get("requires"), f"components.{name}.requires" + ) + backend_requires = _mapping( + component.get("backend_requires", {}), + f"components.{name}.backend_requires", + ) + for values in backend_requires.values(): + dependencies.extend(_strings(values, f"components.{name}")) + for dependency in dependencies: + visit(dependency) + temporary.remove(name) + permanent.add(name) + + for component_name in self.components: + visit(component_name) + + def plan(self, changes: Sequence[Change]) -> dict[str, Any]: + normalized_changes = [ + Change( + status=change.status or "M", + path=_normalize_path(change.path), + previous_path=( + _normalize_path(change.previous_path) + if change.previous_path + else None + ), + ) + for change in changes + ] + if not normalized_changes: + return self._full_plan( + normalized_changes, + [], + [ + "no changed files were provided, so the planner cannot " + "select a safe subset" + ], + ) + + matches: list[dict[str, Any]] = [] + reasons: list[str] = [] + targets: set[str] = set() + job_targets: dict[str, set[str]] = {job_name: set() for job_name in self.jobs} + full = False + + for change in normalized_changes: + paths = [change.path] + if change.previous_path and change.previous_path != change.path: + paths.append(change.previous_path) + for changed_path in paths: + matched = False + for rule in self.rules: + if not self._matches( + self._compiled_rule_paths[rule["name"]], changed_path + ): + continue + matched = True + action = rule["action"] + rule_targets = _strings( + rule.get("targets"), f"rules.{rule['name']}.targets" + ) + matches.append( + { + "path": changed_path, + "rule": rule["name"], + "action": action, + "targets": rule_targets, + } + ) + if action == "full": + full = True + reasons.append( + rule.get("reason") + or f"{changed_path} matched full rule {rule['name']}" + ) + elif action == "targets": + targets.update(rule_targets) + rule_jobs = _strings( + rule.get("jobs"), f"rules.{rule['name']}.jobs" + ) + for job_name in rule_jobs: + job_targets[job_name].update(rule_targets) + + for component_name in self._match_components(changed_path): + matched = True + targets.add(component_name) + component = self.components[component_name] + component_jobs = _strings( + component.get("jobs"), + f"components.{component_name}.jobs", + ) + for job_name in component_jobs: + job_targets[job_name].add(component_name) + matches.append( + { + "path": changed_path, + "rule": f"component:{component_name}", + "action": "targets", + "targets": [component_name], + } + ) + + if not matched: + full = True + reasons.append( + f"unclassified runtime path {changed_path!r} requires " + "the full fallback" + ) + matches.append( + { + "path": changed_path, + "rule": "unclassified", + "action": "full", + "targets": [], + } + ) + + if full: + return self._full_plan(normalized_changes, matches, reasons) + if not targets: + return self._noop_plan(normalized_changes, matches) + + decisions: dict[str, dict[str, Any]] = {} + variants: list[dict[str, Any]] = [] + all_profiles: set[str] = set() + for target in targets: + all_profiles.update( + _strings( + self.components[target].get("verification_profiles"), + f"components.{target}.verification_profiles", + ) + ) + all_tempest_tests = self._tempest_test_patterns(targets) + + for job_name, job_value in self.jobs.items(): + job = _mapping(job_value, f"jobs.{job_name}") + roots = sorted(job_targets[job_name]) + if not roots: + decisions[job_name] = self._skip_decision( + job_name, job, "no changed component requires this job" + ) + continue + + scenario = job["scenario"] + backend = job.get("network_backend") + if scenario == "aio": + deployment_roots = self._test_roots(roots) + components = self._closure(deployment_roots, backend) + tags = [self.components[name].get("tag", name) for name in components] + profiles = sorted( + { + profile + for root in roots + for profile in _strings( + self.components[root].get("verification_profiles"), + f"components.{root}.verification_profiles", + ) + } + ) + tempest_tests = self._tempest_test_patterns(roots) + decision = { + "run": True, + "reason": "selected by changed components", + "scenario": scenario, + "network_backend": backend, + "targets": roots, + "components": components, + "ansible_tags": tags, + "verification_profiles": profiles, + "tempest_tests": tempest_tests, + } + variants.append( + { + "job": job_name, + "network_backend": backend, + "targets": roots, + "deployment_roots": deployment_roots, + "components": components, + "ansible_tags": tags, + "verification_profiles": profiles, + "tempest_tests": tempest_tests, + } + ) + else: + decision = { + "run": True, + "reason": "selected by changed components", + "scenario": scenario, + "network_backend": backend, + "targets": roots, + "components": [], + "ansible_tags": [], + "verification_profiles": sorted(all_profiles), + "tempest_tests": all_tempest_tests, + } + decisions[job_name] = decision + + return { + "version": 1, + "mode": "selective", + "changes": [asdict(change) for change in normalized_changes], + "matches": matches, + "targets": sorted(targets), + "verification_profiles": sorted(all_profiles), + "tempest_tests": all_tempest_tests, + "variants": variants, + "job_decisions": decisions, + "reasons": reasons, + } + + def _match_components(self, path: str) -> list[str]: + matched: set[str] = set() + parts = path.split("/") + role_name: str | None = None + chart_name: str | None = None + if len(parts) >= 3 and parts[0] == "roles": + role_name = parts[1] + elif len(parts) >= 3 and parts[0] == "charts": + if parts[1] == "patches" and len(parts) >= 4: + chart_name = parts[2] + else: + chart_name = parts[1] + + for component_name, component_value in self.components.items(): + component = _mapping(component_value, f"components.{component_name}") + if role_name and role_name in _strings( + component.get("roles", [component_name.replace("-", "_")]), + f"components.{component_name}.roles", + ): + matched.add(component_name) + if chart_name and chart_name in _strings( + component.get("charts", [component_name]), + f"components.{component_name}.charts", + ): + matched.add(component_name) + if self._matches(self._compiled_component_paths[component_name], path): + matched.add(component_name) + return sorted(matched) + + @staticmethod + def _matches(patterns: Iterable[re.Pattern[str]], path: str) -> bool: + return any(pattern.match(path) for pattern in patterns) + + def _closure(self, roots: Iterable[str], backend: str | None) -> list[str]: + closure: set[str] = set() + + def visit(name: str) -> None: + if name in closure: + return + closure.add(name) + component = self.components[name] + dependencies = _strings( + component.get("requires"), f"components.{name}.requires" + ) + if backend: + backend_requires = _mapping( + component.get("backend_requires", {}), + f"components.{name}.backend_requires", + ) + dependencies.extend( + _strings( + backend_requires.get(backend), + f"components.{name}.backend_requires.{backend}", + ) + ) + for dependency in dependencies: + visit(dependency) + + for root in roots: + visit(root) + return sorted(closure) + + def _test_roots(self, targets: Iterable[str]) -> list[str]: + roots: set[str] = set() + + def visit(name: str) -> None: + if name in roots: + return + roots.add(name) + for requirement in _strings( + self.components[name].get("test_requires"), + f"components.{name}.test_requires", + ): + visit(requirement) + + for target in targets: + visit(target) + return sorted(roots) + + def _tempest_test_patterns(self, targets: Iterable[str]) -> list[str]: + patterns: set[str] = set() + for target in targets: + target_patterns = _strings( + self.components[target].get("tempest_tests"), + f"components.{target}.tempest_tests", + ) + if not target_patterns: + return [] + patterns.update(target_patterns) + return sorted(patterns) + + def _full_plan( + self, + changes: Sequence[Change], + matches: list[dict[str, Any]], + reasons: list[str], + ) -> dict[str, Any]: + full_jobs = set(_strings(self.policy["full_jobs"], "full_jobs")) + decisions: dict[str, dict[str, Any]] = {} + for job_name, job_value in self.jobs.items(): + job = _mapping(job_value, f"jobs.{job_name}") + if job_name in full_jobs: + decisions[job_name] = { + "run": True, + "reason": "full fallback", + "scenario": job["scenario"], + "network_backend": job.get("network_backend"), + "targets": [], + "components": [], + "ansible_tags": [], + "verification_profiles": ["full"], + "tempest_tests": [], + } + else: + decisions[job_name] = self._skip_decision( + job_name, job, "job is not part of the full fallback" + ) + return { + "version": 1, + "mode": "full", + "changes": [asdict(change) for change in changes], + "matches": matches, + "targets": [], + "verification_profiles": ["full"], + "tempest_tests": [], + "variants": [], + "job_decisions": decisions, + "reasons": list(dict.fromkeys(reasons)), + } + + def _noop_plan( + self, + changes: Sequence[Change], + matches: list[dict[str, Any]], + ) -> dict[str, Any]: + decisions = { + job_name: self._skip_decision( + job_name, + _mapping(job, f"jobs.{job_name}"), + "all changed paths are ignored", + ) + for job_name, job in self.jobs.items() + } + return { + "version": 1, + "mode": "noop", + "changes": [asdict(change) for change in changes], + "matches": matches, + "targets": [], + "verification_profiles": [], + "tempest_tests": [], + "variants": [], + "job_decisions": decisions, + "reasons": ["all changed paths are ignored by the Molecule policy"], + } + + @staticmethod + def _skip_decision( + job_name: str, job: dict[str, Any], reason: str + ) -> dict[str, Any]: + del job_name + return { + "run": False, + "reason": reason, + "scenario": job["scenario"], + "network_backend": job.get("network_backend"), + "targets": [], + "components": [], + "ansible_tags": [], + "verification_profiles": [], + "tempest_tests": [], + } + + +def render_plan(plan: dict[str, Any]) -> str: + """Render a plan for readable Zuul console output.""" + + lines = [f"Selective Molecule plan: {plan['mode']}"] + lines.append("Changed files:") + for change in plan.get("changes", []): + if change.get("previous_path"): + lines.append( + f" {change['status']} {change['previous_path']} -> " + f"{change['path']}" + ) + else: + lines.append(f" {change['status']} {change['path']}") + if not plan.get("changes"): + lines.append(" (none)") + if plan.get("targets"): + lines.append(f"Targets: {', '.join(plan['targets'])}") + lines.append("Job decisions:") + for job_name, decision in plan["job_decisions"].items(): + state = "RUN" if decision["run"] else "SKIP" + lines.append(f" {state:4} {job_name}: {decision['reason']}") + if decision["run"] and decision.get("components"): + lines.append(" components: " + ", ".join(decision["components"])) + if decision["run"] and decision.get("tempest_tests"): + lines.append( + " Tempest include: " + ", ".join(decision["tempest_tests"]) + ) + for reason in plan.get("reasons", []): + lines.append(f"Reason: {reason}") + return "\n".join(lines) + + +def _load_plan(path: str | Path) -> dict[str, Any]: + with Path(path).open(encoding="utf-8") as stream: + plan = json.load(stream) + if not isinstance(plan, dict) or "job_decisions" not in plan: + raise PolicyError(f"{path} is not a selective CI plan") + return plan + + +def _write_output(value: str, output: str) -> None: + if output == "-": + print(value) + else: + Path(output).write_text(value + "\n", encoding="utf-8") + + +def _collect_changes(args: argparse.Namespace) -> list[Change]: + sources = sum( + ( + bool(args.changed_file), + bool(args.files_from), + bool(args.base or args.head), + ) + ) + if sources != 1: + raise PolicyError( + "provide exactly one change source: --changed-file, --files-from, " + "or --base with --head" + ) + if args.changed_file: + return [ + Change(status="M", path=_normalize_path(path)) for path in args.changed_file + ] + if args.files_from: + if args.files_from == "-": + return parse_changes(sys.stdin) + with Path(args.files_from).open(encoding="utf-8") as stream: + return parse_changes(stream) + if not args.base or not args.head: + raise PolicyError("--base and --head must be provided together") + return git_changes(args.base, args.head) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Plan selective Atmosphere Molecule jobs" + ) + parser.add_argument( + "--config", + default="ci/molecule-plan.yaml", + help="selective CI policy", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + subparsers.add_parser("validate", help="validate the policy") + + plan = subparsers.add_parser("plan", help="create a plan") + plan.add_argument("--changed-file", action="append", default=[]) + plan.add_argument("--files-from") + plan.add_argument("--base") + plan.add_argument("--head") + plan.add_argument("--format", choices=("json", "text"), default="text") + plan.add_argument("--output", default="-") + + render = subparsers.add_parser("render", help="render a JSON plan") + render.add_argument("plan") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _parser() + args = parser.parse_args(argv) + try: + if args.command == "render": + print(render_plan(_load_plan(args.plan))) + return 0 + + planner = Planner.load(args.config) + if args.command == "validate": + print(f"Selective CI policy {args.config} is valid") + return 0 + + plan = planner.plan(_collect_changes(args)) + if args.format == "json": + value = json.dumps(plan, indent=2) + else: + value = render_plan(plan) + _write_output(value, args.output) + return 0 + except (OSError, PolicyError, subprocess.CalledProcessError) as error: + parser.exit(2, f"error: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/charts/patches/tempest/0002-mount-only-ca-certificate.patch b/charts/patches/tempest/0002-mount-only-ca-certificate.patch new file mode 100644 index 0000000000..d6dd0640ba --- /dev/null +++ b/charts/patches/tempest/0002-mount-only-ca-certificate.patch @@ -0,0 +1,13 @@ +diff --git a/tempest/templates/job-run-tests.yaml b/tempest/templates/job-run-tests.yaml +--- a/tempest/templates/job-run-tests.yaml ++++ b/tempest/templates/job-run-tests.yaml +@@ -101,7 +101,7 @@ spec: + - name: stestr + mountPath: /.stestr + - name: tempest-reports + mountPath: /var/lib/tempest/data +-{{- dict "enabled" (or .Values.manifests.certificates .Values.tls.identity) "name" .Values.secrets.tls.identity.api.internal "path" "/etc/tempest/certs" | include "helm-toolkit.snippets.tls_volume_mount" | indent 12 }} ++{{- dict "enabled" (or .Values.manifests.certificates .Values.tls.identity) "name" .Values.secrets.tls.identity.api.internal "path" "/etc/tempest/certs" "certs" (tuple "ca.crt") | include "helm-toolkit.snippets.tls_volume_mount" | indent 12 }} + volumes: + - name: pod-tmp + emptyDir: {} diff --git a/charts/patches/tempest/0003-use-ca-for-python-ssl.patch b/charts/patches/tempest/0003-use-ca-for-python-ssl.patch new file mode 100644 index 0000000000..6f4ee82d53 --- /dev/null +++ b/charts/patches/tempest/0003-use-ca-for-python-ssl.patch @@ -0,0 +1,12 @@ +diff --git a/tempest/templates/job-run-tests.yaml b/tempest/templates/job-run-tests.yaml +--- a/tempest/templates/job-run-tests.yaml ++++ b/tempest/templates/job-run-tests.yaml +@@ -64,6 +64,8 @@ spec: + {{- if or .Values.manifests.certificates .Values.tls.identity }} + - name: REQUESTS_CA_BUNDLE + value: "/etc/tempest/certs/ca.crt" ++ - name: SSL_CERT_FILE ++ value: "/etc/tempest/certs/ca.crt" + {{- end }} + {{- with $env := dict "ksUserSecret" .Values.secrets.identity.admin }} + {{- include "helm-toolkit.snippets.keystone_openrc_env_vars" $env | indent 12 }} diff --git a/charts/patches/tempest/0004-support-ca-only-identity-trust.patch b/charts/patches/tempest/0004-support-ca-only-identity-trust.patch new file mode 100644 index 0000000000..4e8f2f945a --- /dev/null +++ b/charts/patches/tempest/0004-support-ca-only-identity-trust.patch @@ -0,0 +1,23 @@ +diff --git a/tempest/templates/job-ks-user.yaml b/tempest/templates/job-ks-user.yaml +--- a/tempest/templates/job-ks-user.yaml ++++ b/tempest/templates/job-ks-user.yaml +@@ -23,7 +23,7 @@ helm.sh/hook-weight: "-1" + {{- if .Values.helm3_hook }} + {{- $_ := set $ksUserJob "jobAnnotations" (include "metadata.annotations.job.ks_user" . | fromYaml) -}} + {{- end }} +-{{- if or .Values.manifests.certificates .Values.tls.identity -}} ++{{- if and (or .Values.manifests.certificates .Values.tls.identity) (not .Values.tls.identity_ca_only) -}} + {{- $_ := set $ksUserJob "tlsSecret" .Values.secrets.tls.identity.api.internal -}} + {{- end -}} + {{ $ksUserJob | include "helm-toolkit.manifests.job_ks_user" }} +diff --git a/tempest/values.yaml b/tempest/values.yaml +--- a/tempest/values.yaml ++++ b/tempest/values.yaml +@@ -376,6 +376,7 @@ endpoints: + + tls: + identity: false ++ identity_ca_only: false + + manifests: + configmap_bin: true diff --git a/charts/tempest/templates/job-ks-user.yaml b/charts/tempest/templates/job-ks-user.yaml index 4537d5a536..77dba3b260 100644 --- a/charts/tempest/templates/job-ks-user.yaml +++ b/charts/tempest/templates/job-ks-user.yaml @@ -23,7 +23,7 @@ helm.sh/hook-weight: "-1" {{- if .Values.helm3_hook }} {{- $_ := set $ksUserJob "jobAnnotations" (include "metadata.annotations.job.ks_user" . | fromYaml) -}} {{- end }} -{{- if or .Values.manifests.certificates .Values.tls.identity -}} +{{- if and (or .Values.manifests.certificates .Values.tls.identity) (not .Values.tls.identity_ca_only) -}} {{- $_ := set $ksUserJob "tlsSecret" .Values.secrets.tls.identity.api.internal -}} {{- end -}} {{ $ksUserJob | include "helm-toolkit.manifests.job_ks_user" }} diff --git a/charts/tempest/templates/job-run-tests.yaml b/charts/tempest/templates/job-run-tests.yaml index fa1c361897..91193d37fb 100644 --- a/charts/tempest/templates/job-run-tests.yaml +++ b/charts/tempest/templates/job-run-tests.yaml @@ -64,6 +64,8 @@ spec: {{- if or .Values.manifests.certificates .Values.tls.identity }} - name: REQUESTS_CA_BUNDLE value: "/etc/tempest/certs/ca.crt" + - name: SSL_CERT_FILE + value: "/etc/tempest/certs/ca.crt" {{- end }} {{- with $env := dict "ksUserSecret" .Values.secrets.identity.admin }} {{- include "helm-toolkit.snippets.keystone_openrc_env_vars" $env | indent 12 }} @@ -101,7 +103,7 @@ spec: mountPath: /.stestr - name: tempest-reports mountPath: /var/lib/tempest/data -{{- dict "enabled" (or .Values.manifests.certificates .Values.tls.identity) "name" .Values.secrets.tls.identity.api.internal "path" "/etc/tempest/certs" | include "helm-toolkit.snippets.tls_volume_mount" | indent 12 }} +{{- dict "enabled" (or .Values.manifests.certificates .Values.tls.identity) "name" .Values.secrets.tls.identity.api.internal "path" "/etc/tempest/certs" "certs" (tuple "ca.crt") | include "helm-toolkit.snippets.tls_volume_mount" | indent 12 }} volumes: - name: pod-tmp emptyDir: {} diff --git a/charts/tempest/values.yaml b/charts/tempest/values.yaml index 742ec5c324..a018a1a30c 100644 --- a/charts/tempest/values.yaml +++ b/charts/tempest/values.yaml @@ -376,6 +376,7 @@ endpoints: default: 80 tls: identity: false + identity_ca_only: false manifests: configmap_bin: true diff --git a/ci/molecule-plan.yaml b/ci/molecule-plan.yaml new file mode 100644 index 0000000000..d1144e149c --- /dev/null +++ b/ci/molecule-plan.yaml @@ -0,0 +1,721 @@ +--- +version: 1 + +# Zuul schedules these jobs statically. The planner decides which scheduled +# jobs need to execute after inspecting the exact pull request diff. +jobs: + aio-openvswitch: + scenario: aio + network_backend: openvswitch + aio-ovn: + scenario: aio + network_backend: ovn + csi-local-path-provisioner: + scenario: csi + csi-rbd: + scenario: csi + keycloak: + scenario: keycloak + +# An unclassified runtime path and a policy change use the conservative suite. +full_jobs: + - aio-openvswitch + - aio-ovn + - csi-local-path-provisioner + - csi-rbd + - keycloak + +rules: + - name: planner-policy + action: full + reason: the CI selection policy changed + paths: + - .zuul.yaml + - ci/molecule-plan.yaml + - atmosphere/ci/** + - tests/unit/ci/** + - test-playbooks/molecule/** + - molecule/aio/** + - pyproject.toml + - uv.lock + + - name: shared-deployment-code + action: full + reason: shared deployment code can affect unrelated components + paths: + - .charts.yml + - galaxy.yml + - requirements.txt + - playbooks/generate_workspace.yml + - playbooks/infrastructure.yml + - playbooks/monitoring.yml + - playbooks/openstack.yml + - playbooks/site.yml + - molecule/shared/** + - plugins/** + - roles/defaults/** + - roles/ingress/** + - roles/openstack_helm_endpoints/** + - roles/openstack_helm_ingress/** + - roles/openstacksdk/** + - roles/rabbitmq/** + - roles/tempest/** + - charts/tempest/** + - charts/patches/tempest/** + + - name: ceph-playbook + action: targets + targets: + - ceph + jobs: + - aio-openvswitch + - csi-rbd + paths: + - playbooks/ceph.yml + + - name: kubernetes-playbook + action: targets + targets: + - kubernetes + jobs: + - aio-openvswitch + - aio-ovn + - csi-local-path-provisioner + - csi-rbd + - keycloak + paths: + - playbooks/kubernetes.yml + + - name: csi-playbook + action: targets + targets: + - csi + jobs: + - csi-local-path-provisioner + - csi-rbd + paths: + - playbooks/csi.yml + - molecule/csi/** + + - name: keycloak-scenario + action: targets + targets: + - keycloak + jobs: + - keycloak + paths: + - molecule/keycloak/** + + - name: csi-local-path-provisioner + action: targets + targets: + - csi + jobs: + - csi-local-path-provisioner + paths: + - roles/local_path_provisioner/** + - charts/local-path-provisioner/** + - charts/patches/local-path-provisioner/** + + - name: csi-rbd + action: targets + targets: + - csi + jobs: + - csi-rbd + paths: + - roles/ceph_csi_rbd/** + - charts/ceph-csi-rbd/** + - charts/patches/ceph-csi-rbd/** + + - name: openvswitch-runtime + action: targets + targets: + - openvswitch + jobs: + - aio-openvswitch + paths: + - Cargo.lock + - Cargo.toml + - crates/ovsinit/** + + - name: inactive-openstack-services + action: full + reason: the service is not enabled by the AIO scenario + paths: + - roles/designate/** + - roles/ironic/** + - charts/designate/** + - charts/patches/designate/** + - charts/ironic/** + - charts/patches/ironic/** + + - name: operational-workflows + action: full + reason: operational workflows need the complete integration environment + paths: + - playbooks/image_manifest.yml + - playbooks/migrate_from_osa.yml + - playbooks/suspend_project.yml + - playbooks/tempest.yml + - playbooks/terminate_project.yml + - roles/image_manifest/** + - roles/migrate_db_from_osa/** + - roles/migrate_haproxy_from_osa/** + - roles/osa_config_diff/** + - roles/reboot/** + + - name: non-runtime + action: ignore + paths: + - .agents/** + - .github/** + - .gitignore + - .gitreview + - .pre-commit-config.yaml + - .vale.ini + - AGENTS.md + - CLAUDE.md + - CODEOWNERS + - LICENSE + - README.md + - doc/** + - releasenotes/** + - reno.yaml + - renovate.json + +# Dependencies below are CI test-environment requirements. They are deliberately +# independent from any deployment orchestrator and resolve only to tags already +# present in the sequential playbooks on main. +components: + ceph: + roles: [] + charts: [] + jobs: + - aio-openvswitch + - csi-rbd + verification_profiles: + - foundation + - storage + + kubernetes: + roles: + - ethtool + - kubernetes_node_labels + - sysctl + charts: [] + jobs: + - aio-openvswitch + - aio-ovn + - csi-local-path-provisioner + - csi-rbd + - keycloak + verification_profiles: + - foundation + + csi: + roles: + - csi + - hpe_nimble_csi + - powerstore_csi + - portworx + - storpool_csi + - ibm_block_csi_driver + charts: + - hpe-csi-driver + requires: + - ceph + - kubernetes + jobs: + - csi-local-path-provisioner + - csi-rbd + verification_profiles: + - csi + + cert-manager: + roles: + - cert_manager + charts: + - cert-manager + - cert-manager-webhook-infoblox-wapi + - godaddy-webhook + requires: + - kubernetes + jobs: + - keycloak + verification_profiles: + - infrastructure + + cluster-issuer: + roles: + - cluster_issuer + charts: [] + requires: + - cert-manager + jobs: + - keycloak + verification_profiles: + - infrastructure + + ingress-nginx: + roles: + - ingress_nginx + charts: + - ingress-nginx + requires: + - kubernetes + jobs: + - keycloak + verification_profiles: + - infrastructure + + rabbitmq-cluster-operator: + roles: + - rabbitmq_cluster_operator + charts: + - rabbitmq-cluster-operator + requires: + - cert-manager + jobs: + - keycloak + verification_profiles: + - messaging + + percona-xtradb-cluster-operator: + roles: + - percona_xtradb_cluster_operator + charts: + - pxc-operator + requires: + - cert-manager + jobs: + - keycloak + verification_profiles: + - database + + percona-xtradb-cluster: + roles: + - percona_xtradb_cluster + charts: + - pxc-db + requires: + - csi + - percona-xtradb-cluster-operator + jobs: + - keycloak + verification_profiles: + - database + + valkey: + requires: + - cluster-issuer + - csi + jobs: + - aio-openvswitch + verification_profiles: + - cache + + keycloak: + requires: + - cluster-issuer + - ingress-nginx + - percona-xtradb-cluster + jobs: + - keycloak + verification_profiles: + - keycloak-federation + + keepalived: + requires: + - kubernetes + jobs: + - aio-openvswitch + verification_profiles: + - infrastructure + + node-feature-discovery: + roles: + - node_feature_discovery + charts: + - node-feature-discovery + requires: + - kubernetes + jobs: + - aio-openvswitch + verification_profiles: + - monitoring + + kube-prometheus-stack: + roles: + - kube_prometheus_stack + charts: + - kube-prometheus-stack + requires: + - cluster-issuer + - csi + - keycloak + jobs: + - aio-openvswitch + verification_profiles: + - monitoring + + loki: + requires: + - csi + jobs: + - aio-openvswitch + verification_profiles: + - observability + + vector: + requires: + - loki + jobs: + - aio-openvswitch + verification_profiles: + - observability + + goldpinger: + requires: + - kubernetes + jobs: + - aio-openvswitch + verification_profiles: + - monitoring + + ipmi-exporter: + roles: + - ipmi_exporter + charts: + - prometheus-ipmi-exporter + requires: + - kube-prometheus-stack + jobs: + - aio-openvswitch + verification_profiles: + - monitoring + + smartctl-exporter: + roles: + - smartctl_exporter + charts: + - prometheus-smartctl-exporter + requires: + - kube-prometheus-stack + jobs: + - aio-openvswitch + verification_profiles: + - monitoring + + prometheus-pushgateway: + roles: + - prometheus_pushgateway + charts: + - prometheus-pushgateway + requires: + - kube-prometheus-stack + jobs: + - aio-openvswitch + verification_profiles: + - monitoring + + lpfc: + charts: [] + jobs: + - aio-openvswitch + verification_profiles: + - host-storage + + multipathd: + charts: [] + jobs: + - aio-openvswitch + verification_profiles: + - host-storage + + iscsi: + charts: [] + jobs: + - aio-openvswitch + verification_profiles: + - host-storage + + udev: + charts: [] + jobs: + - aio-openvswitch + verification_profiles: + - host-storage + + memcached: + requires: + - kubernetes + jobs: + - aio-openvswitch + verification_profiles: + - cache + + keystone: + requires: + - ingress-nginx + - keycloak + - memcached + - percona-xtradb-cluster + - rabbitmq-cluster-operator + jobs: + - aio-openvswitch + verification_profiles: + - identity + tempest_tests: + - '^tempest\.api\.identity\.' + + barbican: + requires: + - keystone + jobs: + - aio-openvswitch + verification_profiles: + - secrets + + rook-ceph: + roles: + - rook_ceph + charts: + - rook-ceph + requires: + - kubernetes + jobs: + - aio-openvswitch + verification_profiles: + - storage + + rook-ceph-cluster: + roles: + - rook_ceph_cluster + charts: + - rook-ceph-cluster + requires: + - ceph + - keystone + - rook-ceph + jobs: + - aio-openvswitch + verification_profiles: + - storage + + ceph-provisioners: + roles: + - ceph_provisioners + charts: + - ceph-provisioners + requires: + - ceph + - kubernetes + jobs: + - aio-openvswitch + verification_profiles: + - storage + + glance: + requires: + - ceph-provisioners + - keystone + jobs: + - aio-openvswitch + verification_profiles: + - image + tempest_tests: + - '^tempest\.api\.image\.' + + staffeln: + requires: + - keystone + jobs: + - aio-openvswitch + verification_profiles: + - volume + + cinder: + requires: + - ceph-provisioners + - keystone + jobs: + - aio-openvswitch + verification_profiles: + - volume + tempest_tests: + - '^tempest\.api\.volume\.' + + placement: + requires: + - keystone + jobs: + - aio-openvswitch + verification_profiles: + - placement + + openvswitch: + requires: + - kubernetes + jobs: + - aio-openvswitch + verification_profiles: + - network + + frr-k8s: + roles: + - frr_k8s + charts: + - frr-k8s + tag: frr_k8s + requires: + - kubernetes + - ovn + jobs: + - aio-ovn + verification_profiles: + - network + + ovn: + requires: + - openvswitch + jobs: + - aio-ovn + verification_profiles: + - network + + libvirt: + requires: + - cluster-issuer + - kubernetes + jobs: + - aio-openvswitch + verification_profiles: + - compute + + coredns: + requires: + - kubernetes + jobs: + - aio-openvswitch + verification_profiles: + - network + + nova: + requires: + - glance + - libvirt + - placement + test_requires: + - neutron + jobs: + - aio-openvswitch + verification_profiles: + - compute + tempest_tests: + - '^tempest\.api\.compute\.' + - '^tempest\.scenario\.test_server_' + + neutron: + requires: + - nova + backend_requires: + openvswitch: + - coredns + - openvswitch + ovn: + - ovn + jobs: + - aio-openvswitch + - aio-ovn + verification_profiles: + - network + tempest_tests: + - '^tempest\.api\.network\.' + - '^neutron_tempest_plugin\.' + - '^tempest\.scenario\.test_network_' + + heat: + requires: + - keystone + test_requires: + - glance + - neutron + jobs: + - aio-openvswitch + verification_profiles: + - orchestration + + octavia: + requires: + - barbican + - neutron + - nova + jobs: + - aio-openvswitch + verification_profiles: + - load-balancer + tempest_tests: + - '^octavia_tempest_plugin\.' + + magnum: + requires: + - glance + - keystone + test_requires: + - barbican + - cinder + - glance + - heat + - neutron + - octavia + jobs: + - aio-openvswitch + verification_profiles: + - container-infrastructure + + manila: + requires: + - cinder + - neutron + - nova + jobs: + - aio-openvswitch + verification_profiles: + - shared-file-system + + horizon: + requires: + - keystone + test_requires: + - nova + jobs: + - aio-openvswitch + verification_profiles: + - dashboard + tempest_tests: + - '^tempest\.scenario\.test_dashboard_basic_ops\.' + + openstack-exporter: + roles: + - openstack_exporter + charts: + - openstack-exporter + requires: + - cinder + - neutron + jobs: + - aio-openvswitch + verification_profiles: + - monitoring + + openstack-cli: + roles: + - openstack_cli + charts: [] + requires: + - keystone + jobs: + - aio-openvswitch + verification_profiles: + - identity-client diff --git a/doc/source/deploy/index.rst b/doc/source/deploy/index.rst index 63101bdac6..32c8274d88 100644 --- a/doc/source/deploy/index.rst +++ b/doc/source/deploy/index.rst @@ -20,3 +20,4 @@ and setup. glance neutron deployment + selective-ci diff --git a/doc/source/deploy/selective-ci.rst b/doc/source/deploy/selective-ci.rst new file mode 100644 index 0000000000..22b24c7c2a --- /dev/null +++ b/doc/source/deploy/selective-ci.rst @@ -0,0 +1,88 @@ +##################### +Selective Molecule CI +##################### + +Atmosphere uses a declarative impact policy to avoid deploying every service +for every pull request. The policy maps changed paths to the smallest safe +Molecule scenario, deployment dependency closure, and verification profile. +Unknown runtime paths deliberately fall back to the complete test suite. + +The implementation is independent from deployment orchestration. Zuul jobs +inherit the ordinary Atmosphere Molecule jobs and execute the same sequential +Ansible playbooks used by ``main``. For a selective AIO job, the planner passes +the dependency closure as native Ansible tags to the normal converge and +idempotence actions. + +Policy +====== + +The policy is stored in ``ci/molecule-plan.yaml`` and has three sections: + +``jobs`` + Static Zuul jobs which can consume a decision. + +``rules`` + Shared paths, ignored paths, focused scenario paths, and conservative + full-suite fallbacks. + +``components`` + Role and chart ownership, direct CI dependencies, verification profiles, and + the jobs which exercise each component. A component can also declare + ``tempest_tests`` regular expressions to restrict Tempest to its smoke tests. + +Dependencies describe the test environment, not deployment concurrency. For +example, a Keystone change includes Kubernetes, Ceph-backed CSI, the Percona +XtraDB Cluster, Keycloak, RabbitMQ, Memcached, ingress, and certificate +components. It does not include Glance, Nova, Manila, Magnum, or other unrelated +OpenStack APIs. + +Changing the policy +=================== + +Add or update a component in ``ci/molecule-plan.yaml`` when introducing a role +or chart. Keep dependencies explicit and prefer the narrowest job which performs +meaningful verification. Shared code should use a ``full`` rule unless its +impact is safely bounded. + +Validate the policy and inspect representative plans locally: + +.. code-block:: console + + uv run python -m atmosphere.ci.molecule_plan validate + uv run python -m atmosphere.ci.molecule_plan plan \ + --changed-file roles/glance/tasks/main.yml + uv run python -m atmosphere.ci.molecule_plan plan \ + --changed-file roles/neutron/tasks/main.yml + +The planner evaluates both sides of renames and copies. Documentation and +release-note-only changes produce a no-op plan. A path which matches no rule or +component produces a full plan so a new runtime area cannot silently lose test +coverage. + +During selective AIO verification, Tempest receives the union of +``tempest_tests`` for the changed components. The expressions are passed +through Tempest's ``include-list`` support and combined with ``--smoke``. A +component without expressions, including any such component in a multi-target +change, uses the ordinary smoke selection against the services available in +its deployment closure. This provides a conservative fallback for services +whose image has no dedicated smoke-test namespace. + +Zuul artifacts +============== + +Every scheduled selective job prints its decision and publishes +``ci-plan-.json`` with the job logs. Running Molecule output is polled and +printed throughout the deployment. Progress heartbeats report the last +observed Molecule task, while the complete stream is retained as +``molecule-.log``. + +Zuul constructs the job graph before a project job can inspect the pull request +diff, so all five consumer jobs are scheduled statically. Jobs which are not +selected report ``SKIP`` and exit without running Molecule, but still require a +node for their inherited preparation. Avoiding that allocation would require +moving change classification into trusted Zuul configuration. + +The selective AIO jobs allow fifteen minutes for Keycloak and ten minutes for +the Nova, Neutron, and Octavia Helm operations. Clean database migrations and +initial service rollouts can exceed their normal timeouts on a busy test node +even when they complete successfully. diff --git a/molecule/aio/converge.yml b/molecule/aio/converge.yml index 7fc4a91f19..714453dd06 100644 --- a/molecule/aio/converge.yml +++ b/molecule/aio/converge.yml @@ -14,12 +14,18 @@ - name: Install Ceph ansible.builtin.import_playbook: vexxhost.atmosphere.ceph + tags: + - ceph - name: Install Kubernetes ansible.builtin.import_playbook: vexxhost.atmosphere.kubernetes + tags: + - kubernetes - name: Install CSI ansible.builtin.import_playbook: vexxhost.atmosphere.csi + tags: + - csi - name: Deploy Infrastructure ansible.builtin.import_playbook: vexxhost.atmosphere.infrastructure @@ -33,6 +39,8 @@ - name: Configure networking hosts: all become: true + tags: + - always tasks: - name: Add IP address to "br-ex" changed_when: false diff --git a/molecule/aio/prepare.yml b/molecule/aio/prepare.yml index 400ee8ee7b..f217b9031d 100644 --- a/molecule/aio/prepare.yml +++ b/molecule/aio/prepare.yml @@ -4,6 +4,8 @@ - name: Prepare hosts: all become: true + tags: + - always tasks: - name: Workaround system hostname block: @@ -30,6 +32,8 @@ - name: Generate workspace ansible.builtin.import_playbook: vexxhost.atmosphere.generate_workspace + tags: + - always vars: workspace_path: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}" domain_name: "{{ ansible_default_ipv4['address'].replace('.', '-') }}.nip.io" @@ -37,6 +41,8 @@ - name: Setup networking hosts: all become: true + tags: + - always vars: management_bridge: "br-mgmt" tasks: @@ -75,6 +81,10 @@ - name: Create fake devices for Ceph ansible.builtin.import_playbook: vexxhost.ceph.create_fake_devices + tags: + - always - name: Prepare system for Neutron ansible.builtin.import_playbook: ../shared/prepare/neutron.yml + tags: + - always diff --git a/molecule/aio/verify.yml b/molecule/aio/verify.yml index 987f2730cf..5092ec2e6e 100644 --- a/molecule/aio/verify.yml +++ b/molecule/aio/verify.yml @@ -15,8 +15,16 @@ - name: Run tests hosts: localhost gather_facts: false + tags: + - always tasks: - name: Run "stestr" tests + when: >- + {{ + lookup('env', 'ATMOSPHERE_CI_RUN_STESTR') + | default('true', true) + | bool + }} become: true ansible.builtin.shell: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/.venv/bin/stestr run" args: @@ -27,3 +35,5 @@ - name: Run Tempest tests ansible.builtin.import_playbook: vexxhost.atmosphere.tempest + tags: + - always diff --git a/playbooks/tempest.yml b/playbooks/tempest.yml index e40e1ba582..c8f680bf30 100644 --- a/playbooks/tempest.yml +++ b/playbooks/tempest.yml @@ -12,9 +12,14 @@ # License for the specific language governing permissions and limitations # under the License. -- hosts: controllers[0] +- name: Run Tempest tests + hosts: controllers[0] become: true roles: - role: tempest + when: >- + lookup('env', 'ATMOSPHERE_CI_RUN_TEMPEST') + | default('true', true) + | bool tags: - tempest diff --git a/pyproject.toml b/pyproject.toml index 52f16c47b3..ad54ba2b38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "netaddr>=0.8.0", "openstacksdk>1", "pydantic>=2", + "PyYAML>=6.0", "rjsonnet>=0.5.2", "typing_extensions>=4.0", ] @@ -39,6 +40,7 @@ dev = [ "kubernetes", "molecule>=25.11.0", "oslotest>=5.0.1", + "pytest", "stestr>=4.2.0", "testscenarios>=0.5.0", ] diff --git a/releasenotes/notes/selective-molecule-ci-3233e7ee8348d528.yaml b/releasenotes/notes/selective-molecule-ci-3233e7ee8348d528.yaml new file mode 100644 index 0000000000..2f7335a0b1 --- /dev/null +++ b/releasenotes/notes/selective-molecule-ci-3233e7ee8348d528.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Molecule CI now selects related scenarios and service dependencies from + changed pull request files. This reduces test environments for focused + service changes while retaining a complete fallback for shared or unknown + runtime changes. diff --git a/roles/keycloak/defaults/main.yml b/roles/keycloak/defaults/main.yml index ae1f82ba64..cdb7e7deaf 100644 --- a/roles/keycloak/defaults/main.yml +++ b/roles/keycloak/defaults/main.yml @@ -20,6 +20,11 @@ keycloak_helm_release_namespace: auth-system keycloak_helm_kubeconfig: "{{ kubeconfig_path | default('/etc/kubernetes/admin.conf') }}" keycloak_helm_values: {} +# Time to wait for Helm operations to complete, using Go duration syntax. +# +# keycloak_helm_timeout: 15m0s +keycloak_helm_timeout: 10m0s + keycloak_host: "{{ undef('You must specify a Keycloak host using keycloak_host') }}" keycloak_ingress_annotations: {} keycloak_ingress_class_name: "{{ atmosphere_ingress_class_name }}" diff --git a/roles/keycloak/tasks/main.yml b/roles/keycloak/tasks/main.yml index 933a336258..5202e64de6 100644 --- a/roles/keycloak/tasks/main.yml +++ b/roles/keycloak/tasks/main.yml @@ -77,7 +77,7 @@ create_namespace: true kubeconfig: "{{ keycloak_helm_kubeconfig }}" wait: true - timeout: 10m + timeout: "{{ keycloak_helm_timeout }}" values: "{{ _keycloak_helm_values | combine(keycloak_helm_values, recursive=True) }}" - name: Wait until keycloak ready diff --git a/roles/neutron/defaults/main.yml b/roles/neutron/defaults/main.yml index 80deba8144..73c6e51162 100644 --- a/roles/neutron/defaults/main.yml +++ b/roles/neutron/defaults/main.yml @@ -20,6 +20,11 @@ neutron_helm_release_namespace: openstack neutron_helm_kubeconfig: "{{ kubeconfig_path | default('/etc/kubernetes/admin.conf') }}" neutron_helm_values: {} +# Time to wait for Helm operations to complete, using Go duration syntax. +# +# neutron_helm_timeout: 10m0s +neutron_helm_timeout: 5m0s + # List of networks to provision inside OpenStack neutron_networks: [] diff --git a/roles/neutron/tasks/main.yml b/roles/neutron/tasks/main.yml index e368e994b4..81243e5bc9 100644 --- a/roles/neutron/tasks/main.yml +++ b/roles/neutron/tasks/main.yml @@ -64,6 +64,7 @@ release_namespace: "{{ neutron_helm_release_namespace }}" create_namespace: true kubeconfig: "{{ neutron_helm_kubeconfig }}" + timeout: "{{ neutron_helm_timeout }}" values: "{{ _neutron_helm_values | combine(neutron_helm_values, recursive=True) }}" - name: Create Ingress diff --git a/roles/nova/defaults/main.yml b/roles/nova/defaults/main.yml index a951a5f416..1175f338eb 100644 --- a/roles/nova/defaults/main.yml +++ b/roles/nova/defaults/main.yml @@ -20,6 +20,11 @@ nova_helm_release_namespace: openstack nova_helm_kubeconfig: "{{ kubeconfig_path | default('/etc/kubernetes/admin.conf') }}" nova_helm_values: {} +# Time to wait for Helm operations to complete, using Go duration syntax. +# +# nova_helm_timeout: 10m0s +nova_helm_timeout: 5m0s + # Private SSH key used for cold & live migration nova_ssh_key: "{{ undef(hint='You must specifiy an SSH key for Nova.') }}" diff --git a/roles/nova/tasks/main.yml b/roles/nova/tasks/main.yml index b880cd60c5..e3e7374874 100644 --- a/roles/nova/tasks/main.yml +++ b/roles/nova/tasks/main.yml @@ -53,6 +53,7 @@ create_namespace: true kubeconfig: "{{ nova_helm_kubeconfig }}" values: "{{ _nova_helm_values | combine(nova_helm_values, recursive=True) }}" + timeout: "{{ nova_helm_timeout }}" - name: Create Ingress ansible.builtin.include_role: diff --git a/roles/octavia/defaults/main.yml b/roles/octavia/defaults/main.yml index caad285b74..7809863d11 100644 --- a/roles/octavia/defaults/main.yml +++ b/roles/octavia/defaults/main.yml @@ -20,6 +20,11 @@ octavia_helm_release_namespace: openstack octavia_helm_kubeconfig: "{{ kubeconfig_path | default('/etc/kubernetes/admin.conf') }}" octavia_helm_values: {} +# Time to wait for Helm operations to complete, using Go duration syntax. +# +# octavia_helm_timeout: 10m0s +octavia_helm_timeout: 5m0s + # Class name to use for the Ingress octavia_ingress_class_name: "{{ atmosphere_ingress_class_name }}" diff --git a/roles/octavia/tasks/main.yml b/roles/octavia/tasks/main.yml index 46fc6cddcb..9e91966d00 100644 --- a/roles/octavia/tasks/main.yml +++ b/roles/octavia/tasks/main.yml @@ -104,6 +104,7 @@ release_namespace: "{{ octavia_helm_release_namespace }}" create_namespace: true kubeconfig: "{{ octavia_helm_kubeconfig }}" + timeout: "{{ octavia_helm_timeout }}" values: "{{ _octavia_helm_values | combine(octavia_helm_values, recursive=True) }}" - name: Add implied roles diff --git a/roles/tempest/defaults/main.yml b/roles/tempest/defaults/main.yml index e7e5bfc874..bd29f8b749 100644 --- a/roles/tempest/defaults/main.yml +++ b/roles/tempest/defaults/main.yml @@ -19,3 +19,63 @@ tempest_helm_chart_ref: /usr/local/src/tempest tempest_helm_release_namespace: openstack tempest_helm_kubeconfig: "{{ kubeconfig_path | default('/etc/kubernetes/admin.conf') }}" tempest_helm_values: {} + +# Whether to trust the private CA used by the cluster issuer. +# +# tempest_cluster_issuer_ca_enabled: true +tempest_cluster_issuer_ca_enabled: >- + {{ cluster_issuer_type in ['self-signed', 'ca'] }} + +# Namespace containing the cluster issuer CA secret. +# +# tempest_cluster_issuer_ca_namespace: cert-manager +tempest_cluster_issuer_ca_namespace: cert-manager + +# Name of the cluster issuer CA secret. +# +# tempest_cluster_issuer_ca_source_secret_name: cert-manager-selfsigned-ca +tempest_cluster_issuer_ca_source_secret_name: >- + {{ + ( + cluster_issuer_self_signed_secret_name + | default('cert-manager-selfsigned-ca') + ) + if cluster_issuer_type == 'self-signed' + else ( + cluster_issuer_ca_secret_name + | default('cert-manager-issuer-ca') + ) + }} + +# Name of the CA secret copied into the Tempest namespace. +# +# tempest_cluster_issuer_ca_secret_name: tempest-cluster-issuer-ca +tempest_cluster_issuer_ca_secret_name: tempest-cluster-issuer-ca + +# CI deployment mode used to limit Tempest to deployed services. +# +# tempest_ci_mode: selective +tempest_ci_mode: "{{ lookup('env', 'ATMOSPHERE_CI_MODE') | default('full', true) }}" + +# Components available to Tempest during a selective CI deployment. +# +# tempest_ci_components: +# - keystone +tempest_ci_components: >- + {{ + lookup('env', 'ATMOSPHERE_CI_COMPONENTS') + .split(',') + | reject('equalto', '') + | list + }} + +# Regular expressions used to select Tempest tests during selective CI. +# +# tempest_ci_test_patterns: +# - '^tempest\.api\.identity\.' +tempest_ci_test_patterns: >- + {{ + lookup('env', 'ATMOSPHERE_CI_TEMPEST_INCLUDE') + | default('[]', true) + | from_json + }} diff --git a/roles/tempest/tasks/main.yml b/roles/tempest/tasks/main.yml index 3440b2d57a..d34c38ff7b 100644 --- a/roles/tempest/tasks/main.yml +++ b/roles/tempest/tasks/main.yml @@ -18,6 +18,33 @@ vars: openstack_helm_endpoints_chart: tempest +- name: Trust cluster issuer CA in Tempest + when: tempest_cluster_issuer_ca_enabled | bool + block: + - name: Read cluster issuer CA secret + run_once: true + kubernetes.core.k8s_info: + api_version: v1 + kind: Secret + name: "{{ tempest_cluster_issuer_ca_source_secret_name }}" + namespace: "{{ tempest_cluster_issuer_ca_namespace }}" + register: _tempest_cluster_issuer_ca_secret + + - name: Copy cluster issuer CA into Tempest namespace + run_once: true + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Secret + metadata: + name: "{{ tempest_cluster_issuer_ca_secret_name }}" + namespace: "{{ tempest_helm_release_namespace }}" + type: Opaque + data: + ca.crt: >- + {{ _tempest_cluster_issuer_ca_secret.resources[0].data['tls.crt'] }} + - name: Configure tempest block: - name: Get test image object @@ -25,7 +52,9 @@ cloud: atmosphere image: cirros register: _tempest_test_image - when: tempest_helm_values.conf.tempest.compute.image_ref is not defined + when: + - tempest_ci_mode != 'selective' or 'glance' in tempest_ci_components + - tempest_helm_values.conf.tempest.compute.image_ref is not defined - name: Configure test image ref ansible.builtin.set_fact: @@ -36,6 +65,7 @@ compute: image_ref: "{{ _tempest_test_image.images.0.id }}" when: + - tempest_ci_mode != 'selective' or 'glance' in tempest_ci_components - tempest_helm_values.conf.tempest.compute.image_ref is not defined - _tempest_test_image.images | length > 0 @@ -44,7 +74,9 @@ cloud: atmosphere name: m1.tiny register: _tempest_test_flavor - when: tempest_helm_values.conf.tempest.compute.flavor_ref is not defined + when: + - tempest_ci_mode != 'selective' or 'nova' in tempest_ci_components + - tempest_helm_values.conf.tempest.compute.flavor_ref is not defined - name: Set test flavor ref ansible.builtin.set_fact: @@ -55,6 +87,7 @@ compute: flavor_ref: "{{ _tempest_test_flavor.flavors[0].id }}" when: + - tempest_ci_mode != 'selective' or 'nova' in tempest_ci_components - tempest_helm_values.conf.tempest.compute.flavor_ref is not defined - _tempest_test_flavor.flavors[0].id is defined @@ -63,7 +96,9 @@ cloud: atmosphere name: public register: _tempest_test_network - when: tempest_helm_values.conf.tempest.network.public_network_id is not defined + when: + - tempest_ci_mode != 'selective' or 'neutron' in tempest_ci_components + - tempest_helm_values.conf.tempest.network.public_network_id is not defined - name: Set test network ref ansible.builtin.set_fact: @@ -74,6 +109,7 @@ network: public_network_id: "{{ _tempest_test_network.networks[0].id }}" when: + - tempest_ci_mode != 'selective' or 'neutron' in tempest_ci_components - tempest_helm_values.conf.tempest.network.public_network_id is not defined - _tempest_test_network.networks[0].id is defined diff --git a/roles/tempest/vars/main.yml b/roles/tempest/vars/main.yml index 51ebd6e5e0..43e4314944 100644 --- a/roles/tempest/vars/main.yml +++ b/roles/tempest/vars/main.yml @@ -12,10 +12,43 @@ # License for the specific language governing permissions and limitations # under the License. +_tempest_full_service_availability: + cinder: true + glance: true + horizon: true + neutron: true + nova: true + swift: true + +_tempest_ci_service_availability: + barbican: "{{ 'barbican' in tempest_ci_components }}" + cinder: "{{ 'cinder' in tempest_ci_components }}" + glance: "{{ 'glance' in tempest_ci_components }}" + heat: "{{ 'heat' in tempest_ci_components }}" + horizon: "{{ 'horizon' in tempest_ci_components }}" + load_balancer: "{{ 'octavia' in tempest_ci_components }}" + magnum: "{{ 'magnum' in tempest_ci_components }}" + manila: "{{ 'manila' in tempest_ci_components }}" + neutron: "{{ 'neutron' in tempest_ci_components }}" + nova: "{{ 'nova' in tempest_ci_components }}" + placement: "{{ 'placement' in tempest_ci_components }}" + swift: false + +_tempest_ci_test_patterns: >- + {{ + tempest_ci_test_patterns + if tempest_ci_mode == 'selective' + else [] + }} + _tempest_helm_values: endpoints: "{{ openstack_helm_endpoints | combine(_tempest_endpoint_values, recursive=True) }}" images: tags: "{{ atmosphere_images | vexxhost.atmosphere.openstack_helm_image_tags('tempest') }}" + manifests: + certificates: "{{ tempest_cluster_issuer_ca_enabled | bool }}" + tls: + identity_ca_only: "{{ tempest_cluster_issuer_ca_enabled | bool }}" jobs: run_tests: restartPolicy: Never @@ -24,20 +57,29 @@ _tempest_helm_values: labels: include_app_kubernetes_io: false conf: + script: >- + tempest run --config-file /etc/tempest/tempest.conf -w 4 --smoke{{ + ' --include-list /etc/tempest/test-whitelist' + if _tempest_ci_test_patterns | length > 0 + else '' + }} + whitelist: "{{ _tempest_ci_test_patterns }}" tempest: - service_available: - cinder: true - glance: true - horizon: true - neutron: true - nova: true - swift: true + service_available: >- + {{ + _tempest_full_service_availability + | combine( + _tempest_ci_service_availability + if tempest_ci_mode == 'selective' + else {} + ) + }} compute: endpoint_type: internal fixed_network_name: public dashboard: dashboard_url: "https://{{ openstack_helm_endpoints_horizon_api_host }}" - disable_ssl_certificate_validation: "{{ cluster_issuer_type == 'self-signed' }}" + disable_ssl_certificate_validation: false identity: v3_endpoint_type: internal image: @@ -61,6 +103,11 @@ _tempest_helm_values: build_timeout: 60 endpoint_type: internal reseller_admin_role: admin + secrets: + tls: + identity: + api: + internal: "{{ tempest_cluster_issuer_ca_secret_name }}" pvc: enabled: false diff --git a/test-playbooks/molecule/poll-chunk.yml b/test-playbooks/molecule/poll-chunk.yml new file mode 100644 index 0000000000..a4d0a9c6da --- /dev/null +++ b/test-playbooks/molecule/poll-chunk.yml @@ -0,0 +1,10 @@ +# Copyright (c) 2026 VEXXHOST, Inc. +# SPDX-License-Identifier: Apache-2.0 + +- name: Start progress monitor chunk for {{ _atmosphere_ci_phase }} + when: + - not (_atmosphere_ci_phase_result.finished | default(false) | bool) + - >- + (_atmosphere_ci_phase_poll_count | int) + < (_atmosphere_ci_phase_max_polls | int) + ansible.builtin.include_tasks: poll-command.yml diff --git a/test-playbooks/molecule/poll-command.yml b/test-playbooks/molecule/poll-command.yml new file mode 100644 index 0000000000..88fbce2f9d --- /dev/null +++ b/test-playbooks/molecule/poll-command.yml @@ -0,0 +1,105 @@ +# Copyright (c) 2026 VEXXHOST, Inc. +# SPDX-License-Identifier: Apache-2.0 + +- name: Wait before polling {{ _atmosphere_ci_phase }} + when: not (_atmosphere_ci_phase_result.finished | default(false) | bool) + run_once: true # noqa: run-once[task] + ansible.builtin.pause: + seconds: "{{ _atmosphere_ci_phase_poll_interval }}" + +- name: Count progress poll for {{ _atmosphere_ci_phase }} + when: not (_atmosphere_ci_phase_result.finished | default(false) | bool) + run_once: true # noqa: run-once[task] + ansible.builtin.set_fact: + _atmosphere_ci_phase_poll_count: >- + {{ (_atmosphere_ci_phase_poll_count | int) + 1 }} + +- name: Poll status for {{ _atmosphere_ci_phase }} + when: not (_atmosphere_ci_phase_result.finished | default(false) | bool) + run_once: true # noqa: run-once[task] + changed_when: false + failed_when: false + ansible.builtin.async_status: + jid: "{{ _atmosphere_ci_phase_started.ansible_job_id }}" + register: _atmosphere_ci_phase_result + +- name: Inspect live log for {{ _atmosphere_ci_phase }} + run_once: true # noqa: run-once[task] + ansible.builtin.stat: + path: "{{ _atmosphere_ci_phase_log }}" + register: _atmosphere_ci_phase_log_state + +- name: Calculate new output for {{ _atmosphere_ci_phase }} + run_once: true # noqa: run-once[task] + ansible.builtin.set_fact: + _atmosphere_ci_phase_log_bytes: >- + {{ + (_atmosphere_ci_phase_log_state.stat.size | default(0) | int) + - (_atmosphere_ci_phase_log_offset | int) + }} + +- name: Read new output for {{ _atmosphere_ci_phase }} + when: (_atmosphere_ci_phase_log_bytes | int) > 0 + run_once: true # noqa: run-once[task] + changed_when: false + ansible.builtin.shell: + cmd: >- + set -o pipefail; + tail + -c +{{ (_atmosphere_ci_phase_log_offset | int) + 1 }} + {{ _atmosphere_ci_phase_log | quote }} + | head -c {{ _atmosphere_ci_phase_log_bytes | int }} + executable: /bin/bash + register: _atmosphere_ci_phase_log_chunk + +- name: Track last task for {{ _atmosphere_ci_phase }} + when: + - (_atmosphere_ci_phase_log_bytes | int) > 0 + - >- + _atmosphere_ci_phase_log_chunk.stdout_lines + | default([]) + | select('match', '^TASK \[.*\]\s+\*+$') + | list + | length > 0 + run_once: true # noqa: run-once[task] + ansible.builtin.set_fact: + _atmosphere_ci_phase_last_task: >- + {{ + ( + _atmosphere_ci_phase_log_chunk.stdout_lines + | select('match', '^TASK \[.*\]\s+\*+$') + | list + | last + ) + | regex_replace('\s+\*+$', '') + }} + +- name: Advance live log offset for {{ _atmosphere_ci_phase }} + run_once: true # noqa: run-once[task] + ansible.builtin.set_fact: + _atmosphere_ci_phase_log_offset: >- + {{ _atmosphere_ci_phase_log_state.stat.size | default(0) }} + +- name: Report progress heartbeat for {{ _atmosphere_ci_phase }} + when: not (_atmosphere_ci_phase_result.finished | default(false) | bool) + run_once: true # noqa: run-once[task] + ansible.builtin.debug: + msg: |- + {{ _atmosphere_ci_phase }} is still running after {{ + (_atmosphere_ci_phase_poll_count | int) + * (_atmosphere_ci_phase_poll_interval | int) + }} seconds. + Last task: {{ _atmosphere_ci_phase_last_task }} + +- name: Continue monitoring {{ _atmosphere_ci_phase }} + when: + - not (_atmosphere_ci_phase_result.finished | default(false) | bool) + - >- + (_atmosphere_ci_phase_poll_count | int) + < (_atmosphere_ci_phase_max_polls | int) + - >- + ( + (_atmosphere_ci_phase_poll_count | int) + % (_atmosphere_ci_phase_poll_chunk_size | int) + ) != 0 + ansible.builtin.include_tasks: poll-command.yml diff --git a/test-playbooks/molecule/post.yml b/test-playbooks/molecule/post.yml new file mode 100644 index 0000000000..4f084673b8 --- /dev/null +++ b/test-playbooks/molecule/post.yml @@ -0,0 +1,29 @@ +# Copyright (c) 2026 VEXXHOST, Inc. +# SPDX-License-Identifier: Apache-2.0 + +- name: Publish selective Molecule plan + hosts: all + gather_facts: false + become: true + vars: + _atmosphere_ci_work_dir: "{{ zuul.project.src_dir }}" + tasks: + - name: Check for selective Molecule plan + ansible.builtin.stat: + path: "{{ _atmosphere_ci_work_dir }}/ci-plan.json" + register: _atmosphere_ci_plan + + - name: Create log directory + when: _atmosphere_ci_plan.stat.exists + ansible.builtin.file: + path: /tmp/logs + state: directory + mode: "0755" + + - name: Copy selective Molecule plan to logs + when: _atmosphere_ci_plan.stat.exists + ansible.builtin.copy: + src: "{{ _atmosphere_ci_work_dir }}/ci-plan.json" + dest: /tmp/logs/ci-plan-{{ atmosphere_ci_job }}.json + remote_src: true + mode: "0644" diff --git a/test-playbooks/molecule/prepare.yml b/test-playbooks/molecule/prepare.yml new file mode 100644 index 0000000000..7ebd421a45 --- /dev/null +++ b/test-playbooks/molecule/prepare.yml @@ -0,0 +1,45 @@ +# Copyright (c) 2026 VEXXHOST, Inc. +# SPDX-License-Identifier: Apache-2.0 + +- name: Prepare selective Molecule plan + hosts: all + gather_facts: false + tasks: + - name: Generate selective Molecule plan + run_once: true # noqa: run-once[task] + changed_when: false + ansible.builtin.command: + argv: + - uv + - run + - python + - -m + - atmosphere.ci.molecule_plan + - --config + - ci/molecule-plan.yaml + - plan + - --base + - "{{ atmosphere_ci_plan_base }}" + - --head + - "{{ atmosphere_ci_plan_head }}" + - --format + - json + - --output + - ci-plan.json + args: + chdir: "{{ zuul.project.src_dir }}" + + - name: Render selective Molecule plan + run_once: true # noqa: run-once[task] + changed_when: false + ansible.builtin.command: + argv: + - uv + - run + - python + - -m + - atmosphere.ci.molecule_plan + - render + - ci-plan.json + args: + chdir: "{{ zuul.project.src_dir }}" diff --git a/test-playbooks/molecule/run-command.yml b/test-playbooks/molecule/run-command.yml new file mode 100644 index 0000000000..995c780baa --- /dev/null +++ b/test-playbooks/molecule/run-command.yml @@ -0,0 +1,110 @@ +# Copyright (c) 2026 VEXXHOST, Inc. +# SPDX-License-Identifier: Apache-2.0 + +- name: Create Molecule log directory + run_once: true # noqa: run-once[task] + ansible.builtin.file: + path: /tmp/logs + state: directory + mode: "0755" + +- name: Resolve live Molecule log path + run_once: true # noqa: run-once[task] + ansible.builtin.set_fact: + _atmosphere_ci_phase_log: >- + /tmp/logs/molecule-{{ atmosphere_ci_job }}.log + +- name: Start {{ _atmosphere_ci_phase }} + run_once: true # noqa: run-once[task] + changed_when: true + ansible.builtin.shell: + cmd: >- + set -o pipefail; + {{ + _atmosphere_ci_phase_argv + | map('quote') + | join(' ') + }} + > {{ _atmosphere_ci_phase_log | quote }} 2>&1 + executable: /bin/bash + args: + chdir: "{{ zuul.project.src_dir }}" + environment: >- + {{ + _atmosphere_ci_phase_environment + | default({}) + | combine({'PYTHONUNBUFFERED': '1'}) + }} + async: "{{ _atmosphere_ci_phase_timeout }}" + poll: 0 + register: _atmosphere_ci_phase_started + +- name: Initialize progress monitor for {{ _atmosphere_ci_phase }} + run_once: true # noqa: run-once[task] + ansible.builtin.set_fact: + _atmosphere_ci_phase_result: "{{ _atmosphere_ci_phase_started }}" + _atmosphere_ci_phase_log_offset: 0 + _atmosphere_ci_phase_poll_count: 0 + _atmosphere_ci_phase_last_task: Waiting for Molecule output + _atmosphere_ci_phase_poll_interval: >- + {{ atmosphere_ci_progress_interval | default(20) }} + _atmosphere_ci_phase_max_polls: >- + {{ + ( + (_atmosphere_ci_phase_timeout | int) + // (atmosphere_ci_progress_interval | default(20) | int) + ) + + 1 + }} + +- name: Bound progress monitor recursion for {{ _atmosphere_ci_phase }} + run_once: true # noqa: run-once[task] + ansible.builtin.set_fact: + _atmosphere_ci_phase_poll_chunk_size: 100 + +- name: Calculate progress monitor chunks for {{ _atmosphere_ci_phase }} + run_once: true # noqa: run-once[task] + ansible.builtin.set_fact: + _atmosphere_ci_phase_poll_chunks: >- + {{ + ( + (_atmosphere_ci_phase_max_polls | int) + + (_atmosphere_ci_phase_poll_chunk_size | int) + - 1 + ) + // (_atmosphere_ci_phase_poll_chunk_size | int) + }} + +- name: Monitor {{ _atmosphere_ci_phase }} + ansible.builtin.include_tasks: poll-chunk.yml + loop: >- + {{ + range( + 1, + (_atmosphere_ci_phase_poll_chunks | int) + 1 + ) + | list + }} + loop_control: + label: "chunk {{ _atmosphere_ci_poll_chunk_number }}" + loop_var: _atmosphere_ci_poll_chunk_number + +- name: Clean up async state for {{ _atmosphere_ci_phase }} + run_once: true # noqa: run-once[task] + changed_when: false + ansible.builtin.async_status: + jid: "{{ _atmosphere_ci_phase_started.ansible_job_id }}" + mode: cleanup + +- name: Assert success for {{ _atmosphere_ci_phase }} + run_once: true # noqa: run-once[task] + ansible.builtin.assert: + that: + - _atmosphere_ci_phase_result.finished | default(false) | bool + - (_atmosphere_ci_phase_result.rc | default(1) | int) == 0 + fail_msg: >- + {{ _atmosphere_ci_phase }} failed with exit code + {{ _atmosphere_ci_phase_result.rc | default('unknown') }}. + Command stderr: + {{ _atmosphere_ci_phase_result.stderr | default('(none)', true) }} + Complete output is in {{ _atmosphere_ci_phase_log }}. diff --git a/test-playbooks/molecule/selective-run.yml b/test-playbooks/molecule/selective-run.yml new file mode 100644 index 0000000000..5e49373cb3 --- /dev/null +++ b/test-playbooks/molecule/selective-run.yml @@ -0,0 +1,161 @@ +# Copyright (c) 2026 VEXXHOST, Inc. +# SPDX-License-Identifier: Apache-2.0 + +- name: Run selected Molecule scenario + hosts: all + gather_facts: false + vars: + _atmosphere_ci_work_dir: "{{ zuul.project.src_dir }}" + tasks: + - name: Read selective Molecule plan + run_once: true # noqa: run-once[task] + ansible.builtin.slurp: + src: "{{ _atmosphere_ci_work_dir }}/ci-plan.json" + register: _atmosphere_ci_plan_file + + - name: Resolve this job decision + run_once: true # noqa: run-once[task] + ansible.builtin.set_fact: + _atmosphere_ci_plan: >- + {{ _atmosphere_ci_plan_file.content | b64decode | from_json }} + + - name: Assert this job exists in the plan + run_once: true # noqa: run-once[task] + ansible.builtin.assert: + that: + - atmosphere_ci_job in _atmosphere_ci_plan.job_decisions + fail_msg: >- + Selective CI policy has no decision for {{ atmosphere_ci_job }}. + + - name: Resolve selected scenario inputs + run_once: true # noqa: run-once[task] + ansible.builtin.set_fact: + _atmosphere_ci_decision: >- + {{ _atmosphere_ci_plan.job_decisions[atmosphere_ci_job] }} + + - name: Report this job decision + run_once: true # noqa: run-once[task] + ansible.builtin.debug: + msg: |- + Selective Molecule job: {{ atmosphere_ci_job }} + Decision: {{ 'RUN' if _atmosphere_ci_decision.run else 'SKIP' }} + Reason: {{ _atmosphere_ci_decision.reason }} + Scenario: {{ _atmosphere_ci_decision.scenario }} + Targets: {{ _atmosphere_ci_decision.targets | join(', ') | default('(none)', true) }} + Components: {{ _atmosphere_ci_decision.components | join(', ') | default('(full scenario)', true) }} + + - name: Check Molecule inventory directories + when: _atmosphere_ci_decision.run | bool + run_once: true # noqa: run-once[task] + ansible.builtin.stat: + path: "{{ item }}" + follow: true + loop: + - "{{ _atmosphere_ci_work_dir }}" + - >- + {{ _atmosphere_ci_work_dir }}/molecule/{{ + _atmosphere_ci_decision.scenario + }}/inventory + - >- + {{ _atmosphere_ci_work_dir }}/extensions/molecule/{{ + _atmosphere_ci_decision.scenario + }}/inventory + register: _atmosphere_ci_inventory_paths + + - name: Copy Zuul inventory for Molecule + when: + - _atmosphere_ci_decision.run | bool + - item.stat.isdir | default(false) + run_once: true # noqa: run-once[task] + ansible.builtin.copy: + src: "{{ zuul.executor.inventory_file }}" + dest: "{{ item.item }}/inventory.yaml" + mode: "0644" + loop: "{{ _atmosphere_ci_inventory_paths.results }}" + loop_control: + label: "{{ item.item }}" + + - name: Use private addresses in Molecule inventory + when: + - _atmosphere_ci_decision.run | bool + - item.stat.isdir | default(false) + run_once: true # noqa: run-once[task] + ansible.builtin.replace: + path: "{{ item.item }}/inventory.yaml" + regexp: '(^\s*ansible_host:\s*).*$' + replace: '\1"{% raw %}{{ nodepool.private_ipv4 }}{% endraw %}"' + loop: "{{ _atmosphere_ci_inventory_paths.results }}" + loop_control: + label: "{{ item.item }}" + + - name: Prepare Molecule command + when: _atmosphere_ci_decision.run | bool + run_once: true # noqa: run-once[task] + ansible.builtin.set_fact: + _atmosphere_ci_molecule_argv: >- + {{ + [ + 'uv', + 'run', + 'molecule', + 'test', + '--destroy', + 'never', + '-s', + _atmosphere_ci_decision.scenario + ] + + ( + [ + '--', + '--tags', + _atmosphere_ci_decision.ansible_tags | join(',') + ] + if ( + _atmosphere_ci_plan.mode == 'selective' + and _atmosphere_ci_decision.scenario == 'aio' + and _atmosphere_ci_decision.ansible_tags | length > 0 + ) + else [] + ) + }} + _atmosphere_ci_molecule_environment: >- + {{ + molecule_environment + | default({}) + | combine({ + 'ATMOSPHERE_CI_MODE': ( + _atmosphere_ci_plan.mode + if _atmosphere_ci_decision.scenario == 'aio' + else 'full' + ), + 'ATMOSPHERE_CI_COMPONENTS': + _atmosphere_ci_decision.components | join(','), + 'ATMOSPHERE_CI_VERIFICATION_PROFILES': + _atmosphere_ci_decision.verification_profiles | join(','), + 'ATMOSPHERE_CI_TEMPEST_INCLUDE': + _atmosphere_ci_decision.tempest_tests | to_json, + 'ATMOSPHERE_CI_RUN_STESTR': ( + _atmosphere_ci_plan.mode != 'selective' + or 'openvswitch' in _atmosphere_ci_decision.components + or 'ovn' in _atmosphere_ci_decision.components + ) | ternary('true', 'false'), + 'ATMOSPHERE_CI_RUN_TEMPEST': ( + _atmosphere_ci_plan.mode != 'selective' + or 'keystone' in _atmosphere_ci_decision.components + ) | ternary('true', 'false'), + 'MOLECULE_ANSIBLE_ARGS_STRICT_MODE': '1' + }) + }} + + - name: Run standard Molecule lifecycle with progress output + when: _atmosphere_ci_decision.run | bool + ansible.builtin.include_tasks: run-command.yml + vars: + _atmosphere_ci_phase: >- + Molecule {{ _atmosphere_ci_decision.scenario }} + ({{ atmosphere_ci_job }}) + _atmosphere_ci_phase_argv: "{{ _atmosphere_ci_molecule_argv }}" + _atmosphere_ci_phase_environment: >- + {{ _atmosphere_ci_molecule_environment }} + _atmosphere_ci_phase_timeout: >- + {{ atmosphere_ci_molecule_timeout | default(7000) }} diff --git a/tests/unit/ci/__init__.py b/tests/unit/ci/__init__.py new file mode 100644 index 0000000000..01b9f6f84c --- /dev/null +++ b/tests/unit/ci/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 VEXXHOST, Inc. +# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/unit/ci/test_molecule_plan.py b/tests/unit/ci/test_molecule_plan.py new file mode 100644 index 0000000000..b794134792 --- /dev/null +++ b/tests/unit/ci/test_molecule_plan.py @@ -0,0 +1,404 @@ +# Copyright (c) 2026 VEXXHOST, Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import copy +from pathlib import Path + +import pytest +import yaml + +from atmosphere.ci.molecule_plan import ( + Change, + Planner, + PolicyError, + parse_changes, + render_plan, +) + +POLICY_PATH = Path(__file__).parents[3] / "ci" / "molecule-plan.yaml" + + +@pytest.fixture +def planner() -> Planner: + return Planner.load(POLICY_PATH) + + +def plan_path(planner: Planner, path: str) -> dict: + return planner.plan([Change(status="M", path=path)]) + + +def components(plan: dict, job: str = "aio-openvswitch") -> set[str]: + return set(plan["job_decisions"][job]["components"]) + + +def test_policy_is_valid(planner: Planner) -> None: + assert planner.policy["version"] == 1 + + +def test_keystone_uses_small_sequential_closure(planner: Planner) -> None: + plan = plan_path(planner, "roles/keystone/tasks/main.yml") + + assert plan["mode"] == "selective" + assert plan["targets"] == ["keystone"] + assert plan["job_decisions"]["aio-openvswitch"]["run"] is True + assert components(plan) >= { + "ceph", + "cluster-issuer", + "csi", + "keycloak", + "keystone", + "kubernetes", + "percona-xtradb-cluster", + } + assert components(plan).isdisjoint( + {"glance", "magnum", "manila", "neutron", "nova"} + ) + + +def test_glance_includes_storage_and_identity_only( + planner: Planner, +) -> None: + plan = plan_path(planner, "charts/glance/templates/deployment-api.yaml") + decision = plan["job_decisions"]["aio-openvswitch"] + + assert components(plan) >= { + "ceph", + "ceph-provisioners", + "glance", + "keystone", + } + assert components(plan).isdisjoint( + {"cinder", "heat", "magnum", "manila", "neutron", "nova"} + ) + assert decision["tempest_tests"] == [r"^tempest\.api\.image\."] + + +def test_manila_includes_functional_dependencies( + planner: Planner, +) -> None: + plan = plan_path(planner, "charts/patches/manila/0001-example.patch") + decision = plan["job_decisions"]["aio-openvswitch"] + + assert components(plan) >= { + "ceph", + "cinder", + "glance", + "keystone", + "manila", + "neutron", + "nova", + "placement", + } + assert components(plan).isdisjoint( + {"heat", "horizon", "magnum", "openstack-exporter"} + ) + assert decision["tempest_tests"] == [] + + +def test_unmapped_tempest_target_disables_filter_for_combined_change( + planner: Planner, +) -> None: + plan = planner.plan( + [ + Change(status="M", path="roles/glance/tasks/main.yml"), + Change(status="M", path="roles/manila/tasks/main.yml"), + ] + ) + + assert plan["job_decisions"]["aio-openvswitch"]["tempest_tests"] == [] + + +def test_magnum_uses_broad_openstack_environment( + planner: Planner, +) -> None: + plan = plan_path(planner, "roles/magnum/tasks/main.yml") + + assert components(plan) >= { + "barbican", + "cinder", + "glance", + "heat", + "magnum", + "neutron", + "nova", + "octavia", + } + assert components(plan).isdisjoint({"horizon", "manila", "openstack-exporter"}) + + +def test_horizon_includes_compute_for_dashboard_login( + planner: Planner, +) -> None: + plan = plan_path(planner, "roles/horizon/tasks/main.yml") + + assert components(plan) >= { + "glance", + "horizon", + "keystone", + "neutron", + "nova", + "placement", + } + + +def test_nova_adds_neutron_as_a_functional_test_requirement( + planner: Planner, +) -> None: + plan = plan_path(planner, "roles/nova/tasks/main.yml") + + assert components(plan) >= { + "coredns", + "glance", + "neutron", + "nova", + "openvswitch", + "placement", + } + + +def test_neutron_creates_backend_specific_jobs( + planner: Planner, +) -> None: + plan = plan_path(planner, "roles/neutron/tasks/main.yml") + ovs = plan["job_decisions"]["aio-openvswitch"] + ovn = plan["job_decisions"]["aio-ovn"] + + assert ovs["run"] is True + assert ovn["run"] is True + assert "coredns" in ovs["components"] + assert "ovn" not in ovs["components"] + assert "ovn" in ovn["components"] + assert "coredns" not in ovn["components"] + assert ovs["tempest_tests"] == ovn["tempest_tests"] + assert r"^neutron_tempest_plugin\." in ovs["tempest_tests"] + + +def test_csi_provider_paths_select_only_related_scenario( + planner: Planner, +) -> None: + local = plan_path(planner, "roles/local_path_provisioner/tasks/main.yml") + rbd = plan_path(planner, "roles/ceph_csi_rbd/tasks/main.yml") + + assert local["job_decisions"]["csi-local-path-provisioner"]["run"] + assert not local["job_decisions"]["csi-rbd"]["run"] + assert not local["job_decisions"]["aio-openvswitch"]["run"] + assert rbd["job_decisions"]["csi-rbd"]["run"] + assert not rbd["job_decisions"]["csi-local-path-provisioner"]["run"] + + +def test_keycloak_change_uses_focused_scenario( + planner: Planner, +) -> None: + plan = plan_path(planner, "charts/keycloak/templates/statefulset.yaml") + + assert plan["job_decisions"]["keycloak"]["run"] + assert not plan["job_decisions"]["aio-openvswitch"]["run"] + assert not plan["job_decisions"]["aio-ovn"]["run"] + + +def test_ignored_path_is_noop(planner: Planner) -> None: + plan = plan_path(planner, "doc/source/deploy/index.rst") + + assert plan["mode"] == "noop" + assert not any(decision["run"] for decision in plan["job_decisions"].values()) + + +def test_unknown_runtime_path_falls_back_to_every_job( + planner: Planner, +) -> None: + plan = plan_path(planner, "new-runtime-area/config.yaml") + + assert plan["mode"] == "full" + assert all(decision["run"] for decision in plan["job_decisions"].values()) + + +def test_empty_change_list_falls_back_to_every_job( + planner: Planner, +) -> None: + plan = planner.plan([]) + + assert plan["mode"] == "full" + assert all(decision["run"] for decision in plan["job_decisions"].values()) + + +def test_rename_evaluates_old_and_new_paths(planner: Planner) -> None: + plan = planner.plan( + [ + Change( + status="R100", + previous_path="roles/keystone/tasks/old.yml", + path="roles/manila/tasks/new.yml", + ) + ] + ) + + assert plan["targets"] == ["keystone", "manila"] + + +def test_parse_name_status_preserves_rename() -> None: + changes = parse_changes( + [ + "M\troles/keystone/tasks/main.yml\n", + "R100\troles/manila/tasks/old.yml\t" "roles/manila/tasks/new.yml\n", + ] + ) + + assert len(changes) == 2 + assert changes[1].previous_path == "roles/manila/tasks/old.yml" + + +def test_component_graph_cycle_is_rejected() -> None: + policy = yaml.safe_load(POLICY_PATH.read_text(encoding="utf-8")) + invalid = copy.deepcopy(policy) + invalid["components"]["ceph"]["requires"] = ["keystone"] + + with pytest.raises(PolicyError, match="cycle"): + Planner(invalid) + + +def test_duplicate_policy_values_are_rejected() -> None: + policy = yaml.safe_load(POLICY_PATH.read_text(encoding="utf-8")) + invalid = copy.deepcopy(policy) + invalid["components"]["csi"]["jobs"].append("csi-rbd") + + with pytest.raises( + PolicyError, + match=r"components\.csi\.jobs contains duplicate values: csi-rbd", + ): + Planner(invalid) + + +def test_malformed_tempest_test_patterns_are_rejected() -> None: + policy = yaml.safe_load(POLICY_PATH.read_text(encoding="utf-8")) + invalid = copy.deepcopy(policy) + invalid["components"]["keystone"]["tempest_tests"] = "tempest.api.identity" + + with pytest.raises( + PolicyError, + match=r"components\.keystone\.tempest_tests must be a list of strings", + ): + Planner(invalid) + + +def test_invalid_tempest_test_regular_expression_is_rejected() -> None: + policy = yaml.safe_load(POLICY_PATH.read_text(encoding="utf-8")) + invalid = copy.deepcopy(policy) + invalid["components"]["keystone"]["tempest_tests"] = ["["] + + with pytest.raises( + PolicyError, + match=r"components\.keystone\.tempest_tests\[0\] " + r"is not a valid regular expression", + ): + Planner(invalid) + + +def test_every_declared_role_maps_to_its_component( + planner: Planner, +) -> None: + for component_name, policy in planner.components.items(): + for role in policy.get("roles", [component_name.replace("-", "_")]): + plan = plan_path(planner, f"roles/{role}/tasks/main.yml") + assert component_name in plan["targets"] + + +def test_every_declared_chart_and_patch_maps_to_its_component( + planner: Planner, +) -> None: + for component_name, policy in planner.components.items(): + for chart in policy.get("charts", [component_name]): + for chart_root in ("charts", "charts/patches"): + plan = plan_path( + planner, + f"{chart_root}/{chart}/templates/deployment.yaml", + ) + assert component_name in plan["targets"] + + +def test_every_role_is_explicitly_classified(planner: Planner) -> None: + repository = POLICY_PATH.parents[1] + + for role_path in (repository / "roles").iterdir(): + if not role_path.is_dir() or not (role_path / "tasks").exists(): + continue + plan = plan_path(planner, f"roles/{role_path.name}/tasks/main.yml") + assert not any( + match["rule"] == "unclassified" for match in plan["matches"] + ), role_path.name + + +def test_every_chart_and_patch_is_explicitly_classified( + planner: Planner, +) -> None: + repository = POLICY_PATH.parents[1] + + for chart_path in (repository / "charts").glob("*/Chart.yaml"): + chart = chart_path.parent.name + for path in ( + f"charts/{chart}/Chart.yaml", + f"charts/patches/{chart}/0001-example.patch", + ): + plan = plan_path(planner, path) + assert not any( + match["rule"] == "unclassified" for match in plan["matches"] + ), path + + +def test_every_sequential_aio_role_has_a_component( + planner: Planner, +) -> None: + configured_roles = { + role + for component_name, policy in planner.components.items() + for role in policy.get("roles", [component_name.replace("-", "_")]) + } + deployed_roles = set() + for playbook_name in ( + "infrastructure.yml", + "monitoring.yml", + "openstack.yml", + ): + playbook = yaml.safe_load( + (POLICY_PATH.parents[1] / "playbooks" / playbook_name).read_text( + encoding="utf-8" + ) + ) + for play in playbook: + for role in play.get("roles", []): + deployed_roles.add(role["role"] if isinstance(role, dict) else role) + + assert deployed_roles <= configured_roles + + +def test_selective_ci_uses_main_sequential_molecule_flow() -> None: + repository = POLICY_PATH.parents[1] + converge = (repository / "molecule" / "aio" / "converge.yml").read_text( + encoding="utf-8" + ) + openstack = (repository / "playbooks" / "openstack.yml").read_text(encoding="utf-8") + runner = ( + repository / "test-playbooks" / "molecule" / "selective-run.yml" + ).read_text(encoding="utf-8") + policy = POLICY_PATH.read_text(encoding="utf-8") + + assert "vexxhost.atmosphere.ceph" in converge + assert "vexxhost.atmosphere.kubernetes" in converge + assert "vexxhost.atmosphere.openstack" in converge + assert "CEPH_CONTAINER_IMAGE" in openstack + assert "CEPH_CONTAINER_BINARY" in openstack + assert "'molecule'," in runner + assert "'test'," in runner + assert "go build" not in converge + assert "./bin/atmosphere" not in runner + assert "dependency_options" not in policy + + +def test_text_output_is_compact_and_readable(planner: Planner) -> None: + output = render_plan(plan_path(planner, "roles/glance/tasks/main.yml")) + + assert "Selective Molecule plan: selective" in output + assert "RUN aio-openvswitch" in output + assert "SKIP aio-ovn" in output + assert "components:" in output diff --git a/uv.lock b/uv.lock index 8c6c8032b4..0e82617981 100644 --- a/uv.lock +++ b/uv.lock @@ -83,6 +83,7 @@ dependencies = [ { name = "netaddr" }, { name = "openstacksdk" }, { name = "pydantic" }, + { name = "pyyaml" }, { name = "rjsonnet" }, { name = "typing-extensions" }, ] @@ -92,6 +93,7 @@ dev = [ { name = "kubernetes" }, { name = "molecule" }, { name = "oslotest" }, + { name = "pytest" }, { name = "stestr" }, { name = "testscenarios" }, ] @@ -109,6 +111,7 @@ requires-dist = [ { name = "netaddr", specifier = ">=0.8.0" }, { name = "openstacksdk", specifier = ">1" }, { name = "pydantic", specifier = ">=2" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "rjsonnet", specifier = ">=0.5.2" }, { name = "typing-extensions", specifier = ">=4.0" }, ] @@ -118,6 +121,7 @@ dev = [ { name = "kubernetes" }, { name = "molecule", specifier = ">=25.11.0" }, { name = "oslotest", specifier = ">=5.0.1" }, + { name = "pytest" }, { name = "stestr", specifier = ">=4.2.0" }, { name = "testscenarios", specifier = ">=0.5.0" }, ] @@ -469,6 +473,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/67/aecd1d435dbbdcea21a197d708e9ff0bcc7306c2847c6c87cc1a91e2cca4/enrich-1.2.7-py3-none-any.whl", hash = "sha256:f29b2c8c124b4dbd7c975ab5c3568f6c7a47938ea3b7d2106c8a3bd346545e4f", size = 8717, upload-time = "2022-01-10T15:30:32.723Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "fixtures" version = "4.2.5" @@ -517,6 +533,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "iso8601" version = "2.1.0" @@ -1193,6 +1218,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +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 = "python-dateutil" version = "2.9.0.post0" @@ -1662,6 +1705,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/76/17eb3cfd467e7a53f2727e7a879a77c514970a12e23e3ac12e40ad3e0ac4/testtools-2.7.2-py3-none-any.whl", hash = "sha256:11712e29cebbe92187c3ad47ace5c32f91e1bb7a9f1ac5e8684c2b01eaa6fd2d", size = 179922, upload-time = "2024-06-10T13:10:40.452Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "tomlkit" version = "0.13.2" From 356f2a98b3d668fe0886b7a9c697443db63fab2d Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Tue, 28 Jul 2026 18:17:17 +0300 Subject: [PATCH 02/27] fix(rabbitmq): retry transient cluster deployment failures Signed-off-by: Tadas Sutkaitis --- roles/rabbitmq/tasks/main.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/roles/rabbitmq/tasks/main.yml b/roles/rabbitmq/tasks/main.yml index a076ac1197..e13c64e873 100644 --- a/roles/rabbitmq/tasks/main.yml +++ b/roles/rabbitmq/tasks/main.yml @@ -62,3 +62,7 @@ wait_condition: type: ClusterAvailable status: "True" + register: _rabbitmq_cluster_deployment + retries: 3 + delay: 5 + until: _rabbitmq_cluster_deployment is not failed From f9d4ed6652958362199a5bc4330a082b03b9f7e9 Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Tue, 28 Jul 2026 22:53:50 +0300 Subject: [PATCH 03/27] fix(ci): allow full AIO verification to finish Signed-off-by: Tadas Sutkaitis --- .zuul.yaml | 2 ++ doc/source/deploy/selective-ci.rst | 4 +++- test-playbooks/molecule/run-command.yml | 2 ++ tests/unit/ci/test_molecule_plan.py | 18 ++++++++++++++++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.zuul.yaml b/.zuul.yaml index a748e17b64..ec3b413399 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -9,6 +9,7 @@ - test-playbooks/molecule/post.yml vars: atmosphere_ci_job: aio-openvswitch + atmosphere_ci_molecule_timeout: 9000 csi_driver: rbd keycloak_helm_timeout: 15m0s neutron_helm_timeout: 10m0s @@ -26,6 +27,7 @@ - test-playbooks/molecule/post.yml vars: atmosphere_ci_job: aio-ovn + atmosphere_ci_molecule_timeout: 9000 csi_driver: rbd keycloak_helm_timeout: 15m0s neutron_helm_timeout: 10m0s diff --git a/doc/source/deploy/selective-ci.rst b/doc/source/deploy/selective-ci.rst index 22b24c7c2a..37dca86318 100644 --- a/doc/source/deploy/selective-ci.rst +++ b/doc/source/deploy/selective-ci.rst @@ -85,4 +85,6 @@ moving change classification into trusted Zuul configuration. The selective AIO jobs allow fifteen minutes for Keycloak and ten minutes for the Nova, Neutron, and Octavia Helm operations. Clean database migrations and initial service rollouts can exceed their normal timeouts on a busy test node -even when they complete successfully. +even when they complete successfully. The complete AIO Molecule lifecycle has +a 150-minute command limit so full fallback runs have enough time to finish +idempotence and verification. diff --git a/test-playbooks/molecule/run-command.yml b/test-playbooks/molecule/run-command.yml index 995c780baa..b3f100c230 100644 --- a/test-playbooks/molecule/run-command.yml +++ b/test-playbooks/molecule/run-command.yml @@ -105,6 +105,8 @@ fail_msg: >- {{ _atmosphere_ci_phase }} failed with exit code {{ _atmosphere_ci_phase_result.rc | default('unknown') }}. + Status: + {{ _atmosphere_ci_phase_result.msg | default('(none)', true) }}. Command stderr: {{ _atmosphere_ci_phase_result.stderr | default('(none)', true) }} Complete output is in {{ _atmosphere_ci_phase_log }}. diff --git a/tests/unit/ci/test_molecule_plan.py b/tests/unit/ci/test_molecule_plan.py index b794134792..6b04d3e4f3 100644 --- a/tests/unit/ci/test_molecule_plan.py +++ b/tests/unit/ci/test_molecule_plan.py @@ -395,6 +395,24 @@ def test_selective_ci_uses_main_sequential_molecule_flow() -> None: assert "dependency_options" not in policy +def test_full_aio_jobs_have_timeout_headroom() -> None: + repository = POLICY_PATH.parents[1] + zuul_config = yaml.safe_load( + (repository / ".zuul.yaml").read_text(encoding="utf-8") + ) + jobs = { + item["job"]["name"]: item["job"] + for item in zuul_config + if "job" in item + } + + for job_name in ( + "atmosphere-molecule-aio-openvswitch-selective", + "atmosphere-molecule-aio-ovn-selective", + ): + assert jobs[job_name]["vars"]["atmosphere_ci_molecule_timeout"] == 9000 + + def test_text_output_is_compact_and_readable(planner: Planner) -> None: output = render_plan(plan_path(planner, "roles/glance/tasks/main.yml")) From f173c9e35146257115154552c7f580475a8dfc40 Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Wed, 29 Jul 2026 10:04:18 +0300 Subject: [PATCH 04/27] ci: format selective Molecule test Signed-off-by: Tadas Sutkaitis --- tests/unit/ci/test_molecule_plan.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/unit/ci/test_molecule_plan.py b/tests/unit/ci/test_molecule_plan.py index 6b04d3e4f3..4f531f8a88 100644 --- a/tests/unit/ci/test_molecule_plan.py +++ b/tests/unit/ci/test_molecule_plan.py @@ -400,11 +400,7 @@ def test_full_aio_jobs_have_timeout_headroom() -> None: zuul_config = yaml.safe_load( (repository / ".zuul.yaml").read_text(encoding="utf-8") ) - jobs = { - item["job"]["name"]: item["job"] - for item in zuul_config - if "job" in item - } + jobs = {item["job"]["name"]: item["job"] for item in zuul_config if "job" in item} for job_name in ( "atmosphere-molecule-aio-openvswitch-selective", From c52f923cf26cc7d57edbf04ab6304d1adbe22078 Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Wed, 29 Jul 2026 10:36:58 +0300 Subject: [PATCH 05/27] fix(ci): isolate dependent change plans Signed-off-by: Tadas Sutkaitis --- .zuul.yaml | 4 ++-- doc/source/deploy/selective-ci.rst | 6 ++++++ tests/unit/ci/test_molecule_plan.py | 11 +++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.zuul.yaml b/.zuul.yaml index ec3b413399..d59d3aaf3c 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -73,8 +73,8 @@ - project: vars: - atmosphere_ci_plan_base: "origin/{{ zuul.branch }}" - atmosphere_ci_plan_head: "{{ zuul.patchset | default('HEAD') }}" + atmosphere_ci_plan_base: HEAD^1 + atmosphere_ci_plan_head: HEAD check: jobs: - atmosphere-molecule-aio-openvswitch-selective diff --git a/doc/source/deploy/selective-ci.rst b/doc/source/deploy/selective-ci.rst index 37dca86318..ec800939bc 100644 --- a/doc/source/deploy/selective-ci.rst +++ b/doc/source/deploy/selective-ci.rst @@ -59,6 +59,12 @@ release-note-only changes produce a no-op plan. A path which matches no rule or component produces a full plan so a new runtime area cannot silently lose test coverage. +In Zuul, the planner compares the speculative merge commit at ``HEAD`` with its +first parent at ``HEAD^1``. For an ordinary pull request, the first parent is +the target branch. For a pull request with ``Depends-On`` changes, it also +contains those dependencies, so only the current pull request selects test +targets. + During selective AIO verification, Tempest receives the union of ``tempest_tests`` for the changed components. The expressions are passed through Tempest's ``include-list`` support and combined with ``--smoke``. A diff --git a/tests/unit/ci/test_molecule_plan.py b/tests/unit/ci/test_molecule_plan.py index 4f531f8a88..03529a379d 100644 --- a/tests/unit/ci/test_molecule_plan.py +++ b/tests/unit/ci/test_molecule_plan.py @@ -409,6 +409,17 @@ def test_full_aio_jobs_have_timeout_headroom() -> None: assert jobs[job_name]["vars"]["atmosphere_ci_molecule_timeout"] == 9000 +def test_zuul_plan_uses_speculative_parent() -> None: + repository = POLICY_PATH.parents[1] + zuul_config = yaml.safe_load( + (repository / ".zuul.yaml").read_text(encoding="utf-8") + ) + project = next(item["project"] for item in zuul_config if "project" in item) + + assert project["vars"]["atmosphere_ci_plan_base"] == "HEAD^1" + assert project["vars"]["atmosphere_ci_plan_head"] == "HEAD" + + def test_text_output_is_compact_and_readable(planner: Planner) -> None: output = render_plan(plan_path(planner, "roles/glance/tasks/main.yml")) From 476a11014040b4485cb95bcc35c8744e384dd4f5 Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Wed, 29 Jul 2026 16:13:12 +0300 Subject: [PATCH 06/27] fix(ci): avoid unrelated selective test work Signed-off-by: Tadas Sutkaitis --- .zuul.yaml | 200 ++++++++++++++++++++++++++++ atmosphere/ci/molecule_plan.py | 128 +++++++++++++++++- ci/molecule-plan.yaml | 7 +- doc/source/deploy/selective-ci.rst | 12 +- roles/tempest/tasks/main.yml | 2 + roles/tempest/vars/main.yml | 22 ++- tests/unit/ci/test_molecule_plan.py | 98 ++++++++++++++ 7 files changed, 456 insertions(+), 13 deletions(-) diff --git a/.zuul.yaml b/.zuul.yaml index d59d3aaf3c..b54247a2c6 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -1,6 +1,37 @@ - job: name: atmosphere-molecule-aio-openvswitch-selective parent: atmosphere-molecule-aio-openvswitch + irrelevant-files: + - '^playbooks/csi\.yml$' + - '^molecule/csi/.*$' + - '^molecule/keycloak/.*$' + - '^roles/local_path_provisioner/.*$' + - '^charts/local-path-provisioner/.*$' + - '^charts/patches/local-path-provisioner/.*$' + - '^roles/ceph_csi_rbd/.*$' + - '^charts/ceph-csi-rbd/.*$' + - '^charts/patches/ceph-csi-rbd/.*$' + - '^\.agents/.*$' + - '^\.github/.*$' + - '^\.gitignore$' + - '^\.gitreview$' + - '^\.pre-commit-config\.yaml$' + - '^\.vale\.ini$' + - '^AGENTS\.md$' + - '^CLAUDE\.md$' + - '^CODEOWNERS$' + - '^LICENSE$' + - '^README\.md$' + - '^doc/.*$' + - '^releasenotes/.*$' + - '^reno\.yaml$' + - '^renovate\.json$' + - '^roles/(cert_manager|cluster_issuer|csi|frr_k8s|hpe_nimble_csi|ibm_block_csi_driver)/.*$' + - '^roles/(ingress_nginx|keycloak|ovn|percona_xtradb_cluster|percona_xtradb_cluster_operator)/.*$' + - '^roles/(portworx|powerstore_csi|rabbitmq_cluster_operator|storpool_csi)/.*$' + - '^charts/(patches/)?(cert-manager|cert-manager-webhook-infoblox-wapi|frr-k8s)/.*$' + - '^charts/(patches/)?(godaddy-webhook|hpe-csi-driver|ingress-nginx|keycloak|ovn|pxc-db)/.*$' + - '^charts/(patches/)?(pxc-operator|rabbitmq-cluster-operator)/.*$' pre-run: - test-playbooks/molecule/prepare.yml run: @@ -19,6 +50,53 @@ - job: name: atmosphere-molecule-aio-ovn-selective parent: atmosphere-molecule-aio-ovn + irrelevant-files: + - '^playbooks/ceph\.yml$' + - '^playbooks/csi\.yml$' + - '^molecule/csi/.*$' + - '^molecule/keycloak/.*$' + - '^roles/local_path_provisioner/.*$' + - '^charts/local-path-provisioner/.*$' + - '^charts/patches/local-path-provisioner/.*$' + - '^roles/ceph_csi_rbd/.*$' + - '^charts/ceph-csi-rbd/.*$' + - '^charts/patches/ceph-csi-rbd/.*$' + - '^Cargo\.lock$' + - '^Cargo\.toml$' + - '^crates/ovsinit/.*$' + - '^\.agents/.*$' + - '^\.github/.*$' + - '^\.gitignore$' + - '^\.gitreview$' + - '^\.pre-commit-config\.yaml$' + - '^\.vale\.ini$' + - '^AGENTS\.md$' + - '^CLAUDE\.md$' + - '^CODEOWNERS$' + - '^LICENSE$' + - '^README\.md$' + - '^doc/.*$' + - '^releasenotes/.*$' + - '^reno\.yaml$' + - '^renovate\.json$' + - '^roles/(barbican|ceph_provisioners|cert_manager|cinder|cluster_issuer|coredns|csi|glance)/.*$' + - '^roles/(goldpinger|heat|horizon|hpe_nimble_csi|ibm_block_csi_driver|ingress_nginx)/.*$' + - '^roles/(ipmi_exporter|iscsi|keepalived|keycloak|keystone|kube_prometheus_stack|libvirt|loki)/.*$' + - '^roles/(lpfc|magnum|manila|memcached|multipathd|node_feature_discovery|nova|octavia)/.*$' + - '^roles/(openstack_cli|openstack_exporter|openvswitch|percona_xtradb_cluster)/.*$' + - '^roles/(percona_xtradb_cluster_operator|placement|portworx|powerstore_csi)/.*$' + - '^roles/(prometheus_pushgateway|rabbitmq_cluster_operator|rook_ceph|rook_ceph_cluster)/.*$' + - '^roles/(smartctl_exporter|staffeln|storpool_csi|udev|valkey|vector)/.*$' + - '^charts/(patches/)?(barbican|ceph-provisioners|cert-manager)/.*$' + - '^charts/(patches/)?(cert-manager-webhook-infoblox-wapi|cinder|coredns|glance)/.*$' + - '^charts/(patches/)?(godaddy-webhook|goldpinger|heat|horizon|hpe-csi-driver)/.*$' + - '^charts/(patches/)?(ingress-nginx|keepalived|keycloak|keystone|kube-prometheus-stack)/.*$' + - '^charts/(patches/)?(libvirt|loki|magnum|manila|memcached|node-feature-discovery|nova)/.*$' + - '^charts/(patches/)?(octavia|openstack-exporter|openvswitch|placement)/.*$' + - '^charts/(patches/)?(prometheus-ipmi-exporter|prometheus-pushgateway)/.*$' + - '^charts/(patches/)?(prometheus-smartctl-exporter|pxc-db|pxc-operator)/.*$' + - '^charts/(patches/)?(rabbitmq-cluster-operator|rook-ceph|rook-ceph-cluster|staffeln)/.*$' + - '^charts/(patches/)?(valkey|vector)/.*$' pre-run: - test-playbooks/molecule/prepare.yml run: @@ -37,6 +115,47 @@ - job: name: atmosphere-molecule-csi-local-path-provisioner-selective parent: atmosphere-molecule-csi-local-path-provisioner + irrelevant-files: + - '^playbooks/ceph\.yml$' + - '^molecule/keycloak/.*$' + - '^roles/ceph_csi_rbd/.*$' + - '^charts/ceph-csi-rbd/.*$' + - '^charts/patches/ceph-csi-rbd/.*$' + - '^Cargo\.lock$' + - '^Cargo\.toml$' + - '^crates/ovsinit/.*$' + - '^\.agents/.*$' + - '^\.github/.*$' + - '^\.gitignore$' + - '^\.gitreview$' + - '^\.pre-commit-config\.yaml$' + - '^\.vale\.ini$' + - '^AGENTS\.md$' + - '^CLAUDE\.md$' + - '^CODEOWNERS$' + - '^LICENSE$' + - '^README\.md$' + - '^doc/.*$' + - '^releasenotes/.*$' + - '^reno\.yaml$' + - '^renovate\.json$' + - '^roles/(barbican|ceph_provisioners|cert_manager|cinder|cluster_issuer|coredns|frr_k8s|glance)/.*$' + - '^roles/(goldpinger|heat|horizon|ingress_nginx|ipmi_exporter|iscsi|keepalived|keycloak)/.*$' + - '^roles/(keystone|kube_prometheus_stack|libvirt|loki|lpfc|magnum|manila|memcached|multipathd)/.*$' + - '^roles/(neutron|node_feature_discovery|nova|octavia|openstack_cli|openstack_exporter)/.*$' + - '^roles/(openvswitch|ovn|percona_xtradb_cluster|percona_xtradb_cluster_operator|placement)/.*$' + - '^roles/(prometheus_pushgateway|rabbitmq_cluster_operator|rook_ceph|rook_ceph_cluster)/.*$' + - '^roles/(smartctl_exporter|staffeln|udev|valkey|vector)/.*$' + - '^charts/(patches/)?(barbican|ceph-provisioners|cert-manager)/.*$' + - '^charts/(patches/)?(cert-manager-webhook-infoblox-wapi|cinder|coredns|frr-k8s|glance)/.*$' + - '^charts/(patches/)?(godaddy-webhook|goldpinger|heat|horizon|ingress-nginx|keepalived)/.*$' + - '^charts/(patches/)?(keycloak|keystone|kube-prometheus-stack|libvirt|loki|magnum|manila)/.*$' + - '^charts/(patches/)?(memcached|neutron|node-feature-discovery|nova|octavia)/.*$' + - '^charts/(patches/)?(openstack-exporter|openvswitch|ovn|placement)/.*$' + - '^charts/(patches/)?(prometheus-ipmi-exporter|prometheus-pushgateway)/.*$' + - '^charts/(patches/)?(prometheus-smartctl-exporter|pxc-db|pxc-operator)/.*$' + - '^charts/(patches/)?(rabbitmq-cluster-operator|rook-ceph|rook-ceph-cluster|staffeln)/.*$' + - '^charts/(patches/)?(valkey|vector)/.*$' pre-run: - test-playbooks/molecule/prepare.yml run: @@ -49,6 +168,46 @@ - job: name: atmosphere-molecule-csi-rbd-selective parent: atmosphere-molecule-csi-rbd + irrelevant-files: + - '^molecule/keycloak/.*$' + - '^roles/local_path_provisioner/.*$' + - '^charts/local-path-provisioner/.*$' + - '^charts/patches/local-path-provisioner/.*$' + - '^Cargo\.lock$' + - '^Cargo\.toml$' + - '^crates/ovsinit/.*$' + - '^\.agents/.*$' + - '^\.github/.*$' + - '^\.gitignore$' + - '^\.gitreview$' + - '^\.pre-commit-config\.yaml$' + - '^\.vale\.ini$' + - '^AGENTS\.md$' + - '^CLAUDE\.md$' + - '^CODEOWNERS$' + - '^LICENSE$' + - '^README\.md$' + - '^doc/.*$' + - '^releasenotes/.*$' + - '^reno\.yaml$' + - '^renovate\.json$' + - '^roles/(barbican|ceph_provisioners|cert_manager|cinder|cluster_issuer|coredns|frr_k8s|glance)/.*$' + - '^roles/(goldpinger|heat|horizon|ingress_nginx|ipmi_exporter|iscsi|keepalived|keycloak)/.*$' + - '^roles/(keystone|kube_prometheus_stack|libvirt|loki|lpfc|magnum|manila|memcached|multipathd)/.*$' + - '^roles/(neutron|node_feature_discovery|nova|octavia|openstack_cli|openstack_exporter)/.*$' + - '^roles/(openvswitch|ovn|percona_xtradb_cluster|percona_xtradb_cluster_operator|placement)/.*$' + - '^roles/(prometheus_pushgateway|rabbitmq_cluster_operator|rook_ceph|rook_ceph_cluster)/.*$' + - '^roles/(smartctl_exporter|staffeln|udev|valkey|vector)/.*$' + - '^charts/(patches/)?(barbican|ceph-provisioners|cert-manager)/.*$' + - '^charts/(patches/)?(cert-manager-webhook-infoblox-wapi|cinder|coredns|frr-k8s|glance)/.*$' + - '^charts/(patches/)?(godaddy-webhook|goldpinger|heat|horizon|ingress-nginx|keepalived)/.*$' + - '^charts/(patches/)?(keycloak|keystone|kube-prometheus-stack|libvirt|loki|magnum|manila)/.*$' + - '^charts/(patches/)?(memcached|neutron|node-feature-discovery|nova|octavia)/.*$' + - '^charts/(patches/)?(openstack-exporter|openvswitch|ovn|placement)/.*$' + - '^charts/(patches/)?(prometheus-ipmi-exporter|prometheus-pushgateway)/.*$' + - '^charts/(patches/)?(prometheus-smartctl-exporter|pxc-db|pxc-operator)/.*$' + - '^charts/(patches/)?(rabbitmq-cluster-operator|rook-ceph|rook-ceph-cluster|staffeln)/.*$' + - '^charts/(patches/)?(valkey|vector)/.*$' pre-run: - test-playbooks/molecule/prepare.yml run: @@ -62,6 +221,47 @@ name: atmosphere-molecule-keycloak-selective parent: atmosphere-molecule-keycloak timeout: 3600 + irrelevant-files: + - '^playbooks/ceph\.yml$' + - '^playbooks/csi\.yml$' + - '^molecule/csi/.*$' + - '^roles/local_path_provisioner/.*$' + - '^charts/local-path-provisioner/.*$' + - '^charts/patches/local-path-provisioner/.*$' + - '^roles/ceph_csi_rbd/.*$' + - '^charts/ceph-csi-rbd/.*$' + - '^charts/patches/ceph-csi-rbd/.*$' + - '^Cargo\.lock$' + - '^Cargo\.toml$' + - '^crates/ovsinit/.*$' + - '^\.agents/.*$' + - '^\.github/.*$' + - '^\.gitignore$' + - '^\.gitreview$' + - '^\.pre-commit-config\.yaml$' + - '^\.vale\.ini$' + - '^AGENTS\.md$' + - '^CLAUDE\.md$' + - '^CODEOWNERS$' + - '^LICENSE$' + - '^README\.md$' + - '^doc/.*$' + - '^releasenotes/.*$' + - '^reno\.yaml$' + - '^renovate\.json$' + - '^roles/(barbican|ceph_provisioners|cinder|coredns|csi|frr_k8s|glance|goldpinger|heat|horizon)/.*$' + - '^roles/(hpe_nimble_csi|ibm_block_csi_driver|ipmi_exporter|iscsi|keepalived|keystone)/.*$' + - '^roles/(kube_prometheus_stack|libvirt|loki|lpfc|magnum|manila|memcached|multipathd|neutron)/.*$' + - '^roles/(node_feature_discovery|nova|octavia|openstack_cli|openstack_exporter|openvswitch|ovn)/.*$' + - '^roles/(placement|portworx|powerstore_csi|prometheus_pushgateway|rook_ceph|rook_ceph_cluster)/.*$' + - '^roles/(smartctl_exporter|staffeln|storpool_csi|udev|valkey|vector)/.*$' + - '^charts/(patches/)?(barbican|ceph-provisioners|cinder|coredns|frr-k8s|glance|goldpinger)/.*$' + - '^charts/(patches/)?(heat|horizon|hpe-csi-driver|keepalived|keystone)/.*$' + - '^charts/(patches/)?(kube-prometheus-stack|libvirt|loki|magnum|manila|memcached|neutron)/.*$' + - '^charts/(patches/)?(node-feature-discovery|nova|octavia|openstack-exporter|openvswitch)/.*$' + - '^charts/(patches/)?(ovn|placement|prometheus-ipmi-exporter|prometheus-pushgateway)/.*$' + - '^charts/(patches/)?(prometheus-smartctl-exporter|rook-ceph|rook-ceph-cluster|staffeln)/.*$' + - '^charts/(patches/)?(valkey|vector)/.*$' pre-run: - test-playbooks/molecule/prepare.yml run: diff --git a/atmosphere/ci/molecule_plan.py b/atmosphere/ci/molecule_plan.py index 11e344ca48..4e075924b8 100644 --- a/atmosphere/ci/molecule_plan.py +++ b/atmosphere/ci/molecule_plan.py @@ -71,12 +71,39 @@ def _compile_glob(pattern: str) -> re.Pattern[str]: elif character == "?": output.append("[^/]") else: - output.append(re.escape(character)) + output.append(re.escape(character).replace(r"\-", "-")) index += 1 output.append("$") return re.compile("".join(output)) +def _unique(values: Iterable[str]) -> list[str]: + """Return values in their original order without duplicates.""" + + return list(dict.fromkeys(values)) + + +def _grouped_regexes( + prefix: str, + values: Iterable[str], + suffix: str, + max_length: int = 100, +) -> list[str]: + """Group escaped alternatives into readable regular expressions.""" + + groups: list[list[str]] = [] + for value in sorted(values): + escaped = re.escape(value).replace(r"\-", "-") + if ( + groups + and len(prefix + "|".join(groups[-1] + [escaped]) + suffix) <= max_length + ): + groups[-1].append(escaped) + else: + groups.append([escaped]) + return [prefix + "|".join(group) + suffix for group in groups] + + def parse_changes(lines: Iterable[str]) -> list[Change]: """Parse paths or ``git diff --name-status`` records.""" @@ -515,6 +542,93 @@ def plan(self, changes: Sequence[Change]) -> dict[str, Any]: "reasons": reasons, } + def scheduler_irrelevant_files(self) -> dict[str, list[str]]: + """Build conservative Zuul filters from the impact policy. + + An unclassified path is intentionally absent from every list so it + still schedules the complete fallback. A path is irrelevant to a job + only when the policy explicitly ignores it or assigns it exclusively + to other jobs. + """ + + irrelevant: dict[str, list[str]] = {job_name: [] for job_name in self.jobs} + unrelated_roles: dict[str, set[str]] = { + job_name: set() for job_name in self.jobs + } + unrelated_charts: dict[str, set[str]] = { + job_name: set() for job_name in self.jobs + } + + def add(job_names: Iterable[str], patterns: Iterable[str]) -> None: + compiled = [_compile_glob(pattern).pattern for pattern in patterns] + for job_name in job_names: + irrelevant[job_name].extend(compiled) + + all_jobs = set(self.jobs) + for rule in self.rules: + action = rule["action"] + if action == "ignore": + irrelevant_jobs = all_jobs + elif action == "targets": + relevant_jobs = set( + _strings(rule.get("jobs"), f"rules.{rule['name']}.jobs") + ) + irrelevant_jobs = all_jobs - relevant_jobs + else: + continue + add( + sorted(irrelevant_jobs), + _strings(rule.get("paths"), f"rules.{rule['name']}.paths"), + ) + + for component_name, component_value in self.components.items(): + component = _mapping(component_value, f"components.{component_name}") + relevant_jobs = set( + _strings( + component.get("jobs"), + f"components.{component_name}.jobs", + ) + ) + irrelevant_jobs = all_jobs - relevant_jobs + add( + sorted(irrelevant_jobs), + _strings( + component.get("paths"), + f"components.{component_name}.paths", + ), + ) + roles = _strings( + component.get("roles", [component_name.replace("-", "_")]), + f"components.{component_name}.roles", + ) + charts = _strings( + component.get("charts", [component_name]), + f"components.{component_name}.charts", + ) + for job_name in irrelevant_jobs: + unrelated_roles[job_name].update(roles) + unrelated_charts[job_name].update(charts) + + for job_name in self.jobs: + if unrelated_roles[job_name]: + irrelevant[job_name].extend( + _grouped_regexes( + "^roles/(", + unrelated_roles[job_name], + ")/.*$", + ) + ) + if unrelated_charts[job_name]: + irrelevant[job_name].extend( + _grouped_regexes( + "^charts/(patches/)?(", + unrelated_charts[job_name], + ")/.*$", + ) + ) + irrelevant[job_name] = _unique(irrelevant[job_name]) + return irrelevant + def _match_components(self, path: str) -> list[str]: matched: set[str] = set() parts = path.split("/") @@ -786,6 +900,10 @@ def _parser() -> argparse.ArgumentParser: render = subparsers.add_parser("render", help="render a JSON plan") render.add_argument("plan") + subparsers.add_parser( + "scheduler-files", + help="render Zuul irrelevant-files derived from the policy", + ) return parser @@ -801,6 +919,14 @@ def main(argv: Sequence[str] | None = None) -> int: if args.command == "validate": print(f"Selective CI policy {args.config} is valid") return 0 + if args.command == "scheduler-files": + print( + yaml.safe_dump( + planner.scheduler_irrelevant_files(), + sort_keys=False, + ).rstrip() + ) + return 0 plan = planner.plan(_collect_changes(args)) if args.format == "json": diff --git a/ci/molecule-plan.yaml b/ci/molecule-plan.yaml index d1144e149c..668147f4a2 100644 --- a/ci/molecule-plan.yaml +++ b/ci/molecule-plan.yaml @@ -1,8 +1,9 @@ --- version: 1 -# Zuul schedules these jobs statically. The planner decides which scheduled -# jobs need to execute after inspecting the exact pull request diff. +# These jobs form the selector's complete execution set. Zuul uses generated +# irrelevant-files filters to omit jobs assigned exclusively to other paths, +# then the runtime planner validates every job that remains. jobs: aio-openvswitch: scenario: aio @@ -539,6 +540,8 @@ components: requires: - ceph-provisioners - keystone + test_requires: + - glance jobs: - aio-openvswitch verification_profiles: diff --git a/doc/source/deploy/selective-ci.rst b/doc/source/deploy/selective-ci.rst index ec800939bc..8c41593e57 100644 --- a/doc/source/deploy/selective-ci.rst +++ b/doc/source/deploy/selective-ci.rst @@ -53,6 +53,7 @@ Validate the policy and inspect representative plans locally: --changed-file roles/glance/tasks/main.yml uv run python -m atmosphere.ci.molecule_plan plan \ --changed-file roles/neutron/tasks/main.yml + uv run python -m atmosphere.ci.molecule_plan scheduler-files The planner evaluates both sides of renames and copies. Documentation and release-note-only changes produce a no-op plan. A path which matches no rule or @@ -82,11 +83,12 @@ printed throughout the deployment. Progress heartbeats report the last observed Molecule task, while the complete stream is retained as ``molecule-.log``. -Zuul constructs the job graph before a project job can inspect the pull request -diff, so all five consumer jobs are scheduled statically. Jobs which are not -selected report ``SKIP`` and exit without running Molecule, but still require a -node for their inherited preparation. Avoiding that allocation would require -moving change classification into trusted Zuul configuration. +Zuul uses generated ``irrelevant-files`` matchers to omit jobs which the policy +assigns exclusively to other components. The matchers are checked into +``.zuul.yaml`` and unit tests ensure they stay synchronized with +``ci/molecule-plan.yaml``. Unclassified runtime paths match no exclusion and +therefore retain the complete fallback. The planner still validates every +scheduled job at runtime and reports its exact decision. The selective AIO jobs allow fifteen minutes for Keycloak and ten minutes for the Nova, Neutron, and Octavia Helm operations. Clean database migrations and diff --git a/roles/tempest/tasks/main.yml b/roles/tempest/tasks/main.yml index d34c38ff7b..b3041505d7 100644 --- a/roles/tempest/tasks/main.yml +++ b/roles/tempest/tasks/main.yml @@ -64,6 +64,7 @@ tempest: compute: image_ref: "{{ _tempest_test_image.images.0.id }}" + image_ref_alt: "{{ _tempest_test_image.images.0.id }}" when: - tempest_ci_mode != 'selective' or 'glance' in tempest_ci_components - tempest_helm_values.conf.tempest.compute.image_ref is not defined @@ -86,6 +87,7 @@ tempest: compute: flavor_ref: "{{ _tempest_test_flavor.flavors[0].id }}" + flavor_ref_alt: "{{ _tempest_test_flavor.flavors[0].id }}" when: - tempest_ci_mode != 'selective' or 'nova' in tempest_ci_components - tempest_helm_values.conf.tempest.compute.flavor_ref is not defined diff --git a/roles/tempest/vars/main.yml b/roles/tempest/vars/main.yml index 43e4314944..f3249a428f 100644 --- a/roles/tempest/vars/main.yml +++ b/roles/tempest/vars/main.yml @@ -41,6 +41,15 @@ _tempest_ci_test_patterns: >- else [] }} +_tempest_ci_test_regex: >- + {{ + '(' + ~ (_tempest_ci_test_patterns | join('|')) + ~ ').*\[.*\bsmoke\b.*\]' + if _tempest_ci_test_patterns | length > 0 + else '' + }} + _tempest_helm_values: endpoints: "{{ openstack_helm_endpoints | combine(_tempest_endpoint_values, recursive=True) }}" images: @@ -58,12 +67,11 @@ _tempest_helm_values: include_app_kubernetes_io: false conf: script: >- - tempest run --config-file /etc/tempest/tempest.conf -w 4 --smoke{{ - ' --include-list /etc/tempest/test-whitelist' - if _tempest_ci_test_patterns | length > 0 - else '' + tempest run --config-file /etc/tempest/tempest.conf -w 4{{ + ' --regex ' ~ (_tempest_ci_test_regex | quote) + if _tempest_ci_test_regex | length > 0 + else ' --smoke' }} - whitelist: "{{ _tempest_ci_test_patterns }}" tempest: service_available: >- {{ @@ -83,6 +91,7 @@ _tempest_helm_values: identity: v3_endpoint_type: internal image: + disk_formats: aki,ari,qcow2,raw endpoint_type: internal load_balancer: endpoint_type: internal @@ -99,6 +108,9 @@ _tempest_helm_values: image_ssh_user: cirros volume: endpoint_type: internal + storage_protocol: ceph + volume-feature-enabled: + backup: false object-storage: build_timeout: 60 endpoint_type: internal diff --git a/tests/unit/ci/test_molecule_plan.py b/tests/unit/ci/test_molecule_plan.py index 03529a379d..08952cbb90 100644 --- a/tests/unit/ci/test_molecule_plan.py +++ b/tests/unit/ci/test_molecule_plan.py @@ -4,6 +4,7 @@ from __future__ import annotations import copy +import re from pathlib import Path import pytest @@ -158,6 +159,19 @@ def test_nova_adds_neutron_as_a_functional_test_requirement( } +def test_cinder_adds_glance_as_a_functional_test_requirement( + planner: Planner, +) -> None: + plan = plan_path(planner, "roles/cinder/tasks/main.yml") + + assert components(plan) >= { + "ceph-provisioners", + "cinder", + "glance", + "keystone", + } + + def test_neutron_creates_backend_specific_jobs( planner: Planner, ) -> None: @@ -381,6 +395,12 @@ def test_selective_ci_uses_main_sequential_molecule_flow() -> None: runner = ( repository / "test-playbooks" / "molecule" / "selective-run.yml" ).read_text(encoding="utf-8") + tempest_tasks = (repository / "roles" / "tempest" / "tasks" / "main.yml").read_text( + encoding="utf-8" + ) + tempest_vars = (repository / "roles" / "tempest" / "vars" / "main.yml").read_text( + encoding="utf-8" + ) policy = POLICY_PATH.read_text(encoding="utf-8") assert "vexxhost.atmosphere.ceph" in converge @@ -390,6 +410,11 @@ def test_selective_ci_uses_main_sequential_molecule_flow() -> None: assert "CEPH_CONTAINER_BINARY" in openstack assert "'molecule'," in runner assert "'test'," in runner + assert "--regex" in tempest_vars + assert r"\bsmoke\b" in tempest_vars + assert "--include-list" not in tempest_vars + assert "image_ref_alt" in tempest_tasks + assert "flavor_ref_alt" in tempest_tasks assert "go build" not in converge assert "./bin/atmosphere" not in runner assert "dependency_options" not in policy @@ -420,6 +445,79 @@ def test_zuul_plan_uses_speculative_parent() -> None: assert project["vars"]["atmosphere_ci_plan_head"] == "HEAD" +def test_scheduler_filters_match_policy(planner: Planner) -> None: + repository = POLICY_PATH.parents[1] + zuul_config = yaml.safe_load( + (repository / ".zuul.yaml").read_text(encoding="utf-8") + ) + configured = { + job["name"] + .removeprefix("atmosphere-molecule-") + .removesuffix("-selective"): job["irrelevant-files"] + for item in zuul_config + if "job" in item + for job in [item["job"]] + } + samples = {"new-runtime-area/config.yaml"} + for rule in planner.rules: + samples.update( + pattern.replace("**", "example/file.yml") + .replace("*", "example") + .replace("?", "x") + for pattern in rule["paths"] + ) + for component_name, component in planner.components.items(): + samples.update( + f"roles/{role}/tasks/main.yml" + for role in component.get("roles", [component_name.replace("-", "_")]) + ) + for chart in component.get("charts", [component_name]): + samples.add(f"charts/{chart}/Chart.yaml") + samples.add(f"charts/patches/{chart}/0001-change.patch") + samples.update( + pattern.replace("**", "example/file.yml") + .replace("*", "example") + .replace("?", "x") + for pattern in component.get("paths", []) + ) + + for changed_path in samples: + plan = plan_path(planner, changed_path) + expected = { + job_name + for job_name, decision in plan["job_decisions"].items() + if decision["run"] + } + scheduled = { + job_name + for job_name, patterns in configured.items() + if not any(re.match(pattern, changed_path) for pattern in patterns) + } + assert scheduled == expected, changed_path + + +def test_scheduler_filters_preserve_full_fallback(planner: Planner) -> None: + filters = planner.scheduler_irrelevant_files() + + for patterns in filters.values(): + assert not any( + re.match(pattern, "new-runtime-area/config.yaml") for pattern in patterns + ) + + +def test_scheduler_filters_select_only_glance_job(planner: Planner) -> None: + filters = planner.scheduler_irrelevant_files() + changed_path = "roles/glance/README.md" + + scheduled = { + job_name + for job_name, patterns in filters.items() + if not any(re.match(pattern, changed_path) for pattern in patterns) + } + + assert scheduled == {"aio-openvswitch"} + + def test_text_output_is_compact_and_readable(planner: Planner) -> None: output = render_plan(plan_path(planner, "roles/glance/tasks/main.yml")) From 9cf6d88e75d4309e754a788fbbe073fc391fe606 Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Wed, 29 Jul 2026 16:31:24 +0300 Subject: [PATCH 07/27] fix(ci): honor selective job file filters Signed-off-by: Tadas Sutkaitis --- .zuul.yaml | 5 +++++ doc/source/deploy/selective-ci.rst | 4 +++- tests/unit/ci/test_molecule_plan.py | 4 ++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.zuul.yaml b/.zuul.yaml index b54247a2c6..95d4c35771 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -1,6 +1,7 @@ - job: name: atmosphere-molecule-aio-openvswitch-selective parent: atmosphere-molecule-aio-openvswitch + match-on-config-updates: false irrelevant-files: - '^playbooks/csi\.yml$' - '^molecule/csi/.*$' @@ -50,6 +51,7 @@ - job: name: atmosphere-molecule-aio-ovn-selective parent: atmosphere-molecule-aio-ovn + match-on-config-updates: false irrelevant-files: - '^playbooks/ceph\.yml$' - '^playbooks/csi\.yml$' @@ -115,6 +117,7 @@ - job: name: atmosphere-molecule-csi-local-path-provisioner-selective parent: atmosphere-molecule-csi-local-path-provisioner + match-on-config-updates: false irrelevant-files: - '^playbooks/ceph\.yml$' - '^molecule/keycloak/.*$' @@ -168,6 +171,7 @@ - job: name: atmosphere-molecule-csi-rbd-selective parent: atmosphere-molecule-csi-rbd + match-on-config-updates: false irrelevant-files: - '^molecule/keycloak/.*$' - '^roles/local_path_provisioner/.*$' @@ -221,6 +225,7 @@ name: atmosphere-molecule-keycloak-selective parent: atmosphere-molecule-keycloak timeout: 3600 + match-on-config-updates: false irrelevant-files: - '^playbooks/ceph\.yml$' - '^playbooks/csi\.yml$' diff --git a/doc/source/deploy/selective-ci.rst b/doc/source/deploy/selective-ci.rst index 8c41593e57..4b1fe653d1 100644 --- a/doc/source/deploy/selective-ci.rst +++ b/doc/source/deploy/selective-ci.rst @@ -88,7 +88,9 @@ assigns exclusively to other components. The matchers are checked into ``.zuul.yaml`` and unit tests ensure they stay synchronized with ``ci/molecule-plan.yaml``. Unclassified runtime paths match no exclusion and therefore retain the complete fallback. The planner still validates every -scheduled job at runtime and reports its exact decision. +scheduled job at runtime and reports its exact decision. The selective jobs +disable Zuul's implicit configuration-update override because the planner, +policy, playbook, and Zuul configuration paths already bypass every exclusion. The selective AIO jobs allow fifteen minutes for Keycloak and ten minutes for the Nova, Neutron, and Octavia Helm operations. Clean database migrations and diff --git a/tests/unit/ci/test_molecule_plan.py b/tests/unit/ci/test_molecule_plan.py index 08952cbb90..5781c2f2b1 100644 --- a/tests/unit/ci/test_molecule_plan.py +++ b/tests/unit/ci/test_molecule_plan.py @@ -458,6 +458,10 @@ def test_scheduler_filters_match_policy(planner: Planner) -> None: if "job" in item for job in [item["job"]] } + for item in zuul_config: + if "job" in item: + assert item["job"]["match-on-config-updates"] is False + samples = {"new-runtime-area/config.yaml"} for rule in planner.rules: samples.update( From 8ea8d8b1d83b3ecaac2f68bcb7017de7b9b2dee9 Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Wed, 29 Jul 2026 18:09:24 +0300 Subject: [PATCH 08/27] docs(ci): describe focused Tempest selection Signed-off-by: Tadas Sutkaitis --- doc/source/deploy/selective-ci.rst | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/doc/source/deploy/selective-ci.rst b/doc/source/deploy/selective-ci.rst index 4b1fe653d1..7980d48fb0 100644 --- a/doc/source/deploy/selective-ci.rst +++ b/doc/source/deploy/selective-ci.rst @@ -68,11 +68,13 @@ targets. During selective AIO verification, Tempest receives the union of ``tempest_tests`` for the changed components. The expressions are passed -through Tempest's ``include-list`` support and combined with ``--smoke``. A -component without expressions, including any such component in a multi-target -change, uses the ordinary smoke selection against the services available in -its deployment closure. This provides a conservative fallback for services -whose image has no dedicated smoke-test namespace. +to one Tempest regular expression which also requires the ``smoke`` test +attribute. This selects the intersection of the component namespaces and the +smoke suite instead of adding unrelated smoke tests. A component without +expressions, including any such component in a multi-target change, uses the +ordinary smoke selection against the services available in its deployment +closure. This provides a conservative fallback for services whose image has no +dedicated smoke-test namespace. Zuul artifacts ============== From 155e234ebf76689878c2c34de7e4e6aa4cf8f5a2 Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Wed, 29 Jul 2026 18:25:33 +0300 Subject: [PATCH 09/27] fix(manila): allow slow chart deployments Signed-off-by: Tadas Sutkaitis --- .zuul.yaml | 2 ++ roles/manila/defaults/main.yml | 5 +++++ roles/manila/tasks/main.yml | 1 + tests/unit/ci/test_molecule_plan.py | 1 + 4 files changed, 9 insertions(+) diff --git a/.zuul.yaml b/.zuul.yaml index 95d4c35771..4409e44cff 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -47,6 +47,7 @@ neutron_helm_timeout: 10m0s nova_helm_timeout: 10m0s octavia_helm_timeout: 10m0s + manila_helm_timeout: 10m0s - job: name: atmosphere-molecule-aio-ovn-selective @@ -113,6 +114,7 @@ neutron_helm_timeout: 10m0s nova_helm_timeout: 10m0s octavia_helm_timeout: 10m0s + manila_helm_timeout: 10m0s - job: name: atmosphere-molecule-csi-local-path-provisioner-selective diff --git a/roles/manila/defaults/main.yml b/roles/manila/defaults/main.yml index 755c6c69f9..46b3c7b168 100644 --- a/roles/manila/defaults/main.yml +++ b/roles/manila/defaults/main.yml @@ -20,6 +20,11 @@ manila_helm_release_namespace: openstack manila_helm_kubeconfig: "{{ kubeconfig_path | default('/etc/kubernetes/admin.conf') }}" manila_helm_values: {} +# Maximum time allowed for Manila Helm operations. +# +# manila_helm_timeout: 10m0s +manila_helm_timeout: 5m0s + # Class name to use for the Ingress manila_ingress_class_name: "{{ atmosphere_ingress_class_name }}" diff --git a/roles/manila/tasks/main.yml b/roles/manila/tasks/main.yml index 540ce34aeb..b19270a1e8 100644 --- a/roles/manila/tasks/main.yml +++ b/roles/manila/tasks/main.yml @@ -29,6 +29,7 @@ create_namespace: true kubeconfig: "{{ manila_helm_kubeconfig }}" values: "{{ _manila_helm_values | combine(manila_helm_values, recursive=True) }}" + timeout: "{{ manila_helm_timeout }}" - name: Create Ingress ansible.builtin.include_role: diff --git a/tests/unit/ci/test_molecule_plan.py b/tests/unit/ci/test_molecule_plan.py index 5781c2f2b1..cae6d52d1b 100644 --- a/tests/unit/ci/test_molecule_plan.py +++ b/tests/unit/ci/test_molecule_plan.py @@ -432,6 +432,7 @@ def test_full_aio_jobs_have_timeout_headroom() -> None: "atmosphere-molecule-aio-ovn-selective", ): assert jobs[job_name]["vars"]["atmosphere_ci_molecule_timeout"] == 9000 + assert jobs[job_name]["vars"]["manila_helm_timeout"] == "10m0s" def test_zuul_plan_uses_speculative_parent() -> None: From d5796476cbd71220592b9b9147b12460dc0cfefb Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Wed, 29 Jul 2026 18:32:17 +0300 Subject: [PATCH 10/27] feat(ci): add focused OpenStack API checks Signed-off-by: Tadas Sutkaitis --- atmosphere/ci/molecule_plan.py | 79 ++++++++++++++++++++++- ci/molecule-plan.yaml | 23 +++++++ doc/source/deploy/selective-ci.rst | 35 +++++++--- molecule/aio/verify.yml | 21 ++++++ test-playbooks/molecule/selective-run.yml | 9 +-- tests/unit/ci/test_molecule_plan.py | 73 ++++++++++++++++++++- 6 files changed, 223 insertions(+), 17 deletions(-) diff --git a/atmosphere/ci/molecule_plan.py b/atmosphere/ci/molecule_plan.py index 4e075924b8..2bd7f9701a 100644 --- a/atmosphere/ci/molecule_plan.py +++ b/atmosphere/ci/molecule_plan.py @@ -159,6 +159,10 @@ def __init__(self, policy: dict[str, Any]) -> None: self.policy = policy self.jobs = _mapping(policy.get("jobs"), "jobs") self.components = _mapping(policy.get("components"), "components") + self.verification_checks = _mapping( + policy.get("verification_checks", {}), + "verification_checks", + ) rules = policy.get("rules") if not isinstance(rules, list): raise PolicyError("rules must be a list") @@ -196,6 +200,24 @@ def _validate(self, rules: list[Any]) -> None: raise PolicyError("full_jobs must not be empty") self._check_jobs(full_jobs, "full_jobs") + for profile, checks in self.verification_checks.items(): + if not isinstance(checks, list): + raise PolicyError( + f"verification_checks.{profile} must be a list of mappings" + ) + for index, check_value in enumerate(checks): + field = f"verification_checks.{profile}[{index}]" + check = _mapping(check_value, field) + unexpected = sorted(set(check) - {"service", "type"}) + if unexpected: + raise PolicyError( + f"{field} contains unsupported keys: " + f"{', '.join(unexpected)}" + ) + for key in ("service", "type"): + if not isinstance(check.get(key), str) or not check[key]: + raise PolicyError(f"{field}.{key} must be a string") + owners: dict[tuple[str, str], str] = {} for component_name, component_value in self.components.items(): component = _mapping(component_value, f"components.{component_name}") @@ -465,6 +487,7 @@ def plan(self, changes: Sequence[Change]) -> dict[str, Any]: ) ) all_tempest_tests = self._tempest_test_patterns(targets) + all_verification_checks = self._verification_checks(targets) for job_name, job_value in self.jobs.items(): job = _mapping(job_value, f"jobs.{job_name}") @@ -492,6 +515,7 @@ def plan(self, changes: Sequence[Change]) -> dict[str, Any]: } ) tempest_tests = self._tempest_test_patterns(roots) + verification_checks = self._verification_checks(roots) decision = { "run": True, "reason": "selected by changed components", @@ -501,7 +525,9 @@ def plan(self, changes: Sequence[Change]) -> dict[str, Any]: "components": components, "ansible_tags": tags, "verification_profiles": profiles, + "verification_checks": verification_checks, "tempest_tests": tempest_tests, + "run_tempest": self._tempest_required(roots), } variants.append( { @@ -512,7 +538,9 @@ def plan(self, changes: Sequence[Change]) -> dict[str, Any]: "components": components, "ansible_tags": tags, "verification_profiles": profiles, + "verification_checks": verification_checks, "tempest_tests": tempest_tests, + "run_tempest": self._tempest_required(roots), } ) else: @@ -525,7 +553,9 @@ def plan(self, changes: Sequence[Change]) -> dict[str, Any]: "components": [], "ansible_tags": [], "verification_profiles": sorted(all_profiles), + "verification_checks": all_verification_checks, "tempest_tests": all_tempest_tests, + "run_tempest": False, } decisions[job_name] = decision @@ -536,6 +566,7 @@ def plan(self, changes: Sequence[Change]) -> dict[str, Any]: "matches": matches, "targets": sorted(targets), "verification_profiles": sorted(all_profiles), + "verification_checks": all_verification_checks, "tempest_tests": all_tempest_tests, "variants": variants, "job_decisions": decisions, @@ -715,11 +746,45 @@ def _tempest_test_patterns(self, targets: Iterable[str]) -> list[str]: self.components[target].get("tempest_tests"), f"components.{target}.tempest_tests", ) - if not target_patterns: + if not target_patterns and not self._verification_checks([target]): return [] patterns.update(target_patterns) return sorted(patterns) + def _verification_checks( + self, + targets: Iterable[str], + ) -> list[dict[str, str]]: + checks: dict[tuple[str, str], dict[str, str]] = {} + for target in targets: + profiles = _strings( + self.components[target].get("verification_profiles"), + f"components.{target}.verification_profiles", + ) + for profile in profiles: + for check_value in self.verification_checks.get(profile, []): + check = _mapping( + check_value, + f"verification_checks.{profile}", + ) + key = (check["service"], check["type"]) + checks[key] = { + "service": check["service"], + "type": check["type"], + } + return [checks[key] for key in sorted(checks)] + + def _tempest_required(self, targets: Iterable[str]) -> bool: + for target in targets: + if _strings( + self.components[target].get("tempest_tests"), + f"components.{target}.tempest_tests", + ): + return True + if not self._verification_checks([target]): + return True + return False + def _full_plan( self, changes: Sequence[Change], @@ -740,7 +805,9 @@ def _full_plan( "components": [], "ansible_tags": [], "verification_profiles": ["full"], + "verification_checks": [], "tempest_tests": [], + "run_tempest": job["scenario"] == "aio", } else: decisions[job_name] = self._skip_decision( @@ -753,6 +820,7 @@ def _full_plan( "matches": matches, "targets": [], "verification_profiles": ["full"], + "verification_checks": [], "tempest_tests": [], "variants": [], "job_decisions": decisions, @@ -779,6 +847,7 @@ def _noop_plan( "matches": matches, "targets": [], "verification_profiles": [], + "verification_checks": [], "tempest_tests": [], "variants": [], "job_decisions": decisions, @@ -799,7 +868,9 @@ def _skip_decision( "components": [], "ansible_tags": [], "verification_profiles": [], + "verification_checks": [], "tempest_tests": [], + "run_tempest": False, } @@ -830,6 +901,12 @@ def render_plan(plan: dict[str, Any]) -> str: lines.append( " Tempest include: " + ", ".join(decision["tempest_tests"]) ) + if decision["run"] and decision.get("verification_checks"): + checks = ( + f"{check['service']}/{check['type']}" + for check in decision["verification_checks"] + ) + lines.append(" API checks: " + ", ".join(checks)) for reason in plan.get("reasons", []): lines.append(f"Reason: {reason}") return "\n".join(lines) diff --git a/ci/molecule-plan.yaml b/ci/molecule-plan.yaml index 668147f4a2..9d17b7a54a 100644 --- a/ci/molecule-plan.yaml +++ b/ci/molecule-plan.yaml @@ -185,6 +185,29 @@ rules: - reno.yaml - renovate.json +# Read-only OpenStack API checks keyed by the verification profiles used below. +# These provide focused coverage when the Tempest image does not include a +# service-specific plugin. +verification_checks: + secrets: + - service: key_manager + type: secret + placement: + - service: placement + type: resource_provider + orchestration: + - service: orchestration + type: stack + load-balancer: + - service: load_balancer + type: load_balancer + container-infrastructure: + - service: container_infrastructure_management + type: cluster + shared-file-system: + - service: shared_file_system + type: share + # Dependencies below are CI test-environment requirements. They are deliberately # independent from any deployment orchestrator and resolve only to tags already # present in the sequential playbooks on main. diff --git a/doc/source/deploy/selective-ci.rst b/doc/source/deploy/selective-ci.rst index 7980d48fb0..464aa15595 100644 --- a/doc/source/deploy/selective-ci.rst +++ b/doc/source/deploy/selective-ci.rst @@ -16,7 +16,7 @@ idempotence actions. Policy ====== -The policy is stored in ``ci/molecule-plan.yaml`` and has three sections: +The policy is stored in ``ci/molecule-plan.yaml`` and has four sections: ``jobs`` Static Zuul jobs which can consume a decision. @@ -25,6 +25,11 @@ The policy is stored in ``ci/molecule-plan.yaml`` and has three sections: Shared paths, ignored paths, focused scenario paths, and conservative full-suite fallbacks. +``verification_checks`` + Read-only OpenStack resource queries keyed by verification profile. These + provide focused API coverage when the Tempest image does not contain a + service plugin. + ``components`` Role and chart ownership, direct CI dependencies, verification profiles, and the jobs which exercise each component. A component can also declare @@ -70,11 +75,21 @@ During selective AIO verification, Tempest receives the union of ``tempest_tests`` for the changed components. The expressions are passed to one Tempest regular expression which also requires the ``smoke`` test attribute. This selects the intersection of the component namespaces and the -smoke suite instead of adding unrelated smoke tests. A component without -expressions, including any such component in a multi-target change, uses the +smoke suite instead of adding unrelated smoke tests. + +Verification profiles can also resolve to ``verification_checks``. The AIO +verifier runs each selected check with ``openstack.cloud.resources`` against +the deployed public API and its configured certificate authority. A successful +query proves that authentication, service discovery, TLS, and the target API +are working even when the result is empty. Barbican, Placement, Heat, Magnum, +and Manila use this mechanism. Octavia keeps its plugin tests and also receives +an API check. + +A component with neither Tempest expressions nor a resource check uses the ordinary smoke selection against the services available in its deployment -closure. This provides a conservative fallback for services whose image has no -dedicated smoke-test namespace. +closure. This is the conservative fallback. In a multi-target change, resource +checks cover their own components without disabling focused Tempest expressions +from other targets. Zuul artifacts ============== @@ -95,8 +110,8 @@ disable Zuul's implicit configuration-update override because the planner, policy, playbook, and Zuul configuration paths already bypass every exclusion. The selective AIO jobs allow fifteen minutes for Keycloak and ten minutes for -the Nova, Neutron, and Octavia Helm operations. Clean database migrations and -initial service rollouts can exceed their normal timeouts on a busy test node -even when they complete successfully. The complete AIO Molecule lifecycle has -a 150-minute command limit so full fallback runs have enough time to finish -idempotence and verification. +the Nova, Neutron, Octavia, and Manila Helm operations. Clean database +migrations and initial service rollouts can exceed their normal timeouts on a +busy test node even when they complete successfully. The complete AIO Molecule +lifecycle has a 150-minute command limit so full fallback runs have enough time +to finish idempotence and verification. diff --git a/molecule/aio/verify.yml b/molecule/aio/verify.yml index 5092ec2e6e..641b54bc88 100644 --- a/molecule/aio/verify.yml +++ b/molecule/aio/verify.yml @@ -33,6 +33,27 @@ KUBECONFIG: /root/.kube/config ATMOSPHERE_NETWORK_BACKEND: "{{ atmosphere_network_backend }}" +- name: Verify selected OpenStack APIs + hosts: controllers[0] + gather_facts: false + become: true + vars: + _atmosphere_ci_verification_checks: >- + {{ + lookup('env', 'ATMOSPHERE_CI_VERIFICATION_CHECKS') + | default('[]', true) + | from_json + }} + tasks: + - name: Query selected OpenStack service resources + openstack.cloud.resources: + cloud: atmosphere + service: "{{ item.service }}" + type: "{{ item.type }}" + loop: "{{ _atmosphere_ci_verification_checks }}" + loop_control: + label: "{{ item.service }}/{{ item.type }}" + - name: Run Tempest tests ansible.builtin.import_playbook: vexxhost.atmosphere.tempest tags: diff --git a/test-playbooks/molecule/selective-run.yml b/test-playbooks/molecule/selective-run.yml index 5e49373cb3..8b644b2e53 100644 --- a/test-playbooks/molecule/selective-run.yml +++ b/test-playbooks/molecule/selective-run.yml @@ -132,6 +132,8 @@ _atmosphere_ci_decision.components | join(','), 'ATMOSPHERE_CI_VERIFICATION_PROFILES': _atmosphere_ci_decision.verification_profiles | join(','), + 'ATMOSPHERE_CI_VERIFICATION_CHECKS': + _atmosphere_ci_decision.verification_checks | to_json, 'ATMOSPHERE_CI_TEMPEST_INCLUDE': _atmosphere_ci_decision.tempest_tests | to_json, 'ATMOSPHERE_CI_RUN_STESTR': ( @@ -139,10 +141,9 @@ or 'openvswitch' in _atmosphere_ci_decision.components or 'ovn' in _atmosphere_ci_decision.components ) | ternary('true', 'false'), - 'ATMOSPHERE_CI_RUN_TEMPEST': ( - _atmosphere_ci_plan.mode != 'selective' - or 'keystone' in _atmosphere_ci_decision.components - ) | ternary('true', 'false'), + 'ATMOSPHERE_CI_RUN_TEMPEST': + _atmosphere_ci_decision.run_tempest + | ternary('true', 'false'), 'MOLECULE_ANSIBLE_ARGS_STRICT_MODE': '1' }) }} diff --git a/tests/unit/ci/test_molecule_plan.py b/tests/unit/ci/test_molecule_plan.py index cae6d52d1b..17ac6ba7d5 100644 --- a/tests/unit/ci/test_molecule_plan.py +++ b/tests/unit/ci/test_molecule_plan.py @@ -96,6 +96,10 @@ def test_manila_includes_functional_dependencies( {"heat", "horizon", "magnum", "openstack-exporter"} ) assert decision["tempest_tests"] == [] + assert decision["run_tempest"] is False + assert decision["verification_checks"] == [ + {"service": "shared_file_system", "type": "share"} + ] def test_unmapped_tempest_target_disables_filter_for_combined_change( @@ -104,11 +108,59 @@ def test_unmapped_tempest_target_disables_filter_for_combined_change( plan = planner.plan( [ Change(status="M", path="roles/glance/tasks/main.yml"), - Change(status="M", path="roles/manila/tasks/main.yml"), + Change(status="M", path="roles/openstack_cli/tasks/main.yml"), ] ) assert plan["job_decisions"]["aio-openvswitch"]["tempest_tests"] == [] + assert plan["job_decisions"]["aio-openvswitch"]["run_tempest"] is True + + +def test_api_check_keeps_mapped_tempest_filter_for_combined_change( + planner: Planner, +) -> None: + plan = planner.plan( + [ + Change(status="M", path="roles/glance/tasks/main.yml"), + Change(status="M", path="roles/manila/tasks/main.yml"), + ] + ) + decision = plan["job_decisions"]["aio-openvswitch"] + + assert decision["tempest_tests"] == [r"^tempest\.api\.image\."] + assert decision["run_tempest"] is True + assert decision["verification_checks"] == [ + {"service": "shared_file_system", "type": "share"} + ] + + +@pytest.mark.parametrize( + ("role", "service", "resource_type"), + [ + ("barbican", "key_manager", "secret"), + ("placement", "placement", "resource_provider"), + ("heat", "orchestration", "stack"), + ("magnum", "container_infrastructure_management", "cluster"), + ("manila", "shared_file_system", "share"), + ], +) +def test_service_without_tempest_plugin_uses_read_only_api_check( + planner: Planner, + role: str, + service: str, + resource_type: str, +) -> None: + plan = plan_path( + planner, + f"roles/{role}/tasks/main.yml", + ) + decision = plan["job_decisions"]["aio-openvswitch"] + + assert decision["run_tempest"] is False + assert decision["tempest_tests"] == [] + assert decision["verification_checks"] == [ + {"service": service, "type": resource_type} + ] def test_magnum_uses_broad_openstack_environment( @@ -309,6 +361,18 @@ def test_invalid_tempest_test_regular_expression_is_rejected() -> None: Planner(invalid) +def test_malformed_verification_check_is_rejected() -> None: + policy = yaml.safe_load(POLICY_PATH.read_text(encoding="utf-8")) + invalid = copy.deepcopy(policy) + invalid["verification_checks"]["secrets"] = [{"service": "key_manager"}] + + with pytest.raises( + PolicyError, + match=r"verification_checks\.secrets\[0\]\.type must be a string", + ): + Planner(invalid) + + def test_every_declared_role_maps_to_its_component( planner: Planner, ) -> None: @@ -415,6 +479,10 @@ def test_selective_ci_uses_main_sequential_molecule_flow() -> None: assert "--include-list" not in tempest_vars assert "image_ref_alt" in tempest_tasks assert "flavor_ref_alt" in tempest_tasks + assert "ATMOSPHERE_CI_VERIFICATION_CHECKS" in runner + assert "openstack.cloud.resources" in ( + repository / "molecule" / "aio" / "verify.yml" + ).read_text(encoding="utf-8") assert "go build" not in converge assert "./bin/atmosphere" not in runner assert "dependency_options" not in policy @@ -524,9 +592,10 @@ def test_scheduler_filters_select_only_glance_job(planner: Planner) -> None: def test_text_output_is_compact_and_readable(planner: Planner) -> None: - output = render_plan(plan_path(planner, "roles/glance/tasks/main.yml")) + output = render_plan(plan_path(planner, "roles/manila/tasks/main.yml")) assert "Selective Molecule plan: selective" in output assert "RUN aio-openvswitch" in output assert "SKIP aio-ovn" in output assert "components:" in output + assert "API checks: shared_file_system/share" in output From 54e115b344a601ae49ac959bfa1e78175335c837 Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Wed, 29 Jul 2026 18:33:56 +0300 Subject: [PATCH 11/27] fix(ci): exercise API checks in full fallback Signed-off-by: Tadas Sutkaitis --- atmosphere/ci/molecule_plan.py | 8 ++++++-- doc/source/deploy/selective-ci.rst | 3 ++- tests/unit/ci/test_molecule_plan.py | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/atmosphere/ci/molecule_plan.py b/atmosphere/ci/molecule_plan.py index 2bd7f9701a..e6be33d71a 100644 --- a/atmosphere/ci/molecule_plan.py +++ b/atmosphere/ci/molecule_plan.py @@ -792,10 +792,14 @@ def _full_plan( reasons: list[str], ) -> dict[str, Any]: full_jobs = set(_strings(self.policy["full_jobs"], "full_jobs")) + full_verification_checks = self._verification_checks(self.components) decisions: dict[str, dict[str, Any]] = {} for job_name, job_value in self.jobs.items(): job = _mapping(job_value, f"jobs.{job_name}") if job_name in full_jobs: + verification_checks = ( + full_verification_checks if job["scenario"] == "aio" else [] + ) decisions[job_name] = { "run": True, "reason": "full fallback", @@ -805,7 +809,7 @@ def _full_plan( "components": [], "ansible_tags": [], "verification_profiles": ["full"], - "verification_checks": [], + "verification_checks": verification_checks, "tempest_tests": [], "run_tempest": job["scenario"] == "aio", } @@ -820,7 +824,7 @@ def _full_plan( "matches": matches, "targets": [], "verification_profiles": ["full"], - "verification_checks": [], + "verification_checks": full_verification_checks, "tempest_tests": [], "variants": [], "job_decisions": decisions, diff --git a/doc/source/deploy/selective-ci.rst b/doc/source/deploy/selective-ci.rst index 464aa15595..188d4c76ca 100644 --- a/doc/source/deploy/selective-ci.rst +++ b/doc/source/deploy/selective-ci.rst @@ -89,7 +89,8 @@ A component with neither Tempest expressions nor a resource check uses the ordinary smoke selection against the services available in its deployment closure. This is the conservative fallback. In a multi-target change, resource checks cover their own components without disabling focused Tempest expressions -from other targets. +from other targets. Full-fallback AIO jobs execute every declared resource +check. Zuul artifacts ============== diff --git a/tests/unit/ci/test_molecule_plan.py b/tests/unit/ci/test_molecule_plan.py index 17ac6ba7d5..a40ed49ca3 100644 --- a/tests/unit/ci/test_molecule_plan.py +++ b/tests/unit/ci/test_molecule_plan.py @@ -278,6 +278,20 @@ def test_unknown_runtime_path_falls_back_to_every_job( assert plan["mode"] == "full" assert all(decision["run"] for decision in plan["job_decisions"].values()) + expected_checks = { + ("container_infrastructure_management", "cluster"), + ("key_manager", "secret"), + ("load_balancer", "load_balancer"), + ("orchestration", "stack"), + ("placement", "resource_provider"), + ("shared_file_system", "share"), + } + for job_name in ("aio-openvswitch", "aio-ovn"): + assert { + (check["service"], check["type"]) + for check in plan["job_decisions"][job_name]["verification_checks"] + } == expected_checks + assert plan["job_decisions"]["csi-rbd"]["verification_checks"] == [] def test_empty_change_list_falls_back_to_every_job( From d8d4d246584f0371ad496ef750887604e301de5e Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Wed, 29 Jul 2026 22:01:13 +0300 Subject: [PATCH 12/27] fix(ci): reserve time for AIO post-run Signed-off-by: Tadas Sutkaitis --- .zuul.yaml | 2 ++ doc/source/deploy/selective-ci.rst | 4 +++- tests/unit/ci/test_molecule_plan.py | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.zuul.yaml b/.zuul.yaml index 4409e44cff..b075b0cbe3 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -1,6 +1,7 @@ - job: name: atmosphere-molecule-aio-openvswitch-selective parent: atmosphere-molecule-aio-openvswitch + timeout: 10800 match-on-config-updates: false irrelevant-files: - '^playbooks/csi\.yml$' @@ -52,6 +53,7 @@ - job: name: atmosphere-molecule-aio-ovn-selective parent: atmosphere-molecule-aio-ovn + timeout: 10800 match-on-config-updates: false irrelevant-files: - '^playbooks/ceph\.yml$' diff --git a/doc/source/deploy/selective-ci.rst b/doc/source/deploy/selective-ci.rst index 188d4c76ca..d97a44101c 100644 --- a/doc/source/deploy/selective-ci.rst +++ b/doc/source/deploy/selective-ci.rst @@ -115,4 +115,6 @@ the Nova, Neutron, Octavia, and Manila Helm operations. Clean database migrations and initial service rollouts can exceed their normal timeouts on a busy test node even when they complete successfully. The complete AIO Molecule lifecycle has a 150-minute command limit so full fallback runs have enough time -to finish idempotence and verification. +to finish idempotence and verification. The enclosing Zuul job has a +180-minute limit, reserving another thirty minutes for preparation and +post-run artifact collection. diff --git a/tests/unit/ci/test_molecule_plan.py b/tests/unit/ci/test_molecule_plan.py index a40ed49ca3..885580bf59 100644 --- a/tests/unit/ci/test_molecule_plan.py +++ b/tests/unit/ci/test_molecule_plan.py @@ -513,6 +513,7 @@ def test_full_aio_jobs_have_timeout_headroom() -> None: "atmosphere-molecule-aio-openvswitch-selective", "atmosphere-molecule-aio-ovn-selective", ): + assert jobs[job_name]["timeout"] == 10800 assert jobs[job_name]["vars"]["atmosphere_ci_molecule_timeout"] == 9000 assert jobs[job_name]["vars"]["manila_helm_timeout"] == "10m0s" From 7f52cad050fd0deac14c166993dc25b8ca14388f Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Wed, 29 Jul 2026 22:11:35 +0300 Subject: [PATCH 13/27] feat(ci): add focused component verifiers Signed-off-by: Tadas Sutkaitis --- atmosphere/ci/molecule_plan.py | 56 +++++++++--- ci/molecule-plan.yaml | 36 ++++++-- doc/source/deploy/selective-ci.rst | 32 ++++--- molecule/aio/verify.yml | 58 ++++++++++++- tests/unit/ci/test_molecule_plan.py | 128 +++++++++++++++++++++++++--- 5 files changed, 262 insertions(+), 48 deletions(-) diff --git a/atmosphere/ci/molecule_plan.py b/atmosphere/ci/molecule_plan.py index e6be33d71a..0925efab92 100644 --- a/atmosphere/ci/molecule_plan.py +++ b/atmosphere/ci/molecule_plan.py @@ -16,6 +16,12 @@ import yaml +_VERIFICATION_CHECK_FIELDS = { + "kubernetes-deployment": {"kind", "name", "namespace"}, + "openstack-cli": {"arguments", "kind"}, + "openstack-resource": {"kind", "service", "type"}, +} + class PolicyError(ValueError): """Raised when the selective CI policy is invalid.""" @@ -208,15 +214,35 @@ def _validate(self, rules: list[Any]) -> None: for index, check_value in enumerate(checks): field = f"verification_checks.{profile}[{index}]" check = _mapping(check_value, field) - unexpected = sorted(set(check) - {"service", "type"}) + kind = check.get("kind") + if not isinstance(kind, str) or kind not in _VERIFICATION_CHECK_FIELDS: + raise PolicyError( + f"{field}.kind must be one of: " + f"{', '.join(sorted(_VERIFICATION_CHECK_FIELDS))}" + ) + expected = _VERIFICATION_CHECK_FIELDS[kind] + unexpected = sorted(set(check) - expected) if unexpected: raise PolicyError( f"{field} contains unsupported keys: " f"{', '.join(unexpected)}" ) - for key in ("service", "type"): + for key in sorted(expected - {"arguments", "kind"}): if not isinstance(check.get(key), str) or not check[key]: raise PolicyError(f"{field}.{key} must be a string") + if kind == "openstack-cli": + arguments = check.get("arguments") + if ( + not isinstance(arguments, list) + or not arguments + or not all( + isinstance(argument, str) and argument + for argument in arguments + ) + ): + raise PolicyError( + f"{field}.arguments must be a non-empty list of strings" + ) owners: dict[tuple[str, str], str] = {} for component_name, component_value in self.components.items(): @@ -754,8 +780,8 @@ def _tempest_test_patterns(self, targets: Iterable[str]) -> list[str]: def _verification_checks( self, targets: Iterable[str], - ) -> list[dict[str, str]]: - checks: dict[tuple[str, str], dict[str, str]] = {} + ) -> list[dict[str, Any]]: + checks: dict[str, dict[str, Any]] = {} for target in targets: profiles = _strings( self.components[target].get("verification_profiles"), @@ -767,11 +793,8 @@ def _verification_checks( check_value, f"verification_checks.{profile}", ) - key = (check["service"], check["type"]) - checks[key] = { - "service": check["service"], - "type": check["type"], - } + key = json.dumps(check, sort_keys=True) + checks[key] = dict(check) return [checks[key] for key in sorted(checks)] def _tempest_required(self, targets: Iterable[str]) -> bool: @@ -906,11 +929,16 @@ def render_plan(plan: dict[str, Any]) -> str: " Tempest include: " + ", ".join(decision["tempest_tests"]) ) if decision["run"] and decision.get("verification_checks"): - checks = ( - f"{check['service']}/{check['type']}" - for check in decision["verification_checks"] - ) - lines.append(" API checks: " + ", ".join(checks)) + checks = [] + for check in decision["verification_checks"]: + if check["kind"] == "openstack-resource": + label = f"{check['service']}/{check['type']}" + elif check["kind"] == "openstack-cli": + label = "openstack " + " ".join(check["arguments"]) + else: + label = f"{check['namespace']}/{check['name']}" + checks.append(f"{check['kind']}:{label}") + lines.append(" verification: " + ", ".join(checks)) for reason in plan.get("reasons", []): lines.append(f"Reason: {reason}") return "\n".join(lines) diff --git a/ci/molecule-plan.yaml b/ci/molecule-plan.yaml index 9d17b7a54a..ce38f0bd50 100644 --- a/ci/molecule-plan.yaml +++ b/ci/molecule-plan.yaml @@ -185,28 +185,46 @@ rules: - reno.yaml - renovate.json -# Read-only OpenStack API checks keyed by the verification profiles used below. -# These provide focused coverage when the Tempest image does not include a -# service-specific plugin. +# Focused read-only checks keyed by the verification profiles used below. +# These provide coverage when the Tempest image does not include a +# component-specific plugin or test. verification_checks: secrets: - - service: key_manager + - kind: openstack-resource + service: key_manager type: secret placement: - - service: placement + - kind: openstack-resource + service: placement type: resource_provider orchestration: - - service: orchestration + - kind: openstack-resource + service: orchestration type: stack load-balancer: - - service: load_balancer + - kind: openstack-resource + service: load_balancer type: load_balancer container-infrastructure: - - service: container_infrastructure_management + - kind: openstack-resource + service: container_infrastructure_management type: cluster shared-file-system: - - service: shared_file_system + - kind: openstack-resource + service: shared_file_system type: share + identity-client: + - kind: openstack-cli + arguments: + - token + - issue + monitoring: + - kind: kubernetes-deployment + namespace: openstack + name: openstack-exporter + - kind: kubernetes-deployment + namespace: openstack + name: openstack-database-exporter # Dependencies below are CI test-environment requirements. They are deliberately # independent from any deployment orchestrator and resolve only to tags already diff --git a/doc/source/deploy/selective-ci.rst b/doc/source/deploy/selective-ci.rst index d97a44101c..b049d10ab4 100644 --- a/doc/source/deploy/selective-ci.rst +++ b/doc/source/deploy/selective-ci.rst @@ -26,9 +26,9 @@ The policy is stored in ``ci/molecule-plan.yaml`` and has four sections: full-suite fallbacks. ``verification_checks`` - Read-only OpenStack resource queries keyed by verification profile. These - provide focused API coverage when the Tempest image does not contain a - service plugin. + Focused, read-only checks keyed by verification profile. Supported check + kinds query OpenStack resources, run OpenStack client commands, or wait for + Kubernetes deployments. ``components`` Role and chart ownership, direct CI dependencies, verification profiles, and @@ -77,20 +77,28 @@ to one Tempest regular expression which also requires the ``smoke`` test attribute. This selects the intersection of the component namespaces and the smoke suite instead of adding unrelated smoke tests. -Verification profiles can also resolve to ``verification_checks``. The AIO -verifier runs each selected check with ``openstack.cloud.resources`` against -the deployed public API and its configured certificate authority. A successful +Verification profiles can also resolve to ``verification_checks``. An +``openstack-resource`` check uses ``openstack.cloud.resources`` against the +deployed public API and its configured certificate authority. A successful query proves that authentication, service discovery, TLS, and the target API are working even when the result is empty. Barbican, Placement, Heat, Magnum, -and Manila use this mechanism. Octavia keeps its plugin tests and also receives +and Manila use these checks. Octavia keeps its plugin tests and also receives an API check. -A component with neither Tempest expressions nor a resource check uses the +An ``openstack-cli`` check runs an argument list through the deployed +``openstack`` wrapper and generated ``openrc`` file. The OpenStack CLI +component requests a token this way, covering the wrapper container, +authentication, and certificate configuration. A ``kubernetes-deployment`` +check waits until the named deployment is Available. The OpenStack exporter +component waits for both its API and database exporters; their readiness probes +exercise the metrics endpoints. + +A component with neither Tempest expressions nor a verification check uses the ordinary smoke selection against the services available in its deployment -closure. This is the conservative fallback. In a multi-target change, resource -checks cover their own components without disabling focused Tempest expressions -from other targets. Full-fallback AIO jobs execute every declared resource -check. +closure. This is the conservative fallback. In a multi-target change, +verification checks cover their own components without disabling focused +Tempest expressions from other targets. Full-fallback AIO jobs execute every +declared verification check. Zuul artifacts ============== diff --git a/molecule/aio/verify.yml b/molecule/aio/verify.yml index 641b54bc88..2bd4011bac 100644 --- a/molecule/aio/verify.yml +++ b/molecule/aio/verify.yml @@ -44,16 +44,72 @@ | default('[]', true) | from_json }} + _atmosphere_ci_openstack_resource_checks: >- + {{ + _atmosphere_ci_verification_checks + | selectattr('kind', 'equalto', 'openstack-resource') + | list + }} + _atmosphere_ci_openstack_cli_checks: >- + {{ + _atmosphere_ci_verification_checks + | selectattr('kind', 'equalto', 'openstack-cli') + | list + }} + _atmosphere_ci_kubernetes_deployment_checks: >- + {{ + _atmosphere_ci_verification_checks + | selectattr('kind', 'equalto', 'kubernetes-deployment') + | list + }} tasks: - name: Query selected OpenStack service resources openstack.cloud.resources: cloud: atmosphere service: "{{ item.service }}" type: "{{ item.type }}" - loop: "{{ _atmosphere_ci_verification_checks }}" + loop: "{{ _atmosphere_ci_openstack_resource_checks }}" loop_control: label: "{{ item.service }}/{{ item.type }}" + - name: Run selected OpenStack client commands + ansible.builtin.command: + argv: >- + {{ + [ + '/bin/bash', + '-c', + '. /root/openrc && exec openstack "$@"', + 'openstack' + ] + + item.arguments + }} + changed_when: false + loop: "{{ _atmosphere_ci_openstack_cli_checks }}" + loop_control: + label: "openstack {{ item.arguments | join(' ') }}" + + - name: Wait for selected Kubernetes deployments + kubernetes.core.k8s_info: + api_version: apps/v1 + kind: Deployment + namespace: "{{ item.namespace }}" + name: "{{ item.name }}" + register: _atmosphere_ci_deployment + until: + - _atmosphere_ci_deployment.resources | length == 1 + - >- + _atmosphere_ci_deployment.resources[0].status.availableReplicas + | default(0) | int + >= + _atmosphere_ci_deployment.resources[0].spec.replicas + | default(1) | int + retries: 60 + delay: 5 + loop: "{{ _atmosphere_ci_kubernetes_deployment_checks }}" + loop_control: + label: "{{ item.namespace }}/{{ item.name }}" + - name: Run Tempest tests ansible.builtin.import_playbook: vexxhost.atmosphere.tempest tags: diff --git a/tests/unit/ci/test_molecule_plan.py b/tests/unit/ci/test_molecule_plan.py index 885580bf59..9ff074b3e6 100644 --- a/tests/unit/ci/test_molecule_plan.py +++ b/tests/unit/ci/test_molecule_plan.py @@ -98,11 +98,15 @@ def test_manila_includes_functional_dependencies( assert decision["tempest_tests"] == [] assert decision["run_tempest"] is False assert decision["verification_checks"] == [ - {"service": "shared_file_system", "type": "share"} + { + "kind": "openstack-resource", + "service": "shared_file_system", + "type": "share", + } ] -def test_unmapped_tempest_target_disables_filter_for_combined_change( +def test_cli_check_keeps_mapped_tempest_filter_for_combined_change( planner: Planner, ) -> None: plan = planner.plan( @@ -111,9 +115,16 @@ def test_unmapped_tempest_target_disables_filter_for_combined_change( Change(status="M", path="roles/openstack_cli/tasks/main.yml"), ] ) + decision = plan["job_decisions"]["aio-openvswitch"] - assert plan["job_decisions"]["aio-openvswitch"]["tempest_tests"] == [] - assert plan["job_decisions"]["aio-openvswitch"]["run_tempest"] is True + assert decision["tempest_tests"] == [r"^tempest\.api\.image\."] + assert decision["run_tempest"] is True + assert decision["verification_checks"] == [ + { + "arguments": ["token", "issue"], + "kind": "openstack-cli", + } + ] def test_api_check_keeps_mapped_tempest_filter_for_combined_change( @@ -130,7 +141,11 @@ def test_api_check_keeps_mapped_tempest_filter_for_combined_change( assert decision["tempest_tests"] == [r"^tempest\.api\.image\."] assert decision["run_tempest"] is True assert decision["verification_checks"] == [ - {"service": "shared_file_system", "type": "share"} + { + "kind": "openstack-resource", + "service": "shared_file_system", + "type": "share", + } ] @@ -159,7 +174,43 @@ def test_service_without_tempest_plugin_uses_read_only_api_check( assert decision["run_tempest"] is False assert decision["tempest_tests"] == [] assert decision["verification_checks"] == [ - {"service": service, "type": resource_type} + { + "kind": "openstack-resource", + "service": service, + "type": resource_type, + } + ] + + +def test_openstack_cli_uses_focused_client_check(planner: Planner) -> None: + plan = plan_path(planner, "roles/openstack_cli/tasks/main.yml") + decision = plan["job_decisions"]["aio-openvswitch"] + + assert decision["run_tempest"] is False + assert decision["verification_checks"] == [ + { + "arguments": ["token", "issue"], + "kind": "openstack-cli", + } + ] + + +def test_openstack_exporter_waits_for_both_deployments(planner: Planner) -> None: + plan = plan_path(planner, "roles/openstack_exporter/tasks/main.yml") + decision = plan["job_decisions"]["aio-openvswitch"] + + assert decision["run_tempest"] is False + assert decision["verification_checks"] == [ + { + "kind": "kubernetes-deployment", + "name": "openstack-database-exporter", + "namespace": "openstack", + }, + { + "kind": "kubernetes-deployment", + "name": "openstack-exporter", + "namespace": "openstack", + }, ] @@ -287,10 +338,25 @@ def test_unknown_runtime_path_falls_back_to_every_job( ("shared_file_system", "share"), } for job_name in ("aio-openvswitch", "aio-ovn"): + checks = plan["job_decisions"][job_name]["verification_checks"] assert { (check["service"], check["type"]) - for check in plan["job_decisions"][job_name]["verification_checks"] + for check in checks + if check["kind"] == "openstack-resource" } == expected_checks + assert { + tuple(check["arguments"]) + for check in checks + if check["kind"] == "openstack-cli" + } == {("token", "issue")} + assert { + (check["namespace"], check["name"]) + for check in checks + if check["kind"] == "kubernetes-deployment" + } == { + ("openstack", "openstack-database-exporter"), + ("openstack", "openstack-exporter"), + } assert plan["job_decisions"]["csi-rbd"]["verification_checks"] == [] @@ -378,7 +444,12 @@ def test_invalid_tempest_test_regular_expression_is_rejected() -> None: def test_malformed_verification_check_is_rejected() -> None: policy = yaml.safe_load(POLICY_PATH.read_text(encoding="utf-8")) invalid = copy.deepcopy(policy) - invalid["verification_checks"]["secrets"] = [{"service": "key_manager"}] + invalid["verification_checks"]["secrets"] = [ + { + "kind": "openstack-resource", + "service": "key_manager", + } + ] with pytest.raises( PolicyError, @@ -387,6 +458,36 @@ def test_malformed_verification_check_is_rejected() -> None: Planner(invalid) +def test_unknown_verification_check_kind_is_rejected() -> None: + policy = yaml.safe_load(POLICY_PATH.read_text(encoding="utf-8")) + invalid = copy.deepcopy(policy) + invalid["verification_checks"]["secrets"] = [{"kind": "shell"}] + + with pytest.raises( + PolicyError, + match=r"verification_checks\.secrets\[0\]\.kind must be one of", + ): + Planner(invalid) + + +def test_empty_openstack_cli_arguments_are_rejected() -> None: + policy = yaml.safe_load(POLICY_PATH.read_text(encoding="utf-8")) + invalid = copy.deepcopy(policy) + invalid["verification_checks"]["identity-client"] = [ + { + "arguments": [], + "kind": "openstack-cli", + } + ] + + with pytest.raises( + PolicyError, + match=r"verification_checks\.identity-client\[0\]\.arguments must be " + r"a non-empty list of strings", + ): + Planner(invalid) + + def test_every_declared_role_maps_to_its_component( planner: Planner, ) -> None: @@ -494,9 +595,12 @@ def test_selective_ci_uses_main_sequential_molecule_flow() -> None: assert "image_ref_alt" in tempest_tasks assert "flavor_ref_alt" in tempest_tasks assert "ATMOSPHERE_CI_VERIFICATION_CHECKS" in runner - assert "openstack.cloud.resources" in ( - repository / "molecule" / "aio" / "verify.yml" - ).read_text(encoding="utf-8") + verifier = (repository / "molecule" / "aio" / "verify.yml").read_text( + encoding="utf-8" + ) + assert "openstack.cloud.resources" in verifier + assert "Run selected OpenStack client commands" in verifier + assert "Wait for selected Kubernetes deployments" in verifier assert "go build" not in converge assert "./bin/atmosphere" not in runner assert "dependency_options" not in policy @@ -613,4 +717,4 @@ def test_text_output_is_compact_and_readable(planner: Planner) -> None: assert "RUN aio-openvswitch" in output assert "SKIP aio-ovn" in output assert "components:" in output - assert "API checks: shared_file_system/share" in output + assert "verification: openstack-resource:shared_file_system/share" in output From 615849b08cd3c8712afdafae5512fa5fa58af229 Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Thu, 30 Jul 2026 07:23:24 +0300 Subject: [PATCH 14/27] fix(ci): allow Keycloak migrations to finish Signed-off-by: Tadas Sutkaitis --- .zuul.yaml | 10 ++++++++-- doc/source/deploy/selective-ci.rst | 18 ++++++++++-------- tests/unit/ci/test_molecule_plan.py | 4 ++++ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/.zuul.yaml b/.zuul.yaml index b075b0cbe3..587fd16159 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -44,7 +44,10 @@ atmosphere_ci_job: aio-openvswitch atmosphere_ci_molecule_timeout: 9000 csi_driver: rbd - keycloak_helm_timeout: 15m0s + keycloak_helm_timeout: 30m0s + keycloak_helm_values: + startupProbe: + failureThreshold: 300 neutron_helm_timeout: 10m0s nova_helm_timeout: 10m0s octavia_helm_timeout: 10m0s @@ -112,7 +115,10 @@ atmosphere_ci_job: aio-ovn atmosphere_ci_molecule_timeout: 9000 csi_driver: rbd - keycloak_helm_timeout: 15m0s + keycloak_helm_timeout: 30m0s + keycloak_helm_values: + startupProbe: + failureThreshold: 300 neutron_helm_timeout: 10m0s nova_helm_timeout: 10m0s octavia_helm_timeout: 10m0s diff --git a/doc/source/deploy/selective-ci.rst b/doc/source/deploy/selective-ci.rst index b049d10ab4..da52626861 100644 --- a/doc/source/deploy/selective-ci.rst +++ b/doc/source/deploy/selective-ci.rst @@ -118,11 +118,13 @@ scheduled job at runtime and reports its exact decision. The selective jobs disable Zuul's implicit configuration-update override because the planner, policy, playbook, and Zuul configuration paths already bypass every exclusion. -The selective AIO jobs allow fifteen minutes for Keycloak and ten minutes for -the Nova, Neutron, Octavia, and Manila Helm operations. Clean database -migrations and initial service rollouts can exceed their normal timeouts on a -busy test node even when they complete successfully. The complete AIO Molecule -lifecycle has a 150-minute command limit so full fallback runs have enough time -to finish idempotence and verification. The enclosing Zuul job has a -180-minute limit, reserving another thirty minutes for preparation and -post-run artifact collection. +The selective AIO jobs allow thirty minutes for Keycloak and ten minutes for +the Nova, Neutron, Octavia, and Manila Helm operations. Keycloak's startup +probe also allows twenty-five minutes so Kubernetes does not restart the +service during its initial database migration. Clean database migrations and +initial service rollouts can exceed their normal timeouts on a busy test node +even when they complete successfully. The complete AIO Molecule lifecycle has +a 150-minute command limit so full fallback runs have enough time to finish +idempotence and verification. The enclosing Zuul job has a 180-minute limit, +reserving another thirty minutes for preparation and post-run artifact +collection. diff --git a/tests/unit/ci/test_molecule_plan.py b/tests/unit/ci/test_molecule_plan.py index 9ff074b3e6..673bf8b3b9 100644 --- a/tests/unit/ci/test_molecule_plan.py +++ b/tests/unit/ci/test_molecule_plan.py @@ -619,6 +619,10 @@ def test_full_aio_jobs_have_timeout_headroom() -> None: ): assert jobs[job_name]["timeout"] == 10800 assert jobs[job_name]["vars"]["atmosphere_ci_molecule_timeout"] == 9000 + assert jobs[job_name]["vars"]["keycloak_helm_timeout"] == "30m0s" + assert jobs[job_name]["vars"]["keycloak_helm_values"] == { + "startupProbe": {"failureThreshold": 300} + } assert jobs[job_name]["vars"]["manila_helm_timeout"] == "10m0s" From 395f8794515762a3f096b7f27b99dcdd21ffd82c Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Thu, 30 Jul 2026 07:36:28 +0300 Subject: [PATCH 15/27] refactor(ci): simplify component dependency policy Signed-off-by: Tadas Sutkaitis --- atmosphere/ci/molecule_plan.py | 111 ++++++++++- ci/molecule-plan.yaml | 299 +++++++++++----------------- doc/source/deploy/selective-ci.rst | 64 +++++- tests/unit/ci/test_molecule_plan.py | 116 ++++++++++- 4 files changed, 391 insertions(+), 199 deletions(-) diff --git a/atmosphere/ci/molecule_plan.py b/atmosphere/ci/molecule_plan.py index 0925efab92..66803795ec 100644 --- a/atmosphere/ci/molecule_plan.py +++ b/atmosphere/ci/molecule_plan.py @@ -21,6 +21,21 @@ "openstack-cli": {"arguments", "kind"}, "openstack-resource": {"kind", "service", "type"}, } +_COMPONENT_FIELDS = { + "backend_requires", + "charts", + "jobs", + "paths", + "requires", + "roles", + "stacks", + "tag", + "tempest_tests", + "test_requires", + "verification_profiles", +} +_COMPONENT_DEFAULT_FIELDS = {"jobs"} +_DEPENDENCY_STACK_FIELDS = {"requires", "stacks"} class PolicyError(ValueError): @@ -164,7 +179,16 @@ class Planner: def __init__(self, policy: dict[str, Any]) -> None: self.policy = policy self.jobs = _mapping(policy.get("jobs"), "jobs") - self.components = _mapping(policy.get("components"), "components") + self.component_defaults = _mapping( + policy.get("component_defaults", {}), + "component_defaults", + ) + self.dependency_stacks = _mapping( + policy.get("dependency_stacks", {}), + "dependency_stacks", + ) + self.raw_components = _mapping(policy.get("components"), "components") + self.components = self._resolve_components() self.verification_checks = _mapping( policy.get("verification_checks", {}), "verification_checks", @@ -185,9 +209,90 @@ def load(cls, path: str | Path) -> "Planner": raise PolicyError("policy document must be a mapping") return cls(policy) + def _resolve_components(self) -> dict[str, dict[str, Any]]: + """Apply defaults and dependency stacks to component declarations.""" + + unexpected_defaults = sorted( + set(self.component_defaults) - _COMPONENT_DEFAULT_FIELDS + ) + if unexpected_defaults: + raise PolicyError( + "component_defaults contains unsupported keys: " + f"{', '.join(unexpected_defaults)}" + ) + default_jobs = _strings( + self.component_defaults.get("jobs"), + "component_defaults.jobs", + ) + defaults = {"jobs": default_jobs} if default_jobs else {} + + expanded_stacks: dict[str, list[str]] = {} + expanding: set[str] = set() + + def expand_stack(name: str) -> list[str]: + if name in expanded_stacks: + return expanded_stacks[name] + if name not in self.dependency_stacks: + raise PolicyError(f"unknown dependency stack {name!r}") + if name in expanding: + raise PolicyError( + f"dependency stack graph contains a cycle at {name!r}" + ) + + expanding.add(name) + field = f"dependency_stacks.{name}" + stack = _mapping(self.dependency_stacks[name], field) + unexpected = sorted(set(stack) - _DEPENDENCY_STACK_FIELDS) + if unexpected: + raise PolicyError( + f"{field} contains unsupported keys: {', '.join(unexpected)}" + ) + + dependencies: list[str] = [] + for nested in _strings(stack.get("stacks"), f"{field}.stacks"): + dependencies.extend(expand_stack(nested)) + for dependency in _strings(stack.get("requires"), f"{field}.requires"): + if dependency not in self.raw_components: + raise PolicyError( + f"{field} requires unknown component {dependency!r}" + ) + dependencies.append(dependency) + + expanding.remove(name) + expanded_stacks[name] = _unique(dependencies) + return expanded_stacks[name] + + for stack_name in self.dependency_stacks: + expand_stack(stack_name) + + resolved: dict[str, dict[str, Any]] = {} + for component_name, component_value in self.raw_components.items(): + field = f"components.{component_name}" + raw_component = _mapping(component_value, field) + unexpected = sorted(set(raw_component) - _COMPONENT_FIELDS) + if unexpected: + raise PolicyError( + f"{field} contains unsupported keys: {', '.join(unexpected)}" + ) + + component = dict(defaults) + component.update(raw_component) + stack_names = _strings(component.get("stacks"), f"{field}.stacks") + dependencies: list[str] = [] + for stack_name in stack_names: + dependencies.extend(expand_stack(stack_name)) + dependencies.extend( + _strings(component.get("requires"), f"{field}.requires") + ) + component["requires"] = _unique(dependencies) + component.setdefault("roles", [component_name.replace("-", "_")]) + component.setdefault("charts", [component_name]) + resolved[component_name] = component + return resolved + def _validate(self, rules: list[Any]) -> None: - if self.policy.get("version") != 1: - raise PolicyError("policy version must be 1") + if self.policy.get("version") != 2: + raise PolicyError("policy version must be 2") if not self.jobs: raise PolicyError("at least one job must be configured") if not self.components: diff --git a/ci/molecule-plan.yaml b/ci/molecule-plan.yaml index ce38f0bd50..4d35cb1264 100644 --- a/ci/molecule-plan.yaml +++ b/ci/molecule-plan.yaml @@ -1,5 +1,5 @@ --- -version: 1 +version: 2 # These jobs form the selector's complete execution set. Zuul uses generated # irrelevant-files filters to omit jobs assigned exclusively to other paths, @@ -226,9 +226,64 @@ verification_checks: namespace: openstack name: openstack-database-exporter -# Dependencies below are CI test-environment requirements. They are deliberately -# independent from any deployment orchestrator and resolve only to tags already -# present in the sequential playbooks on main. +# Most components run in the Open vSwitch AIO job. Components only declare jobs +# when they need a different scenario or network backend. +component_defaults: + jobs: + - aio-openvswitch + +# Reusable test-environment dependency stacks keep deployment structure out of +# individual service declarations. Stacks can compose other stacks. A backend +# migration should normally change one stack while service ownership and +# verification remain unchanged. +dependency_stacks: + kubernetes-workload: + requires: + - kubernetes + + ceph-kubernetes: + requires: + - ceph + - kubernetes + + public-endpoint: + requires: + - cluster-issuer + - ingress-nginx + + keycloak-foundation: + stacks: + - public-endpoint + requires: + - percona-xtradb-cluster + + identity-foundation: + stacks: + - public-endpoint + requires: + - keycloak + - memcached + - percona-xtradb-cluster + - rabbitmq-cluster-operator + + openstack-api: + requires: + - keystone + + ceph-backed-openstack-api: + stacks: + - openstack-api + requires: + - ceph-provisioners + + monitoring-foundation: + requires: + - kube-prometheus-stack + +# Components infer ownership of ``roles/`` and +# ``charts/``. The ``roles`` and ``charts`` keys below are only aliases +# or explicit empty ownership declarations. Dependencies resolve to Ansible +# tags already present in the sequential playbooks on main. components: ceph: roles: [] @@ -265,9 +320,8 @@ components: - ibm_block_csi_driver charts: - hpe-csi-driver - requires: - - ceph - - kubernetes + stacks: + - ceph-kubernetes jobs: - csi-local-path-provisioner - csi-rbd @@ -275,22 +329,18 @@ components: - csi cert-manager: - roles: - - cert_manager charts: - cert-manager - cert-manager-webhook-infoblox-wapi - godaddy-webhook - requires: - - kubernetes + stacks: + - kubernetes-workload jobs: - keycloak verification_profiles: - infrastructure cluster-issuer: - roles: - - cluster_issuer charts: [] requires: - cert-manager @@ -300,22 +350,14 @@ components: - infrastructure ingress-nginx: - roles: - - ingress_nginx - charts: - - ingress-nginx - requires: - - kubernetes + stacks: + - kubernetes-workload jobs: - keycloak verification_profiles: - infrastructure rabbitmq-cluster-operator: - roles: - - rabbitmq_cluster_operator - charts: - - rabbitmq-cluster-operator requires: - cert-manager jobs: @@ -324,8 +366,6 @@ components: - messaging percona-xtradb-cluster-operator: - roles: - - percona_xtradb_cluster_operator charts: - pxc-operator requires: @@ -336,8 +376,6 @@ components: - database percona-xtradb-cluster: - roles: - - percona_xtradb_cluster charts: - pxc-db requires: @@ -352,265 +390,174 @@ components: requires: - cluster-issuer - csi - jobs: - - aio-openvswitch verification_profiles: - cache keycloak: - requires: - - cluster-issuer - - ingress-nginx - - percona-xtradb-cluster + stacks: + - keycloak-foundation jobs: - keycloak verification_profiles: - keycloak-federation keepalived: - requires: - - kubernetes - jobs: - - aio-openvswitch + stacks: + - kubernetes-workload verification_profiles: - infrastructure node-feature-discovery: - roles: - - node_feature_discovery - charts: - - node-feature-discovery - requires: - - kubernetes - jobs: - - aio-openvswitch + stacks: + - kubernetes-workload verification_profiles: - monitoring kube-prometheus-stack: - roles: - - kube_prometheus_stack - charts: - - kube-prometheus-stack requires: - cluster-issuer - csi - keycloak - jobs: - - aio-openvswitch verification_profiles: - monitoring loki: requires: - csi - jobs: - - aio-openvswitch verification_profiles: - observability vector: requires: - loki - jobs: - - aio-openvswitch verification_profiles: - observability goldpinger: - requires: - - kubernetes - jobs: - - aio-openvswitch + stacks: + - kubernetes-workload verification_profiles: - monitoring ipmi-exporter: - roles: - - ipmi_exporter charts: - prometheus-ipmi-exporter - requires: - - kube-prometheus-stack - jobs: - - aio-openvswitch + stacks: + - monitoring-foundation verification_profiles: - monitoring smartctl-exporter: - roles: - - smartctl_exporter charts: - prometheus-smartctl-exporter - requires: - - kube-prometheus-stack - jobs: - - aio-openvswitch + stacks: + - monitoring-foundation verification_profiles: - monitoring prometheus-pushgateway: - roles: - - prometheus_pushgateway - charts: - - prometheus-pushgateway - requires: - - kube-prometheus-stack - jobs: - - aio-openvswitch + stacks: + - monitoring-foundation verification_profiles: - monitoring lpfc: charts: [] - jobs: - - aio-openvswitch verification_profiles: - host-storage multipathd: charts: [] - jobs: - - aio-openvswitch verification_profiles: - host-storage iscsi: charts: [] - jobs: - - aio-openvswitch verification_profiles: - host-storage udev: charts: [] - jobs: - - aio-openvswitch verification_profiles: - host-storage memcached: - requires: - - kubernetes - jobs: - - aio-openvswitch + stacks: + - kubernetes-workload verification_profiles: - cache keystone: - requires: - - ingress-nginx - - keycloak - - memcached - - percona-xtradb-cluster - - rabbitmq-cluster-operator - jobs: - - aio-openvswitch + stacks: + - identity-foundation verification_profiles: - identity tempest_tests: - '^tempest\.api\.identity\.' barbican: - requires: - - keystone - jobs: - - aio-openvswitch + stacks: + - openstack-api verification_profiles: - secrets rook-ceph: - roles: - - rook_ceph - charts: - - rook-ceph - requires: - - kubernetes - jobs: - - aio-openvswitch + stacks: + - kubernetes-workload verification_profiles: - storage rook-ceph-cluster: - roles: - - rook_ceph_cluster - charts: - - rook-ceph-cluster requires: - ceph - keystone - rook-ceph - jobs: - - aio-openvswitch verification_profiles: - storage ceph-provisioners: - roles: - - ceph_provisioners - charts: - - ceph-provisioners - requires: - - ceph - - kubernetes - jobs: - - aio-openvswitch + stacks: + - ceph-kubernetes verification_profiles: - storage glance: - requires: - - ceph-provisioners - - keystone - jobs: - - aio-openvswitch + stacks: + - ceph-backed-openstack-api verification_profiles: - image tempest_tests: - '^tempest\.api\.image\.' staffeln: - requires: - - keystone - jobs: - - aio-openvswitch + stacks: + - openstack-api verification_profiles: - volume cinder: - requires: - - ceph-provisioners - - keystone + stacks: + - ceph-backed-openstack-api test_requires: - glance - jobs: - - aio-openvswitch verification_profiles: - volume tempest_tests: - '^tempest\.api\.volume\.' placement: - requires: - - keystone - jobs: - - aio-openvswitch + stacks: + - openstack-api verification_profiles: - placement openvswitch: - requires: - - kubernetes - jobs: - - aio-openvswitch + stacks: + - kubernetes-workload verification_profiles: - network frr-k8s: - roles: - - frr_k8s - charts: - - frr-k8s tag: frr_k8s requires: - kubernetes @@ -629,19 +576,16 @@ components: - network libvirt: + stacks: + - kubernetes-workload requires: - cluster-issuer - - kubernetes - jobs: - - aio-openvswitch verification_profiles: - compute coredns: - requires: - - kubernetes - jobs: - - aio-openvswitch + stacks: + - kubernetes-workload verification_profiles: - network @@ -652,8 +596,6 @@ components: - placement test_requires: - neutron - jobs: - - aio-openvswitch verification_profiles: - compute tempest_tests: @@ -680,13 +622,11 @@ components: - '^tempest\.scenario\.test_network_' heat: - requires: - - keystone + stacks: + - openstack-api test_requires: - glance - neutron - jobs: - - aio-openvswitch verification_profiles: - orchestration @@ -695,17 +635,16 @@ components: - barbican - neutron - nova - jobs: - - aio-openvswitch verification_profiles: - load-balancer tempest_tests: - '^octavia_tempest_plugin\.' magnum: + stacks: + - openstack-api requires: - glance - - keystone test_requires: - barbican - cinder @@ -713,8 +652,6 @@ components: - heat - neutron - octavia - jobs: - - aio-openvswitch verification_profiles: - container-infrastructure @@ -723,43 +660,29 @@ components: - cinder - neutron - nova - jobs: - - aio-openvswitch verification_profiles: - shared-file-system horizon: - requires: - - keystone + stacks: + - openstack-api test_requires: - nova - jobs: - - aio-openvswitch verification_profiles: - dashboard tempest_tests: - '^tempest\.scenario\.test_dashboard_basic_ops\.' openstack-exporter: - roles: - - openstack_exporter - charts: - - openstack-exporter requires: - cinder - neutron - jobs: - - aio-openvswitch verification_profiles: - monitoring openstack-cli: - roles: - - openstack_cli charts: [] - requires: - - keystone - jobs: - - aio-openvswitch + stacks: + - openstack-api verification_profiles: - identity-client diff --git a/doc/source/deploy/selective-ci.rst b/doc/source/deploy/selective-ci.rst index da52626861..0e44bd93ba 100644 --- a/doc/source/deploy/selective-ci.rst +++ b/doc/source/deploy/selective-ci.rst @@ -16,11 +16,20 @@ idempotence actions. Policy ====== -The policy is stored in ``ci/molecule-plan.yaml`` and has four sections: +The policy is stored in ``ci/molecule-plan.yaml`` and has these sections: ``jobs`` Static Zuul jobs which can consume a decision. +``component_defaults`` + Values inherited by components unless they declare an exception. Most + components use the Open vSwitch AIO job, so they do not repeat it. + +``dependency_stacks`` + Reusable groups of test-environment dependencies. Stacks can include other + stacks, allowing services to share definitions such as + ``identity-foundation`` and ``ceph-backed-openstack-api``. + ``rules`` Shared paths, ignored paths, focused scenario paths, and conservative full-suite fallbacks. @@ -31,9 +40,12 @@ The policy is stored in ``ci/molecule-plan.yaml`` and has four sections: Kubernetes deployments. ``components`` - Role and chart ownership, direct CI dependencies, verification profiles, and - the jobs which exercise each component. A component can also declare - ``tempest_tests`` regular expressions to restrict Tempest to its smoke tests. + CI dependencies, verification profiles, and exceptions to the defaults. A + component automatically owns ``roles/`` with hyphens changed + to underscores and ``charts/``. The ``roles`` and ``charts`` + fields are only needed for aliases or explicit empty ownership. A component + can also declare ``tempest_tests`` regular expressions to restrict Tempest + to its smoke tests. Dependencies describe the test environment, not deployment concurrency. For example, a Keystone change includes Kubernetes, Ceph-backed CSI, the Percona @@ -45,9 +57,47 @@ Changing the policy =================== Add or update a component in ``ci/molecule-plan.yaml`` when introducing a role -or chart. Keep dependencies explicit and prefer the narrowest job which performs -meaningful verification. Shared code should use a ``full`` rule unless its -impact is safely bounded. +or chart. Use an existing dependency stack before adding direct requirements, +and add a new stack when several components share the same environment. Declare +ownership and jobs only when they differ from the inferred defaults. Shared +code should use a ``full`` rule unless its impact is safely bounded. + +For example, migrating only Keycloak from PXC to a PostgreSQL operator does not +require editing Keystone, Glance, or their path matching. Add components for +the operator and database, then change the database required by +``keycloak-foundation``: + +.. code-block:: yaml + + dependency_stacks: + keycloak-foundation: + stacks: + - public-endpoint + requires: + - keycloak-postgresql + + components: + cloudnative-pg-operator: + charts: + - cloudnative-pg + jobs: + - keycloak + roles: + - cloudnative_pg_operator + + keycloak-postgresql: + charts: [] + jobs: + - keycloak + requires: + - cloudnative-pg-operator + - csi + roles: + - keycloak_postgresql + +Keystone continues to receive PXC from ``identity-foundation`` for its own +database while receiving PostgreSQL transitively through Keycloak. The policy +validator rejects unknown components and dependency-stack cycles. Validate the policy and inspect representative plans locally: diff --git a/tests/unit/ci/test_molecule_plan.py b/tests/unit/ci/test_molecule_plan.py index 673bf8b3b9..60930fb280 100644 --- a/tests/unit/ci/test_molecule_plan.py +++ b/tests/unit/ci/test_molecule_plan.py @@ -35,7 +35,34 @@ def components(plan: dict, job: str = "aio-openvswitch") -> set[str]: def test_policy_is_valid(planner: Planner) -> None: - assert planner.policy["version"] == 1 + assert planner.policy["version"] == 2 + + +def test_component_defaults_and_inferred_ownership_reduce_policy_noise( + planner: Planner, +) -> None: + assert "jobs" not in planner.raw_components["glance"] + assert "roles" not in planner.raw_components["ingress-nginx"] + assert "charts" not in planner.raw_components["ingress-nginx"] + assert planner.components["glance"]["jobs"] == ["aio-openvswitch"] + assert planner.components["ingress-nginx"]["roles"] == ["ingress_nginx"] + assert planner.components["ingress-nginx"]["charts"] == ["ingress-nginx"] + + +def test_dependency_stacks_expand_to_identity_dependencies(planner: Planner) -> None: + assert planner.components["keycloak"]["requires"] == [ + "cluster-issuer", + "ingress-nginx", + "percona-xtradb-cluster", + ] + assert planner.components["keystone"]["requires"] == [ + "cluster-issuer", + "ingress-nginx", + "keycloak", + "memcached", + "percona-xtradb-cluster", + "rabbitmq-cluster-operator", + ] def test_keystone_uses_small_sequential_closure(planner: Planner) -> None: @@ -76,6 +103,38 @@ def test_glance_includes_storage_and_identity_only( assert decision["tempest_tests"] == [r"^tempest\.api\.image\."] +def test_glance_and_keystone_changes_use_the_union_scope(planner: Planner) -> None: + plan = planner.plan( + [ + Change(status="M", path="roles/glance/tasks/main.yml"), + Change(status="M", path="roles/keystone/tasks/main.yml"), + ] + ) + decision = plan["job_decisions"]["aio-openvswitch"] + + assert plan["targets"] == ["glance", "keystone"] + assert set(decision["components"]) == { + "ceph", + "ceph-provisioners", + "cert-manager", + "cluster-issuer", + "csi", + "glance", + "ingress-nginx", + "keycloak", + "keystone", + "kubernetes", + "memcached", + "percona-xtradb-cluster", + "percona-xtradb-cluster-operator", + "rabbitmq-cluster-operator", + } + assert decision["tempest_tests"] == [ + r"^tempest\.api\.identity\.", + r"^tempest\.api\.image\.", + ] + + def test_manila_includes_functional_dependencies( planner: Planner, ) -> None: @@ -404,6 +463,61 @@ def test_component_graph_cycle_is_rejected() -> None: Planner(invalid) +def test_dependency_stack_cycle_is_rejected() -> None: + policy = yaml.safe_load(POLICY_PATH.read_text(encoding="utf-8")) + invalid = copy.deepcopy(policy) + invalid["dependency_stacks"]["public-endpoint"]["stacks"] = ["keycloak-foundation"] + + with pytest.raises(PolicyError, match="dependency stack graph contains a cycle"): + Planner(invalid) + + +def test_unknown_dependency_stack_is_rejected() -> None: + policy = yaml.safe_load(POLICY_PATH.read_text(encoding="utf-8")) + invalid = copy.deepcopy(policy) + invalid["components"]["keycloak"]["stacks"] = ["missing-database"] + + with pytest.raises(PolicyError, match="unknown dependency stack"): + Planner(invalid) + + +def test_keycloak_backend_stack_can_migrate_without_changing_keystone_database() -> ( + None +): + policy = yaml.safe_load(POLICY_PATH.read_text(encoding="utf-8")) + migrated = copy.deepcopy(policy) + migrated["components"]["cloudnative-pg-operator"] = { + "charts": ["cloudnative-pg"], + "jobs": ["keycloak"], + "requires": ["cert-manager"], + "roles": ["cloudnative_pg_operator"], + "verification_profiles": ["database"], + } + migrated["components"]["keycloak-postgresql"] = { + "charts": [], + "jobs": ["keycloak"], + "requires": ["cloudnative-pg-operator", "csi"], + "roles": ["keycloak_postgresql"], + "verification_profiles": ["database"], + } + migrated["dependency_stacks"]["keycloak-foundation"]["requires"] = [ + "keycloak-postgresql" + ] + + migrated_planner = Planner(migrated) + plan = plan_path(migrated_planner, "roles/keystone/tasks/main.yml") + closure = components(plan) + + assert migrated_planner.components["keycloak"]["requires"] == [ + "cluster-issuer", + "ingress-nginx", + "keycloak-postgresql", + ] + assert "keycloak-postgresql" in closure + assert "cloudnative-pg-operator" in closure + assert "percona-xtradb-cluster" in closure + + def test_duplicate_policy_values_are_rejected() -> None: policy = yaml.safe_load(POLICY_PATH.read_text(encoding="utf-8")) invalid = copy.deepcopy(policy) From 6867579c30a7d8289bf43fda396e33b9f2469ec7 Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Thu, 30 Jul 2026 17:06:29 +0300 Subject: [PATCH 16/27] fix(ci): allow Nova migrations to finish Signed-off-by: Tadas Sutkaitis --- .zuul.yaml | 4 ++-- doc/source/deploy/selective-ci.rst | 13 +++++++------ tests/unit/ci/test_molecule_plan.py | 1 + 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.zuul.yaml b/.zuul.yaml index 587fd16159..040ef9597d 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -49,7 +49,7 @@ startupProbe: failureThreshold: 300 neutron_helm_timeout: 10m0s - nova_helm_timeout: 10m0s + nova_helm_timeout: 20m0s octavia_helm_timeout: 10m0s manila_helm_timeout: 10m0s @@ -120,7 +120,7 @@ startupProbe: failureThreshold: 300 neutron_helm_timeout: 10m0s - nova_helm_timeout: 10m0s + nova_helm_timeout: 20m0s octavia_helm_timeout: 10m0s manila_helm_timeout: 10m0s diff --git a/doc/source/deploy/selective-ci.rst b/doc/source/deploy/selective-ci.rst index 0e44bd93ba..a2b64b942a 100644 --- a/doc/source/deploy/selective-ci.rst +++ b/doc/source/deploy/selective-ci.rst @@ -168,12 +168,13 @@ scheduled job at runtime and reports its exact decision. The selective jobs disable Zuul's implicit configuration-update override because the planner, policy, playbook, and Zuul configuration paths already bypass every exclusion. -The selective AIO jobs allow thirty minutes for Keycloak and ten minutes for -the Nova, Neutron, Octavia, and Manila Helm operations. Keycloak's startup -probe also allows twenty-five minutes so Kubernetes does not restart the -service during its initial database migration. Clean database migrations and -initial service rollouts can exceed their normal timeouts on a busy test node -even when they complete successfully. The complete AIO Molecule lifecycle has +The selective AIO jobs allow thirty minutes for Keycloak, twenty minutes for +Nova, and ten minutes for the Neutron, Octavia, and Manila Helm operations. +Keycloak's startup probe also allows twenty-five minutes so Kubernetes does +not restart the service during its initial database migration. Clean database +migrations and initial service rollouts can exceed their normal timeouts on a +busy test node even when they complete successfully. The complete AIO Molecule +lifecycle has a 150-minute command limit so full fallback runs have enough time to finish idempotence and verification. The enclosing Zuul job has a 180-minute limit, reserving another thirty minutes for preparation and post-run artifact diff --git a/tests/unit/ci/test_molecule_plan.py b/tests/unit/ci/test_molecule_plan.py index 60930fb280..a7487e2cee 100644 --- a/tests/unit/ci/test_molecule_plan.py +++ b/tests/unit/ci/test_molecule_plan.py @@ -734,6 +734,7 @@ def test_full_aio_jobs_have_timeout_headroom() -> None: assert jobs[job_name]["timeout"] == 10800 assert jobs[job_name]["vars"]["atmosphere_ci_molecule_timeout"] == 9000 assert jobs[job_name]["vars"]["keycloak_helm_timeout"] == "30m0s" + assert jobs[job_name]["vars"]["nova_helm_timeout"] == "20m0s" assert jobs[job_name]["vars"]["keycloak_helm_values"] == { "startupProbe": {"failureThreshold": 300} } From eef6283c0bfa9ae4c42809dddd47292f597e3a67 Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Thu, 30 Jul 2026 21:52:01 +0300 Subject: [PATCH 17/27] fix(ci): scope combination metadata per job Signed-off-by: Tadas Sutkaitis --- atmosphere/ci/molecule_plan.py | 16 +++++-- doc/source/deploy/selective-ci.rst | 6 +++ tests/unit/ci/test_molecule_plan.py | 74 +++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/atmosphere/ci/molecule_plan.py b/atmosphere/ci/molecule_plan.py index 66803795ec..0b1547b7b3 100644 --- a/atmosphere/ci/molecule_plan.py +++ b/atmosphere/ci/molecule_plan.py @@ -675,6 +675,16 @@ def plan(self, changes: Sequence[Change]) -> dict[str, Any]: } ) else: + profiles = sorted( + { + profile + for root in roots + for profile in _strings( + self.components[root].get("verification_profiles"), + f"components.{root}.verification_profiles", + ) + } + ) decision = { "run": True, "reason": "selected by changed components", @@ -683,9 +693,9 @@ def plan(self, changes: Sequence[Change]) -> dict[str, Any]: "targets": roots, "components": [], "ansible_tags": [], - "verification_profiles": sorted(all_profiles), - "verification_checks": all_verification_checks, - "tempest_tests": all_tempest_tests, + "verification_profiles": profiles, + "verification_checks": self._verification_checks(roots), + "tempest_tests": self._tempest_test_patterns(roots), "run_tempest": False, } decisions[job_name] = decision diff --git a/doc/source/deploy/selective-ci.rst b/doc/source/deploy/selective-ci.rst index a2b64b942a..583c4f88c1 100644 --- a/doc/source/deploy/selective-ci.rst +++ b/doc/source/deploy/selective-ci.rst @@ -53,6 +53,12 @@ XtraDB Cluster, Keycloak, RabbitMQ, Memcached, ingress, and certificate components. It does not include Glance, Nova, Manila, Magnum, or other unrelated OpenStack APIs. +For a multi-component change, each job receives only the changed targets +assigned to that job and then expands their shared dependency closure. For +example, a Neutron change selects both network backends, while an unrelated +Open vSwitch-only target is not added to the OVN job. Verification profiles +and checks are scoped the same way. + Changing the policy =================== diff --git a/tests/unit/ci/test_molecule_plan.py b/tests/unit/ci/test_molecule_plan.py index a7487e2cee..51d171d5d1 100644 --- a/tests/unit/ci/test_molecule_plan.py +++ b/tests/unit/ci/test_molecule_plan.py @@ -4,6 +4,8 @@ from __future__ import annotations import copy +import itertools +import json import re from pathlib import Path @@ -30,6 +32,15 @@ def plan_path(planner: Planner, path: str) -> dict: return planner.plan([Change(status="M", path=path)]) +def component_path(planner: Planner, component_name: str) -> str: + roles = planner.components[component_name]["roles"] + if roles: + return f"roles/{roles[0]}/tasks/main.yml" + if component_name == "ceph": + return "playbooks/ceph.yml" + raise AssertionError(f"{component_name} has no representative change path") + + def components(plan: dict, job: str = "aio-openvswitch") -> set[str]: return set(plan["job_decisions"][job]["components"]) @@ -624,6 +635,69 @@ def test_every_declared_chart_and_patch_maps_to_its_component( assert component_name in plan["targets"] +def test_every_component_pair_produces_the_union_of_individual_plans( + planner: Planner, +) -> None: + individual = { + name: plan_path(planner, component_path(planner, name)) + for name in planner.components + } + + for left_name, right_name in itertools.combinations(planner.components, 2): + combined = planner.plan( + [ + Change(status="M", path=component_path(planner, left_name)), + Change(status="M", path=component_path(planner, right_name)), + ] + ) + + for job_name, decision in combined["job_decisions"].items(): + left = individual[left_name]["job_decisions"][job_name] + right = individual[right_name]["job_decisions"][job_name] + + assert decision["run"] is (left["run"] or right["run"]) + for field in ( + "targets", + "components", + "ansible_tags", + "verification_profiles", + ): + assert set(decision[field]) == set(left[field]) | set(right[field]), ( + left_name, + right_name, + job_name, + field, + ) + + running = [item for item in (left, right) if item["run"]] + broad_tempest = any( + item["run_tempest"] and not item["tempest_tests"] for item in running + ) + expected_tempest = ( + set() + if broad_tempest + else set(left["tempest_tests"]) | set(right["tempest_tests"]) + ) + assert set(decision["tempest_tests"]) == expected_tempest, ( + left_name, + right_name, + job_name, + ) + assert decision["run_tempest"] is any( + item["run_tempest"] for item in running + ) + + checks = { + json.dumps(check, sort_keys=True) + for check in decision["verification_checks"] + } + expected_checks = { + json.dumps(check, sort_keys=True) + for check in left["verification_checks"] + right["verification_checks"] + } + assert checks == expected_checks, (left_name, right_name, job_name) + + def test_every_role_is_explicitly_classified(planner: Planner) -> None: repository = POLICY_PATH.parents[1] From 7dcb18cae994d8ef978bc5ab1b367130993fab30 Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Thu, 30 Jul 2026 22:35:12 +0300 Subject: [PATCH 18/27] feat(ci): add focused foundation verifiers Signed-off-by: Tadas Sutkaitis --- atmosphere/ci/molecule_plan.py | 80 +++++++-- ci/molecule-plan.yaml | 204 ++++++++++++++++++++++- doc/source/deploy/selective-ci.rst | 43 +++-- molecule/aio/verify.yml | 111 +++++++++++++ tests/unit/ci/test_molecule_plan.py | 247 +++++++++++++++++++++++++++- 5 files changed, 650 insertions(+), 35 deletions(-) diff --git a/atmosphere/ci/molecule_plan.py b/atmosphere/ci/molecule_plan.py index 0b1547b7b3..a018ef267d 100644 --- a/atmosphere/ci/molecule_plan.py +++ b/atmosphere/ci/molecule_plan.py @@ -17,17 +17,30 @@ import yaml _VERIFICATION_CHECK_FIELDS = { + "host-command": {"arguments", "kind"}, + "kubernetes-daemonset": {"kind", "name", "namespace"}, "kubernetes-deployment": {"kind", "name", "namespace"}, + "kubernetes-node": {"kind"}, + "kubernetes-resource": { + "api_version", + "kind", + "name", + "namespace", + "resource_kind", + }, + "kubernetes-statefulset": {"kind", "name", "namespace"}, "openstack-cli": {"arguments", "kind"}, "openstack-resource": {"kind", "service", "type"}, } _COMPONENT_FIELDS = { "backend_requires", "charts", + "check_profiles", "jobs", "paths", "requires", "roles", + "run_tempest", "stacks", "tag", "tempest_tests", @@ -335,7 +348,7 @@ def _validate(self, rules: list[Any]) -> None: for key in sorted(expected - {"arguments", "kind"}): if not isinstance(check.get(key), str) or not check[key]: raise PolicyError(f"{field}.{key} must be a string") - if kind == "openstack-cli": + if kind in {"host-command", "openstack-cli"}: arguments = check.get("arguments") if ( not isinstance(arguments, list) @@ -389,10 +402,31 @@ def _validate(self, rules: list[Any]) -> None: component.get("verification_profiles"), f"components.{component_name}.verification_profiles", ) + check_profiles = _strings( + component.get("check_profiles"), + f"components.{component_name}.check_profiles", + ) + for profile in check_profiles: + if profile not in self.verification_checks: + raise PolicyError( + f"components.{component_name}.check_profiles references " + f"unknown verification check profile {profile!r}" + ) tempest_tests = _strings( component.get("tempest_tests"), f"components.{component_name}.tempest_tests", ) + if "run_tempest" in component and not isinstance( + component["run_tempest"], bool + ): + raise PolicyError( + f"components.{component_name}.run_tempest must be a boolean" + ) + if component.get("run_tempest") is False and tempest_tests: + raise PolicyError( + f"components.{component_name}.run_tempest cannot be false " + "when tempest_tests are configured" + ) for index, pattern in enumerate(tempest_tests): try: re.compile(pattern) @@ -694,8 +728,10 @@ def plan(self, changes: Sequence[Change]) -> dict[str, Any]: "components": [], "ansible_tags": [], "verification_profiles": profiles, - "verification_checks": self._verification_checks(roots), - "tempest_tests": self._tempest_test_patterns(roots), + # Non-AIO scenarios own their verification lifecycle and + # do not execute the AIO verifier or Tempest selection. + "verification_checks": [], + "tempest_tests": [], "run_tempest": False, } decisions[job_name] = decision @@ -883,11 +919,13 @@ def visit(name: str) -> None: def _tempest_test_patterns(self, targets: Iterable[str]) -> list[str]: patterns: set[str] = set() for target in targets: + if not self._component_requires_tempest(target): + continue target_patterns = _strings( self.components[target].get("tempest_tests"), f"components.{target}.tempest_tests", ) - if not target_patterns and not self._verification_checks([target]): + if not target_patterns: return [] patterns.update(target_patterns) return sorted(patterns) @@ -899,8 +937,8 @@ def _verification_checks( checks: dict[str, dict[str, Any]] = {} for target in targets: profiles = _strings( - self.components[target].get("verification_profiles"), - f"components.{target}.verification_profiles", + self.components[target].get("check_profiles"), + f"components.{target}.check_profiles", ) for profile in profiles: for check_value in self.verification_checks.get(profile, []): @@ -912,16 +950,19 @@ def _verification_checks( checks[key] = dict(check) return [checks[key] for key in sorted(checks)] - def _tempest_required(self, targets: Iterable[str]) -> bool: - for target in targets: - if _strings( - self.components[target].get("tempest_tests"), + def _component_requires_tempest(self, target: str) -> bool: + component = self.components[target] + if "run_tempest" in component: + return component["run_tempest"] + return bool( + _strings( + component.get("tempest_tests"), f"components.{target}.tempest_tests", - ): - return True - if not self._verification_checks([target]): - return True - return False + ) + ) or not self._verification_checks([target]) + + def _tempest_required(self, targets: Iterable[str]) -> bool: + return any(self._component_requires_tempest(target) for target in targets) def _full_plan( self, @@ -1050,6 +1091,15 @@ def render_plan(plan: dict[str, Any]) -> str: label = f"{check['service']}/{check['type']}" elif check["kind"] == "openstack-cli": label = "openstack " + " ".join(check["arguments"]) + elif check["kind"] == "host-command": + label = " ".join(check["arguments"]) + elif check["kind"] == "kubernetes-node": + label = "Ready" + elif check["kind"] == "kubernetes-resource": + label = ( + f"{check['resource_kind']}:" + f"{check['namespace']}/{check['name']}" + ) else: label = f"{check['namespace']}/{check['name']}" checks.append(f"{check['kind']}:{label}") diff --git a/ci/molecule-plan.yaml b/ci/molecule-plan.yaml index 4d35cb1264..af39fae867 100644 --- a/ci/molecule-plan.yaml +++ b/ci/molecule-plan.yaml @@ -185,9 +185,9 @@ rules: - reno.yaml - renovate.json -# Focused read-only checks keyed by the verification profiles used below. -# These provide coverage when the Tempest image does not include a -# component-specific plugin or test. +# Focused checks referenced explicitly by component ``check_profiles``. These +# provide coverage when the Tempest image does not include a component-specific +# plugin or when a foundation component has no OpenStack API. verification_checks: secrets: - kind: openstack-resource @@ -225,6 +225,112 @@ verification_checks: - kind: kubernetes-deployment namespace: openstack name: openstack-database-exporter + staffeln: + - kind: kubernetes-deployment + namespace: openstack + name: staffeln-api + - kind: kubernetes-deployment + namespace: openstack + name: staffeln-conductor + ceph: + - kind: host-command + arguments: + - cephadm + - shell + - -- + - ceph + - status + kubernetes: + - kind: kubernetes-node + valkey: + - kind: kubernetes-statefulset + namespace: openstack + name: valkey-node + keepalived: + - kind: kubernetes-daemonset + namespace: openstack + name: keepalived + node-feature-discovery: + - kind: kubernetes-deployment + namespace: monitoring + name: node-feature-discovery-master + - kind: kubernetes-daemonset + namespace: monitoring + name: node-feature-discovery-worker + kube-prometheus-stack: + - kind: kubernetes-deployment + namespace: monitoring + name: kube-prometheus-stack-operator + loki: + - kind: kubernetes-statefulset + namespace: monitoring + name: loki + - kind: kubernetes-deployment + namespace: monitoring + name: loki-gateway + vector: + - kind: kubernetes-daemonset + namespace: monitoring + name: vector + goldpinger: + - kind: kubernetes-daemonset + namespace: monitoring + name: goldpinger + ipmi-exporter: + - kind: kubernetes-daemonset + namespace: monitoring + name: ipmi-exporter + smartctl-exporter: + - kind: kubernetes-daemonset + namespace: monitoring + name: prometheus-smartctl-exporter-0 + prometheus-pushgateway: + - kind: kubernetes-deployment + namespace: monitoring + name: prometheus-pushgateway + multipathd: + - kind: host-command + arguments: + - systemctl + - is-active + - multipathd + iscsi: + - kind: host-command + arguments: + - systemctl + - is-active + - iscsid + udev: + - kind: host-command + arguments: + - udevadm + - control + - --ping + memcached: + - kind: kubernetes-deployment + namespace: openstack + name: memcached-memcached + rook-ceph: + - kind: kubernetes-deployment + namespace: rook-ceph + name: rook-ceph-operator + rook-ceph-cluster: + - kind: kubernetes-resource + api_version: ceph.rook.io/v1 + resource_kind: CephCluster + namespace: openstack + name: ceph + ceph-provisioners: + - kind: kubernetes-resource + api_version: v1 + resource_kind: Service + namespace: openstack + name: ceph-mon + - kind: kubernetes-resource + api_version: v1 + resource_kind: Endpoints + namespace: openstack + name: ceph-mon # Most components run in the Open vSwitch AIO job. Components only declare jobs # when they need a different scenario or network backend. @@ -291,6 +397,8 @@ components: jobs: - aio-openvswitch - csi-rbd + check_profiles: + - ceph verification_profiles: - foundation - storage @@ -307,6 +415,8 @@ components: - csi-local-path-provisioner - csi-rbd - keycloak + check_profiles: + - kubernetes verification_profiles: - foundation @@ -390,6 +500,8 @@ components: requires: - cluster-issuer - csi + check_profiles: + - valkey verification_profiles: - cache @@ -404,12 +516,16 @@ components: keepalived: stacks: - kubernetes-workload + check_profiles: + - keepalived verification_profiles: - infrastructure node-feature-discovery: stacks: - kubernetes-workload + check_profiles: + - node-feature-discovery verification_profiles: - monitoring @@ -418,24 +534,32 @@ components: - cluster-issuer - csi - keycloak + check_profiles: + - kube-prometheus-stack verification_profiles: - monitoring loki: requires: - csi + check_profiles: + - loki verification_profiles: - observability vector: requires: - loki + check_profiles: + - vector verification_profiles: - observability goldpinger: stacks: - kubernetes-workload + check_profiles: + - goldpinger verification_profiles: - monitoring @@ -443,7 +567,9 @@ components: charts: - prometheus-ipmi-exporter stacks: - - monitoring-foundation + - kubernetes-workload + check_profiles: + - ipmi-exporter verification_profiles: - monitoring @@ -452,38 +578,53 @@ components: - prometheus-smartctl-exporter stacks: - monitoring-foundation + check_profiles: + - smartctl-exporter verification_profiles: - monitoring prometheus-pushgateway: stacks: - monitoring-foundation + check_profiles: + - prometheus-pushgateway verification_profiles: - monitoring lpfc: charts: [] + # The role deliberately has no runtime resource on hosts without FC HBAs; + # converge and idempotence are its complete portable verification. + run_tempest: false verification_profiles: - host-storage multipathd: charts: [] + check_profiles: + - multipathd verification_profiles: - host-storage iscsi: charts: [] + check_profiles: + - iscsi verification_profiles: - host-storage udev: charts: [] + check_profiles: + - udev verification_profiles: - host-storage memcached: stacks: - kubernetes-workload + check_profiles: + - memcached verification_profiles: - cache @@ -498,12 +639,16 @@ components: barbican: stacks: - openstack-api + check_profiles: + - secrets verification_profiles: - secrets rook-ceph: stacks: - kubernetes-workload + check_profiles: + - rook-ceph verification_profiles: - storage @@ -512,12 +657,16 @@ components: - ceph - keystone - rook-ceph + check_profiles: + - rook-ceph-cluster verification_profiles: - storage ceph-provisioners: stacks: - ceph-kubernetes + check_profiles: + - ceph-provisioners verification_profiles: - storage @@ -532,8 +681,10 @@ components: staffeln: stacks: - openstack-api + check_profiles: + - staffeln verification_profiles: - - volume + - staffeln cinder: stacks: @@ -548,14 +699,22 @@ components: placement: stacks: - openstack-api + check_profiles: + - placement verification_profiles: - placement openvswitch: stacks: - kubernetes-workload + test_requires: + - neutron verification_profiles: - network + tempest_tests: + - '^tempest\.api\.network\.' + - '^neutron_tempest_plugin\.' + - '^tempest\.scenario\.test_network_' frr-k8s: tag: frr_k8s @@ -564,30 +723,53 @@ components: - ovn jobs: - aio-ovn + test_requires: + - neutron verification_profiles: - network + tempest_tests: + - '^tempest\.api\.network\.' + - '^neutron_tempest_plugin\.' + - '^tempest\.scenario\.test_network_' ovn: requires: - openvswitch jobs: - aio-ovn + test_requires: + - neutron verification_profiles: - network + tempest_tests: + - '^tempest\.api\.network\.' + - '^neutron_tempest_plugin\.' + - '^tempest\.scenario\.test_network_' libvirt: stacks: - kubernetes-workload requires: - cluster-issuer + test_requires: + - nova verification_profiles: - compute + tempest_tests: + - '^tempest\.api\.compute\.' + - '^tempest\.scenario\.test_server_' coredns: stacks: - kubernetes-workload + test_requires: + - neutron verification_profiles: - network + tempest_tests: + - '^tempest\.api\.network\.' + - '^neutron_tempest_plugin\.' + - '^tempest\.scenario\.test_network_' nova: requires: @@ -627,6 +809,8 @@ components: test_requires: - glance - neutron + check_profiles: + - orchestration verification_profiles: - orchestration @@ -635,6 +819,8 @@ components: - barbican - neutron - nova + check_profiles: + - load-balancer verification_profiles: - load-balancer tempest_tests: @@ -652,6 +838,8 @@ components: - heat - neutron - octavia + check_profiles: + - container-infrastructure verification_profiles: - container-infrastructure @@ -660,6 +848,8 @@ components: - cinder - neutron - nova + check_profiles: + - shared-file-system verification_profiles: - shared-file-system @@ -677,6 +867,8 @@ components: requires: - cinder - neutron + check_profiles: + - monitoring verification_profiles: - monitoring @@ -684,5 +876,7 @@ components: charts: [] stacks: - openstack-api + check_profiles: + - identity-client verification_profiles: - identity-client diff --git a/doc/source/deploy/selective-ci.rst b/doc/source/deploy/selective-ci.rst index 583c4f88c1..a1620f5bf5 100644 --- a/doc/source/deploy/selective-ci.rst +++ b/doc/source/deploy/selective-ci.rst @@ -35,17 +35,17 @@ The policy is stored in ``ci/molecule-plan.yaml`` and has these sections: full-suite fallbacks. ``verification_checks`` - Focused, read-only checks keyed by verification profile. Supported check - kinds query OpenStack resources, run OpenStack client commands, or wait for - Kubernetes deployments. + Reusable focused checks. Supported check kinds query OpenStack resources, + run OpenStack client or host commands, and wait for Kubernetes Deployments, + DaemonSets, StatefulSets, named resources, or ready Nodes. ``components`` - CI dependencies, verification profiles, and exceptions to the defaults. A - component automatically owns ``roles/`` with hyphens changed - to underscores and ``charts/``. The ``roles`` and ``charts`` - fields are only needed for aliases or explicit empty ownership. A component - can also declare ``tempest_tests`` regular expressions to restrict Tempest - to its smoke tests. + CI dependencies, descriptive verification profiles, explicit + ``check_profiles``, and exceptions to the defaults. A component automatically + owns ``roles/`` with hyphens changed to underscores and + ``charts/``. The ``roles`` and ``charts`` fields are only + needed for aliases or explicit empty ownership. A component can also declare + ``tempest_tests`` regular expressions to restrict Tempest to its smoke tests. Dependencies describe the test environment, not deployment concurrency. For example, a Keystone change includes Kubernetes, Ceph-backed CSI, the Percona @@ -59,6 +59,14 @@ example, a Neutron change selects both network backends, while an unrelated Open vSwitch-only target is not added to the OVN job. Verification profiles and checks are scoped the same way. +Low-level network targets such as Open vSwitch, OVN, FRR, and CoreDNS deploy +Neutron as a test-only requirement and run the focused network Tempest tests. +Components without a Tempest namespace can instead declare focused readiness +checks; Staffeln, for example, waits for its API and conductor deployments. +Designate and Ironic are not enabled by the AIO scenario, so their paths retain +the complete fallback instead of claiming focused coverage which the scenario +cannot provide. + Changing the policy =================== @@ -133,8 +141,10 @@ to one Tempest regular expression which also requires the ``smoke`` test attribute. This selects the intersection of the component namespaces and the smoke suite instead of adding unrelated smoke tests. -Verification profiles can also resolve to ``verification_checks``. An -``openstack-resource`` check uses ``openstack.cloud.resources`` against the +Components select reusable ``verification_checks`` through +``check_profiles``. Descriptive ``verification_profiles`` remain independent, +so two monitoring components do not accidentally execute each other's checks. +An ``openstack-resource`` check uses ``openstack.cloud.resources`` against the deployed public API and its configured certificate authority. A successful query proves that authentication, service discovery, TLS, and the target API are working even when the result is empty. Barbican, Placement, Heat, Magnum, @@ -147,14 +157,21 @@ component requests a token this way, covering the wrapper container, authentication, and certificate configuration. A ``kubernetes-deployment`` check waits until the named deployment is Available. The OpenStack exporter component waits for both its API and database exporters; their readiness probes -exercise the metrics endpoints. +exercise the metrics endpoints. DaemonSet and StatefulSet checks wait for their +ready replica counts, while the Node check requires a Ready Kubernetes node. +Host commands use argument lists without shell interpretation and cover +services such as Ceph, iSCSI, and Multipath where Kubernetes has no resource to +query. Focused checks apply only to AIO jobs; CSI and Keycloak scenarios retain +their own complete Molecule verification lifecycle. A component with neither Tempest expressions nor a verification check uses the ordinary smoke selection against the services available in its deployment closure. This is the conservative fallback. In a multi-target change, verification checks cover their own components without disabling focused Tempest expressions from other targets. Full-fallback AIO jobs execute every -declared verification check. +declared verification check. A host role with no portable runtime resource can +set ``run_tempest: false`` and rely on Molecule converge and idempotence; this +exception is explicit and cannot be combined with Tempest expressions. Zuul artifacts ============== diff --git a/molecule/aio/verify.yml b/molecule/aio/verify.yml index 2bd4011bac..a5bd9ac565 100644 --- a/molecule/aio/verify.yml +++ b/molecule/aio/verify.yml @@ -62,6 +62,36 @@ | selectattr('kind', 'equalto', 'kubernetes-deployment') | list }} + _atmosphere_ci_kubernetes_daemonset_checks: >- + {{ + _atmosphere_ci_verification_checks + | selectattr('kind', 'equalto', 'kubernetes-daemonset') + | list + }} + _atmosphere_ci_kubernetes_statefulset_checks: >- + {{ + _atmosphere_ci_verification_checks + | selectattr('kind', 'equalto', 'kubernetes-statefulset') + | list + }} + _atmosphere_ci_kubernetes_node_checks: >- + {{ + _atmosphere_ci_verification_checks + | selectattr('kind', 'equalto', 'kubernetes-node') + | list + }} + _atmosphere_ci_kubernetes_resource_checks: >- + {{ + _atmosphere_ci_verification_checks + | selectattr('kind', 'equalto', 'kubernetes-resource') + | list + }} + _atmosphere_ci_host_command_checks: >- + {{ + _atmosphere_ci_verification_checks + | selectattr('kind', 'equalto', 'host-command') + | list + }} tasks: - name: Query selected OpenStack service resources openstack.cloud.resources: @@ -110,6 +140,87 @@ loop_control: label: "{{ item.namespace }}/{{ item.name }}" + - name: Wait for selected Kubernetes resources + kubernetes.core.k8s_info: + api_version: "{{ item.api_version }}" + kind: "{{ item.resource_kind }}" + namespace: "{{ item.namespace }}" + name: "{{ item.name }}" + register: _atmosphere_ci_resource + until: _atmosphere_ci_resource.resources | length == 1 + retries: 60 + delay: 5 + loop: "{{ _atmosphere_ci_kubernetes_resource_checks }}" + loop_control: + label: "{{ item.resource_kind }} {{ item.namespace }}/{{ item.name }}" + + - name: Wait for selected Kubernetes daemon sets + kubernetes.core.k8s_info: + api_version: apps/v1 + kind: DaemonSet + namespace: "{{ item.namespace }}" + name: "{{ item.name }}" + register: _atmosphere_ci_daemonset + until: + - _atmosphere_ci_daemonset.resources | length == 1 + - >- + _atmosphere_ci_daemonset.resources[0].status.numberReady + | default(0) | int + >= + _atmosphere_ci_daemonset.resources[0].status.desiredNumberScheduled + | default(0) | int + retries: 60 + delay: 5 + loop: "{{ _atmosphere_ci_kubernetes_daemonset_checks }}" + loop_control: + label: "{{ item.namespace }}/{{ item.name }}" + + - name: Wait for selected Kubernetes stateful sets + kubernetes.core.k8s_info: + api_version: apps/v1 + kind: StatefulSet + namespace: "{{ item.namespace }}" + name: "{{ item.name }}" + register: _atmosphere_ci_statefulset + until: + - _atmosphere_ci_statefulset.resources | length == 1 + - >- + _atmosphere_ci_statefulset.resources[0].status.readyReplicas + | default(0) | int + >= + _atmosphere_ci_statefulset.resources[0].spec.replicas + | default(1) | int + retries: 60 + delay: 5 + loop: "{{ _atmosphere_ci_kubernetes_statefulset_checks }}" + loop_control: + label: "{{ item.namespace }}/{{ item.name }}" + + - name: Wait for the selected Kubernetes cluster + kubernetes.core.k8s_info: + api_version: v1 + kind: Node + register: _atmosphere_ci_nodes + until: + - _atmosphere_ci_nodes.resources | length > 0 + - >- + _atmosphere_ci_nodes.resources[0].status.conditions + | selectattr('type', 'equalto', 'Ready') + | selectattr('status', 'equalto', 'True') + | list + | length == 1 + retries: 60 + delay: 5 + loop: "{{ _atmosphere_ci_kubernetes_node_checks }}" + + - name: Run selected host commands + ansible.builtin.command: + argv: "{{ item.arguments }}" + changed_when: false + loop: "{{ _atmosphere_ci_host_command_checks }}" + loop_control: + label: "{{ item.arguments | join(' ') }}" + - name: Run Tempest tests ansible.builtin.import_playbook: vexxhost.atmosphere.tempest tags: diff --git a/tests/unit/ci/test_molecule_plan.py b/tests/unit/ci/test_molecule_plan.py index 51d171d5d1..9e56f9f191 100644 --- a/tests/unit/ci/test_molecule_plan.py +++ b/tests/unit/ci/test_molecule_plan.py @@ -265,6 +265,27 @@ def test_openstack_cli_uses_focused_client_check(planner: Planner) -> None: ] +def test_staffeln_waits_for_api_and_conductor_deployments( + planner: Planner, +) -> None: + plan = plan_path(planner, "roles/staffeln/tasks/main.yml") + decision = plan["job_decisions"]["aio-openvswitch"] + + assert decision["run_tempest"] is False + assert decision["verification_checks"] == [ + { + "kind": "kubernetes-deployment", + "name": "staffeln-api", + "namespace": "openstack", + }, + { + "kind": "kubernetes-deployment", + "name": "staffeln-conductor", + "namespace": "openstack", + }, + ] + + def test_openstack_exporter_waits_for_both_deployments(planner: Planner) -> None: plan = plan_path(planner, "roles/openstack_exporter/tasks/main.yml") decision = plan["job_decisions"]["aio-openvswitch"] @@ -284,6 +305,71 @@ def test_openstack_exporter_waits_for_both_deployments(planner: Planner) -> None ] +def test_descriptive_profiles_do_not_select_unrelated_checks( + planner: Planner, +) -> None: + plan = plan_path(planner, "roles/node_feature_discovery/tasks/main.yml") + decision = plan["job_decisions"]["aio-openvswitch"] + + assert decision["verification_profiles"] == ["monitoring"] + assert decision["run_tempest"] is False + assert decision["verification_checks"] == [ + { + "kind": "kubernetes-daemonset", + "name": "node-feature-discovery-worker", + "namespace": "monitoring", + }, + { + "kind": "kubernetes-deployment", + "name": "node-feature-discovery-master", + "namespace": "monitoring", + }, + ] + + +def test_ipmi_exporter_uses_only_its_actual_kubernetes_dependency( + planner: Planner, +) -> None: + plan = plan_path(planner, "roles/ipmi_exporter/tasks/main.yml") + + assert components(plan) == {"ipmi-exporter", "kubernetes"} + assert components(plan).isdisjoint({"keycloak", "kube-prometheus-stack"}) + + +def test_smartctl_exporter_keeps_prometheus_operator_dependency( + planner: Planner, +) -> None: + plan = plan_path(planner, "roles/smartctl_exporter/tasks/main.yml") + + assert components(plan) >= {"kube-prometheus-stack", "smartctl-exporter"} + + +def test_lpfc_relies_on_portable_converge_and_idempotence( + planner: Planner, +) -> None: + plan = plan_path(planner, "roles/lpfc/tasks/main.yml") + decision = plan["job_decisions"]["aio-openvswitch"] + + assert decision["run_tempest"] is False + assert decision["verification_checks"] == [] + + +def test_plan_renderer_supports_foundation_check_kinds(planner: Planner) -> None: + plan = planner.plan( + [ + Change(status="M", path="playbooks/ceph.yml"), + Change(status="M", path="roles/kubernetes/tasks/main.yml"), + Change(status="M", path="roles/valkey/tasks/main.yml"), + ] + ) + + rendered = render_plan(plan) + + assert "host-command:cephadm shell -- ceph status" in rendered + assert "kubernetes-node:Ready" in rendered + assert "kubernetes-statefulset:openstack/valkey-node" in rendered + + def test_magnum_uses_broad_openstack_environment( planner: Planner, ) -> None: @@ -362,6 +448,32 @@ def test_neutron_creates_backend_specific_jobs( assert r"^neutron_tempest_plugin\." in ovs["tempest_tests"] +@pytest.mark.parametrize( + ("role", "job_name"), + [ + ("coredns", "aio-openvswitch"), + ("frr_k8s", "aio-ovn"), + ("openvswitch", "aio-openvswitch"), + ("ovn", "aio-ovn"), + ], +) +def test_network_foundations_run_focused_neutron_tests( + planner: Planner, + role: str, + job_name: str, +) -> None: + plan = plan_path(planner, f"roles/{role}/tasks/main.yml") + decision = plan["job_decisions"][job_name] + + assert "neutron" in decision["components"] + assert decision["run_tempest"] is True + assert set(decision["tempest_tests"]) == { + r"^neutron_tempest_plugin\.", + r"^tempest\.api\.network\.", + r"^tempest\.scenario\.test_network_", + } + + def test_csi_provider_paths_select_only_related_scenario( planner: Planner, ) -> None: @@ -375,6 +487,16 @@ def test_csi_provider_paths_select_only_related_scenario( assert not rbd["job_decisions"]["csi-local-path-provisioner"]["run"] +def test_scenario_specific_jobs_do_not_receive_aio_checks( + planner: Planner, +) -> None: + ceph = plan_path(planner, "playbooks/ceph.yml") + kubernetes = plan_path(planner, "roles/kubernetes/tasks/main.yml") + + assert ceph["job_decisions"]["csi-rbd"]["verification_checks"] == [] + assert kubernetes["job_decisions"]["keycloak"]["verification_checks"] == [] + + def test_keycloak_change_uses_focused_scenario( planner: Planner, ) -> None: @@ -419,17 +541,83 @@ def test_unknown_runtime_path_falls_back_to_every_job( for check in checks if check["kind"] == "openstack-cli" } == {("token", "issue")} - assert { + deployments = { (check["namespace"], check["name"]) for check in checks if check["kind"] == "kubernetes-deployment" - } == { + } + assert deployments == { + ("monitoring", "kube-prometheus-stack-operator"), + ("monitoring", "loki-gateway"), + ("monitoring", "node-feature-discovery-master"), + ("monitoring", "prometheus-pushgateway"), + ("openstack", "memcached-memcached"), ("openstack", "openstack-database-exporter"), ("openstack", "openstack-exporter"), + ("openstack", "staffeln-api"), + ("openstack", "staffeln-conductor"), + ("rook-ceph", "rook-ceph-operator"), + } + assert { + (check["namespace"], check["name"]) + for check in checks + if check["kind"] == "kubernetes-daemonset" + } == { + ("monitoring", "goldpinger"), + ("monitoring", "ipmi-exporter"), + ("monitoring", "node-feature-discovery-worker"), + ("monitoring", "prometheus-smartctl-exporter-0"), + ("monitoring", "vector"), + ("openstack", "keepalived"), + } + assert { + (check["namespace"], check["name"]) + for check in checks + if check["kind"] == "kubernetes-statefulset" + } == { + ("monitoring", "loki"), + ("openstack", "valkey-node"), + } + assert { + ( + check["api_version"], + check["resource_kind"], + check["namespace"], + check["name"], + ) + for check in checks + if check["kind"] == "kubernetes-resource" + } == { + ("ceph.rook.io/v1", "CephCluster", "openstack", "ceph"), + ("v1", "Endpoints", "openstack", "ceph-mon"), + ("v1", "Service", "openstack", "ceph-mon"), + } + assert sum(check["kind"] == "kubernetes-node" for check in checks) == 1 + assert { + tuple(check["arguments"]) + for check in checks + if check["kind"] == "host-command" + } == { + ("cephadm", "shell", "--", "ceph", "status"), + ("systemctl", "is-active", "iscsid"), + ("systemctl", "is-active", "multipathd"), + ("udevadm", "control", "--ping"), } assert plan["job_decisions"]["csi-rbd"]["verification_checks"] == [] +@pytest.mark.parametrize("service", ["designate", "ironic"]) +def test_inactive_openstack_service_uses_full_fallback( + planner: Planner, + service: str, +) -> None: + plan = plan_path(planner, f"roles/{service}/tasks/main.yml") + + assert plan["mode"] == "full" + assert all(decision["run"] for decision in plan["job_decisions"].values()) + assert plan["reasons"] == ["the service is not enabled by the AIO scenario"] + + def test_empty_change_list_falls_back_to_every_job( planner: Planner, ) -> None: @@ -613,6 +801,42 @@ def test_empty_openstack_cli_arguments_are_rejected() -> None: Planner(invalid) +def test_unknown_check_profile_is_rejected() -> None: + policy = yaml.safe_load(POLICY_PATH.read_text(encoding="utf-8")) + invalid = copy.deepcopy(policy) + invalid["components"]["keystone"]["check_profiles"] = ["missing"] + + with pytest.raises( + PolicyError, + match=r"components\.keystone\.check_profiles references unknown", + ): + Planner(invalid) + + +def test_run_tempest_must_be_boolean() -> None: + policy = yaml.safe_load(POLICY_PATH.read_text(encoding="utf-8")) + invalid = copy.deepcopy(policy) + invalid["components"]["lpfc"]["run_tempest"] = "false" + + with pytest.raises( + PolicyError, + match=r"components\.lpfc\.run_tempest must be a boolean", + ): + Planner(invalid) + + +def test_tempest_patterns_cannot_be_disabled() -> None: + policy = yaml.safe_load(POLICY_PATH.read_text(encoding="utf-8")) + invalid = copy.deepcopy(policy) + invalid["components"]["glance"]["run_tempest"] = False + + with pytest.raises( + PolicyError, + match=r"components\.glance\.run_tempest cannot be false", + ): + Planner(invalid) + + def test_every_declared_role_maps_to_its_component( planner: Planner, ) -> None: @@ -698,6 +922,20 @@ def test_every_component_pair_produces_the_union_of_individual_plans( assert checks == expected_checks, (left_name, right_name, job_name) +def test_every_isolated_aio_component_has_focused_verification( + planner: Planner, +) -> None: + for component_name in planner.components: + plan = plan_path(planner, component_path(planner, component_name)) + for job_name, decision in plan["job_decisions"].items(): + if decision["scenario"] != "aio" or not decision["run"]: + continue + assert not (decision["run_tempest"] and not decision["tempest_tests"]), ( + component_name, + job_name, + ) + + def test_every_role_is_explicitly_classified(planner: Planner) -> None: repository = POLICY_PATH.parents[1] @@ -789,6 +1027,11 @@ def test_selective_ci_uses_main_sequential_molecule_flow() -> None: assert "openstack.cloud.resources" in verifier assert "Run selected OpenStack client commands" in verifier assert "Wait for selected Kubernetes deployments" in verifier + assert "Wait for selected Kubernetes resources" in verifier + assert "Wait for selected Kubernetes daemon sets" in verifier + assert "Wait for selected Kubernetes stateful sets" in verifier + assert "Wait for the selected Kubernetes cluster" in verifier + assert "Run selected host commands" in verifier assert "go build" not in converge assert "./bin/atmosphere" not in runner assert "dependency_options" not in policy From 8868a471e5930983bbb2db6d4e19a4d8ccfc66b8 Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Thu, 30 Jul 2026 23:15:48 +0300 Subject: [PATCH 19/27] fix(octavia): retry transient quota requests Signed-off-by: Tadas Sutkaitis --- roles/octavia/tasks/main.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/roles/octavia/tasks/main.yml b/roles/octavia/tasks/main.yml index 9e91966d00..1929ea1b20 100644 --- a/roles/octavia/tasks/main.yml +++ b/roles/octavia/tasks/main.yml @@ -95,6 +95,10 @@ gigabytes: -1 security_group: -1 security_group_rule: -1 + register: _octavia_admin_compute_quotaset + retries: 60 + delay: 5 + until: _octavia_admin_compute_quotaset is not failed - name: Deploy Helm chart run_once: true From d3d2ca8217405ac19f29c5a970e0aad12c2030e3 Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Thu, 30 Jul 2026 23:31:37 +0300 Subject: [PATCH 20/27] fix(ci): include all pull request commits Signed-off-by: Tadas Sutkaitis --- .zuul.yaml | 4 +++- doc/source/deploy/selective-ci.rst | 11 ++++++----- tests/unit/ci/test_molecule_plan.py | 7 +++++-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.zuul.yaml b/.zuul.yaml index 040ef9597d..1421997bee 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -288,7 +288,9 @@ - project: vars: - atmosphere_ci_plan_base: HEAD^1 + atmosphere_ci_plan_base: >- + {{ 'HEAD^1' if (zuul['items'] | length) > 1 + else 'origin/' ~ zuul['branch'] }} atmosphere_ci_plan_head: HEAD check: jobs: diff --git a/doc/source/deploy/selective-ci.rst b/doc/source/deploy/selective-ci.rst index a1620f5bf5..bd1558c951 100644 --- a/doc/source/deploy/selective-ci.rst +++ b/doc/source/deploy/selective-ci.rst @@ -129,11 +129,12 @@ release-note-only changes produce a no-op plan. A path which matches no rule or component produces a full plan so a new runtime area cannot silently lose test coverage. -In Zuul, the planner compares the speculative merge commit at ``HEAD`` with its -first parent at ``HEAD^1``. For an ordinary pull request, the first parent is -the target branch. For a pull request with ``Depends-On`` changes, it also -contains those dependencies, so only the current pull request selects test -targets. +In Zuul, an ordinary pull request is compared with the merge base of ``HEAD`` +and its target branch. This includes every commit in the pull request rather +than only the most recent commit. For a pull request with ``Depends-On`` +changes, the planner compares ``HEAD`` with ``HEAD^1`` because that first parent +is the speculative dependency state. This keeps dependency changes out of the +current pull request's test selection. During selective AIO verification, Tempest receives the union of ``tempest_tests`` for the changed components. The expressions are passed diff --git a/tests/unit/ci/test_molecule_plan.py b/tests/unit/ci/test_molecule_plan.py index 9e56f9f191..cc6a0c7ee7 100644 --- a/tests/unit/ci/test_molecule_plan.py +++ b/tests/unit/ci/test_molecule_plan.py @@ -1058,14 +1058,17 @@ def test_full_aio_jobs_have_timeout_headroom() -> None: assert jobs[job_name]["vars"]["manila_helm_timeout"] == "10m0s" -def test_zuul_plan_uses_speculative_parent() -> None: +def test_zuul_plan_uses_pr_base_or_speculative_parent() -> None: repository = POLICY_PATH.parents[1] zuul_config = yaml.safe_load( (repository / ".zuul.yaml").read_text(encoding="utf-8") ) project = next(item["project"] for item in zuul_config if "project" in item) - assert project["vars"]["atmosphere_ci_plan_base"] == "HEAD^1" + assert project["vars"]["atmosphere_ci_plan_base"] == ( + "{{ 'HEAD^1' if (zuul['items'] | length) > 1 " + "else 'origin/' ~ zuul['branch'] }}" + ) assert project["vars"]["atmosphere_ci_plan_head"] == "HEAD" From 5a0de0445ad8d3184baa7d1f61d75225bb8d9c99 Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Fri, 31 Jul 2026 00:02:25 +0300 Subject: [PATCH 21/27] fix(keepalived): create deployment namespace Signed-off-by: Tadas Sutkaitis --- roles/keepalived/tasks/main.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/roles/keepalived/tasks/main.yml b/roles/keepalived/tasks/main.yml index ceaa88ab0e..204f594f8e 100644 --- a/roles/keepalived/tasks/main.yml +++ b/roles/keepalived/tasks/main.yml @@ -12,6 +12,17 @@ # License for the specific language governing permissions and limitations # under the License. +- name: Create namespace + run_once: true + when: keepalived_enabled | bool + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Namespace + metadata: + name: openstack + - name: Deploy service run_once: true when: keepalived_enabled | bool From 3082f3b97cdac439195caaef6bb7de73dc0aca3a Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Fri, 31 Jul 2026 00:08:36 +0300 Subject: [PATCH 22/27] fix(roles): create Kubernetes namespaces Signed-off-by: Tadas Sutkaitis --- roles/ceph_provisioners/tasks/main.yml | 10 ++++++++++ roles/ipmi_exporter/tasks/main.yml | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/roles/ceph_provisioners/tasks/main.yml b/roles/ceph_provisioners/tasks/main.yml index 5f555c97fa..6893ae665d 100644 --- a/roles/ceph_provisioners/tasks/main.yml +++ b/roles/ceph_provisioners/tasks/main.yml @@ -12,6 +12,16 @@ # License for the specific language governing permissions and limitations # under the License. +- name: Create namespace + run_once: true + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Namespace + metadata: + name: "{{ ceph_provisioners_helm_release_namespace }}" + - name: Collect "ceph mon dump" output from a monitor delegate_to: "{{ groups[ceph_provisioners_ceph_mon_group][0] }}" run_once: true diff --git a/roles/ipmi_exporter/tasks/main.yml b/roles/ipmi_exporter/tasks/main.yml index 7047f286ff..3c7c9686d2 100644 --- a/roles/ipmi_exporter/tasks/main.yml +++ b/roles/ipmi_exporter/tasks/main.yml @@ -12,6 +12,16 @@ # License for the specific language governing permissions and limitations # under the License. +- name: Create namespace + run_once: true + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Namespace + metadata: + name: monitoring + - name: Deploy service kubernetes.core.k8s: state: present From c59700935f29efc3cbcfe661c821b21cf012097c Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Fri, 31 Jul 2026 00:24:23 +0300 Subject: [PATCH 23/27] fix(valkey): create deployment namespace Signed-off-by: Tadas Sutkaitis --- roles/valkey/tasks/main.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/roles/valkey/tasks/main.yml b/roles/valkey/tasks/main.yml index bca9956faa..e2124f0131 100644 --- a/roles/valkey/tasks/main.yml +++ b/roles/valkey/tasks/main.yml @@ -1,6 +1,16 @@ # Copyright (c) 2025 VEXXHOST, Inc. # SPDX-License-Identifier: Apache-2.0 +- name: Create namespace + run_once: true + kubernetes.core.k8s: + state: present + definition: + apiVersion: v1 + kind: Namespace + metadata: + name: "{{ valkey_helm_release_namespace }}" + - name: Create TLS resources kubernetes.core.k8s: state: present From 7f2d3b8265d29273ac26444f03c74b634678b8a3 Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Fri, 31 Jul 2026 00:48:04 +0300 Subject: [PATCH 24/27] fix(rook-ceph-cluster): create service scope Signed-off-by: Tadas Sutkaitis --- roles/rook_ceph_cluster/tasks/main.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/roles/rook_ceph_cluster/tasks/main.yml b/roles/rook_ceph_cluster/tasks/main.yml index 45916bfc84..20d341d610 100644 --- a/roles/rook_ceph_cluster/tasks/main.yml +++ b/roles/rook_ceph_cluster/tasks/main.yml @@ -108,6 +108,19 @@ kubeconfig: "{{ rook_ceph_cluster_helm_kubeconfig }}" values: "{{ _rook_ceph_cluster_helm_values | combine(rook_ceph_cluster_helm_values, recursive=True) }}" +- name: Create OpenStack service domain + run_once: true + vexxhost.atmosphere.identity_domain: + name: service + +- name: Create OpenStack service project + run_once: true + changed_when: false + ansible.builtin.command: + cmd: openstack project create --or-show --enable --domain service service + environment: + OS_CLOUD: atmosphere + - name: Create OpenStack user openstack.cloud.identity_user: cloud: atmosphere From 1a0e3fe0526394901ab0cd6719e12425e89d1a31 Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Fri, 31 Jul 2026 01:24:40 +0300 Subject: [PATCH 25/27] fix(ci): include Valkey for Octavia Signed-off-by: Tadas Sutkaitis --- ci/molecule-plan.yaml | 1 + doc/source/deploy/selective-ci.rst | 2 ++ tests/unit/ci/test_molecule_plan.py | 9 +++++++++ 3 files changed, 12 insertions(+) diff --git a/ci/molecule-plan.yaml b/ci/molecule-plan.yaml index af39fae867..e0b1c7daf8 100644 --- a/ci/molecule-plan.yaml +++ b/ci/molecule-plan.yaml @@ -819,6 +819,7 @@ components: - barbican - neutron - nova + - valkey check_profiles: - load-balancer verification_profiles: diff --git a/doc/source/deploy/selective-ci.rst b/doc/source/deploy/selective-ci.rst index bd1558c951..3bcd28e319 100644 --- a/doc/source/deploy/selective-ci.rst +++ b/doc/source/deploy/selective-ci.rst @@ -63,6 +63,8 @@ Low-level network targets such as Open vSwitch, OVN, FRR, and CoreDNS deploy Neutron as a test-only requirement and run the focused network Tempest tests. Components without a Tempest namespace can instead declare focused readiness checks; Staffeln, for example, waits for its API and conductor deployments. +Octavia requires Valkey explicitly because its API and worker pods mount the +Valkey jobboard certificate secret. Designate and Ironic are not enabled by the AIO scenario, so their paths retain the complete fallback instead of claiming focused coverage which the scenario cannot provide. diff --git a/tests/unit/ci/test_molecule_plan.py b/tests/unit/ci/test_molecule_plan.py index cc6a0c7ee7..5d9d5b1990 100644 --- a/tests/unit/ci/test_molecule_plan.py +++ b/tests/unit/ci/test_molecule_plan.py @@ -384,10 +384,19 @@ def test_magnum_uses_broad_openstack_environment( "neutron", "nova", "octavia", + "valkey", } assert components(plan).isdisjoint({"horizon", "manila", "openstack-exporter"}) +def test_octavia_includes_valkey_jobboard_dependency( + planner: Planner, +) -> None: + plan = plan_path(planner, "roles/octavia/tasks/main.yml") + + assert components(plan) >= {"octavia", "valkey"} + + def test_horizon_includes_compute_for_dashboard_login( planner: Planner, ) -> None: From effb1f514b52f84790c206e408920438416e6cb7 Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Fri, 31 Jul 2026 02:29:19 +0300 Subject: [PATCH 26/27] fix(rook-ceph-cluster): manage identity with SDK Signed-off-by: Tadas Sutkaitis --- roles/rook_ceph_cluster/tasks/main.yml | 28 ++++++++++++-------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/roles/rook_ceph_cluster/tasks/main.yml b/roles/rook_ceph_cluster/tasks/main.yml index 20d341d610..5a0a1ae40b 100644 --- a/roles/rook_ceph_cluster/tasks/main.yml +++ b/roles/rook_ceph_cluster/tasks/main.yml @@ -115,11 +115,12 @@ - name: Create OpenStack service project run_once: true - changed_when: false - ansible.builtin.command: - cmd: openstack project create --or-show --enable --domain service service - environment: - OS_CLOUD: atmosphere + openstack.cloud.project: + cloud: atmosphere + name: service + domain: service + enabled: true + state: present - name: Create OpenStack user openstack.cloud.identity_user: @@ -130,16 +131,13 @@ # NOTE(mnaser): https://storyboard.openstack.org/#!/story/2010579 - name: Grant access to "service" project - changed_when: false - ansible.builtin.command: - cmd: >- - openstack role add - --user-domain service - --project service - --user {{ openstack_helm_endpoints.identity.auth.rgw.username }} - admin - environment: - OS_CLOUD: atmosphere + openstack.cloud.role_assignment: + cloud: atmosphere + user: "{{ openstack_helm_endpoints.identity.auth.rgw.username }}" + user_domain: service + role: admin + project: service + project_domain: service - name: Create OpenStack service openstack.cloud.catalog_service: From 2ef127a7250b945b7250b96f102932041170fa58 Mon Sep 17 00:00:00 2001 From: Tadas Sutkaitis Date: Fri, 31 Jul 2026 15:31:46 +0300 Subject: [PATCH 27/27] fix(ci): include Cinder for Octavia Signed-off-by: Tadas Sutkaitis --- ci/molecule-plan.yaml | 1 + doc/source/deploy/selective-ci.rst | 3 ++- tests/unit/ci/test_molecule_plan.py | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/ci/molecule-plan.yaml b/ci/molecule-plan.yaml index e0b1c7daf8..7cb0f73c5d 100644 --- a/ci/molecule-plan.yaml +++ b/ci/molecule-plan.yaml @@ -817,6 +817,7 @@ components: octavia: requires: - barbican + - cinder - neutron - nova - valkey diff --git a/doc/source/deploy/selective-ci.rst b/doc/source/deploy/selective-ci.rst index 3bcd28e319..a0b53e35f5 100644 --- a/doc/source/deploy/selective-ci.rst +++ b/doc/source/deploy/selective-ci.rst @@ -64,7 +64,8 @@ Neutron as a test-only requirement and run the focused network Tempest tests. Components without a Tempest namespace can instead declare focused readiness checks; Staffeln, for example, waits for its API and conductor deployments. Octavia requires Valkey explicitly because its API and worker pods mount the -Valkey jobboard certificate secret. +Valkey jobboard certificate secret. It also requires Cinder because amphora +instances boot from volumes. Designate and Ironic are not enabled by the AIO scenario, so their paths retain the complete fallback instead of claiming focused coverage which the scenario cannot provide. diff --git a/tests/unit/ci/test_molecule_plan.py b/tests/unit/ci/test_molecule_plan.py index 5d9d5b1990..fd9dd52afd 100644 --- a/tests/unit/ci/test_molecule_plan.py +++ b/tests/unit/ci/test_molecule_plan.py @@ -389,12 +389,12 @@ def test_magnum_uses_broad_openstack_environment( assert components(plan).isdisjoint({"horizon", "manila", "openstack-exporter"}) -def test_octavia_includes_valkey_jobboard_dependency( +def test_octavia_includes_runtime_dependencies( planner: Planner, ) -> None: plan = plan_path(planner, "roles/octavia/tasks/main.yml") - assert components(plan) >= {"octavia", "valkey"} + assert components(plan) >= {"cinder", "octavia", "valkey"} def test_horizon_includes_compute_for_dashboard_login(