Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Implemented PolicyDslValidator with command-line options for strict mode and JSON output. - Created PolicySchemaExporter to generate JSON schemas for policy-related models. - Developed PolicySimulationSmoke tool to validate policy simulations against expected outcomes. - Added project files and necessary dependencies for each tool. - Ensured proper error handling and usage instructions across tools.
184 lines
5.8 KiB
Python
184 lines
5.8 KiB
Python
#!/usr/bin/env python3
|
|
|
|
"""
|
|
Validate NuGet source ordering for StellaOps.
|
|
|
|
Ensures `local-nuget` is the highest priority feed in both NuGet.config and the
|
|
Directory.Build.props restore configuration. Fails fast with actionable errors
|
|
so CI/offline kit workflows can assert deterministic restore ordering.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import subprocess
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
NUGET_CONFIG = REPO_ROOT / "NuGet.config"
|
|
ROOT_PROPS = REPO_ROOT / "Directory.Build.props"
|
|
EXPECTED_SOURCE_KEYS = ["local", "dotnet-public", "nuget.org"]
|
|
|
|
|
|
class ValidationError(Exception):
|
|
"""Raised when validation fails."""
|
|
|
|
|
|
def _fail(message: str) -> None:
|
|
raise ValidationError(message)
|
|
|
|
|
|
def _parse_xml(path: Path) -> ET.ElementTree:
|
|
try:
|
|
return ET.parse(path)
|
|
except FileNotFoundError as exc:
|
|
_fail(f"Missing required file: {path}")
|
|
except ET.ParseError as exc:
|
|
_fail(f"Could not parse XML for {path}: {exc}")
|
|
|
|
|
|
def validate_nuget_config() -> None:
|
|
tree = _parse_xml(NUGET_CONFIG)
|
|
root = tree.getroot()
|
|
|
|
package_sources = root.find("packageSources")
|
|
if package_sources is None:
|
|
_fail("NuGet.config must declare a <packageSources> section.")
|
|
|
|
children = list(package_sources)
|
|
if not children or children[0].tag != "clear":
|
|
_fail("NuGet.config packageSources must begin with a <clear /> element.")
|
|
|
|
adds = [child for child in children if child.tag == "add"]
|
|
if not adds:
|
|
_fail("NuGet.config packageSources must define at least one <add> entry.")
|
|
|
|
keys = [add.attrib.get("key") for add in adds]
|
|
if keys[: len(EXPECTED_SOURCE_KEYS)] != EXPECTED_SOURCE_KEYS:
|
|
formatted = ", ".join(keys) or "<empty>"
|
|
_fail(
|
|
"NuGet.config packageSources must list feeds in the order "
|
|
f"{EXPECTED_SOURCE_KEYS}. Found: {formatted}"
|
|
)
|
|
|
|
local_value = adds[0].attrib.get("value", "")
|
|
if Path(local_value).name != "local-nuget":
|
|
_fail(
|
|
"NuGet.config local feed should point at the repo-local mirror "
|
|
f"'local-nuget', found value '{local_value}'."
|
|
)
|
|
|
|
clear = package_sources.find("clear")
|
|
if clear is None:
|
|
_fail("NuGet.config packageSources must start with <clear /> to avoid inherited feeds.")
|
|
|
|
|
|
def validate_directory_build_props() -> None:
|
|
tree = _parse_xml(ROOT_PROPS)
|
|
root = tree.getroot()
|
|
defaults = None
|
|
for element in root.findall(".//_StellaOpsDefaultRestoreSources"):
|
|
defaults = [fragment.strip() for fragment in element.text.split(";") if fragment.strip()]
|
|
break
|
|
|
|
if defaults is None:
|
|
_fail("Directory.Build.props must define _StellaOpsDefaultRestoreSources.")
|
|
|
|
expected_props = [
|
|
"$(StellaOpsLocalNuGetSource)",
|
|
"$(StellaOpsDotNetPublicSource)",
|
|
"$(StellaOpsNuGetOrgSource)",
|
|
]
|
|
if defaults != expected_props:
|
|
_fail(
|
|
"Directory.Build.props _StellaOpsDefaultRestoreSources must list feeds "
|
|
f"in the order {expected_props}. Found: {defaults}"
|
|
)
|
|
|
|
restore_nodes = root.findall(".//RestoreSources")
|
|
if not restore_nodes:
|
|
_fail("Directory.Build.props must override RestoreSources to force deterministic ordering.")
|
|
|
|
uses_default_first = any(
|
|
node.text
|
|
and node.text.strip().startswith("$(_StellaOpsDefaultRestoreSources)")
|
|
for node in restore_nodes
|
|
)
|
|
if not uses_default_first:
|
|
_fail(
|
|
"Directory.Build.props RestoreSources override must place "
|
|
"$(_StellaOpsDefaultRestoreSources) at the beginning."
|
|
)
|
|
|
|
|
|
def assert_single_nuget_config() -> None:
|
|
extra_configs: list[Path] = []
|
|
configs: set[Path] = set()
|
|
for glob in ("NuGet.config", "nuget.config"):
|
|
try:
|
|
result = subprocess.run(
|
|
["rg", "--files", f"-g{glob}"],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=REPO_ROOT,
|
|
)
|
|
except FileNotFoundError as exc:
|
|
_fail("ripgrep (rg) is required for validation but was not found on PATH.")
|
|
if result.returncode not in (0, 1):
|
|
_fail(
|
|
f"ripgrep failed while searching for {glob}: {result.stderr.strip() or result.returncode}"
|
|
)
|
|
for line in result.stdout.splitlines():
|
|
configs.add((REPO_ROOT / line).resolve())
|
|
|
|
configs.discard(NUGET_CONFIG.resolve())
|
|
extra_configs.extend(sorted(configs))
|
|
if extra_configs:
|
|
formatted = "\n ".join(str(path.relative_to(REPO_ROOT)) for path in extra_configs)
|
|
_fail(
|
|
"Unexpected additional NuGet.config files detected. "
|
|
"Consolidate feed configuration in the repo root:\n "
|
|
f"{formatted}"
|
|
)
|
|
|
|
|
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Verify StellaOps NuGet feeds prioritise the local mirror."
|
|
)
|
|
parser.add_argument(
|
|
"--skip-rg",
|
|
action="store_true",
|
|
help="Skip ripgrep discovery of extra NuGet.config files (useful for focused runs).",
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
args = parse_args(argv)
|
|
validations = [
|
|
("NuGet.config ordering", validate_nuget_config),
|
|
("Directory.Build.props restore override", validate_directory_build_props),
|
|
]
|
|
if not args.skip_rg:
|
|
validations.append(("single NuGet.config", assert_single_nuget_config))
|
|
|
|
for label, check in validations:
|
|
try:
|
|
check()
|
|
except ValidationError as exc:
|
|
sys.stderr.write(f"[FAIL] {label}: {exc}\n")
|
|
return 1
|
|
else:
|
|
sys.stdout.write(f"[OK] {label}\n")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|