sky-lattice/src/skylattice/commands/wizard_cmd.py

165 lines
6.2 KiB
Python
Raw Normal View History

from __future__ import annotations
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import typer
from rich.console import Console
from rich.prompt import Prompt
from skylattice.core.decisions import create_decision
from skylattice.core.intent import load_intent, mark_wizard_answered, save_intent
from skylattice.core.paths import blueprint_dir, find_customer_root, load_yaml, set_by_path
console = Console()
def _parse_list(raw: str) -> list[str]:
if not raw or raw.strip().lower() == "none":
return []
return [p.strip() for p in raw.split(",") if p.strip()]
def _slug_decision(prefix: str, name: str) -> str:
day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
safe = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return f"{day}-{prefix}-{safe}"
def _apply_answer(
intent: dict[str, Any],
customer_root: Path,
q: dict[str, Any],
raw: str,
*,
answer_map: dict[str, Any] | None = None,
) -> None:
qtype = q.get("type")
qid = q["id"]
answer_map = answer_map or {}
def _rationale_for(name: str) -> str:
key = f"{qid}_rationale"
if key in answer_map:
return str(answer_map[key]).format(name=name)
per_name = answer_map.get(f"{qid}_rationales") or {}
if isinstance(per_name, dict) and name in per_name:
return str(per_name[name])
if answer_map:
# Non-interactive answers file: do not block on Prompt
return str(
answer_map.get("default_rationale")
or f"Deferred via wizard answers for {name}"
)
return Prompt.ask(q.get("decision_prompt", "Rationale?").format(name=name))
if qtype in ("string",) and q.get("intent_path"):
set_by_path(intent, q["intent_path"], raw.strip())
elif qtype == "choice" and q.get("intent_path"):
set_by_path(intent, q["intent_path"], raw.strip())
elif qtype == "list" and q.get("intent_path"):
set_by_path(intent, q["intent_path"], _parse_list(raw) or q.get("default"))
elif qtype == "domain_list" and q.get("intent_path"):
names = _parse_list(raw)
profile = q.get("default_access_profile") or "standard"
intent["domains"] = [
{"name": n, "access_profile": profile, "zones": ["landing", "raw", "curated", "marts"]}
for n in names
]
elif qtype == "restricted_domains":
names = _parse_list(raw)
for d in intent.get("domains") or []:
if d["name"] in names:
d["access_profile"] = "restricted"
elif qtype == "override_with_decision":
names = _parse_list(raw)
overrides = list(intent.get("overrides") or [])
for name in names:
rationale = _rationale_for(name)
did = _slug_decision("override", name)
create_decision(
customer_root,
decision_id=did,
applies_to=q.get("override_path_template", "domains.{name}").format(name=name),
rationale=rationale,
)
overrides.append(
{
"path": q.get("override_path_template", "domains.{name}.prod_curated_read").format(name=name),
"value": True,
"decision_id": did,
}
)
intent["overrides"] = overrides
elif qtype == "unmanaged_list":
names = _parse_list(raw)
unmanaged = list(intent.get("unmanaged") or [])
kind = q.get("kind") or "role"
for name in names:
rationale = _rationale_for(name)
did = _slug_decision("unmanaged", name)
create_decision(
customer_root,
decision_id=did,
applies_to=f"unmanaged.{kind}.{name}",
rationale=rationale,
)
unmanaged.append({"kind": kind, "name": name, "reason": rationale, "decision_id": did})
intent["unmanaged"] = unmanaged
else:
console.print(f"[yellow]Unknown question type {qtype}; skipped[/yellow]")
mark_wizard_answered(intent, qid)
def run(
mode: str | None = typer.Option(None, "--mode", help="greenfield|brownfield (default: from intent)"),
resume: bool = typer.Option(False, "--resume", help="Only ask unanswered questions"),
path: Path | None = typer.Option(None, "--path", help="Customer project path"),
answers: Path | None = typer.Option(None, "--answers", help="Non-interactive answers YAML"),
) -> None:
"""Guided interview that writes intent.yaml and decisions/."""
customer_root = path.resolve() if path else find_customer_root()
intent = load_intent(customer_root)
mode = mode or intent.get("mode") or "greenfield"
catalog_name = f"catalog.{mode}.yaml"
catalog = load_yaml(blueprint_dir() / "wizard" / catalog_name)
questions = catalog.get("questions") or []
answered = set((intent.get("wizard") or {}).get("answered") or [])
answer_map: dict[str, Any] = {}
if answers:
answer_map = load_yaml(answers) or {}
console.print(f"[bold]Sky Lattice wizard[/bold] ({mode}) — {customer_root}")
if "discover" in (catalog.get("prerequisites") or []):
console.print("[dim]Tip: run platformctl discover before brownfield adoption.[/dim]")
for q in questions:
qid = q["id"]
if resume and qid in answered:
continue
if qid in answer_map:
raw = answer_map[qid]
if isinstance(raw, list):
raw = ",".join(str(x) for x in raw)
else:
raw = str(raw)
console.print(f"{q['prompt']}{raw}")
else:
default = q.get("default")
if isinstance(default, list):
default_s = ",".join(str(x) for x in default)
else:
default_s = "" if default is None else str(default)
raw = Prompt.ask(q["prompt"], default=default_s)
_apply_answer(intent, customer_root, q, raw, answer_map=answer_map)
intent["mode"] = mode
save_intent(customer_root, intent)
console.print("[green]Wizard complete.[/green] Run: platformctl validate && platformctl plan")