ci: scripts: add YAML manifest loader and package_apply subcommand
Currently testbed-cdrouter.py only supports importing packages from
opaque .gz binary exports via the package_import subcommand. This
requires staging, committing, and name-patching through the CDRouter
import API, and provides no way to define packages as readable,
version-controlled YAML.
So lets add a resolve_manifest() method that loads YAML package
manifests with full include resolution and a package_apply
subcommand that upserts packages on the CDRouter instance via the
packages API.
The manifest resolver has strict validation:
- Key allowlist (name, description, includes, tests, options,
dedup_tests); unknown keys are rejected
- Forbidden keys (config, device, config_id, device_id)
- Root manifest name must match its filename
- Group manifests (under groups/) must not define name,
description, or options
- Type checks on all fields
Include resolution uses depth-first traversal with cycle
detection, path traversal rejection, and root-wins option merge.
Test deduplication is configurable via the root-only dedup_tests
boolean (defaults to false, preserving duplicates). Non-root
manifests (both includes and groups) must not define dedup_tests.
The package_apply subcommand performs deterministic upserts:
it always writes the complete manifest-driven state (testlist,
description, options) and only creates a new package on genuine
not-found errors from the CDRouter API.
Runtime config/device binding remains in the existing package_run
flow, keeping package manifests environment-agnostic.
References: PCF-2532
Assisted-by: codex:gpt-5.4:xhigh
Assisted-by: claude:opus-4-6:1m-context
Signed-off-by: Petr Štetiar <petr.stetiar@prplfoundation.org>
This commit is contained in:
parent
6bc69fde54
commit
fe9c778e77
@ -7,6 +7,7 @@ import os
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
import yaml
|
||||
import humanize
|
||||
|
||||
from dataclasses import dataclass
|
||||
@ -257,7 +258,9 @@ class TestbedCDRouter:
|
||||
if timeout and (time.time() - start_time) >= timeout:
|
||||
elapsed = time.time() - start_time
|
||||
logging.error(
|
||||
"Test execution timeout exceeded after {}.".format(humanize.naturaldelta(elapsed))
|
||||
"Test execution timeout exceeded after {}.".format(
|
||||
humanize.naturaldelta(elapsed)
|
||||
)
|
||||
)
|
||||
self.package_stop()
|
||||
return False
|
||||
@ -513,6 +516,272 @@ class TestbedCDRouter:
|
||||
self.cdr.configs.create(config)
|
||||
logging.info("Imported config '{}' from '{}'".format(name, self.args.filename))
|
||||
|
||||
def resolve_manifest(
|
||||
self, manifest_path, visited=None, root_manifest_path=None, dedup_tests=None
|
||||
):
|
||||
manifest_path = os.path.realpath(manifest_path)
|
||||
packages_dir = os.path.realpath(self.packages_path)
|
||||
groups_dir = os.path.realpath(os.path.join(packages_dir, "groups"))
|
||||
allowed_keys = {
|
||||
"name",
|
||||
"description",
|
||||
"includes",
|
||||
"tests",
|
||||
"options",
|
||||
"dedup_tests",
|
||||
}
|
||||
|
||||
if not manifest_path.startswith(packages_dir + os.sep):
|
||||
logging.error(
|
||||
"Manifest path '{}' is outside packages directory '{}'".format(
|
||||
manifest_path, packages_dir
|
||||
)
|
||||
)
|
||||
exit(1)
|
||||
|
||||
if visited is None:
|
||||
visited = []
|
||||
|
||||
if root_manifest_path is None:
|
||||
root_manifest_path = manifest_path
|
||||
|
||||
if manifest_path in visited:
|
||||
cycle_path = " -> ".join(visited + [manifest_path])
|
||||
logging.error("Include cycle detected: {}".format(cycle_path))
|
||||
exit(1)
|
||||
|
||||
if not os.path.isfile(manifest_path):
|
||||
logging.error("Package manifest not found: {}".format(manifest_path))
|
||||
exit(1)
|
||||
|
||||
with open(manifest_path, "r") as f:
|
||||
manifest = yaml.safe_load(f)
|
||||
|
||||
if not isinstance(manifest, dict):
|
||||
logging.error("Invalid manifest format in '{}'".format(manifest_path))
|
||||
exit(1)
|
||||
|
||||
forbidden = {"config", "device", "config_id", "device_id"}
|
||||
found = forbidden & set(manifest.keys())
|
||||
if found:
|
||||
logging.error(
|
||||
"Forbidden keys in '{}': {}".format(
|
||||
manifest_path, ", ".join(sorted(found))
|
||||
)
|
||||
)
|
||||
exit(1)
|
||||
|
||||
unknown = set(manifest.keys()) - allowed_keys
|
||||
if unknown:
|
||||
logging.error(
|
||||
"Unknown manifest keys in '{}': {}".format(
|
||||
manifest_path, ", ".join(sorted(unknown))
|
||||
)
|
||||
)
|
||||
exit(1)
|
||||
|
||||
is_group_manifest = manifest_path == groups_dir or manifest_path.startswith(
|
||||
groups_dir + os.sep
|
||||
)
|
||||
if is_group_manifest:
|
||||
group_forbidden = {"name", "description", "options"}
|
||||
group_found = group_forbidden & set(manifest.keys())
|
||||
if group_found:
|
||||
logging.error(
|
||||
"Group manifest '{}' must not define: {}".format(
|
||||
manifest_path, ", ".join(sorted(group_found))
|
||||
)
|
||||
)
|
||||
exit(1)
|
||||
|
||||
if "name" in manifest and not isinstance(manifest["name"], str):
|
||||
logging.error(
|
||||
"Manifest 'name' must be a string in '{}'".format(manifest_path)
|
||||
)
|
||||
exit(1)
|
||||
|
||||
if "description" in manifest and not isinstance(manifest["description"], str):
|
||||
logging.error(
|
||||
"Manifest 'description' must be a string in '{}'".format(manifest_path)
|
||||
)
|
||||
exit(1)
|
||||
|
||||
if "dedup_tests" in manifest and not isinstance(manifest["dedup_tests"], bool):
|
||||
logging.error(
|
||||
"Manifest 'dedup_tests' must be a boolean in '{}'".format(
|
||||
manifest_path
|
||||
)
|
||||
)
|
||||
exit(1)
|
||||
|
||||
includes = manifest.get("includes", [])
|
||||
if not isinstance(includes, list) or not all(
|
||||
isinstance(entry, str) for entry in includes
|
||||
):
|
||||
logging.error(
|
||||
"Manifest 'includes' must be a list of strings in '{}'".format(
|
||||
manifest_path
|
||||
)
|
||||
)
|
||||
exit(1)
|
||||
|
||||
tests = manifest.get("tests")
|
||||
if not isinstance(tests, list) or not all(
|
||||
isinstance(entry, str) for entry in tests
|
||||
):
|
||||
logging.error(
|
||||
"Manifest 'tests' must be a list of strings in '{}'".format(
|
||||
manifest_path
|
||||
)
|
||||
)
|
||||
exit(1)
|
||||
|
||||
options = manifest.get("options", {})
|
||||
if options is None:
|
||||
options = {}
|
||||
if not isinstance(options, dict):
|
||||
logging.error(
|
||||
"Manifest 'options' must be a map in '{}'".format(manifest_path)
|
||||
)
|
||||
exit(1)
|
||||
|
||||
if manifest_path == root_manifest_path:
|
||||
expected_name = os.path.splitext(os.path.basename(manifest_path))[0]
|
||||
name = manifest.get("name")
|
||||
if not name:
|
||||
logging.error(
|
||||
"Root manifest '{}' must define non-empty 'name'".format(
|
||||
manifest_path
|
||||
)
|
||||
)
|
||||
exit(1)
|
||||
dedup_tests = manifest.get("dedup_tests", False)
|
||||
if name != expected_name:
|
||||
logging.error(
|
||||
"Manifest name '{}' does not match filename '{}'".format(
|
||||
name, expected_name
|
||||
)
|
||||
)
|
||||
exit(1)
|
||||
else:
|
||||
if "dedup_tests" in manifest:
|
||||
logging.error(
|
||||
"Manifest '{}' must not define 'dedup_tests' "
|
||||
"(root manifest only)".format(manifest_path)
|
||||
)
|
||||
exit(1)
|
||||
if dedup_tests is None:
|
||||
dedup_tests = False
|
||||
|
||||
# Process includes depth-first
|
||||
all_tests = []
|
||||
all_options = {}
|
||||
manifest_dir = os.path.dirname(manifest_path)
|
||||
|
||||
for inc in includes:
|
||||
if os.path.isabs(inc):
|
||||
logging.error(
|
||||
"Include path '{}' in '{}' must be relative".format(
|
||||
inc, manifest_path
|
||||
)
|
||||
)
|
||||
exit(1)
|
||||
|
||||
inc_path = os.path.realpath(os.path.join(manifest_dir, inc))
|
||||
resolved = self.resolve_manifest(
|
||||
inc_path,
|
||||
visited=visited + [manifest_path],
|
||||
root_manifest_path=root_manifest_path,
|
||||
dedup_tests=dedup_tests,
|
||||
)
|
||||
all_tests.extend(resolved.get("tests", []))
|
||||
all_options.update(resolved.get("options", {}))
|
||||
|
||||
# Append current manifest's tests
|
||||
all_tests.extend(tests)
|
||||
|
||||
if dedup_tests:
|
||||
# Dedup: keep first occurrence
|
||||
seen = set()
|
||||
resolved_tests = []
|
||||
for t in all_tests:
|
||||
if t not in seen:
|
||||
seen.add(t)
|
||||
resolved_tests.append(t)
|
||||
else:
|
||||
resolved_tests = all_tests
|
||||
|
||||
# Root options win on conflicts
|
||||
all_options.update(options)
|
||||
|
||||
return {
|
||||
"name": manifest.get("name", ""),
|
||||
"description": manifest.get("description", ""),
|
||||
"tests": resolved_tests,
|
||||
"options": all_options,
|
||||
}
|
||||
|
||||
def build_package_options(self, options):
|
||||
from cdrouter.packages import Options as PackageOptions
|
||||
|
||||
package_options = PackageOptions()
|
||||
for key, value in options.items():
|
||||
if not hasattr(package_options, key):
|
||||
logging.error("Unknown package option '{}'".format(key))
|
||||
exit(1)
|
||||
setattr(package_options, key, value)
|
||||
|
||||
return package_options
|
||||
|
||||
def package_apply(self):
|
||||
self.connect()
|
||||
|
||||
manifest_path = os.path.join(
|
||||
self.packages_path, self.args.package_name + ".yaml"
|
||||
)
|
||||
resolved = self.resolve_manifest(manifest_path)
|
||||
|
||||
name = resolved["name"]
|
||||
tests = resolved["tests"]
|
||||
description = resolved.get("description", "")
|
||||
options = resolved.get("options", {})
|
||||
|
||||
if not name:
|
||||
logging.error(
|
||||
"Resolved manifest name is empty for '{}'".format(manifest_path)
|
||||
)
|
||||
exit(1)
|
||||
|
||||
if not tests:
|
||||
logging.error("Resolved manifest has empty test list for '{}'".format(name))
|
||||
exit(1)
|
||||
|
||||
package_options = self.build_package_options(options)
|
||||
|
||||
existing = None
|
||||
try:
|
||||
existing = self.cdr.packages.get_by_name(name)
|
||||
except CDRouterError as e:
|
||||
error = str(e).lower()
|
||||
if "not found" not in error and "no such package" not in error:
|
||||
raise
|
||||
|
||||
if existing:
|
||||
p = self.cdr.packages.get(existing.id)
|
||||
p.testlist = tests
|
||||
p.description = description or ""
|
||||
p.options = package_options
|
||||
self.cdr.packages.edit(p)
|
||||
logging.info("Updated package '{}' with {} tests".format(name, len(tests)))
|
||||
return
|
||||
|
||||
from cdrouter.packages import Package
|
||||
|
||||
p = Package(name=name, description=description or "", testlist=tests)
|
||||
p.options = package_options
|
||||
p = self.cdr.packages.create(p)
|
||||
logging.info("Created package '{}' with {} tests".format(name, len(tests)))
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(
|
||||
@ -575,6 +844,14 @@ def main():
|
||||
subparser.add_argument("-n", "--name", help="package name, (default: filename)")
|
||||
subparser.set_defaults(func=TestbedCDRouter.package_import)
|
||||
|
||||
subparser = subparsers.add_parser(
|
||||
"package_apply", help="apply package from YAML manifest"
|
||||
)
|
||||
subparser.add_argument(
|
||||
"package_name", help="package name (resolves to packages/<name>.yaml)"
|
||||
)
|
||||
subparser.set_defaults(func=TestbedCDRouter.package_apply)
|
||||
|
||||
subparser = subparsers.add_parser("config_export", help="export configuration")
|
||||
subparser.add_argument("name", help="configuration name")
|
||||
subparser.add_argument("filename", help="destination filename")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user