ci: scripts: add CDRouter .gz to YAML package converter

Currently CDRouter test packages are stored as opaque .gz binary
exports in .testbed/cdrouter/packages/. These binary blobs are not
human-readable, not diffable, and carry implicit fields that leak
runtime state into the repository.

So lets add a converter script that reads the two-line JSON format
inside .gz exports and emits clean unversioned YAML manifests. The
converter extracts name, description, testlist (as tests), and
options from each .gz file. By default it compacts the options map
by omitting keys matching known CDRouter defaults, keeping only
semantic overrides. The --preserve-all-options flag emits the full
source options map when needed.

Fidelity verification expands both source and generated options
with known defaults before comparison, so compact manifests with
omitted defaults still pass. Testlist order is compared exactly.
The converter rejects packages where the internal name does not
match the .gz filename.

CLI supports --input-dir, --output-dir, --filter, --dry-run,
--overwrite, --preserve-all-options, and --verbose flags. Exits
non-zero if any package fails fidelity.

References: PCF-2531, PCF-2535
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:
Petr Štetiar 2026-02-19 18:45:35 +01:00 committed by Hela Saadani
parent f0c7a4251d
commit 6bc69fde54
2 changed files with 450 additions and 0 deletions

View File

@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""Convert CDRouter .gz package exports to YAML manifests."""
import argparse
import fnmatch
import gzip
import json
import logging
import os
import sys
import yaml
OPTION_DEFAULTS = {
"forever": False,
"loop": "0",
"repeat": "0",
"maxfail": "0",
"duration": "0",
"duration_units": "seconds",
"duration_interrupt": False,
"duration_no_error": False,
"wait": "0",
"pause": False,
"shuffle": False,
"seed": "0",
"retry": "0",
"rdelay": "0",
"sync": False,
}
FORBIDDEN_KEYS = {"config", "device", "config_id", "device_id"}
log = logging.getLogger("cdrouter-package-convert")
def read_gz_package(gz_path):
"""Read a CDRouter .gz export and return the package dict.
The .gz file contains 2-3 lines:
- Line 0: metadata header (version, names only)
- Line 1: full data (configs, packages, devices)
- Line 2: null-byte padding (ignored)
"""
with gzip.open(gz_path, "rt", errors="replace") as fh:
lines = fh.readlines()
# Find JSON data lines (skip empty/null-padded lines)
json_lines = []
for line in lines:
stripped = line.strip().strip("\x00").strip()
if stripped:
json_lines.append(stripped)
if len(json_lines) < 2:
raise ValueError(f"Expected at least 2 JSON lines, got {len(json_lines)}")
data = json.loads(json_lines[1])
packages = data.get("packages", [])
if not packages:
raise ValueError("No packages found in data line")
return packages[0]
def normalize_options(options):
"""Return a normalized source options map."""
if not options:
return {}
return dict(options)
def compact_options(options):
"""Return a compact options map with default values removed."""
normalized = normalize_options(options)
compact = {}
for key, value in normalized.items():
default = OPTION_DEFAULTS.get(key)
if default is None or value != default:
compact[key] = value
return compact
def expand_options_with_defaults(options):
"""Return options expanded with defaults for semantic comparisons."""
expanded = dict(OPTION_DEFAULTS)
expanded.update(normalize_options(options))
return expanded
def package_to_yaml_dict(pkg, preserve_all_options=False):
"""Convert a package dict to the output YAML schema."""
result = {"name": pkg["name"]}
description = pkg.get("description", "")
if description:
result["description"] = description
testlist = pkg.get("testlist", [])
result["tests"] = testlist
if preserve_all_options:
options = normalize_options(pkg.get("options"))
else:
options = compact_options(pkg.get("options"))
if options:
result["options"] = options
# Verify no forbidden keys leaked through
for key in FORBIDDEN_KEYS:
assert key not in result, f"Forbidden key '{key}' in output"
return result
def verify_fidelity(pkg, yaml_dict):
"""Verify round-trip fidelity between source .gz and generated YAML.
Returns (passed, errors) tuple.
"""
errors = []
# Check testlist order and content
src_tests = pkg.get("testlist", [])
yaml_tests = yaml_dict.get("tests", [])
if src_tests != yaml_tests:
if len(src_tests) != len(yaml_tests):
errors.append(
f"testlist length mismatch: source={len(src_tests)}, "
f"yaml={len(yaml_tests)}"
)
else:
for i, (s, y) in enumerate(zip(src_tests, yaml_tests)):
if s != y:
errors.append(f"testlist[{i}] mismatch: source={s!r}, yaml={y!r}")
# Check semantic options fidelity after default expansion.
src_options = expand_options_with_defaults(pkg.get("options"))
yaml_options = expand_options_with_defaults(yaml_dict.get("options", {}))
if src_options != yaml_options:
for key in set(src_options) | set(yaml_options):
sv = src_options.get(key)
yv = yaml_options.get(key)
if sv != yv:
errors.append(f"options[{key!r}] mismatch: source={sv!r}, yaml={yv!r}")
# Check forbidden keys
for key in FORBIDDEN_KEYS:
if key in yaml_dict:
errors.append(f"forbidden key '{key}' present in output")
return len(errors) == 0, errors
def convert_package(
gz_path, output_dir, dry_run=False, overwrite=False, preserve_all_options=False
):
"""Convert a single .gz package to YAML. Returns (name, passed, errors)."""
basename = os.path.splitext(os.path.basename(gz_path))[0]
yaml_path = os.path.join(output_dir, basename + ".yaml")
log.info("Converting %s", gz_path)
try:
pkg = read_gz_package(gz_path)
except Exception as e:
return basename, False, [f"Failed to read .gz: {e}"]
package_name = pkg.get("name")
if package_name != basename:
return (
basename,
False,
[
"Package name does not match filename: "
f"filename={basename!r}, package={package_name!r}"
],
)
yaml_dict = package_to_yaml_dict(pkg, preserve_all_options)
passed, errors = verify_fidelity(pkg, yaml_dict)
if not passed:
for err in errors:
log.error(" FIDELITY FAIL [%s]: %s", basename, err)
return basename, False, errors
if not overwrite and os.path.exists(yaml_path):
log.info(" Skipping %s (already exists, use --overwrite)", yaml_path)
return basename, True, []
if dry_run:
log.info(" Would write %s", yaml_path)
content = yaml.dump(yaml_dict, default_flow_style=False, sort_keys=False)
log.info("--- %s ---\n%s---", basename, content)
else:
os.makedirs(output_dir, exist_ok=True)
with open(yaml_path, "w") as fh:
yaml.dump(yaml_dict, fh, default_flow_style=False, sort_keys=False)
log.info(" Wrote %s", yaml_path)
return basename, True, []
def main():
parser = argparse.ArgumentParser(
description="Convert CDRouter .gz package exports to YAML manifests"
)
parser.add_argument(
"--input-dir",
default=".testbed/cdrouter/packages/",
help="Directory containing .gz files (default: .testbed/cdrouter/packages/)",
)
parser.add_argument(
"--output-dir",
default=".testbed/cdrouter/packages/",
help="Directory for YAML output (default: .testbed/cdrouter/packages/)",
)
parser.add_argument(
"--filter",
default=None,
help="Glob pattern to filter which .gz files to convert",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print what would be done without writing files",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Overwrite existing YAML files (default: skip if exists)",
)
parser.add_argument(
"--preserve-all-options",
action="store_true",
help="Preserve full options map instead of compacting defaults",
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Enable verbose output",
)
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(levelname)s: %(message)s",
)
if not os.path.isdir(args.input_dir):
log.error("Input directory does not exist: %s", args.input_dir)
sys.exit(1)
gz_files = sorted(f for f in os.listdir(args.input_dir) if f.endswith(".gz"))
if args.filter:
gz_files = [f for f in gz_files if fnmatch.fnmatch(f, args.filter)]
if not gz_files:
log.warning("No .gz files found in %s", args.input_dir)
sys.exit(0)
log.info("Found %d .gz file(s) to convert", len(gz_files))
results = []
for gz_file in gz_files:
gz_path = os.path.join(args.input_dir, gz_file)
name, passed, errors = convert_package(
gz_path,
args.output_dir,
args.dry_run,
args.overwrite,
args.preserve_all_options,
)
results.append((name, passed, errors))
# Print fidelity report
print()
print("=" * 60)
print("Fidelity Report")
print("=" * 60)
all_passed = True
for name, passed, errors in results:
status = "PASS" if passed else "FAIL"
print(f" {status} {name}")
if errors:
for err in errors:
print(f" {err}")
if not passed:
all_passed = False
print("=" * 60)
total = len(results)
passed_count = sum(1 for _, p, _ in results if p)
print(f" {passed_count}/{total} passed")
if not all_passed:
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,144 @@
# CDRouter Packages
This directory contains CDRouter package manifests in YAML format.
## Package Format (YAML)
Each package is defined in a single file `<package-name>.yaml` in this
directory:
```yaml
name: CDRouter-Top-100+mDNS
description: Optional human-readable description
includes: # optional
- groups/ipv4-top-100.yaml # relative path to included manifest
dedup_tests: true # optional, root-only (default: false)
tests:
- cdrouter_nat_1
- cdrouter_nat_2
- dns_1
options: # optional pass-through package options
maxfail: "5"
```
Required fields: `name` (must match filename), `tests`.
Forbidden fields: `config`, `device`, `config_id`, `device_id` (runtime
binding stays in CI).
## Package Options
The `options` map controls scheduler behavior for a package run. Keys below
are recognized by the converter, which compacts any key whose value matches
its default so hand-written manifests only need to set overrides.
`package_apply` rejects option keys not known to the `cdrouter` Python SDK.
Authoritative semantics live in CDRouter's API/CLI reference.
| Key | Type | Default | Meaning |
|---|---|---|---|
| `forever` | bool | `false` | Run indefinitely, ignoring `loop`/`repeat` termination. |
| `loop` | int string | `"0"` | Number of full testlist iterations. `"0"` = one pass. |
| `repeat` | int string | `"0"` | Extra repetitions of each test per pass. |
| `maxfail` | int string | `"0"` | Abort after N test failures. `"0"` = no limit. |
| `duration` | int string | `"0"` | Wall-clock budget for the package. `"0"` = unbounded. |
| `duration_units` | string | `"seconds"` | Unit for `duration` (`seconds` or `minutes`). |
| `duration_interrupt` | bool | `false` | On timeout, interrupt the running test instead of letting it finish. |
| `duration_no_error` | bool | `false` | Do not flag a duration timeout as a failure. |
| `wait` | int string | `"0"` | Seconds to pause between tests. |
| `pause` | bool | `false` | Pause between tests and wait for resume. |
| `shuffle` | bool | `false` | Randomize test order within each loop. |
| `seed` | int string | `"0"` | Seed for `shuffle`. `"0"` = random. |
| `retry` | int string | `"0"` | Retry each failing test up to N times. |
| `rdelay` | int string | `"0"` | Seconds to wait between retry attempts. |
| `sync` | bool | `false` | Synchronize state between tests for reproducibility. |
Integer-valued options are stored as strings in the CDRouter API — quote them
in YAML (e.g. `maxfail: "5"`). Booleans are unquoted (`true` / `false`). Pass
`--preserve-all-options` to the converter to keep default-valued keys in
generated manifests.
## Include Semantics
Manifests can include other manifests via `includes`:
- Includes are resolved **depth-first** in listed order.
- Tests from included manifests appear **before** the current manifest's tests.
- **Dedup** is configurable via root `dedup_tests` (default: `false`):
- `false`: duplicates are preserved in final test order.
- `true`: first occurrence wins, later duplicates are dropped.
- Include paths are resolved relative to the including file and must stay under
`cdrouter/packages/`.
- Options merge: included options first, root manifest wins on conflicts.
- `name` and `description` are taken from the root manifest only.
- `dedup_tests` is taken from the root manifest only.
- Circular includes are detected and rejected.
## Shared Test Groups (Optional)
Reusable test subsets can be placed under `groups/`:
```yaml
tests:
- cdrouter_nat_1
- cdrouter_nat_2
```
Group files must not define `name`, `description`, or `options`. Package
manifests reference them via `includes`:
```yaml
includes:
- groups/ipv4-top-100.yaml
```
Included manifests and group manifests must not define `dedup_tests`.
## Module-Based Packages
Some packages use CDRouter module entries instead of individual tests:
```yaml
name: CDRouter-UPnP-IPv46
tests:
- cdrouter_ssdp_1
- cdrouter_ssdp_2
- MODULE_upnp.tcl # runs all tests in the upnp module
```
## Migration from .gz Exports
Existing `.gz` package exports can be converted to YAML using:
```bash
.gitlab/scripts/cdrouter-package-convert.py \
--input-dir .testbed/cdrouter/packages/ \
--output-dir .testbed/cdrouter/packages/ \
--overwrite
```
The converter reads the two-line JSON format inside `.gz` files and emits YAML
manifests. By default it compacts the `options` map by omitting keys set to
known defaults, and verifies fidelity by checking exact testlist order plus
semantic options equivalence after default expansion. Use
`--preserve-all-options` to emit the full source options map when needed.
## CI Integration
CI jobs use `package_apply` to upsert packages via the CDRouter API from YAML
manifests:
```bash
.gitlab/scripts/testbed-cdrouter.py package_apply $TEST_PACKAGE
```
This resolves `packages/$TEST_PACKAGE.yaml`, processes includes, and creates or
updates the named package on the CDRouter instance. Runtime config/device
binding remains in the `package_run` step.
To validate a manifest offline (no CDRouter connection) for pre-commit or CI
lint, use `package_validate`, which resolves includes and checks option keys
against the `cdrouter` SDK:
```bash
.gitlab/scripts/testbed-cdrouter.py package_validate $TEST_PACKAGE
```