156 lines
5.3 KiB
Python
156 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
DAYLIGHT_START = "10:00"
|
|
DAYLIGHT_END = "16:00"
|
|
|
|
|
|
def load_manifest(path: str | Path) -> dict[str, Any]:
|
|
data = yaml.safe_load(Path(path).read_text()) or {}
|
|
data.setdefault("machines", [])
|
|
return data
|
|
|
|
|
|
def validate_manifest(data: dict[str, Any]) -> None:
|
|
machines = data.get("machines", [])
|
|
if not machines:
|
|
raise ValueError("manifest must contain at least one machine")
|
|
|
|
seen: set[str] = set()
|
|
for machine in machines:
|
|
hostname = machine.get("hostname", "").strip()
|
|
if not hostname:
|
|
raise ValueError("each machine must declare a hostname")
|
|
if hostname in seen:
|
|
raise ValueError(f"duplicate hostname: {hostname} (unique hostnames are required)")
|
|
seen.add(hostname)
|
|
|
|
for field in ("machine_type", "ram_gb", "cpu_cores", "os", "adapter_condition"):
|
|
if field not in machine:
|
|
raise ValueError(f"machine {hostname} missing required field: {field}")
|
|
|
|
|
|
def _laptops(machines: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
return [m for m in machines if m.get("machine_type") == "laptop"]
|
|
|
|
|
|
def _desktop(machines: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
for machine in machines:
|
|
if machine.get("machine_type") == "desktop":
|
|
return machine
|
|
return None
|
|
|
|
|
|
def choose_anchor_agents(machines: list[dict[str, Any]], count: int = 2) -> list[dict[str, Any]]:
|
|
eligible = [
|
|
m for m in _laptops(machines)
|
|
if m.get("adapter_condition") in {"good", "ok"} and m.get("always_on_capable", True)
|
|
]
|
|
eligible.sort(key=lambda m: (m.get("idle_watts", 9999), -m.get("ram_gb", 0), -m.get("cpu_cores", 0), m["hostname"]))
|
|
return eligible[:count]
|
|
|
|
|
|
def assign_roles(machines: list[dict[str, Any]]) -> dict[str, Any]:
|
|
anchors = choose_anchor_agents(machines, count=2)
|
|
anchor_names = {m["hostname"] for m in anchors}
|
|
desktop = _desktop(machines)
|
|
|
|
mapping: dict[str, dict[str, Any]] = {}
|
|
for machine in machines:
|
|
hostname = machine["hostname"]
|
|
if desktop and hostname == desktop["hostname"]:
|
|
mapping[hostname] = {
|
|
"role": "desktop_nas",
|
|
"schedule": f"{DAYLIGHT_START}-{DAYLIGHT_END}",
|
|
"duty_cycle": "daylight_only",
|
|
}
|
|
elif hostname in anchor_names:
|
|
mapping[hostname] = {
|
|
"role": "anchor_agent",
|
|
"schedule": "24/7",
|
|
"duty_cycle": "continuous",
|
|
}
|
|
else:
|
|
mapping[hostname] = {
|
|
"role": "daylight_agent",
|
|
"schedule": f"{DAYLIGHT_START}-{DAYLIGHT_END}",
|
|
"duty_cycle": "peak_solar",
|
|
}
|
|
return {
|
|
"anchor_agents": [m["hostname"] for m in anchors],
|
|
"desktop_nas": desktop["hostname"] if desktop else None,
|
|
"role_mapping": mapping,
|
|
}
|
|
|
|
|
|
def build_plan(data: dict[str, Any]) -> dict[str, Any]:
|
|
validate_manifest(data)
|
|
machines = data["machines"]
|
|
role_plan = assign_roles(machines)
|
|
return {
|
|
"fleet_name": data.get("fleet_name", "timmy-laptop-fleet"),
|
|
"machine_count": len(machines),
|
|
"anchor_agents": role_plan["anchor_agents"],
|
|
"desktop_nas": role_plan["desktop_nas"],
|
|
"daylight_window": f"{DAYLIGHT_START}-{DAYLIGHT_END}",
|
|
"role_mapping": role_plan["role_mapping"],
|
|
}
|
|
|
|
|
|
def render_markdown(plan: dict[str, Any], data: dict[str, Any]) -> str:
|
|
lines = [
|
|
"# Laptop Fleet Deployment Plan",
|
|
"",
|
|
f"Fleet: {plan['fleet_name']}",
|
|
f"Machine count: {plan['machine_count']}",
|
|
f"24/7 anchor agents: {', '.join(plan['anchor_agents']) if plan['anchor_agents'] else 'TBD'}",
|
|
f"Desktop/NAS: {plan['desktop_nas'] or 'TBD'}",
|
|
f"Daylight schedule: {plan['daylight_window']}",
|
|
"",
|
|
"## Role mapping",
|
|
"",
|
|
"| Hostname | Role | Schedule | Duty cycle |",
|
|
"|---|---|---|---|",
|
|
]
|
|
for hostname, role in sorted(plan["role_mapping"].items()):
|
|
lines.append(f"| {hostname} | {role['role']} | {role['schedule']} | {role['duty_cycle']} |")
|
|
|
|
lines.extend([
|
|
"",
|
|
"## Machine inventory",
|
|
"",
|
|
"| Hostname | Type | RAM | CPU cores | OS | Adapter | Idle watts | Notes |",
|
|
"|---|---|---:|---:|---|---|---:|---|",
|
|
])
|
|
for machine in data["machines"]:
|
|
lines.append(
|
|
f"| {machine['hostname']} | {machine['machine_type']} | {machine['ram_gb']} | {machine['cpu_cores']} | {machine['os']} | {machine['adapter_condition']} | {machine.get('idle_watts', 'n/a')} | {machine.get('notes', '')} |"
|
|
)
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Plan LAB-005 laptop fleet deployment.")
|
|
parser.add_argument("manifest", help="Path to laptop fleet manifest YAML")
|
|
parser.add_argument("--markdown", action="store_true", help="Render a markdown deployment plan instead of JSON")
|
|
args = parser.parse_args()
|
|
|
|
data = load_manifest(args.manifest)
|
|
plan = build_plan(data)
|
|
if args.markdown:
|
|
print(render_markdown(plan, data))
|
|
else:
|
|
print(json.dumps(plan, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|