-
Notifications
You must be signed in to change notification settings - Fork 2
feat(w0-pr6): json-to-profile.py -- auto-write node profile from probe JSON #1486
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,349 @@ | ||
| #!/usr/bin/env python3 | ||
| """json-to-profile.py — Convert a glances-autodetect probe JSON into a PMOVES node profile YAML. | ||
|
|
||
| USAGE: | ||
| python json-to-profile.py --json /path/to/probe.json --node-id pmoves-rdna4 \\ | ||
| [--out-dir pmoves/config/profiles/] [--dry-run] [--force] | ||
|
|
||
| INPUT: JSON produced by deploy/provision/glances-autodetect.sh (or .ps1) | ||
| OUTPUT: YAML profile written to <out-dir>/<node-id>.yaml (or printed with --dry-run) | ||
|
|
||
| Requires: pyyaml (pip install pyyaml) | ||
| All other dependencies are stdlib only. | ||
| """ | ||
|
|
||
| import argparse | ||
| import json | ||
| import os | ||
| import sys | ||
|
|
||
| try: | ||
| import yaml | ||
| except ImportError: | ||
| print( | ||
| "ERROR: pyyaml is required but not installed.\n" | ||
| " Run: pip install pyyaml", | ||
| file=sys.stderr, | ||
| ) | ||
| sys.exit(1) | ||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Mapping helpers | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| _REQUIRED_PROBE_FIELDS = ("cpu", "ram_gb", "suggested_node_type") | ||
|
|
||
|
|
||
| def validate_probe(data: dict) -> None: | ||
| missing = [f for f in _REQUIRED_PROBE_FIELDS if f not in data] | ||
| if missing: | ||
| print( | ||
| f"ERROR: Probe JSON is missing required fields: {', '.join(missing)}", | ||
| file=sys.stderr, | ||
| ) | ||
| sys.exit(1) | ||
|
|
||
|
|
||
| _TAILSCALE_ROLE_MAP = { | ||
| "gpu-5090": "gpu-node", | ||
| "gpu-4090": "gpu-node", | ||
| "rdna4-workstation": "gpu-node", | ||
| "dgx-spark": "gpu-node", | ||
| "pve-member": "pve-member", | ||
| "pve-member-fresh": "pve-member", | ||
| "desktop-workstation": "desktop-workstation", | ||
| } | ||
|
|
||
|
|
||
| def map_arch(arch_str: str) -> str: | ||
| """Normalise architecture string to PMOVES canonical form.""" | ||
| return {"x86_64": "amd64", "aarch64": "arm64"}.get(arch_str, arch_str) | ||
|
|
||
|
|
||
| def map_tailscale_role(suggested_node_type: str) -> str: | ||
| """Return the Tailscale role for a given suggested node type.""" | ||
| return _TAILSCALE_ROLE_MAP.get(suggested_node_type, "unknown") | ||
|
|
||
|
|
||
| def build_tags(arch: str, gpus: list, suggested_node_type: str) -> list: | ||
| """Derive hardware tags from arch, GPU list, and suggested node type.""" | ||
| tags = [map_arch(arch)] | ||
|
|
||
| for g in gpus: | ||
| vendor = g.get("vendor", "").lower() | ||
| if vendor in ("nvidia", "amd", "intel") and vendor not in tags: | ||
| tags.append(vendor) | ||
|
|
||
| # Workload class tag derived from node type | ||
| ntype = suggested_node_type.lower() | ||
| if ntype.startswith("gpu-") or ntype.startswith("rdna4-"): | ||
| tags.append("workstation") | ||
| elif ntype == "dgx-spark": | ||
| tags.append("dgx") | ||
| elif ntype.startswith("pve-member"): | ||
| tags.append("pve") | ||
| elif ntype.startswith("desktop-"): | ||
| tags.append("desktop") | ||
| elif ntype.startswith("kvm"): | ||
| tags.append("kvm") | ||
| # else: no workload tag for unknown | ||
|
|
||
| return tags | ||
|
|
||
|
|
||
| def build_name(suggested_node_type: str, cpu_model: str) -> str: | ||
| """Derive a human-readable profile name.""" | ||
| return f"{suggested_node_type} — {cpu_model}" | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Core builder | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| def build_profile(data: dict, node_id: str) -> dict: | ||
| """Map probe JSON dict to a PMOVES node profile dict.""" | ||
|
|
||
| os_info = data.get("os", {}) | ||
| arch = data.get("arch", "x86_64") | ||
| cpu = data.get("cpu", {}) | ||
| ram_gb = data.get("ram_gb", 0) | ||
| gpus = data.get("gpus", []) | ||
| nic_collisions = data.get("nic_collisions", []) | ||
| platform_hints = data.get("platform_hints", {}) | ||
| suggested_node_type = data.get("suggested_node_type", "unknown") | ||
| suggestion_confidence = data.get("suggestion_confidence", "high") | ||
|
|
||
| # --- CPU sub-dict --- | ||
| cpu_dict = { | ||
| "vendor": _infer_cpu_vendor(cpu.get("model", "")), | ||
| "model": cpu.get("model", ""), | ||
| "cores": cpu.get("cores_physical", 0), | ||
| "threads": cpu.get("cores_logical", 0), | ||
| "arch": map_arch(arch), | ||
| } | ||
|
|
||
| # --- GPU(s) --- | ||
| gpu_block = _build_gpu_block(gpus) | ||
|
|
||
| # --- Hardware dict --- | ||
| hardware: dict = {"cpu": cpu_dict} | ||
| hardware.update(gpu_block) # injects "gpus" key (list, always consistent) | ||
| hardware["ram_gb"] = ram_gb | ||
| hardware["tags"] = build_tags(arch, gpus, suggested_node_type) | ||
|
|
||
| # --- compose_overrides --- | ||
| compose_overrides = ( | ||
| ["docker-compose.arm64.override.yml"] if arch == "aarch64" else [] | ||
| ) | ||
|
|
||
| # --- notes --- | ||
| notes: list = [] | ||
|
|
||
| # nic_collisions | ||
| ghost_adapter_warning = False | ||
| if nic_collisions: | ||
| ghost_adapter_warning = True | ||
| for col in nic_collisions: | ||
| primary = col.get("primary", "") | ||
| ghost = col.get("ghost", "") | ||
| subnet = col.get("subnet", "") | ||
| notes.append( | ||
| f"NIC collision on {subnet}: primary={primary} ghost={ghost}" | ||
| ) | ||
|
|
||
| # Docker not present | ||
| if not platform_hints.get("has_docker", True): | ||
| notes.append( | ||
| "Docker not detected — install Docker before running compose stacks." | ||
| ) | ||
|
|
||
| # Low confidence | ||
| if suggestion_confidence == "low": | ||
| notes.append( | ||
| "Auto-detected node type has low confidence — review and override manually." | ||
| ) | ||
|
|
||
| # Windows host | ||
| if os_info.get("distro", "").lower() == "windows": | ||
| notes.append( | ||
| "Windows host — add docker-compose.windows.override.yml to compose_overrides." | ||
| ) | ||
|
|
||
| # --- Assemble profile --- | ||
| profile: dict = { | ||
| "id": node_id, | ||
| "name": build_name(suggested_node_type, cpu.get("model", "")), | ||
| "hardware": hardware, | ||
| "compose_overrides": compose_overrides, | ||
| "services": ["mesh-agent", "fleet-agent"], | ||
| "tailscale": { | ||
| "role": map_tailscale_role(suggested_node_type), | ||
| "hostname_pattern": node_id, | ||
| }, | ||
| "notes": notes, | ||
| } | ||
|
|
||
| # ghost_adapter_warning sits at top-level if triggered | ||
| if ghost_adapter_warning: | ||
| profile["ghost_adapter_warning"] = True | ||
|
|
||
| return profile | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Private helpers | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| def _infer_cpu_vendor(model: str) -> str: | ||
| """Best-effort CPU vendor from model string.""" | ||
| m = model.lower() | ||
| if "amd" in m or "ryzen" in m or "epyc" in m or "threadripper" in m: | ||
| return "AMD" | ||
| if "intel" in m or "core" in m or "xeon" in m or "celeron" in m or "pentium" in m: | ||
| return "Intel" | ||
| if "arm" in m or "cortex" in m or "grace" in m or "neoverse" in m: | ||
| return "ARM" | ||
| if "apple" in m: | ||
| return "Apple" | ||
| return "Unknown" | ||
|
|
||
|
|
||
| def _build_gpu_block(gpus: list) -> dict: | ||
| """Return {gpus: [...]} for any number of GPUs (consistent list, never singular gpu key).""" | ||
| if not gpus: | ||
| return {} | ||
| return { | ||
| "gpus": [ | ||
| { | ||
| "vendor": g.get("vendor", ""), | ||
| "model": g.get("model", ""), | ||
| "vram_gb": g.get("vram_gb", 0), | ||
| } | ||
| for g in gpus | ||
| ] | ||
| } | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # YAML dumper — preserve insertion order, no aliases, literal strings | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| class _NoAliasDumper(yaml.Dumper): | ||
| """YAML dumper that never emits aliases and preserves dict insertion order.""" | ||
|
|
||
| def ignore_aliases(self, data): | ||
| return True | ||
|
|
||
|
|
||
| def _dump_yaml(profile: dict) -> str: | ||
| return yaml.dump( | ||
| profile, | ||
| Dumper=_NoAliasDumper, | ||
| default_flow_style=False, | ||
| allow_unicode=True, | ||
| sort_keys=False, | ||
| ) | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # I/O helpers | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| def load_json(path: str) -> dict: | ||
| """Load and parse a JSON file, with clear error messages.""" | ||
| if not os.path.isfile(path): | ||
| print(f"ERROR: JSON file not found: {path}", file=sys.stderr) | ||
| sys.exit(1) | ||
| try: | ||
| with open(path, "r", encoding="utf-8") as fh: | ||
| return json.load(fh) | ||
| except json.JSONDecodeError as exc: | ||
| print(f"ERROR: Failed to parse JSON: {exc}", file=sys.stderr) | ||
| sys.exit(1) | ||
|
|
||
|
|
||
| def write_profile(yaml_text: str, out_dir: str, node_id: str, force: bool) -> str: | ||
| """Write profile YAML to <out_dir>/<node_id>.yaml. Returns the output path.""" | ||
| os.makedirs(out_dir, exist_ok=True) | ||
| out_path = os.path.join(out_dir, f"{node_id}.yaml") | ||
| if os.path.exists(out_path) and not force: | ||
| print( | ||
| f"ERROR: Profile already exists: {out_path}\n" | ||
| " Use --force to overwrite.", | ||
| file=sys.stderr, | ||
| ) | ||
| sys.exit(1) | ||
| with open(out_path, "w", encoding="utf-8") as fh: | ||
| fh.write(yaml_text) | ||
| return out_path | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # CLI | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| def build_parser() -> argparse.ArgumentParser: | ||
| p = argparse.ArgumentParser( | ||
| prog="json-to-profile.py", | ||
| description=( | ||
| "Convert a glances-autodetect probe JSON into a PMOVES node profile YAML.\n\n" | ||
| "Reads JSON probe output produced by glances-autodetect.sh (or .ps1) and\n" | ||
| "writes a PMOVES node profile YAML to --out-dir/<node-id>.yaml.\n\n" | ||
| "Example:\n" | ||
| " python json-to-profile.py \\\n" | ||
| " --json /tmp/probe.json \\\n" | ||
| " --node-id pmoves-rdna4 \\\n" | ||
| " --out-dir pmoves/config/profiles/" | ||
| ), | ||
| formatter_class=argparse.RawDescriptionHelpFormatter, | ||
| ) | ||
| p.add_argument( | ||
| "--json", | ||
| required=True, | ||
| metavar="PATH", | ||
| help="Path to the probe JSON file (from glances-autodetect.sh --json-file=PATH).", | ||
| ) | ||
| p.add_argument( | ||
| "--node-id", | ||
| required=True, | ||
| metavar="ID", | ||
| help="Fleet hostname used as profile id and output filename (e.g. pmoves-rdna4).", | ||
| ) | ||
| p.add_argument( | ||
| "--out-dir", | ||
| default="pmoves/config/profiles/", | ||
| metavar="DIR", | ||
| help="Directory to write <node-id>.yaml into. Created if absent. (default: pmoves/config/profiles/)", | ||
| ) | ||
| p.add_argument( | ||
| "--dry-run", | ||
| action="store_true", | ||
| help="Print generated YAML to stdout; do not write any file.", | ||
| ) | ||
| p.add_argument( | ||
| "--force", | ||
| action="store_true", | ||
| help="Overwrite an existing profile. Default: error if the file already exists.", | ||
| ) | ||
| return p | ||
|
|
||
|
|
||
| def main() -> None: | ||
| parser = build_parser() | ||
| args = parser.parse_args() | ||
|
|
||
| data = load_json(args.json) | ||
| validate_probe(data) | ||
| profile = build_profile(data, args.node_id) | ||
| yaml_text = _dump_yaml(profile) | ||
|
|
||
| if args.dry_run: | ||
| print(yaml_text, end="") | ||
| return | ||
|
|
||
| out_path = write_profile(yaml_text, args.out_dir, args.node_id, args.force) | ||
| print(f"Profile written: {out_path}") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The default
--out-diris a relative string (pmoves/config/profiles/), so it is resolved against the caller’s current working directory rather than the repository root. When operators run the script as documented fromdeploy/provision(python json-to-profile.py ...), it writes todeploy/provision/pmoves/config/profiles/...instead of the realpmoves/config/profiles/..., and the generated profile is silently missed by profile-loading tooling. Compute the default from__file__(or require an explicit absolute/normalized path) to avoid this miswrite.Useful? React with 👍 / 👎.