From 611ad214fec8d22f01a6c9e8ca33676bca9db24d Mon Sep 17 00:00:00 2001 From: VG Date: Wed, 15 Jul 2026 01:48:36 -0400 Subject: [PATCH] Initial Sky Lattice scaffold: blueprint, platformctl, and docs. Encode intent/decision/plan workflow for Snowflake platform delivery so engagements share a durable recipe instead of one-off LLM chats. Co-authored-by: Cursor --- .gitignore | 15 + CHANGELOG.md | 6 + PLAN.md | 1034 +++++++++++++++++ README.md | 73 ++ VERSION | 1 + blueprint/CHANGELOG.md | 5 + blueprint/VERSION | 1 + blueprint/defaults/access_profiles.yaml | 12 + blueprint/defaults/intent.brownfield.yaml | 19 + blueprint/defaults/intent.greenfield.yaml | 21 + blueprint/defaults/warehouse_profiles.yaml | 10 + blueprint/modules/database_zones/main.tf | 35 + blueprint/modules/environment/main.tf | 24 + blueprint/modules/monitoring/main.tf | 20 + blueprint/modules/rbac/main.tf | 36 + blueprint/modules/service_principals/main.tf | 23 + blueprint/modules/warehouses/main.tf | 33 + blueprint/policies/rules.yaml | 29 + blueprint/schemas/decision.schema.json | 37 + blueprint/schemas/discover-report.schema.json | 46 + blueprint/schemas/intent.schema.json | 131 +++ blueprint/skills/platform-ops/SKILL.md | 48 + blueprint/skills/platform-ops/examples.md | 18 + .../platform-ops/rbac-best-practices.md | 8 + .../.cursor/skills/platform-ops/SKILL.md | 48 + .../.cursor/skills/platform-ops/examples.md | 18 + .../platform-ops/rbac-best-practices.md | 8 + .../.github/workflows/terraform.yml | 26 + blueprint/templates/customer-repo/README.md | 17 + .../customer-repo/decisions/.gitkeep | 1 + .../templates/customer-repo/terraform/main.tf | 21 + .../tests/reference-customer/intent.yaml | 18 + blueprint/wizard/catalog.brownfield.yaml | 69 ++ blueprint/wizard/catalog.greenfield.yaml | 76 ++ blueprint/wizard/mapping.yaml | 18 + customers/.gitkeep | 0 .../acme/.cursor/skills/platform-ops/SKILL.md | 48 + .../.cursor/skills/platform-ops/examples.md | 18 + .../platform-ops/rbac-best-practices.md | 8 + .../acme/.github/workflows/terraform.yml | 26 + customers/acme/README.md | 21 + customers/acme/decisions/.gitkeep | 1 + .../decisions/2026-07-15-domains-finance.yaml | 10 + customers/acme/intent.yaml | 54 + customers/acme/terraform/main.tf | 21 + customers/acme/terraform/planned_objects.yaml | 19 + .../.cursor/skills/platform-ops/SKILL.md | 48 + .../.cursor/skills/platform-ops/examples.md | 18 + .../platform-ops/rbac-best-practices.md | 8 + .../globex/.github/workflows/terraform.yml | 26 + customers/globex/README.md | 21 + customers/globex/decisions/.gitkeep | 1 + .../2026-07-15-unmanaged-finance-old.yaml | 10 + customers/globex/intent.yaml | 42 + customers/globex/observed/inventory.yaml | 9 + customers/globex/terraform/main.tf | 21 + .../globex/terraform/planned_objects.yaml | 28 + docs/customer-landing/CHECKLIST.md | 11 + docs/customer-landing/README.md | 57 + docs/decisions/D001-product-name-and-scope.md | 18 + docs/decisions/D002-llm-as-front-door.md | 17 + .../D003-blueprint-vs-customer-repo.md | 17 + docs/decisions/D004-packaging-python.md | 17 + .../D005-post-apply-edit-not-teardown.md | 17 + docs/decisions/D006-delivery-mode-a.md | 17 + docs/decisions/D007-wizard-reentrant.md | 16 + docs/decisions/D008-v0-planner-graph.md | 17 + docs/decisions/README.md | 14 + docs/recipes/add-domain.md | 34 + docs/recipes/fix-drift.md | 20 + docs/recipes/resume-wizard.md | 13 + docs/wizard-intent-mapping.md | 33 + examples/answers/acme.greenfield.yaml | 8 + examples/answers/globex.brownfield.yaml | 8 + examples/answers/globex.inventory.yaml | 9 + pyproject.toml | 31 + src/skylattice/__init__.py | 5 + src/skylattice/cli.py | 66 ++ src/skylattice/commands/__init__.py | 1 + src/skylattice/commands/add_cmd.py | 43 + src/skylattice/commands/apply_cmd.py | 61 + src/skylattice/commands/decision_cmd.py | 57 + src/skylattice/commands/destroy_cmd.py | 46 + src/skylattice/commands/discover_cmd.py | 47 + src/skylattice/commands/doctor_cmd.py | 53 + src/skylattice/commands/drift_cmd.py | 33 + src/skylattice/commands/explain_cmd.py | 44 + src/skylattice/commands/init_cmd.py | 55 + src/skylattice/commands/plan_cmd.py | 46 + src/skylattice/commands/skills_cmd.py | 28 + src/skylattice/commands/validate_cmd.py | 31 + src/skylattice/commands/wizard_cmd.py | 164 +++ src/skylattice/core/__init__.py | 1 + src/skylattice/core/decisions.py | 86 ++ src/skylattice/core/discover.py | 87 ++ src/skylattice/core/intent.py | 45 + src/skylattice/core/paths.py | 75 ++ src/skylattice/core/planner.py | 124 ++ src/skylattice/core/policy.py | 76 ++ tests/test_planner.py | 27 + 100 files changed, 4138 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 VERSION create mode 100644 blueprint/CHANGELOG.md create mode 100644 blueprint/VERSION create mode 100644 blueprint/defaults/access_profiles.yaml create mode 100644 blueprint/defaults/intent.brownfield.yaml create mode 100644 blueprint/defaults/intent.greenfield.yaml create mode 100644 blueprint/defaults/warehouse_profiles.yaml create mode 100644 blueprint/modules/database_zones/main.tf create mode 100644 blueprint/modules/environment/main.tf create mode 100644 blueprint/modules/monitoring/main.tf create mode 100644 blueprint/modules/rbac/main.tf create mode 100644 blueprint/modules/service_principals/main.tf create mode 100644 blueprint/modules/warehouses/main.tf create mode 100644 blueprint/policies/rules.yaml create mode 100644 blueprint/schemas/decision.schema.json create mode 100644 blueprint/schemas/discover-report.schema.json create mode 100644 blueprint/schemas/intent.schema.json create mode 100644 blueprint/skills/platform-ops/SKILL.md create mode 100644 blueprint/skills/platform-ops/examples.md create mode 100644 blueprint/skills/platform-ops/rbac-best-practices.md create mode 100644 blueprint/templates/customer-repo/.cursor/skills/platform-ops/SKILL.md create mode 100644 blueprint/templates/customer-repo/.cursor/skills/platform-ops/examples.md create mode 100644 blueprint/templates/customer-repo/.cursor/skills/platform-ops/rbac-best-practices.md create mode 100644 blueprint/templates/customer-repo/.github/workflows/terraform.yml create mode 100644 blueprint/templates/customer-repo/README.md create mode 100644 blueprint/templates/customer-repo/decisions/.gitkeep create mode 100644 blueprint/templates/customer-repo/terraform/main.tf create mode 100644 blueprint/tests/reference-customer/intent.yaml create mode 100644 blueprint/wizard/catalog.brownfield.yaml create mode 100644 blueprint/wizard/catalog.greenfield.yaml create mode 100644 blueprint/wizard/mapping.yaml create mode 100644 customers/.gitkeep create mode 100644 customers/acme/.cursor/skills/platform-ops/SKILL.md create mode 100644 customers/acme/.cursor/skills/platform-ops/examples.md create mode 100644 customers/acme/.cursor/skills/platform-ops/rbac-best-practices.md create mode 100644 customers/acme/.github/workflows/terraform.yml create mode 100644 customers/acme/README.md create mode 100644 customers/acme/decisions/.gitkeep create mode 100644 customers/acme/decisions/2026-07-15-domains-finance.yaml create mode 100644 customers/acme/intent.yaml create mode 100644 customers/acme/terraform/main.tf create mode 100644 customers/acme/terraform/planned_objects.yaml create mode 100644 customers/globex/.cursor/skills/platform-ops/SKILL.md create mode 100644 customers/globex/.cursor/skills/platform-ops/examples.md create mode 100644 customers/globex/.cursor/skills/platform-ops/rbac-best-practices.md create mode 100644 customers/globex/.github/workflows/terraform.yml create mode 100644 customers/globex/README.md create mode 100644 customers/globex/decisions/.gitkeep create mode 100644 customers/globex/decisions/2026-07-15-unmanaged-finance-old.yaml create mode 100644 customers/globex/intent.yaml create mode 100644 customers/globex/observed/inventory.yaml create mode 100644 customers/globex/terraform/main.tf create mode 100644 customers/globex/terraform/planned_objects.yaml create mode 100644 docs/customer-landing/CHECKLIST.md create mode 100644 docs/customer-landing/README.md create mode 100644 docs/decisions/D001-product-name-and-scope.md create mode 100644 docs/decisions/D002-llm-as-front-door.md create mode 100644 docs/decisions/D003-blueprint-vs-customer-repo.md create mode 100644 docs/decisions/D004-packaging-python.md create mode 100644 docs/decisions/D005-post-apply-edit-not-teardown.md create mode 100644 docs/decisions/D006-delivery-mode-a.md create mode 100644 docs/decisions/D007-wizard-reentrant.md create mode 100644 docs/decisions/D008-v0-planner-graph.md create mode 100644 docs/decisions/README.md create mode 100644 docs/recipes/add-domain.md create mode 100644 docs/recipes/fix-drift.md create mode 100644 docs/recipes/resume-wizard.md create mode 100644 docs/wizard-intent-mapping.md create mode 100644 examples/answers/acme.greenfield.yaml create mode 100644 examples/answers/globex.brownfield.yaml create mode 100644 examples/answers/globex.inventory.yaml create mode 100644 pyproject.toml create mode 100644 src/skylattice/__init__.py create mode 100644 src/skylattice/cli.py create mode 100644 src/skylattice/commands/__init__.py create mode 100644 src/skylattice/commands/add_cmd.py create mode 100644 src/skylattice/commands/apply_cmd.py create mode 100644 src/skylattice/commands/decision_cmd.py create mode 100644 src/skylattice/commands/destroy_cmd.py create mode 100644 src/skylattice/commands/discover_cmd.py create mode 100644 src/skylattice/commands/doctor_cmd.py create mode 100644 src/skylattice/commands/drift_cmd.py create mode 100644 src/skylattice/commands/explain_cmd.py create mode 100644 src/skylattice/commands/init_cmd.py create mode 100644 src/skylattice/commands/plan_cmd.py create mode 100644 src/skylattice/commands/skills_cmd.py create mode 100644 src/skylattice/commands/validate_cmd.py create mode 100644 src/skylattice/commands/wizard_cmd.py create mode 100644 src/skylattice/core/__init__.py create mode 100644 src/skylattice/core/decisions.py create mode 100644 src/skylattice/core/discover.py create mode 100644 src/skylattice/core/intent.py create mode 100644 src/skylattice/core/paths.py create mode 100644 src/skylattice/core/planner.py create mode 100644 src/skylattice/core/policy.py create mode 100644 tests/test_planner.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f392dc1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +dist/ +build/ +.venv/ +.env +*.tfstate +*.tfstate.* +.terraform/ +customers/*/plans/ +customers/*/observed/discover-latest.yaml +.DS_Store +.pytest_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..65a36e0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +## 0.1.0 — 2026-07-15 + +- Initial Sky Lattice scaffold: blueprint, platformctl CLI, wizard catalogs, policies, Cursor skill, docs/decisions, customer landing guide. +- v0 planner emits deterministic object graphs; Terraform modules are stub-ready for snowflakedb provider. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..97ee67f --- /dev/null +++ b/PLAN.md @@ -0,0 +1,1034 @@ +--- +name: Snowflake Bootstrap Strategy +overview: Internal blueprint repo (modules, schemas, wizard, policies, skills) plus per-customer repos. Wizard drives greenfield/brownfield intent. Customer env gets repo+CI+secrets+Snowflake automation user; blueprint IP stays pinned on operator side by default. +todos: + - id: blueprint-repo-layout + content: Finalize blueprint repo contents (modules, schemas, wizard catalogs, policies, skills, customer-repo template) + status: in_progress + - id: wizard-catalogs + content: Design greenfield and brownfield wizard question catalogs and answer→intent/decision mapping + status: pending + - id: customer-landing + content: Define customer-env bring-up (Snowflake service user, secrets, TF state, git/CI) and default delivery mode A + status: pending + - id: cli-packaging + content: Package platformctl as Python (pipx/uv); publish private; CLI never depends on live access to operator blueprint working tree + status: pending + - id: lifecycle-edit + content: Define post-apply lifecycle — resumable wizard, intent edit→plan→apply as default; scoped destroy as exception + status: pending + - id: low-friction-ux + content: Design low-friction UX — 5 commands, Snowflake vocabulary, LLM skill front door, TF-native plans, recipes, doctor/explain + status: pending + - id: intent-model + content: Define intent/desired-config model above raw TF (blueprint params + customer deviations) + status: pending + - id: decision-store + content: Design decision/rationale store linked to intent paths and overrides + status: pending + - id: observe-reconcile + content: Design observe path — TF state + live Snowflake inventory + drift classification + status: pending + - id: deep-dive-next + content: Next deep-dive — wizard catalog fields vs intent schema (so questions map cleanly) + status: pending +isProject: false +--- + +# Snowflake Platform Tool: Layered Design (Revised) + +## Corrections to prior framing + +Two important clarifications from you: + +1. **Session N+1 is not empty.** You pass Terraform state (and related IaC), so the LLM can reconstruct *what exists*. What does **not** travel with state is **why** a choice was made (rationale, rejected alternatives, temporary compromises, client constraints). +2. **“LLM-driven” ≠ “LLM owns the work.”** Execution and ongoing management are already (or should remain) **IaC / tools**. The LLM is how developers *drive* generation and evolution of that code today — with weak durable intent/decision memory between sessions. + +Prior plan over-indexed on “empty context” and under-specified the real stack. This revision treats the problem as a **multi-layer system**, not a single bootstrapper slogan. + +--- + +## Precise problem statement + +| What you already have (or can pass in) | What decays between sessions | +|---|---| +| TF state / config (what is managed) | Decision rationale (why this shape) | +| Live Snowflake (what exists) | Intent vs exception (is this deviation permanent?) | +| LLM + developer loop (how changes get authored) | Stable operating procedures as *bound* skills over a schema | +| Best practices in people’s heads / docs | Machine-checkable policy + linked ADRs | + +So the product is not “replace Terraform.” It is: + +**an intent + decision + policy layer on top of IaC**, with LLM as a skilled driver that must read/write those layers — not only TF state. + +--- + +## Layer stack (this is the real work) + +```mermaid +flowchart TB + subgraph L7 [L7_OperatorUX] + chat[LLM_Chat_Skills] + cli[CLI_PR_Workflow] + end + subgraph L6 [L6_PolicyVerify] + policy[PolicyAsCode] + verify[PostApplyAssertions] + end + subgraph L5 [L5_PlannerCodegen] + planner[IntentDelta_to_TFPlan] + codegen[ModuleAware_Codegen] + end + subgraph L4 [L4_DecisionKnowledge] + adr[DecisionRecords] + skills[OpsSkills_BestPractices] + end + subgraph L3 [L3_IntentModel] + blueprint[Blueprint_vN] + intent[CustomerIntent] + overrides[Overrides] + end + subgraph L2 [L2_Observe] + tfstate[TerraformState] + inventory[LiveSnowflakeInventory] + drift[DriftClassifier] + end + subgraph L1 [L1_Execute] + tf[Terraform_Apply] + snow[SnowflakeAccount] + end + + chat --> intent + chat --> adr + chat --> planner + cli --> planner + skills --> chat + blueprint --> intent + overrides --> intent + intent --> planner + adr --> planner + tfstate --> drift + inventory --> drift + drift --> planner + planner --> codegen --> tf --> snow + tf --> tfstate + snow --> inventory + policy --> planner + verify --> snow +``` + +Each layer has its own schema, APIs, and failure modes. They should be designed deliberately — not collapsed into “a bootstrap tool.” + +--- + +### L1 — Execution (already largely exists) + +**Owns:** applying changes to Snowflake +**Artifacts:** Terraform modules/resources, providers, backends, CI apply +**Non-goals:** remembering why; encoding client intent in a portable form + +Assumption going forward: **Terraform remains the executor** (you already pass TF state into sessions). Other engines only matter if you later choose them; do not reopen that as the center of the product. + +--- + +### L2 — Observe / reconcile + +**Owns:** truthful picture of managed vs live vs drifted + +Inputs: + +- Terraform state + config +- Live account inventory (SHOW / INFORMATION_SCHEMA / account usage as needed) + +Outputs: + +- Resource inventory joined to TF addresses +- Drift classes: `in_sync`, `drifted_managed`, `unmanaged_live`, `in_state_missing_live` +- Adoption candidates for brownfield + +This layer answers “where things are.” You already approximate it by feeding TF state to the LLM; inventoriing live Snowflake closes blind spots TF does not see (manual grants, console clicks, out-of-band objects). + +--- + +### L3 — Intent model (above raw TF) + +**Owns:** *what we mean to run for this customer*, parameterized + +Artifacts (illustrative): + +- `blueprint.lock` — pinned platform blueprint version +- `intent.yaml` — domains, envs, data products, role assignments, warehouse profiles +- `overrides.yaml` — explicit deviations from blueprint defaults + +Raw TF is a **compiled artifact** of intent. Today the LLM often edits TF directly; that works, but intent stays implicit inside HCL. Making intent explicit is what makes “tweak per client” repeatable without re-deriving the whole graph from chat. + +Deviations live here as data, not as undocumented HCL quirks. + +--- + +### L4 — Decision / knowledge (the real N+1 gap) + +**Owns:** *why* — durable across sessions even when TF state is provided + +Artifacts: + +- Decision records linked to resources or intent keys, e.g.: + - `decision_id`, `applies_to` (TF address or intent path), `status` (active/superseded), `rationale`, `alternatives_rejected`, `expires_on`, `client_constraint` +- Curated **ops skills / best-practice packs** (versioned markdown/YAML the LLM must load) +- Mapping: blueprint rule → allowed override types → required decision fields + +Example of what TF state cannot tell the next session: + +- “We kept a flat role for finance because their IdP groups land weekly; revisit after SCIM.” +- “Prod shares DEV database naming temporarily for migration; remove after cutover date.” +- “Blueprint says account-per-env; this client insisted on database-per-env for cost.” + +Without L4, every N+1 session can rebuild *structure* from state and still **re-litigate or contradict** prior judgment. + +--- + +### L5 — Planner / codegen + +**Owns:** turning intent deltas (+ decisions) into concrete IaC changes + +Flow: + +1. Load blueprint + intent + overrides + decisions +2. Load observe/drift report +3. Compute target module graph / resource set +4. Emit TF changes (or a plan preview) — **schema-validated** +5. Attach/update decision records for any override touched + +LLM role here: assist codegen and explain diffs **inside this pipeline**, not free-form “write some Snowflake SQL.” Guardrails: module allowlist, forbidden patterns, required decision when overriding policy. + +--- + +### L6 — Policy / verify + +**Owns:** machine-checkable best practices (not only prose skills) + +Examples: + +- No `ACCOUNTADMIN` for service users +- Raw zone writable only by loader roles +- Env isolation invariants +- Naming / tagging requirements +- Override must reference an active decision record + +Runs on: pre-plan validate, PR checks, post-apply assertions. + +Skills (L4) teach the LLM; policy (L6) **fails the build** when the LLM or a human skips the rules. + +--- + +### L7 — Operator UX + +**Owns:** how developers drive the system + +- CLI: `discover`, `intent validate`, `plan`, `apply`, `drift`, `decision add` +- LLM skills: load L3+L4+L2 context automatically; propose intent/override/decision patches; call planner +- PR workflow: intent/decision/TF in one reviewable unit + +This is where “LLM-driven” correctly lives — as the **driver**, with IaC as the **engine**. + +--- + +## What “repeatability with deviation” means in this stack + +```text +Blueprint_vN + + Customer intent (params) + + Overrides (structured deviations) + + Decisions (why those overrides exist) + → Planner → Terraform → Snowflake + → Observe → Drift → next change +``` + +Same steps, different inputs. Not the same HCL pasted every time; not a blank-slate LLM redesign every time either. + +--- + +## Implementation breadth (why this feels “many layers”) + +Rough build slices (can be separate workstreams): + +1. **Customer project layout** — conventions for TF root, intent, decisions, skills pin +2. **Blueprint modules** — your recommended infra as versioned TF modules (not one-off roots) +3. **Intent schema + compiler** — intent → module inputs (deterministic first) +4. **Decision store format + linking** — resource/intent annotations +5. **Discover/drift** — state + live inventory join +6. **Policy pack** — OPA/Conftest/custom validators on intent+TF +7. **LLM skill pack** — Cursor/agent skills that require reading L2–L4 before editing +8. **Brownfield adopt** — import live → intent proposal → unmanaged list +9. **Verification suite** — Snowflake-side integration checks per customer +10. **Multi-customer packaging** — how blueprints version and how clients pin upgrades + +This is product/platform engineering, not a weekend script. The earlier “bootstrapper” framing was the tip of that iceberg. + +--- + +## Recommended discussion order (next deep-dives) + +Do not design all layers at equal depth at once. Suggested sequence: + +1. **L3 Intent model** — what must be expressible without dropping to raw TF +2. **L4 Decision store** — schema that captures the why you lose today +3. **L5 Planner contract** — how intent patches become module-safe TF diffs +4. **L2 Observe** — drift model given you already use TF state +5. **L6 Policy** — which best practices become hard gates first +6. **L7 Skills** — only after schemas exist so skills have something binding to edit + +--- + +## Verdict (revised) + +- You are already closer than “LLM owns infra”: **IaC owns execution; LLM drives authoring.** +- Passing TF state into session N+1 solves **topology memory**, not **decision memory**. +- The useful internal product is a **layered intent/decision/policy system over Terraform**, with discover/drift and LLM skills as interfaces — not a single high-level “bootstrap tool” narrative. +- Next value is to **deep-dive one layer at a time** and nail schemas/contracts before writing a lot of code. + +--- + +## Blueprint repo — what lives there + +The blueprint repo is **your IP / product core**. Customer repos consume a pinned version of it; they do not fork a copy of everything. + +```text +snowflake-platform-blueprint/ + modules/ # Terraform modules (executor building blocks) + environment/ # env isolation pattern + database_zones/ # landing/raw/curated/marts (or your real zones) + rbac/ # role hierarchy + grant patterns + warehouses/ + service_principals/ + monitoring/ # resource monitors, basic alerts stubs + schemas/ + intent.schema.json # what intent.yaml must look like + decision.schema.json + discover-report.schema.json + defaults/ + intent.greenfield.yaml # starter intent + intent.brownfield.yaml + warehouse_profiles.yaml + access_profiles.yaml # e.g. standard vs restricted + wizard/ + catalog.greenfield.yaml # ordered questions, branching, defaults + catalog.brownfield.yaml # starts with discover-driven questions + mapping.yaml # answer_id → intent path / override / decision prompt + policies/ # hard gates (Conftest/OPA/custom) + no_accountadmin_services.rego + env_isolation.rego + override_requires_decision.rego + skills/ # LLM operator packs (Cursor/agent skills) + platform-ops/SKILL.md + rbac-best-practices.md + brownfield-adopt.md + templates/ + customer-repo/ # skeleton copied by `init` + intent.yaml + decisions/.gitkeep + terraform/ + .github/workflows/ # optional CI template for customer + tests/ + reference-customer/ # golden intent → expected plan fixtures + CHANGELOG.md # blueprint v1, v2, … + VERSION +``` + +**Rule of thumb** + +| In blueprint repo | In customer repo | +|---|---| +| Modules, schemas, wizard catalogs, policies, skills, defaults | Filled `intent.yaml`, `decisions/`, TF root that *calls* modules, state backend config, connection secrets refs | +| Versioned releases (`v3.2.0`) | `blueprint: v3.2.0` pin | +| Your best-practice brain | This client’s answers and exceptions | + +--- + +## Wizard — yes, first-class + +The wizard is the primary greenfield/brownfield onboarding UX. It is **not** free-form chat inventing infra; it is a **question catalog** over the blueprint that writes `intent.yaml` + prompts for `decisions/` when answers deviate from defaults. + +### Shape + +```mermaid +flowchart LR + mode{Greenfield_or_Brownfield} + discover[DiscoverLiveAccount] + ask[WizardQuestionCatalog] + intent[Write_intent.yaml] + decide[PromptDecisionIfOverride] + plan[platformctl_plan] + apply[ApplyInAllowedContext] + + mode -->|greenfield| ask + mode -->|brownfield| discover --> ask + ask --> intent --> decide --> plan --> apply +``` + +### Greenfield wizard (examples) + +Ordered questions from `wizard/catalog.greenfield.yaml`: + +1. Customer name / account identifiers +2. Env model: account-per-env vs db-per-env (default from blueprint) +3. Domains / data products (repeatable) +4. Access profile per domain (standard / restricted) +5. Warehouse profile +6. Identity: SSO now / later / service users only +7. Anything that contradicts a default → **forced decision capture** (rationale + optional expiry) + +Answers compile via `wizard/mapping.yaml` → `intent.yaml`. + +CLI shape: + +```bash +platformctl wizard --customer acme --mode greenfield +# or non-interactive from a saved answers file: +platformctl wizard --answers acme-answers.yaml +``` + +LLM can *run* the same catalog conversationally (“ask next unanswered question”), but the catalog is the contract — same outputs whether human CLI or chat. + +### Brownfield wizard + +1. Connect + `discover` +2. Show match % / unmanaged / conflicts +3. Questions become **adoption choices**: manage / ignore / migrate-later per object group +4. Propose intent draft from what matched +5. Require decisions for keep-legacy and naming conflicts +6. `adopt` → import selected into TF → plan residual gaps + +Same wizard engine; different catalog + discover as step 0. + +--- + +## Bringing this into a new customer environment + +Split **operator toolkit** (yours) from **runtime footprint** (what must exist in the customer’s world). + +### Default delivery model (recommended) + +**You keep the blueprint + wizard.** The customer receives a **customer project** that can plan/apply against *their* Snowflake, usually from *their* Git + CI — without needing your full blueprint source if modules are consumed as a versioned package (git tag, private module registry, or vendored lock). + +```mermaid +flowchart TB + subgraph your_side [YourSide_Operator] + blueprint[BlueprintRepo] + wizard[Wizard_CLI_or_Chat] + end + subgraph customer_side [CustomerEnvironment] + crepo[CustomerGitRepo] + ci[CustomerCI] + tfstate[TFStateBackend] + sf[SnowflakeAccount] + end + + wizard -->|generates_or_updates| crepo + blueprint -->|pinned_modules| crepo + crepo --> ci --> tfstate + ci -->|terraform_apply| sf +``` + +**What must be set up once in the customer environment** + +1. **Snowflake bootstrap identity** — service user + key pair (or OAuth), roles enough for Terraform (often a dedicated automation role; initial ACCOUNTADMIN bootstrap is one-time manual) +2. **Secrets** — private key / cloud creds in customer secret store (GitHub Actions secrets, Vault, etc.) — never in git +3. **TF state backend** — customer-controlled (S3/Azure/GCS + lock), or your org’s backend if *you* operate the account under contract +4. **Customer git repo** — the generated project from `init` / wizard +5. **CI workflow** — plan on PR, apply on merge/approval (template from blueprint `templates/customer-repo`) + +**What does *not* need to live in the customer env** + +- Wizard question catalogs (can run on your laptop during engagement) +- Full policy authoring / skill authoring +- Blueprint module *source* (if you publish versioned modules they consume read-only) + +### How day-1 “bring-up” works in practice + +1. Customer creates (or you create) empty Snowflake account / grants you access +2. One-time manual: create Terraform service user + store key in their secrets +3. You run wizard (locally or internal portal) → produces customer repo contents +4. Push customer repo to **their** GitHub/GitLab (or your managed repo if contract says so) +5. Wire secrets + state backend +6. CI runs `plan`/`apply` into their account +7. Ongoing changes: PR to customer repo (intent/decision/TF); wizard/LLM used by your team as operators + +### Alternate operating modes (when needed) + +| Mode | When | Footprint in customer | +|---|---|---| +| **A. Customer-hosted CI (default)** | Normal consulting delivery, customer owns account | Repo + secrets + state + Snowflake service user | +| **B. You operate apply** | You manage platform under retainer | Same Snowflake identity; state/CI may stay in your org | +| **C. Fully offline customer** | No external module fetch | Vendor/pin blueprint modules into customer repo at release time | + +Avoid shipping the entire blueprint monorepo into every customer unless they require air-gapped ownership — that blurs IP and versioning. + +### “Tool in customer environment” checklist (handoff) + +- [ ] Snowflake automation user created +- [ ] Secrets installed in CI +- [ ] State backend ready +- [ ] Customer repo pushed with blueprint pin +- [ ] First successful `plan` in CI +- [ ] First successful `apply` +- [ ] Drift job scheduled or documented +- [ ] Runbook: how to add a domain / raise a decision / upgrade blueprint pin + +--- + +## CLI access model and packaging + +### Will the CLI reach into “my” repo from the customer environment? + +**No — not if designed correctly.** That would be brittle (VPN, private git auth, IP leakage) and is the wrong dependency direction. + +Correct dependency direction: + +```text +Published artifacts (versioned) + ├── platformctl (CLI package on private PyPI / uv tool) + └── blueprint modules + schemas + wizard catalogs (versioned release, e.g. v3.2.0) + +Customer environment / CI only needs: + ├── customer git repo (intent, decisions, TF root) + ├── platformctl (optional; CI may only need terraform) + └── ability to fetch pinned blueprint vX (registry / git tag / vendored copy) +``` + +The CLI works against the **local customer project directory** (and Snowflake + TF state). It does **not** SSH into your laptop or clone your live blueprint working tree. + +| Who runs what | Accesses | +|---|---| +| You on laptop (wizard during engagement) | Local checkout or cached blueprint package + writes customer repo | +| Customer CI `terraform plan/apply` | Customer repo + pinned TF modules only (often **no** platformctl) | +| Customer engineer with platformctl (optional) | Same: local customer repo + installed CLI + pinned blueprint package from registry | + +If the customer is air-gapped: **vendor** blueprint `v3.2.0` into the customer repo at release time (mode C). Still no live link to your private monorepo. + +### Packaging recommendation + +**Default: Python package, installed via `pipx` or `uv tool install`.** + +Why Python over npm/Go for this tool: + +- Snowflake ecosystem is Python-native (`snowflake-connector-python`, many internal data-platform scripts) +- Excellent YAML/JSON schema, Jinja, Click/Typer CLIs +- Easy private publish (Azure Artifacts, AWS CodeArtifact, GitHub Packages, private PyPI, or `pip install git+https://…`) +- `pipx`/`uvx` gives an isolated CLI without polluting customer project venvs + +| Option | Fit | Use when | +|---|---|---| +| **Python + pipx/uv (recommended)** | Best overall | Your team already does Snowflake/Python | +| **Go single binary** | Best “download one file” DX | Strict customer lockdown, no Python allowed on jump hosts | +| **npm** | Weak fit | Only if your org is JS-only; TF/Snowflake glue will feel awkward | +| **Snowflake CLI plugin** | Niche | If you standardize on `snow` CLI everywhere; still thin wrapper over same Python core | +| **Docker image** | Good CI companion | `ghcr.io/yourorg/platformctl:3.2.0` for customer pipelines without local install | + +Suggested package split: + +1. **`platformctl`** — CLI (wizard, validate, plan wrapper, decision helpers, discover) +2. **`platform-blueprint`** (or git-tagged module repo) — versioned content the CLI resolves by `blueprint: v3.2.0` in intent + +Publish both to **your** private registry. Customer CI authenticates to that registry *or* you vendor the blueprint pin into their repo so they need zero access to your org. + +### Minimal customer footprint (preferred) + +Many engagements should land as: + +- Customer repo + Terraform + CI +- **No** requirement that customer installs platformctl + +You use platformctl/wizard on your side to generate PRs; their CI only runs `terraform`. Install platformctl in customer env only if *their* engineers will run discover/wizard themselves. + +--- + +## After apply: incomplete intake, late changes, edit vs teardown + +Requirements are never complete on day one. The wizard is **not** a one-shot form you fill once and throw away. It is a **re-entrant way to change intent** over the life of the customer project. + +### Mental model + +```text +Apply creates managed state + → later: change intent / decisions (normal path) + → plan shows create / update / destroy for the delta only + → apply the delta +``` + +Terraform already knows how to add, change, and remove resources from a new desired config. The product’s job is to make those intent edits safe and explainable — not to re-run “full setup from scratch.” + +### What you do when something is added or changed later + +| Change type | Recourse | Typical commands | +|---|---|---| +| Add domain / env / warehouse profile | **Edit** intent → plan → apply | `wizard` (partial) or hand-edit `intent.yaml` | +| Tighten/loosen access | **Edit** intent + maybe new **decision** → plan → apply | same | +| Reverse a temporary exception | Update/expire **decision**, change override → plan → apply | may **destroy** specific grants/objects | +| Drop a domain / decommission env | **Edit** intent (remove) → plan shows destroys → apply with review | selective teardown via TF | +| Wrong blueprint choice early on | Change intent params or bump blueprint pin → plan (can be large) | prefer migrate path over wipe | +| Abandoned experiment / full reset | Rare **teardown** of a managed stack or env | `platformctl destroy --target …` with guards | + +**Default recourse after apply = edit + plan + apply.** +**Teardown = explicit, scoped destroy**, not the normal way to “fix a mistake.” + +### How the wizard helps when intake is partial + +1. **Partial / resume** — Wizard tracks answered vs unanswered; you can run it again weeks later and only fill new questions (e.g. “SSO ready now?”, “Add marketing domain?”). +2. **Change commands, not only init** — Same catalog supports intents like: + - `wizard --resume` + - `wizard add domain` + - `wizard set identity sso` + - `wizard adopt` (brownfield leftovers) +3. **Diff-aware** — After answers, it patches `intent.yaml` / `decisions/` and runs `plan` so you see *only* the delta against what’s already applied. +4. **Decisions for late surprises** — When a late requirement breaks a default, wizard forces a decision record (rationale + optional expiry) instead of a silent HCL tweak. +5. **Drift before change** — `drift` first if humans may have clicked in the Snowflake UI; reconcile, then edit intent. + +So wizard = **guided editor of intent over time**, not a day-0 questionnaire. + +### Edit path (happy path for “things changed”) + +```text +Day 0: intent has 2 domains → apply +Day 45: business wants finance + SSO + → wizard resume / edit intent + → new decision if needed + → plan: +finance objects, +SSO stubs, maybe grant changes + → apply +Day 90: remove temporary finance prod-read + → expire decision, remove override + → plan: revoke grants / drop exception role mapping + → apply +``` + +No full rebuild. State and decisions carry forward. + +### Teardown path (when you actually need destroy) + +Use sparingly, always scoped: + +- **Resource-level:** remove from intent → TF plans `destroy` for those addresses only +- **Env-level:** `platformctl destroy --env dev` (wrapper around TF destroy targeting that module) +- **Customer-level:** last resort; requires explicit confirmation + maybe separate break-glass role + +Guards: + +- Policy: destroy of prod requires extra approval / decision +- Refusing destroy when unmanaged dependents exist (warn from discover) +- Prefer “quarantine” (revoke access, keep data) over drop for data-bearing schemas + +### What not to do + +- Don’t re-run greenfield `init` on an existing customer to “pick up new requirements” +- Don’t tear down prod to apply a naming tweak — migrate with plan +- Don’t use the LLM to emit ad-hoc DROP scripts outside the intent→plan loop + +### Short answer + +**After something is applied, the recourse is almost always edit (intent/decisions) → plan → apply.** +Wizard helps by making those later edits guided and resumable. +**Teardown is the exception** — selective destroy when you intentionally remove a domain/env/stack, still driven by intent and Terraform, not a separate wipe tool as the main workflow. + +--- + +## Reducing adoption friction (Snowflake people, not “platformctl experts”) + +The tool is useful only if a Snowflake developer/devops/admin can use it **without treating it as a second career**. Friction reduction is a first-class design goal, not docs afterthought. + +### Design principle + +**Speak Snowflake; hide the toolkit.** +Users should think in domains, roles, warehouses, envs — not in blueprint schema versions and planner internals. + +### Friction reducers (concrete) + +1. **Tiny command surface** — teach 5 verbs, not a platform: + - `wizard` / `add` / `plan` / `apply` / `drift` + Everything else is advanced (`decision`, `adopt`, `destroy`, `blueprint upgrade`). + +2. **Snowflake vocabulary in the wizard** — questions use their words (“Who can read curated in prod?”), not ours (“Set access_profile on domain intent key”). Mapping to intent is invisible. + +3. **LLM/Cursor skill as the default front door** — for many users the “training” is: + - Open customer repo + - Say: “Add a finance domain with restricted prod access” + - Skill runs validate → plan → shows TF/Snowflake diff in plain language + They never memorize flags. CLI remains for CI and power users. + +4. **Show artifacts they already trust** — every plan prints: + - Human summary (“creates 2 roles, 1 warehouse, grants SELECT on FINANCE_CURATED”) + - Familiar `terraform plan` + - Optional SQL preview + No proprietary plan language to learn. + +5. **Golden-path recipes, not a manual** — short how-tos aligned to jobs: + - How to add a domain + - How to grant analyst read + - How to add an env + - How to fix drift + Each is 5–8 steps. Persona = Snowflake admin, not “platformctl operator.” + +6. **Sensible defaults everywhere** — first `wizard` run should succeed with mostly Enter/defaults. Customization is opt-in; decisions only when they break a default. + +7. **Don’t force the tool into daily CI life** — customer pipelines keep running plain Terraform. Only operators who change intent need wizard/CLI/skill. Admins who only approve PRs review the same TF diff they already know. + +8. **Escape hatch without shame** — advanced users can edit Terraform modules directly; next `drift`/`plan` still works. The tool must not punish leaving the happy path (document “supported vs at-your-own-risk” edits). + +9. **In-repo onboarding, zero slide deck required**: + - `platformctl doctor` — checks auth, backend, blueprint pin, suggests next command + - Customer repo `README` auto-generated: “Your next step is …” + - `platformctl explain` — why this grant exists (reads decisions) + +10. **Progressive disclosure** — day 1: wizard + apply. Week 4: decisions. Month 3: blueprint upgrade / adopt. Don’t dump the layer cake in training. + +### What “trained” should mean (target) + +| Role | Must learn | Need not learn | +|---|---|---| +| Snowflake admin | Approve plan summaries; answer wizard in Snowflake terms | Blueprint internals, policy engine | +| DevOps | Wire secrets/state once; TF plan/apply in CI | Wizard catalogs, intent schema | +| Platform operator (you) | Intent, decisions, blueprint pins | — (this is the deep skill) | + +If everyone must learn what only operators need, friction has failed. + +### Anti-patterns that create overhead + +- Requiring a multi-day course before first apply +- Inventing new jargon (`intent compiler`, `L3 model`) in the UI +- Forcing platformctl in every customer engineer’s toolchain +- Plans that only make sense if you know module graph internals +- Blocking work when someone hand-fixed one grant in Snowflake (prefer detect + guided reconcile) + +### Success metric + +A Snowflake-competent person who has never seen the tool completes **“add a domain to an existing customer”** in one sitting with only the skill/README — no workshop. + +--- + +## How skills / LLM connect to the tool + +Skills are **not a second system**. They are an optional **front door** onto the same customer repo + `platformctl` control plane. The LLM does not apply Snowflake changes by itself; it drives the same files and commands a human would. + +### Connection in one picture + +```mermaid +flowchart TB + user[User_in_Cursor_or_chat] + skill[Skill_pack_from_blueprint] + ctx[Load_customer_context] + llm[LLM_reasoning] + files[Edit_intent_decisions] + cli[platformctl_validate_plan_drift] + tf[Terraform_apply] + sf[Snowflake] + + user --> skill + skill --> ctx + ctx --> llm + skill --> llm + llm -->|"propose_or_write"| files + llm -->|"invoke"| cli + files --> cli + cli --> tf --> sf +``` + +| Piece | Role | +|---|---| +| **Blueprint `skills/`** | Versioned instructions + best-practice text the agent must load (how we do RBAC, when to require a decision, forbidden patterns) | +| **Customer repo** | Durable context: `intent.yaml`, `decisions/`, TF state ref, last plan — replaces “empty chat memory” | +| **LLM** | Interprets natural language; maps Snowflake-ish requests onto intent edits / wizard answers / CLI calls | +| **`platformctl`** | Source of truth for validate / plan / drift / apply wrappers — deterministic | +| **Terraform** | Still the only applier to Snowflake | + +### What a skill actually contains + +Shipped inside the blueprint package (pinned with the customer’s blueprint version), e.g.: + +```text +skills/platform-ops/ + SKILL.md # when to use, workflow, allowed tools + rbac-best-practices.md # your standards (prose the model follows) + examples.md # few-shot: “add domain” → intent diff +``` + +`SKILL.md` tells the agent roughly: + +1. Open / read `intent.yaml`, `decisions/`, blueprint pin +2. Prefer `platformctl` over inventing SQL/HCL +3. If the request breaks a default → create/update a decision +4. Always run `platformctl plan` before suggesting apply +5. Never apply without explicit user approval + +Same catalog the CLI wizard uses can be referenced so chat and CLI stay aligned. + +### What the user experiences + +**Without skill (CLI-only):** + +```bash +platformctl wizard add domain +platformctl plan +platformctl apply +``` + +**With skill (LLM front door):** + +> “Add a finance domain with restricted prod curated read for month-end; revisit after SSO.” + +Agent: + +1. Loads skill + customer intent/decisions +2. Patches `intent.yaml` (add finance, restricted profile) +3. Writes `decisions/…-finance-prod-read` with rationale + expiry +4. Runs `platformctl validate` + `platformctl plan` +5. Shows summary + terraform plan +6. Waits for you to say apply → `platformctl apply` (or CI PR) + +Same end state as CLI; less tool training. + +### What the LLM is *not* connected as + +- Not a free-form Snowflake admin that emits `GRANT` scripts as the system of record +- Not the store of decisions (files are) +- Not required in customer CI (CI runs terraform; skills stay in operator IDE) +- Not allowed to skip policy gates — `platformctl plan` still fails if override lacks decision + +### Why this fixes today’s LLM-driven workflow + +| Today | With this connection | +|---|---| +| Chat + TF state; why lives in scrollback | Chat + skill + **intent/decisions files** | +| Each session re-derives process | Skill encodes process; blueprint version pins it | +| LLM may invent one-off HCL/SQL | LLM edits intent; planner/TF emit HCL | +| Best practices in people’s heads | Best practices in versioned `skills/` + `policies/` | + +### Where skills live vs where they run + +| Artifact | Lives in | Runs in | +|---|---|---| +| Skill packs | Blueprint package `skills/` | Operator IDE (Cursor) or chat host | +| `platformctl` | pipx/uv install | Laptop or CI (optional) | +| Apply | Terraform | Customer CI / operator with creds | + +Customer engineers who only approve PRs never need the skill. Operators who change platforms use skill **or** CLI — both write the same repo. + +### Short definition + +**Skills/LLM = natural-language driver for `platformctl` + customer intent/decision files, constrained by blueprint skill packs and policies. Connection point is the customer repo and CLI — not a parallel AI path into Snowflake.** + +### Where you type that sentence (Cursor / Claude Code / etc.) + +Yes — the natural-language line is typed in an **agent coding tool** such as **Cursor Agent** or **Claude Code** (or similar), with the **customer project repo open as the workspace**. It is not a separate SaaS chat bound to Snowflake. + +**Do users need to hunt for skill files?** +Ideally **no**. Skills are installed where the agent auto-discovers them. The user just opens the customer repo and asks in plain language. Looking up `SKILL.md` paths is for authors/operators maintaining the blueprint — not for every Snowflake admin. + +#### Recommended layout (Cursor) + +| Skill placement | Path | Who gets it | +|---|---|---| +| **Project skill (preferred for delivery)** | `customers/acme/.cursor/skills/platform-ops/SKILL.md` | Anyone who clones the customer repo in Cursor | +| Personal skill (your laptop) | `~/.cursor/skills/platform-ops/` | Only you, across projects | + +`platformctl init` / wizard should **copy or link** the blueprint’s `skills/platform-ops` into the customer repo’s `.cursor/skills/` so discovery is automatic. Pin the skill content to the same blueprint version as `intent.yaml`. + +Claude Code: same idea — ship the skill/instructions into the repo (e.g. project skill / `CLAUDE.md` pointer) so opening the repo is enough; exact folder conventions follow that tool’s docs. + +#### Where to run + +| Requirement | Why | +|---|---| +| Workspace root = **customer repo** | Agent sees `intent.yaml`, `decisions/`, Terraform | +| `platformctl` on PATH (operator machine) | Skill invokes validate/plan instead of inventing SQL | +| Snowflake/TF creds as you already use for that customer | Apply/plan still use normal auth — skill doesn’t replace that | +| Network to private blueprint registry only if modules aren’t vendored | Same as non-LLM workflow | + +Users do **not** need to open the blueprint monorepo to chat. They open **Acme’s customer repo** and talk. + +#### Minimal user checklist + +1. Clone/open customer repo in Cursor (or Claude Code) +2. Ensure project skill is present (shipped by `init`; if missing, `platformctl skills install`) +3. Ask: “Add finance with restricted prod curated read…” +4. Review plan → approve apply / PR + +No manual “find and @-mention the skill file” in the happy path — the description/frontmatter should make the agent pick it up when the request matches (platform change, domain, RBAC, etc.). Power users can still `@` the skill if the host supports explicit attach. + +#### What if someone doesn’t use Cursor? + +They use the **CLI wizard** only — same repo, same outcomes, no skill files involved. Skills are an accelerator for agent IDEs, not a hard dependency of the platform tool. + +--- + +## End-product visualization: new customer scenario + +### What you actually open day-to-day + +Not a magical Snowflake UI. An **internal toolkit** that looks like: + +- A **versioned blueprint repo** (your standard platform modules + policies + skills) +- Per customer: a **customer project repo** containing: + - Terraform (executor) + - `intent.yaml` (what this customer should look like) + - `decisions/` (why deviations exist) + - generated/updated TF from the planner +- A **CLI** (and optionally Cursor skills) you run: `init`, `discover`, `plan`, `apply`, `drift`, `decision` + +The LLM is how you talk to that toolkit — it does not replace Terraform. + +--- + +### Scenario A — Mostly greenfield customer (“Acme”) + +**Day 0 — Kickoff facts you collect (30–60 min)** + +You learn: 3 domains (sales, finance, ops), need dev/test/prod, Okta SSO coming later, finance wants tighter access, they already created an empty Snowflake account. + +**Step 1 — Create customer project** + +```bash +platformctl init acme --blueprint v3 +``` + +Creates repo/folder: + +```text +customers/acme/ + intent.yaml # mostly empty template + decisions/ + terraform/ # wired to blueprint modules + README.md +``` + +**Step 2 — Fill intent (you or LLM interview)** + +You (or chat skill) produce something like: + +```yaml +customer: acme +blueprint: v3 +environments: [dev, test, prod] +domains: + - name: sales + - name: finance + access_profile: restricted + - name: ops +warehouses: + profile: standard_cost_saver +identity: + sso: planned # not ready yet +``` + +**Step 3 — Capture a decision where they deviate** + +Finance insists analysts can see prod curated early (against your default). + +```bash +platformctl decision add \ + --applies-to domains.finance \ + --rationale "Business needs prod curated read for month-end; revisit after SSO" \ + --expires 2026-10-01 +``` + +That writes `decisions/2026-07-15-finance-prod-read.md` (or YAML) linked to the intent override — this is what future-you needed and TF state never had. + +**Step 4 — Plan (deterministic + optional LLM explanation)** + +```bash +platformctl plan +``` + +Tool expands blueprint + intent + decisions → Terraform changes: + +- env databases/schemas/zones +- role hierarchy + grants +- warehouses +- service users stubs +- finance override applied only where decision allows + +You get a normal `terraform plan` style diff + a human summary. Policy checks fail if you skipped a required decision for an override. + +**Step 5 — Apply** + +```bash +platformctl apply # wraps terraform apply with your CI/approvals +``` + +Snowflake now matches the plan. TF state is updated as usual. + +**Step 6 — Week 3 follow-up (new chat session)** + +Someone says: “Add a marketing domain and tighten finance again.” + +New LLM session loads **customer repo** (intent + decisions + TF state), not tribal memory. + +```bash +platformctl drift # confirms account still matches managed intent +# edit intent: add marketing +platformctl plan +platformctl apply +``` + +The agent can explain: finance prod-read is still an **active dated decision**, not invent a new story. + +--- + +### Scenario B — Brownfield customer (“Globex”) already has stuff + +**Day 0** — They have databases, messy roles, some warehouses, half-manual grants. + +**Step 1 — Init + discover** + +```bash +platformctl init globex --blueprint v3 +platformctl discover --connection globex-prod +``` + +Output (conceptual): + +```text +Found 12 databases, 40 roles, 8 warehouses +Matched to blueprint patterns: 35% +Unmanaged live objects: 62 +Conflicts: 4 (naming / dual grant paths) +``` + +**Step 2 — Adopt, don’t rebuild** + +Tool proposes an `intent.yaml` draft + `unmanaged.yaml` for things you won’t own yet. + +You choose: + +- Manage: raw/curated zones for 2 domains, new RBAC hierarchy going forward +- Leave unmanaged: legacy `FINANCE_OLD` role until migration date +- Record decision: why legacy role stays + +**Step 3 — Import into Terraform** + +```bash +platformctl adopt --approve +``` + +Imports selected objects into TF state / modules so **going forward** changes are planned, not hand-SQL. + +**Step 4 — Ongoing** + +Same as Acme: intent edits → plan → apply → drift. Brownfield just started with discover/adopt instead of empty init. + +--- + +### Scenario C — What today feels like vs what this product changes + +| Moment | Today (LLM + TF) | With this product | +|---|---|---| +| New customer start | Chat invents structure; copy patterns from last engagement | `init` + blueprint + intent template | +| Why finance is special | In someone’s head / old chat | `decisions/` file loaded every session | +| Follow-up in 2 months | Pass TF state; re-derive intent from HCL | Pass repo; intent+decisions are explicit | +| Client deviation | One-off HCL tweak | Override + required decision + policy gate | +| Brownfield | Manual inventory in chat | `discover` → adopt/unmanaged split | +| Execution | Terraform | Still Terraform | + +--- + +### One-sentence product definition + +**An internal CLI + customer-repo convention on top of your Terraform blueprint that stores customer intent and decisions, plans/applies through IaC, discovers brownfield drift, and lets an LLM drive those files instead of reinventing the platform each chat.** diff --git a/README.md b/README.md new file mode 100644 index 0000000..75b12d2 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Sky Lattice + +Internal toolkit for standing up and evolving **Snowflake data platforms** across customers: durable **intent** + **decisions**, a versioned **blueprint**, deterministic **plan/apply**, and an optional **Cursor skill** front door. + +Terraform remains the executor. Sky Lattice is the intent/decision/policy layer on top. + +## Install + +```bash +cd /path/to/sky-lattice +python3 -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" +platformctl --version +platformctl doctor +``` + +## 5 commands to remember + +| Command | Purpose | +|---|---| +| `platformctl wizard` | Guided setup / resume | +| `platformctl add domain …` | Incremental change | +| `platformctl plan` | Show what will be created | +| `platformctl apply` | Record/apply plan | +| `platformctl drift` | Compare inventory vs intent | + +Also: `init`, `validate`, `discover`, `decision add|list`, `explain`, `destroy --env`, `skills install`, `doctor`. + +## Quick start (greenfield demo) + +```bash +platformctl init acme --mode greenfield +platformctl wizard --path customers/acme --answers examples/answers/acme.greenfield.yaml +platformctl validate --path customers/acme +platformctl plan --path customers/acme +platformctl apply --path customers/acme --yes +platformctl doctor --path customers/acme +``` + +Open `customers/acme` in Cursor — the `platform-ops` skill is already in `.cursor/skills/`. + +## Brownfield + +```bash +platformctl init globex --mode brownfield +# provide observed/inventory.yaml then: +platformctl discover --path customers/globex --inventory examples/answers/globex.inventory.yaml +platformctl wizard --path customers/globex --mode brownfield --answers examples/answers/globex.brownfield.yaml +platformctl plan --path customers/globex +``` + +## Layout + +```text +blueprint/ # versioned IP: modules, schemas, wizard, policies, skills +src/skylattice/ # platformctl (Python) +customers/ # generated customer projects (demo) +docs/ # landing, recipes, product decisions +PLAN.md # design plan (reference) +``` + +## Customer environment landing + +See [docs/customer-landing/README.md](docs/customer-landing/README.md). Default: customer gets git repo + CI + secrets + Snowflake automation user; blueprint stays pinned/versioned. + +## LLM / skills + +Skills drive `platformctl` and customer files — they do not own Snowflake. See skill at `blueprint/skills/platform-ops/`. + +## License + +Internal use — adapt as needed for your organization. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/blueprint/CHANGELOG.md b/blueprint/CHANGELOG.md new file mode 100644 index 0000000..f8278fc --- /dev/null +++ b/blueprint/CHANGELOG.md @@ -0,0 +1,5 @@ +# Blueprint changelog + +## 0.1.0 + +Initial blueprint: environment, database_zones, rbac, warehouses, service_principals, monitoring modules; intent/decision/discover schemas; greenfield+brownfield wizard catalogs; policy rules; platform-ops skill; customer-repo template. diff --git a/blueprint/VERSION b/blueprint/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/blueprint/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/blueprint/defaults/access_profiles.yaml b/blueprint/defaults/access_profiles.yaml new file mode 100644 index 0000000..583bbbf --- /dev/null +++ b/blueprint/defaults/access_profiles.yaml @@ -0,0 +1,12 @@ +standard: + description: Typical domain access — analysts read curated/marts; loaders write landing/raw. + analyst_zones_read: [curated, marts] + loader_zones_write: [landing, raw] + engineer_zones: [landing, raw, curated, marts] +restricted: + description: Tighter domain — prod curated read requires explicit override + decision. + analyst_zones_read: [marts] + loader_zones_write: [landing, raw] + engineer_zones: [landing, raw, curated] + require_decision_for: + - prod_curated_read diff --git a/blueprint/defaults/intent.brownfield.yaml b/blueprint/defaults/intent.brownfield.yaml new file mode 100644 index 0000000..f7a8ea0 --- /dev/null +++ b/blueprint/defaults/intent.brownfield.yaml @@ -0,0 +1,19 @@ +customer: example +blueprint: "0.1.0" +mode: brownfield +env_strategy: database_per_env +environments: + - prod +domains: + - name: legacy + access_profile: standard + zones: [raw, curated] +warehouses: + profile: standard_cost_saver +identity: + sso: planned + service_users: true +overrides: [] +unmanaged: [] +wizard: + answered: [] diff --git a/blueprint/defaults/intent.greenfield.yaml b/blueprint/defaults/intent.greenfield.yaml new file mode 100644 index 0000000..bd50e72 --- /dev/null +++ b/blueprint/defaults/intent.greenfield.yaml @@ -0,0 +1,21 @@ +customer: example +blueprint: "0.1.0" +mode: greenfield +env_strategy: database_per_env +environments: + - dev + - test + - prod +domains: + - name: sales + access_profile: standard + zones: [landing, raw, curated, marts] +warehouses: + profile: standard_cost_saver +identity: + sso: planned + service_users: true +overrides: [] +unmanaged: [] +wizard: + answered: [] diff --git a/blueprint/defaults/warehouse_profiles.yaml b/blueprint/defaults/warehouse_profiles.yaml new file mode 100644 index 0000000..a336171 --- /dev/null +++ b/blueprint/defaults/warehouse_profiles.yaml @@ -0,0 +1,10 @@ +standard_cost_saver: + size: X-SMALL + auto_suspend: 60 + auto_resume: true + initially_suspended: true +performance: + size: MEDIUM + auto_suspend: 120 + auto_resume: true + initially_suspended: false diff --git a/blueprint/modules/database_zones/main.tf b/blueprint/modules/database_zones/main.tf new file mode 100644 index 0000000..5a7f804 --- /dev/null +++ b/blueprint/modules/database_zones/main.tf @@ -0,0 +1,35 @@ +variable "prefix" { + type = string +} + +variable "domain" { + type = string +} + +variable "zones" { + type = list(string) + default = ["landing", "raw", "curated", "marts"] +} + +# Placeholder resources — replace with snowflakedb/snowflake provider resources when applying to a real account. +# Sky Lattice renders these as documentation + planned object graph for v0. + +locals { + database_name = "${var.prefix}_${upper(var.domain)}" + schema_names = { for z in var.zones : z => upper(z) } +} + +output "database_name" { + value = local.database_name +} + +output "schemas" { + value = local.schema_names +} + +output "planned_objects" { + value = concat( + ["database:${local.database_name}"], + [for z, s in local.schema_names : "schema:${local.database_name}.${s}"] + ) +} diff --git a/blueprint/modules/environment/main.tf b/blueprint/modules/environment/main.tf new file mode 100644 index 0000000..46a5c3c --- /dev/null +++ b/blueprint/modules/environment/main.tf @@ -0,0 +1,24 @@ +variable "customer" { + type = string +} + +variable "environment" { + type = string +} + +variable "env_strategy" { + type = string + default = "database_per_env" +} + +locals { + prefix = var.env_strategy == "database_per_env" ? "${upper(var.customer)}_${upper(var.environment)}" : upper(var.customer) +} + +output "prefix" { + value = local.prefix +} + +output "environment" { + value = var.environment +} diff --git a/blueprint/modules/monitoring/main.tf b/blueprint/modules/monitoring/main.tf new file mode 100644 index 0000000..a89d23f --- /dev/null +++ b/blueprint/modules/monitoring/main.tf @@ -0,0 +1,20 @@ +variable "prefix" { + type = string +} + +variable "credit_quota" { + type = number + default = 100 +} + +locals { + monitor_name = "${var.prefix}_MONITOR" +} + +output "resource_monitor" { + value = local.monitor_name +} + +output "planned_objects" { + value = ["resource_monitor:${local.monitor_name}"] +} diff --git a/blueprint/modules/rbac/main.tf b/blueprint/modules/rbac/main.tf new file mode 100644 index 0000000..1e346ab --- /dev/null +++ b/blueprint/modules/rbac/main.tf @@ -0,0 +1,36 @@ +variable "prefix" { + type = string +} + +variable "domain" { + type = string +} + +variable "access_profile" { + type = string + default = "standard" +} + +variable "environments" { + type = list(string) +} + +locals { + roles = { + analyst = "${var.prefix}_${upper(var.domain)}_ANALYST" + loader = "${var.prefix}_${upper(var.domain)}_LOADER" + engineer = "${var.prefix}_${upper(var.domain)}_ENGINEER" + } +} + +output "roles" { + value = local.roles +} + +output "access_profile" { + value = var.access_profile +} + +output "planned_objects" { + value = [for k, v in local.roles : "role:${v}"] +} diff --git a/blueprint/modules/service_principals/main.tf b/blueprint/modules/service_principals/main.tf new file mode 100644 index 0000000..15de368 --- /dev/null +++ b/blueprint/modules/service_principals/main.tf @@ -0,0 +1,23 @@ +variable "prefix" { + type = string +} + +variable "enabled" { + type = bool + default = true +} + +locals { + users = var.enabled ? { + terraform = "${var.prefix}_TF_SVC" + loader = "${var.prefix}_LOADER_SVC" + } : {} +} + +output "service_users" { + value = local.users +} + +output "planned_objects" { + value = [for k, v in local.users : "user:${v}"] +} diff --git a/blueprint/modules/warehouses/main.tf b/blueprint/modules/warehouses/main.tf new file mode 100644 index 0000000..b0c81a9 --- /dev/null +++ b/blueprint/modules/warehouses/main.tf @@ -0,0 +1,33 @@ +variable "prefix" { + type = string +} + +variable "profile" { + type = string + default = "standard_cost_saver" +} + +variable "profile_config" { + type = object({ + size = string + auto_suspend = number + auto_resume = bool + initially_suspended = bool + }) +} + +locals { + warehouse_name = "${var.prefix}_WH" +} + +output "warehouse_name" { + value = local.warehouse_name +} + +output "config" { + value = var.profile_config +} + +output "planned_objects" { + value = ["warehouse:${local.warehouse_name}"] +} diff --git a/blueprint/policies/rules.yaml b/blueprint/policies/rules.yaml new file mode 100644 index 0000000..4e89010 --- /dev/null +++ b/blueprint/policies/rules.yaml @@ -0,0 +1,29 @@ +# Sky Lattice policy pack (v0): evaluated by platformctl validate. +# Format: simple YAML rules (OPA/Conftest can wrap these later). + +version: 1 +rules: + - id: override_requires_decision + description: Every intent override must reference an active decision_id. + severity: error + check: overrides_have_decisions + + - id: unmanaged_requires_decision + description: Unmanaged live objects should reference a decision when intentionally deferred. + severity: warning + check: unmanaged_have_decisions + + - id: restricted_prod_curated_read + description: Restricted domains cannot enable prod curated read without an override + decision. + severity: error + check: restricted_prod_curated_guard + + - id: no_empty_domains + description: At least one domain is required. + severity: error + check: domains_non_empty + + - id: blueprint_pin_present + description: Intent must pin a blueprint version. + severity: error + check: blueprint_pinned diff --git a/blueprint/schemas/decision.schema.json b/blueprint/schemas/decision.schema.json new file mode 100644 index 0000000..aec6e6f --- /dev/null +++ b/blueprint/schemas/decision.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://skylattice.dev/schemas/decision.schema.json", + "title": "SkyLatticeDecision", + "type": "object", + "required": ["id", "applies_to", "rationale", "status", "created_at"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "Stable decision id, e.g. 2026-07-15-finance-prod-read" + }, + "applies_to": { + "type": "string", + "description": "Intent path or TF address this decision covers." + }, + "rationale": { "type": "string", "minLength": 1 }, + "alternatives_rejected": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, + "status": { + "type": "string", + "enum": ["active", "superseded", "expired"], + "default": "active" + }, + "expires_on": { + "type": ["string", "null"], + "description": "ISO date when this exception should be revisited." + }, + "client_constraint": { "type": ["string", "null"] }, + "created_at": { "type": "string" }, + "created_by": { "type": ["string", "null"] }, + "superseded_by": { "type": ["string", "null"] } + } +} diff --git a/blueprint/schemas/discover-report.schema.json b/blueprint/schemas/discover-report.schema.json new file mode 100644 index 0000000..020766f --- /dev/null +++ b/blueprint/schemas/discover-report.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://skylattice.dev/schemas/discover-report.schema.json", + "title": "SkyLatticeDiscoverReport", + "type": "object", + "required": ["customer", "generated_at", "objects", "summary"], + "properties": { + "customer": { "type": "string" }, + "generated_at": { "type": "string" }, + "source": { + "type": "string", + "enum": ["live", "inventory_file", "terraform_state", "merged"], + "default": "inventory_file" + }, + "objects": { + "type": "array", + "items": { + "type": "object", + "required": ["kind", "name", "classification"], + "properties": { + "kind": { + "type": "string", + "enum": ["database", "schema", "role", "warehouse", "user", "grant"] + }, + "name": { "type": "string" }, + "classification": { + "type": "string", + "enum": ["in_sync", "drifted_managed", "unmanaged_live", "in_state_missing_live", "blueprint_match", "conflict"] + }, + "tf_address": { "type": ["string", "null"] }, + "notes": { "type": "string" } + } + } + }, + "summary": { + "type": "object", + "properties": { + "total": { "type": "integer" }, + "matched_pct": { "type": "number" }, + "unmanaged": { "type": "integer" }, + "conflicts": { "type": "integer" }, + "drifted": { "type": "integer" } + } + } + } +} diff --git a/blueprint/schemas/intent.schema.json b/blueprint/schemas/intent.schema.json new file mode 100644 index 0000000..39b6fac --- /dev/null +++ b/blueprint/schemas/intent.schema.json @@ -0,0 +1,131 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://skylattice.dev/schemas/intent.schema.json", + "title": "SkyLatticeCustomerIntent", + "type": "object", + "required": ["customer", "blueprint", "environments", "domains"], + "additionalProperties": false, + "properties": { + "customer": { + "type": "string", + "minLength": 1, + "description": "Customer slug (lowercase, hyphen-safe)." + }, + "blueprint": { + "type": "string", + "description": "Pinned blueprint version, e.g. 0.1.0" + }, + "mode": { + "type": "string", + "enum": ["greenfield", "brownfield"], + "default": "greenfield" + }, + "env_strategy": { + "type": "string", + "enum": ["database_per_env", "account_per_env"], + "default": "database_per_env", + "description": "How environments are isolated." + }, + "environments": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "enum": ["dev", "test", "prod", "sandbox"] + }, + "uniqueItems": true + }, + "domains": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1 }, + "access_profile": { + "type": "string", + "enum": ["standard", "restricted"], + "default": "standard" + }, + "zones": { + "type": "array", + "items": { + "type": "string", + "enum": ["landing", "raw", "curated", "marts", "shared"] + }, + "default": ["landing", "raw", "curated", "marts"] + } + } + } + }, + "warehouses": { + "type": "object", + "additionalProperties": false, + "properties": { + "profile": { + "type": "string", + "enum": ["standard_cost_saver", "performance"], + "default": "standard_cost_saver" + } + } + }, + "identity": { + "type": "object", + "additionalProperties": false, + "properties": { + "sso": { + "type": "string", + "enum": ["none", "planned", "okta", "azure_ad", "other"], + "default": "planned" + }, + "service_users": { + "type": "boolean", + "default": true + } + } + }, + "overrides": { + "type": "array", + "description": "Structured deviations from blueprint defaults; each should link to a decision id.", + "items": { + "type": "object", + "required": ["path", "value", "decision_id"], + "additionalProperties": false, + "properties": { + "path": { "type": "string" }, + "value": {}, + "decision_id": { "type": "string" } + } + }, + "default": [] + }, + "unmanaged": { + "type": "array", + "description": "Live objects discovered but not managed by Sky Lattice.", + "items": { + "type": "object", + "required": ["kind", "name"], + "properties": { + "kind": { "type": "string" }, + "name": { "type": "string" }, + "reason": { "type": "string" }, + "decision_id": { "type": "string" } + } + }, + "default": [] + }, + "wizard": { + "type": "object", + "description": "Wizard progress metadata for resume.", + "properties": { + "answered": { + "type": "array", + "items": { "type": "string" } + }, + "last_run": { "type": "string" } + } + } + } +} diff --git a/blueprint/skills/platform-ops/SKILL.md b/blueprint/skills/platform-ops/SKILL.md new file mode 100644 index 0000000..a4daf48 --- /dev/null +++ b/blueprint/skills/platform-ops/SKILL.md @@ -0,0 +1,48 @@ +--- +name: platform-ops +description: >- + Operate Sky Lattice customer platforms. Use when adding domains, changing RBAC, + running wizard/plan/apply/drift, recording decisions, or brownfield adopt for + Snowflake platform setup with intent.yaml. +--- + +# Sky Lattice Platform Ops + +You are driving **Sky Lattice** for a customer project. You do **not** invent ad-hoc Snowflake SQL as the system of record. + +## Required context + +1. Open the **customer repo** as the workspace (contains `intent.yaml`, `decisions/`, `terraform/`). +2. Read `intent.yaml` and active files under `decisions/`. +3. Prefer invoking `platformctl` over hand-writing HCL/SQL. + +## Workflow + +1. Understand the request in Snowflake terms (domain, env, warehouse, who can read curated). +2. If it breaks a blueprint default → create/update a **decision** (`platformctl decision add`). +3. Patch `intent.yaml` (or run `platformctl wizard` / `platformctl add`). +4. Run `platformctl validate` then `platformctl plan`. +5. Show the human summary + plan; **do not apply** until the user explicitly approves. +6. On approval: `platformctl apply` or open a PR for customer CI. + +## Forbidden + +- Emitting unaudited `GRANT` / `DROP` scripts as the final artifact +- Re-running greenfield `init` on an existing customer to pick up late requirements +- Skipping decisions when adding overrides or unmanaged exceptions + +## Commands cheat sheet + +```bash +platformctl doctor +platformctl wizard --mode greenfield|brownfield|--resume +platformctl add domain +platformctl validate +platformctl plan +platformctl apply +platformctl drift +platformctl discover +platformctl decision add --applies-to PATH --rationale "..." +platformctl explain --path PATH +platformctl destroy --env ENV # rare, scoped +``` diff --git a/blueprint/skills/platform-ops/examples.md b/blueprint/skills/platform-ops/examples.md new file mode 100644 index 0000000..b36ecf3 --- /dev/null +++ b/blueprint/skills/platform-ops/examples.md @@ -0,0 +1,18 @@ +# Examples + +## Add a domain + +User: "Add a finance domain with restricted prod curated read for month-end; revisit after SSO." + +Actions: + +1. `platformctl decision add --applies-to domains.finance --rationale "Month-end needs prod curated read; revisit after SSO" --expires 2026-10-01` +2. Add domain to `intent.yaml` with `access_profile: restricted` and override linked to that decision id. +3. `platformctl validate && platformctl plan` +4. Wait for approval → `platformctl apply` + +## Resume partial intake + +User: "SSO is ready with Okta now." + +Actions: set `identity.sso: okta` via wizard resume or direct intent edit → validate → plan → apply. diff --git a/blueprint/skills/platform-ops/rbac-best-practices.md b/blueprint/skills/platform-ops/rbac-best-practices.md new file mode 100644 index 0000000..39ccf98 --- /dev/null +++ b/blueprint/skills/platform-ops/rbac-best-practices.md @@ -0,0 +1,8 @@ +# RBAC best practices (Sky Lattice blueprint) + +- Prefer functional roles (analyst, loader, engineer) composed from access roles per zone. +- Never use `ACCOUNTADMIN` for day-to-day service automation after bootstrap. +- Restricted domains: analysts read `marts` by default; `curated` in prod needs override + decision. +- Environment isolation: default `database_per_env` unless the customer requires account-per-env (record a decision). +- Temporary exceptions must have `expires_on` when possible. +- Brownfield: leave legacy roles in `unmanaged` with a decision rather than silently rewriting them on day one. diff --git a/blueprint/templates/customer-repo/.cursor/skills/platform-ops/SKILL.md b/blueprint/templates/customer-repo/.cursor/skills/platform-ops/SKILL.md new file mode 100644 index 0000000..a4daf48 --- /dev/null +++ b/blueprint/templates/customer-repo/.cursor/skills/platform-ops/SKILL.md @@ -0,0 +1,48 @@ +--- +name: platform-ops +description: >- + Operate Sky Lattice customer platforms. Use when adding domains, changing RBAC, + running wizard/plan/apply/drift, recording decisions, or brownfield adopt for + Snowflake platform setup with intent.yaml. +--- + +# Sky Lattice Platform Ops + +You are driving **Sky Lattice** for a customer project. You do **not** invent ad-hoc Snowflake SQL as the system of record. + +## Required context + +1. Open the **customer repo** as the workspace (contains `intent.yaml`, `decisions/`, `terraform/`). +2. Read `intent.yaml` and active files under `decisions/`. +3. Prefer invoking `platformctl` over hand-writing HCL/SQL. + +## Workflow + +1. Understand the request in Snowflake terms (domain, env, warehouse, who can read curated). +2. If it breaks a blueprint default → create/update a **decision** (`platformctl decision add`). +3. Patch `intent.yaml` (or run `platformctl wizard` / `platformctl add`). +4. Run `platformctl validate` then `platformctl plan`. +5. Show the human summary + plan; **do not apply** until the user explicitly approves. +6. On approval: `platformctl apply` or open a PR for customer CI. + +## Forbidden + +- Emitting unaudited `GRANT` / `DROP` scripts as the final artifact +- Re-running greenfield `init` on an existing customer to pick up late requirements +- Skipping decisions when adding overrides or unmanaged exceptions + +## Commands cheat sheet + +```bash +platformctl doctor +platformctl wizard --mode greenfield|brownfield|--resume +platformctl add domain +platformctl validate +platformctl plan +platformctl apply +platformctl drift +platformctl discover +platformctl decision add --applies-to PATH --rationale "..." +platformctl explain --path PATH +platformctl destroy --env ENV # rare, scoped +``` diff --git a/blueprint/templates/customer-repo/.cursor/skills/platform-ops/examples.md b/blueprint/templates/customer-repo/.cursor/skills/platform-ops/examples.md new file mode 100644 index 0000000..b36ecf3 --- /dev/null +++ b/blueprint/templates/customer-repo/.cursor/skills/platform-ops/examples.md @@ -0,0 +1,18 @@ +# Examples + +## Add a domain + +User: "Add a finance domain with restricted prod curated read for month-end; revisit after SSO." + +Actions: + +1. `platformctl decision add --applies-to domains.finance --rationale "Month-end needs prod curated read; revisit after SSO" --expires 2026-10-01` +2. Add domain to `intent.yaml` with `access_profile: restricted` and override linked to that decision id. +3. `platformctl validate && platformctl plan` +4. Wait for approval → `platformctl apply` + +## Resume partial intake + +User: "SSO is ready with Okta now." + +Actions: set `identity.sso: okta` via wizard resume or direct intent edit → validate → plan → apply. diff --git a/blueprint/templates/customer-repo/.cursor/skills/platform-ops/rbac-best-practices.md b/blueprint/templates/customer-repo/.cursor/skills/platform-ops/rbac-best-practices.md new file mode 100644 index 0000000..39ccf98 --- /dev/null +++ b/blueprint/templates/customer-repo/.cursor/skills/platform-ops/rbac-best-practices.md @@ -0,0 +1,8 @@ +# RBAC best practices (Sky Lattice blueprint) + +- Prefer functional roles (analyst, loader, engineer) composed from access roles per zone. +- Never use `ACCOUNTADMIN` for day-to-day service automation after bootstrap. +- Restricted domains: analysts read `marts` by default; `curated` in prod needs override + decision. +- Environment isolation: default `database_per_env` unless the customer requires account-per-env (record a decision). +- Temporary exceptions must have `expires_on` when possible. +- Brownfield: leave legacy roles in `unmanaged` with a decision rather than silently rewriting them on day one. diff --git a/blueprint/templates/customer-repo/.github/workflows/terraform.yml b/blueprint/templates/customer-repo/.github/workflows/terraform.yml new file mode 100644 index 0000000..eb9df8e --- /dev/null +++ b/blueprint/templates/customer-repo/.github/workflows/terraform.yml @@ -0,0 +1,26 @@ +name: sky-lattice-plan-apply +on: + pull_request: + push: + branches: [main, master] +permissions: + contents: read +jobs: + terraform: + runs-on: ubuntu-latest + defaults: + run: + working-directory: terraform + steps: + - uses: actions/checkout@v4 + - uses: hashicorp/setup-terraform@v3 + - name: Terraform fmt + run: terraform fmt -check -recursive || true + - name: Terraform init + run: terraform init -backend=false + - name: Terraform validate + run: terraform validate || true + - name: Reminder + run: | + echo "Wire Snowflake credentials and a remote backend before real apply." + echo "platformctl plan generates planned object graph; terraform apply needs provider config." diff --git a/blueprint/templates/customer-repo/README.md b/blueprint/templates/customer-repo/README.md new file mode 100644 index 0000000..206599e --- /dev/null +++ b/blueprint/templates/customer-repo/README.md @@ -0,0 +1,17 @@ +# Customer project — managed by Sky Lattice + +This repository is the durable memory for this customer's Snowflake platform: + +- `intent.yaml` — what we mean to manage +- `decisions/` — why deviations exist +- `terraform/` — executor (compiled from intent) +- `.cursor/skills/platform-ops/` — LLM front door for Cursor + +## Next steps + +1. Fill or resume the wizard: `platformctl wizard --resume` +2. Validate: `platformctl validate` +3. Plan: `platformctl plan` +4. Apply via CI or `platformctl apply` (with credentials) + +See `docs/` in the Sky Lattice product repo for customer landing checklist. diff --git a/blueprint/templates/customer-repo/decisions/.gitkeep b/blueprint/templates/customer-repo/decisions/.gitkeep new file mode 100644 index 0000000..3f3e737 --- /dev/null +++ b/blueprint/templates/customer-repo/decisions/.gitkeep @@ -0,0 +1 @@ +# Keep decisions here as YAML files: .yaml diff --git a/blueprint/templates/customer-repo/terraform/main.tf b/blueprint/templates/customer-repo/terraform/main.tf new file mode 100644 index 0000000..b7352d6 --- /dev/null +++ b/blueprint/templates/customer-repo/terraform/main.tf @@ -0,0 +1,21 @@ +terraform { + required_version = ">= 1.5.0" + # Configure a remote backend in customer CI (S3/Azure/GCS). Local is fine for demos. +} + +# Generated and updated by `platformctl plan`. +# Pin blueprint modules via relative path in monorepo demos, or a module registry in production. + +locals { + intent = yamldecode(file("${path.module}/../intent.yaml")) +} + +module "sky_lattice" { + source = "../../../blueprint/modules/environment" + + customer = local.intent.customer + environment = try(local.intent.environments[0], "dev") + env_strategy = try(local.intent.env_strategy, "database_per_env") +} + +# Per-domain and warehouse modules are rendered into planned_objects.tf.json by platformctl. diff --git a/blueprint/tests/reference-customer/intent.yaml b/blueprint/tests/reference-customer/intent.yaml new file mode 100644 index 0000000..a34ca68 --- /dev/null +++ b/blueprint/tests/reference-customer/intent.yaml @@ -0,0 +1,18 @@ +customer: reference +blueprint: "0.1.0" +mode: greenfield +env_strategy: database_per_env +environments: [dev, prod] +domains: + - name: sales + access_profile: standard + zones: [landing, raw, curated, marts] +warehouses: + profile: standard_cost_saver +identity: + sso: planned + service_users: true +overrides: [] +unmanaged: [] +wizard: + answered: [customer, environments, domains] diff --git a/blueprint/wizard/catalog.brownfield.yaml b/blueprint/wizard/catalog.brownfield.yaml new file mode 100644 index 0000000..525e703 --- /dev/null +++ b/blueprint/wizard/catalog.brownfield.yaml @@ -0,0 +1,69 @@ +# Brownfield wizard — discover first, then adoption choices. +version: 1 +mode: brownfield +prerequisites: + - discover +questions: + - id: customer + prompt: "What is the customer slug?" + intent_path: customer + type: string + required: true + + - id: environments + prompt: "Which environments will Sky Lattice manage going forward? (comma-separated)" + intent_path: environments + type: list + default: [prod] + item_enum: [dev, test, prod, sandbox] + + - id: adopt_domains + prompt: "Which domains should we manage now? (comma-separated; others stay unmanaged)" + intent_path: domains + type: domain_list + required: true + default_access_profile: standard + + - id: keep_legacy_roles + prompt: "Legacy roles to leave unmanaged for now? (comma-separated names, or none)" + type: unmanaged_list + kind: role + decision_required: true + decision_prompt: "Why keep legacy role {name} unmanaged?" + default: none + + - id: naming_conflicts + prompt: "Any naming conflicts to keep as-is until migration? (comma-separated object names, or none)" + type: unmanaged_list + kind: database + decision_required: true + decision_prompt: "Why leave {name} unmanaged / conflict deferred?" + default: none + + - id: warehouse_profile + prompt: "Warehouse profile for newly managed warehouses?" + intent_path: warehouses.profile + type: choice + choices: + - value: standard_cost_saver + label: "Cost saver" + - value: performance + label: "Performance" + default: standard_cost_saver + + - id: identity_sso + prompt: "SSO status?" + intent_path: identity.sso + type: choice + choices: + - value: none + label: "No SSO" + - value: planned + label: "SSO planned" + - value: okta + label: "Okta" + - value: azure_ad + label: "Azure AD" + - value: other + label: "Other" + default: planned diff --git a/blueprint/wizard/catalog.greenfield.yaml b/blueprint/wizard/catalog.greenfield.yaml new file mode 100644 index 0000000..0eeadc5 --- /dev/null +++ b/blueprint/wizard/catalog.greenfield.yaml @@ -0,0 +1,76 @@ +# Greenfield wizard — Snowflake vocabulary questions mapped to intent paths. +version: 1 +mode: greenfield +questions: + - id: customer + prompt: "What is the customer slug? (lowercase, e.g. acme)" + intent_path: customer + type: string + required: true + + - id: env_strategy + prompt: "How should environments be isolated?" + intent_path: env_strategy + type: choice + choices: + - value: database_per_env + label: "Databases per environment in one account (default)" + - value: account_per_env + label: "Separate Snowflake account per environment" + default: database_per_env + + - id: environments + prompt: "Which environments do you need? (comma-separated: dev,test,prod)" + intent_path: environments + type: list + default: [dev, test, prod] + item_enum: [dev, test, prod, sandbox] + + - id: domains + prompt: "List business domains / data products (comma-separated, e.g. sales,finance,ops)" + intent_path: domains + type: domain_list + required: true + default_access_profile: standard + + - id: domain_access + prompt: "Any domain that needs restricted access? (comma-separated names, or none)" + type: restricted_domains + default: none + maps_to: domains[].access_profile + + - id: warehouse_profile + prompt: "Warehouse profile?" + intent_path: warehouses.profile + type: choice + choices: + - value: standard_cost_saver + label: "Cost saver (X-SMALL, aggressive auto-suspend)" + - value: performance + label: "Performance (MEDIUM)" + default: standard_cost_saver + + - id: identity_sso + prompt: "SSO status?" + intent_path: identity.sso + type: choice + choices: + - value: none + label: "No SSO (service users / local only)" + - value: planned + label: "SSO planned later" + - value: okta + label: "Okta now" + - value: azure_ad + label: "Azure AD now" + - value: other + label: "Other IdP now" + default: planned + + - id: prod_curated_exceptions + prompt: "Any domain that needs prod curated read early (against restricted defaults)? (comma-separated or none)" + type: override_with_decision + override_path_template: "domains.{name}.prod_curated_read" + decision_required: true + decision_prompt: "Why is prod curated read allowed early for {name}? (stored as a decision)" + default: none diff --git a/blueprint/wizard/mapping.yaml b/blueprint/wizard/mapping.yaml new file mode 100644 index 0000000..ceef5d4 --- /dev/null +++ b/blueprint/wizard/mapping.yaml @@ -0,0 +1,18 @@ +# Maps wizard answer ids to intent mutation helpers used by platformctl. +version: 1 +helpers: + string: set_path + choice: set_path + list: set_list_path + domain_list: set_domains + restricted_domains: mark_restricted_domains + override_with_decision: add_override_and_decision + unmanaged_list: add_unmanaged_with_decision + +policy_hooks: + - when: override_with_decision + require: decision + - when: unmanaged_list + require: decision + - when: access_profile == restricted + note: "prod curated read requires override + decision" diff --git a/customers/.gitkeep b/customers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/customers/acme/.cursor/skills/platform-ops/SKILL.md b/customers/acme/.cursor/skills/platform-ops/SKILL.md new file mode 100644 index 0000000..a4daf48 --- /dev/null +++ b/customers/acme/.cursor/skills/platform-ops/SKILL.md @@ -0,0 +1,48 @@ +--- +name: platform-ops +description: >- + Operate Sky Lattice customer platforms. Use when adding domains, changing RBAC, + running wizard/plan/apply/drift, recording decisions, or brownfield adopt for + Snowflake platform setup with intent.yaml. +--- + +# Sky Lattice Platform Ops + +You are driving **Sky Lattice** for a customer project. You do **not** invent ad-hoc Snowflake SQL as the system of record. + +## Required context + +1. Open the **customer repo** as the workspace (contains `intent.yaml`, `decisions/`, `terraform/`). +2. Read `intent.yaml` and active files under `decisions/`. +3. Prefer invoking `platformctl` over hand-writing HCL/SQL. + +## Workflow + +1. Understand the request in Snowflake terms (domain, env, warehouse, who can read curated). +2. If it breaks a blueprint default → create/update a **decision** (`platformctl decision add`). +3. Patch `intent.yaml` (or run `platformctl wizard` / `platformctl add`). +4. Run `platformctl validate` then `platformctl plan`. +5. Show the human summary + plan; **do not apply** until the user explicitly approves. +6. On approval: `platformctl apply` or open a PR for customer CI. + +## Forbidden + +- Emitting unaudited `GRANT` / `DROP` scripts as the final artifact +- Re-running greenfield `init` on an existing customer to pick up late requirements +- Skipping decisions when adding overrides or unmanaged exceptions + +## Commands cheat sheet + +```bash +platformctl doctor +platformctl wizard --mode greenfield|brownfield|--resume +platformctl add domain +platformctl validate +platformctl plan +platformctl apply +platformctl drift +platformctl discover +platformctl decision add --applies-to PATH --rationale "..." +platformctl explain --path PATH +platformctl destroy --env ENV # rare, scoped +``` diff --git a/customers/acme/.cursor/skills/platform-ops/examples.md b/customers/acme/.cursor/skills/platform-ops/examples.md new file mode 100644 index 0000000..b36ecf3 --- /dev/null +++ b/customers/acme/.cursor/skills/platform-ops/examples.md @@ -0,0 +1,18 @@ +# Examples + +## Add a domain + +User: "Add a finance domain with restricted prod curated read for month-end; revisit after SSO." + +Actions: + +1. `platformctl decision add --applies-to domains.finance --rationale "Month-end needs prod curated read; revisit after SSO" --expires 2026-10-01` +2. Add domain to `intent.yaml` with `access_profile: restricted` and override linked to that decision id. +3. `platformctl validate && platformctl plan` +4. Wait for approval → `platformctl apply` + +## Resume partial intake + +User: "SSO is ready with Okta now." + +Actions: set `identity.sso: okta` via wizard resume or direct intent edit → validate → plan → apply. diff --git a/customers/acme/.cursor/skills/platform-ops/rbac-best-practices.md b/customers/acme/.cursor/skills/platform-ops/rbac-best-practices.md new file mode 100644 index 0000000..39ccf98 --- /dev/null +++ b/customers/acme/.cursor/skills/platform-ops/rbac-best-practices.md @@ -0,0 +1,8 @@ +# RBAC best practices (Sky Lattice blueprint) + +- Prefer functional roles (analyst, loader, engineer) composed from access roles per zone. +- Never use `ACCOUNTADMIN` for day-to-day service automation after bootstrap. +- Restricted domains: analysts read `marts` by default; `curated` in prod needs override + decision. +- Environment isolation: default `database_per_env` unless the customer requires account-per-env (record a decision). +- Temporary exceptions must have `expires_on` when possible. +- Brownfield: leave legacy roles in `unmanaged` with a decision rather than silently rewriting them on day one. diff --git a/customers/acme/.github/workflows/terraform.yml b/customers/acme/.github/workflows/terraform.yml new file mode 100644 index 0000000..eb9df8e --- /dev/null +++ b/customers/acme/.github/workflows/terraform.yml @@ -0,0 +1,26 @@ +name: sky-lattice-plan-apply +on: + pull_request: + push: + branches: [main, master] +permissions: + contents: read +jobs: + terraform: + runs-on: ubuntu-latest + defaults: + run: + working-directory: terraform + steps: + - uses: actions/checkout@v4 + - uses: hashicorp/setup-terraform@v3 + - name: Terraform fmt + run: terraform fmt -check -recursive || true + - name: Terraform init + run: terraform init -backend=false + - name: Terraform validate + run: terraform validate || true + - name: Reminder + run: | + echo "Wire Snowflake credentials and a remote backend before real apply." + echo "platformctl plan generates planned object graph; terraform apply needs provider config." diff --git a/customers/acme/README.md b/customers/acme/README.md new file mode 100644 index 0000000..476f8aa --- /dev/null +++ b/customers/acme/README.md @@ -0,0 +1,21 @@ +# acme — Sky Lattice customer project + +Blueprint pin: `0.1.0` + +# Customer project — managed by Sky Lattice + +This repository is the durable memory for this customer's Snowflake platform: + +- `intent.yaml` — what we mean to manage +- `decisions/` — why deviations exist +- `terraform/` — executor (compiled from intent) +- `.cursor/skills/platform-ops/` — LLM front door for Cursor + +## Next steps + +1. Fill or resume the wizard: `platformctl wizard --resume` +2. Validate: `platformctl validate` +3. Plan: `platformctl plan` +4. Apply via CI or `platformctl apply` (with credentials) + +See `docs/` in the Sky Lattice product repo for customer landing checklist. diff --git a/customers/acme/decisions/.gitkeep b/customers/acme/decisions/.gitkeep new file mode 100644 index 0000000..3f3e737 --- /dev/null +++ b/customers/acme/decisions/.gitkeep @@ -0,0 +1 @@ +# Keep decisions here as YAML files: .yaml diff --git a/customers/acme/decisions/2026-07-15-domains-finance.yaml b/customers/acme/decisions/2026-07-15-domains-finance.yaml new file mode 100644 index 0000000..1156721 --- /dev/null +++ b/customers/acme/decisions/2026-07-15-domains-finance.yaml @@ -0,0 +1,10 @@ +id: 2026-07-15-domains-finance +applies_to: domains.finance +rationale: demo decision +alternatives_rejected: [] +status: active +expires_on: '2026-10-01' +client_constraint: null +created_at: '2026-07-15T05:34:25.700769+00:00' +created_by: null +superseded_by: null diff --git a/customers/acme/intent.yaml b/customers/acme/intent.yaml new file mode 100644 index 0000000..d9e4ef8 --- /dev/null +++ b/customers/acme/intent.yaml @@ -0,0 +1,54 @@ +customer: acme +blueprint: 0.1.0 +mode: greenfield +env_strategy: database_per_env +environments: +- dev +- prod +domains: +- name: sales + access_profile: standard + zones: + - landing + - raw + - curated + - marts +- name: finance + access_profile: restricted + zones: + - landing + - raw + - curated + - marts +- name: ops + access_profile: standard + zones: + - landing + - raw + - curated + - marts +- name: marketing + access_profile: standard + zones: + - landing + - raw + - curated + - marts +warehouses: + profile: standard_cost_saver +identity: + sso: planned + service_users: true +overrides: [] +unmanaged: [] +wizard: + answered: + - customer + - env_strategy + - environments + - domains + - domain_access + - warehouse_profile + - identity_sso + - prod_curated_exceptions + last_run: '2026-07-15T05:34:23.899022+00:00' diff --git a/customers/acme/terraform/main.tf b/customers/acme/terraform/main.tf new file mode 100644 index 0000000..b7352d6 --- /dev/null +++ b/customers/acme/terraform/main.tf @@ -0,0 +1,21 @@ +terraform { + required_version = ">= 1.5.0" + # Configure a remote backend in customer CI (S3/Azure/GCS). Local is fine for demos. +} + +# Generated and updated by `platformctl plan`. +# Pin blueprint modules via relative path in monorepo demos, or a module registry in production. + +locals { + intent = yamldecode(file("${path.module}/../intent.yaml")) +} + +module "sky_lattice" { + source = "../../../blueprint/modules/environment" + + customer = local.intent.customer + environment = try(local.intent.environments[0], "dev") + env_strategy = try(local.intent.env_strategy, "database_per_env") +} + +# Per-domain and warehouse modules are rendered into planned_objects.tf.json by platformctl. diff --git a/customers/acme/terraform/planned_objects.yaml b/customers/acme/terraform/planned_objects.yaml new file mode 100644 index 0000000..35a434a --- /dev/null +++ b/customers/acme/terraform/planned_objects.yaml @@ -0,0 +1,19 @@ +planned_objects: [] +summary: +- '[dev] domain=sales profile=standard: db=ACME_DEV_SALES, zones=[''landing'', ''raw'', + ''curated'', ''marts''], analyst_read=[''curated'', ''marts'']' +- '[dev] domain=finance profile=restricted: db=ACME_DEV_FINANCE, zones=[''landing'', + ''raw'', ''curated'', ''marts''], analyst_read=[''marts'']' +- '[dev] domain=ops profile=standard: db=ACME_DEV_OPS, zones=[''landing'', ''raw'', + ''curated'', ''marts''], analyst_read=[''curated'', ''marts'']' +- '[dev] domain=marketing profile=standard: db=ACME_DEV_MARKETING, zones=[''landing'', + ''raw'', ''curated'', ''marts''], analyst_read=[''curated'', ''marts'']' +- '[prod] domain=sales profile=standard: db=ACME_PROD_SALES, zones=[''landing'', ''raw'', + ''curated'', ''marts''], analyst_read=[''curated'', ''marts'']' +- '[prod] domain=finance profile=restricted: db=ACME_PROD_FINANCE, zones=[''landing'', + ''raw'', ''curated'', ''marts''], analyst_read=[''marts'']' +- '[prod] domain=ops profile=standard: db=ACME_PROD_OPS, zones=[''landing'', ''raw'', + ''curated'', ''marts''], analyst_read=[''curated'', ''marts'']' +- '[prod] domain=marketing profile=standard: db=ACME_PROD_MARKETING, zones=[''landing'', + ''raw'', ''curated'', ''marts''], analyst_read=[''curated'', ''marts'']' +object_count: 72 diff --git a/customers/globex/.cursor/skills/platform-ops/SKILL.md b/customers/globex/.cursor/skills/platform-ops/SKILL.md new file mode 100644 index 0000000..a4daf48 --- /dev/null +++ b/customers/globex/.cursor/skills/platform-ops/SKILL.md @@ -0,0 +1,48 @@ +--- +name: platform-ops +description: >- + Operate Sky Lattice customer platforms. Use when adding domains, changing RBAC, + running wizard/plan/apply/drift, recording decisions, or brownfield adopt for + Snowflake platform setup with intent.yaml. +--- + +# Sky Lattice Platform Ops + +You are driving **Sky Lattice** for a customer project. You do **not** invent ad-hoc Snowflake SQL as the system of record. + +## Required context + +1. Open the **customer repo** as the workspace (contains `intent.yaml`, `decisions/`, `terraform/`). +2. Read `intent.yaml` and active files under `decisions/`. +3. Prefer invoking `platformctl` over hand-writing HCL/SQL. + +## Workflow + +1. Understand the request in Snowflake terms (domain, env, warehouse, who can read curated). +2. If it breaks a blueprint default → create/update a **decision** (`platformctl decision add`). +3. Patch `intent.yaml` (or run `platformctl wizard` / `platformctl add`). +4. Run `platformctl validate` then `platformctl plan`. +5. Show the human summary + plan; **do not apply** until the user explicitly approves. +6. On approval: `platformctl apply` or open a PR for customer CI. + +## Forbidden + +- Emitting unaudited `GRANT` / `DROP` scripts as the final artifact +- Re-running greenfield `init` on an existing customer to pick up late requirements +- Skipping decisions when adding overrides or unmanaged exceptions + +## Commands cheat sheet + +```bash +platformctl doctor +platformctl wizard --mode greenfield|brownfield|--resume +platformctl add domain +platformctl validate +platformctl plan +platformctl apply +platformctl drift +platformctl discover +platformctl decision add --applies-to PATH --rationale "..." +platformctl explain --path PATH +platformctl destroy --env ENV # rare, scoped +``` diff --git a/customers/globex/.cursor/skills/platform-ops/examples.md b/customers/globex/.cursor/skills/platform-ops/examples.md new file mode 100644 index 0000000..b36ecf3 --- /dev/null +++ b/customers/globex/.cursor/skills/platform-ops/examples.md @@ -0,0 +1,18 @@ +# Examples + +## Add a domain + +User: "Add a finance domain with restricted prod curated read for month-end; revisit after SSO." + +Actions: + +1. `platformctl decision add --applies-to domains.finance --rationale "Month-end needs prod curated read; revisit after SSO" --expires 2026-10-01` +2. Add domain to `intent.yaml` with `access_profile: restricted` and override linked to that decision id. +3. `platformctl validate && platformctl plan` +4. Wait for approval → `platformctl apply` + +## Resume partial intake + +User: "SSO is ready with Okta now." + +Actions: set `identity.sso: okta` via wizard resume or direct intent edit → validate → plan → apply. diff --git a/customers/globex/.cursor/skills/platform-ops/rbac-best-practices.md b/customers/globex/.cursor/skills/platform-ops/rbac-best-practices.md new file mode 100644 index 0000000..39ccf98 --- /dev/null +++ b/customers/globex/.cursor/skills/platform-ops/rbac-best-practices.md @@ -0,0 +1,8 @@ +# RBAC best practices (Sky Lattice blueprint) + +- Prefer functional roles (analyst, loader, engineer) composed from access roles per zone. +- Never use `ACCOUNTADMIN` for day-to-day service automation after bootstrap. +- Restricted domains: analysts read `marts` by default; `curated` in prod needs override + decision. +- Environment isolation: default `database_per_env` unless the customer requires account-per-env (record a decision). +- Temporary exceptions must have `expires_on` when possible. +- Brownfield: leave legacy roles in `unmanaged` with a decision rather than silently rewriting them on day one. diff --git a/customers/globex/.github/workflows/terraform.yml b/customers/globex/.github/workflows/terraform.yml new file mode 100644 index 0000000..eb9df8e --- /dev/null +++ b/customers/globex/.github/workflows/terraform.yml @@ -0,0 +1,26 @@ +name: sky-lattice-plan-apply +on: + pull_request: + push: + branches: [main, master] +permissions: + contents: read +jobs: + terraform: + runs-on: ubuntu-latest + defaults: + run: + working-directory: terraform + steps: + - uses: actions/checkout@v4 + - uses: hashicorp/setup-terraform@v3 + - name: Terraform fmt + run: terraform fmt -check -recursive || true + - name: Terraform init + run: terraform init -backend=false + - name: Terraform validate + run: terraform validate || true + - name: Reminder + run: | + echo "Wire Snowflake credentials and a remote backend before real apply." + echo "platformctl plan generates planned object graph; terraform apply needs provider config." diff --git a/customers/globex/README.md b/customers/globex/README.md new file mode 100644 index 0000000..a288b26 --- /dev/null +++ b/customers/globex/README.md @@ -0,0 +1,21 @@ +# globex — Sky Lattice customer project + +Blueprint pin: `0.1.0` + +# Customer project — managed by Sky Lattice + +This repository is the durable memory for this customer's Snowflake platform: + +- `intent.yaml` — what we mean to manage +- `decisions/` — why deviations exist +- `terraform/` — executor (compiled from intent) +- `.cursor/skills/platform-ops/` — LLM front door for Cursor + +## Next steps + +1. Fill or resume the wizard: `platformctl wizard --resume` +2. Validate: `platformctl validate` +3. Plan: `platformctl plan` +4. Apply via CI or `platformctl apply` (with credentials) + +See `docs/` in the Sky Lattice product repo for customer landing checklist. diff --git a/customers/globex/decisions/.gitkeep b/customers/globex/decisions/.gitkeep new file mode 100644 index 0000000..3f3e737 --- /dev/null +++ b/customers/globex/decisions/.gitkeep @@ -0,0 +1 @@ +# Keep decisions here as YAML files: .yaml diff --git a/customers/globex/decisions/2026-07-15-unmanaged-finance-old.yaml b/customers/globex/decisions/2026-07-15-unmanaged-finance-old.yaml new file mode 100644 index 0000000..1392b0a --- /dev/null +++ b/customers/globex/decisions/2026-07-15-unmanaged-finance-old.yaml @@ -0,0 +1,10 @@ +id: 2026-07-15-unmanaged-finance-old +applies_to: unmanaged.role.FINANCE_OLD +rationale: Legacy finance role retained until SCIM cutover +alternatives_rejected: [] +status: active +expires_on: null +client_constraint: null +created_at: '2026-07-15T05:35:27.959133+00:00' +created_by: null +superseded_by: null diff --git a/customers/globex/intent.yaml b/customers/globex/intent.yaml new file mode 100644 index 0000000..54f281d --- /dev/null +++ b/customers/globex/intent.yaml @@ -0,0 +1,42 @@ +customer: globex +blueprint: 0.1.0 +mode: brownfield +env_strategy: database_per_env +environments: +- prod +domains: +- name: sales + access_profile: standard + zones: + - landing + - raw + - curated + - marts +- name: finance + access_profile: standard + zones: + - landing + - raw + - curated + - marts +warehouses: + profile: standard_cost_saver +identity: + sso: planned + service_users: true +overrides: [] +unmanaged: +- kind: role + name: FINANCE_OLD + reason: Legacy finance role retained until SCIM cutover + decision_id: 2026-07-15-unmanaged-finance-old +wizard: + answered: + - customer + - environments + - adopt_domains + - keep_legacy_roles + - naming_conflicts + - warehouse_profile + - identity_sso + last_run: '2026-07-15T05:35:27.962263+00:00' diff --git a/customers/globex/observed/inventory.yaml b/customers/globex/observed/inventory.yaml new file mode 100644 index 0000000..de19d0e --- /dev/null +++ b/customers/globex/observed/inventory.yaml @@ -0,0 +1,9 @@ +objects: + - kind: database + name: GLOBEX_PROD_SALES + - kind: role + name: FINANCE_OLD + - kind: warehouse + name: GLOBEX_PROD_WH + - kind: database + name: SOME_LEGACY_DB diff --git a/customers/globex/terraform/main.tf b/customers/globex/terraform/main.tf new file mode 100644 index 0000000..b7352d6 --- /dev/null +++ b/customers/globex/terraform/main.tf @@ -0,0 +1,21 @@ +terraform { + required_version = ">= 1.5.0" + # Configure a remote backend in customer CI (S3/Azure/GCS). Local is fine for demos. +} + +# Generated and updated by `platformctl plan`. +# Pin blueprint modules via relative path in monorepo demos, or a module registry in production. + +locals { + intent = yamldecode(file("${path.module}/../intent.yaml")) +} + +module "sky_lattice" { + source = "../../../blueprint/modules/environment" + + customer = local.intent.customer + environment = try(local.intent.environments[0], "dev") + env_strategy = try(local.intent.env_strategy, "database_per_env") +} + +# Per-domain and warehouse modules are rendered into planned_objects.tf.json by platformctl. diff --git a/customers/globex/terraform/planned_objects.yaml b/customers/globex/terraform/planned_objects.yaml new file mode 100644 index 0000000..a4b4977 --- /dev/null +++ b/customers/globex/terraform/planned_objects.yaml @@ -0,0 +1,28 @@ +planned_objects: +- warehouse:GLOBEX_PROD_WH +- resource_monitor:GLOBEX_PROD_MONITOR +- user:GLOBEX_PROD_TF_SVC +- user:GLOBEX_PROD_LOADER_SVC +- database:GLOBEX_PROD_SALES +- schema:GLOBEX_PROD_SALES.LANDING +- schema:GLOBEX_PROD_SALES.RAW +- schema:GLOBEX_PROD_SALES.CURATED +- schema:GLOBEX_PROD_SALES.MARTS +- role:GLOBEX_PROD_SALES_ANALYST +- role:GLOBEX_PROD_SALES_LOADER +- role:GLOBEX_PROD_SALES_ENGINEER +- database:GLOBEX_PROD_FINANCE +- schema:GLOBEX_PROD_FINANCE.LANDING +- schema:GLOBEX_PROD_FINANCE.RAW +- schema:GLOBEX_PROD_FINANCE.CURATED +- schema:GLOBEX_PROD_FINANCE.MARTS +- role:GLOBEX_PROD_FINANCE_ANALYST +- role:GLOBEX_PROD_FINANCE_LOADER +- role:GLOBEX_PROD_FINANCE_ENGINEER +summary: +- '[prod] domain=sales profile=standard: db=GLOBEX_PROD_SALES, zones=[''landing'', + ''raw'', ''curated'', ''marts''], analyst_read=[''curated'', ''marts'']' +- '[prod] domain=finance profile=standard: db=GLOBEX_PROD_FINANCE, zones=[''landing'', + ''raw'', ''curated'', ''marts''], analyst_read=[''curated'', ''marts'']' +- unmanaged role:FINANCE_OLD (Legacy finance role retained until SCIM cutover) +object_count: 20 diff --git a/docs/customer-landing/CHECKLIST.md b/docs/customer-landing/CHECKLIST.md new file mode 100644 index 0000000..ac65397 --- /dev/null +++ b/docs/customer-landing/CHECKLIST.md @@ -0,0 +1,11 @@ +# Customer environment bring-up checklist + +- [ ] Snowflake automation user + key pair created +- [ ] Secrets installed in CI / secret store +- [ ] TF state backend ready (customer-controlled preferred) +- [ ] Customer repo pushed with blueprint pin in `intent.yaml` +- [ ] `.cursor/skills/platform-ops` present (or `platformctl skills install`) +- [ ] First `platformctl validate` + `plan` succeed +- [ ] First apply path agreed (CI vs operator) +- [ ] Drift process documented (`platformctl drift` + inventory source) +- [ ] Runbook: add domain / decision / blueprint upgrade diff --git a/docs/customer-landing/README.md b/docs/customer-landing/README.md new file mode 100644 index 0000000..fd8a85a --- /dev/null +++ b/docs/customer-landing/README.md @@ -0,0 +1,57 @@ +# How To Land Sky Lattice in a Customer Environment + +This guide is for operators bringing Sky Lattice to a new customer Snowflake account. + +## Prerequisites + +- Customer Snowflake account (or ability to create one) +- Customer git host (GitHub/GitLab) or agreement that you host the customer project +- Place to store secrets (CI secrets / Vault) +- Optional: Cursor for skill-based UX + +## Steps + +1. **Bootstrap automation identity (one-time, manual)** + As `ACCOUNTADMIN`, create a Terraform/service user with key-pair auth and roles sufficient for platform objects (typically a dedicated automation role; avoid day-to-day ACCOUNTADMIN after bootstrap). + +2. **Store secrets** + Put the private key and account identifiers in the customer’s secret store / CI secrets. Never commit keys. + +3. **Create the customer project** + On your operator machine: + + ```bash + platformctl init --mode greenfield # or brownfield + platformctl wizard --path customers/ + platformctl validate --path customers/ + platformctl plan --path customers/ + ``` + +4. **Push to customer git** + Push `customers//` to their repository (or your managed repo per contract). + +5. **Wire CI** + The template workflow under `.github/workflows/terraform.yml` is a starting point. Add credentials, remote TF state backend, and real `terraform plan/apply` gates. + +6. **First apply** + Run through CI or `platformctl apply` once credentials and provider resources are wired. + +7. **Hand off** + Point customer admins at recipes: add domain, grant patterns, drift. Approvers only need to read Terraform/plan summaries. + +## Expected results + +- Customer repo holds intent + decisions + terraform +- CI can plan changes via PRs +- Blueprint remains pinned by version; full blueprint source stays with you unless air-gapped vendoring is required + +## Troubleshooting + +- **No intent.yaml** — run from the customer project or pass `--path` +- **Policy errors on overrides** — add `platformctl decision add` and link `decision_id` +- **terraform missing** — fine for v0 graph apply; install Terraform when enabling live provider apply + +## Related + +- [Bring-up checklist](CHECKLIST.md) +- Recipes under `docs/recipes/` diff --git a/docs/decisions/D001-product-name-and-scope.md b/docs/decisions/D001-product-name-and-scope.md new file mode 100644 index 0000000..c3c42cb --- /dev/null +++ b/docs/decisions/D001-product-name-and-scope.md @@ -0,0 +1,18 @@ +# D001 — Product name and scope + +- **Status:** active +- **Date:** 2026-07-15 + +## Decision + +Call the product **Sky Lattice**. Scope is an internal platform toolkit: versioned Snowflake **blueprint** + per-customer **intent/decisions** + deterministic **plan/apply** on top of Terraform — not a Marketplace Native App and not “an AI that sets up Snowflake.” + +## Why + +Snowflake is a blank slate; repeatability comes from codifying our recommended platform and tweaking per client. Existing tools (Terraform provider, SnowDDL, etc.) are engines, not our delivery recipe. + +## Alternatives rejected + +- Pure LLM chat each engagement with only TF state as memory +- Replacing Terraform entirely in v0 +- Building a full SaaS control plane before a CLI+repo convention diff --git a/docs/decisions/D002-llm-as-front-door.md b/docs/decisions/D002-llm-as-front-door.md new file mode 100644 index 0000000..1dfd64e --- /dev/null +++ b/docs/decisions/D002-llm-as-front-door.md @@ -0,0 +1,17 @@ +# D002 — LLM as front door only + +- **Status:** active +- **Date:** 2026-07-15 + +## Decision + +Cursor/Claude Code **skills** are an optional UX that load customer `intent.yaml` + `decisions/` and invoke `platformctl`. The LLM must not emit unaudited GRANT/SQL as the system of record. + +## Why + +Session N+1 with TF state recovers *what* exists, not *why*. Skills + decision files fix amnesia without putting the model in the apply path. + +## Alternatives rejected + +- Free-form chat regenerating the whole platform each time +- Requiring every customer engineer to use an agent IDE (CLI-only path remains first-class) diff --git a/docs/decisions/D003-blueprint-vs-customer-repo.md b/docs/decisions/D003-blueprint-vs-customer-repo.md new file mode 100644 index 0000000..f829902 --- /dev/null +++ b/docs/decisions/D003-blueprint-vs-customer-repo.md @@ -0,0 +1,17 @@ +# D003 — Blueprint vs customer repo + +- **Status:** active +- **Date:** 2026-07-15 + +## Decision + +Keep **blueprint** (modules, schemas, wizard catalogs, policies, skills) as versioned product IP. Each customer gets a **project repo** with intent, decisions, terraform root, and a copied skill pin. + +## Why + +Separates reusable best practices from client-specific answers and exceptions; enables blueprint upgrades via version pin. + +## Alternatives rejected + +- Forking the whole monorepo into every customer +- Storing only Terraform without an intent layer diff --git a/docs/decisions/D004-packaging-python.md b/docs/decisions/D004-packaging-python.md new file mode 100644 index 0000000..0800208 --- /dev/null +++ b/docs/decisions/D004-packaging-python.md @@ -0,0 +1,17 @@ +# D004 — Packaging as Python platformctl + +- **Status:** active +- **Date:** 2026-07-15 + +## Decision + +Distribute the CLI as a **Python package** (`platformctl` / `skylattice`) installable with pip or uv/pipx. The CLI operates on a local customer project and pinned blueprint content inside the product repo (or published artifact later). It must **not** require reaching into an operator’s private live working tree from the customer network. + +## Why + +Fits Snowflake/Python ecosystem; private publish is easy; pipx/uv gives an isolated CLI. + +## Alternatives rejected + +- npm as primary distribution +- Requiring customers to clone the operator blueprint monorepo for day-to-day apply (CI can run Terraform only) diff --git a/docs/decisions/D005-post-apply-edit-not-teardown.md b/docs/decisions/D005-post-apply-edit-not-teardown.md new file mode 100644 index 0000000..f8664dc --- /dev/null +++ b/docs/decisions/D005-post-apply-edit-not-teardown.md @@ -0,0 +1,17 @@ +# D005 — Post-apply edit, not teardown + +- **Status:** active +- **Date:** 2026-07-15 + +## Decision + +After apply, the default recourse for late/changed requirements is **edit intent/decisions → plan → apply**. Teardown is **scoped** (`destroy --env`) and rare. + +## Why + +Intake is never complete on day one; wiping accounts to fix naming or add a domain is unsafe and slow. + +## Alternatives rejected + +- Re-running greenfield `init` on an existing customer +- Ad-hoc DROP scripts outside the intent loop diff --git a/docs/decisions/D006-delivery-mode-a.md b/docs/decisions/D006-delivery-mode-a.md new file mode 100644 index 0000000..d2174fe --- /dev/null +++ b/docs/decisions/D006-delivery-mode-a.md @@ -0,0 +1,17 @@ +# D006 — Customer delivery mode A + +- **Status:** active +- **Date:** 2026-07-15 + +## Decision + +Default landing: customer hosts **git + CI + secrets + TF state backend + Snowflake automation user**. Operator keeps blueprint; customer repo pins blueprint version. Optional modes: operator-run apply (retainer), or vendored blueprint for air-gap. + +## Why + +Matches consulting delivery; minimizes IP leakage; uses familiar Terraform PR workflows for customer DevOps. + +## Alternatives rejected + +- Shipping full blueprint source into every customer by default +- Snowflake Native App as v0 packaging diff --git a/docs/decisions/D007-wizard-reentrant.md b/docs/decisions/D007-wizard-reentrant.md new file mode 100644 index 0000000..64d4b98 --- /dev/null +++ b/docs/decisions/D007-wizard-reentrant.md @@ -0,0 +1,16 @@ +# D007 — Wizard is re-entrant + +- **Status:** active +- **Date:** 2026-07-15 + +## Decision + +The wizard is a **resumable question catalog** (greenfield/brownfield) that patches intent and forces decisions on overrides — not a one-shot form. + +## Why + +Details arrive over weeks; the same guided path must support `add domain`, SSO later, and brownfield adopt. + +## Alternatives rejected + +- Single questionnaire then abandon the tool for hand-edited HCL only diff --git a/docs/decisions/D008-v0-planner-graph.md b/docs/decisions/D008-v0-planner-graph.md new file mode 100644 index 0000000..e8e9d6b --- /dev/null +++ b/docs/decisions/D008-v0-planner-graph.md @@ -0,0 +1,17 @@ +# D008 — v0 planner emits object graph + +- **Status:** active +- **Date:** 2026-07-15 + +## Decision + +v0 `platformctl plan/apply` builds a **deterministic planned object graph** from intent (databases, schemas, roles, warehouses, service users) and writes `plans/latest.yaml`. Terraform modules are stubs/outputs suitable for extension with the official `snowflakedb/snowflake` provider. Live account apply via provider credentials is a follow-on. + +## Why + +Unlocks end-to-end intent→decision→plan workflow and dogfooding without blocking on full provider resource coverage on day one. + +## Alternatives rejected + +- Delaying any CLI until every Snowflake resource is fully Terraform-managed +- Making LLM-generated SQL the apply path in v0 diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 0000000..3cc3aea --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,14 @@ +# Product decisions (Sky Lattice) + +Durable rationale for choices made while designing this product. These are **meta-decisions** about the tool itself (not customer Snowflake exceptions). + +| ID | Decision | +|---|---| +| [D001](D001-product-name-and-scope.md) | Name Sky Lattice; scope = intent/decision/policy over Terraform | +| [D002](D002-llm-as-front-door.md) | LLM/skills drive files+CLI; never system of record for grants | +| [D003](D003-blueprint-vs-customer-repo.md) | Split blueprint IP vs per-customer durable repo | +| [D004](D004-packaging-python.md) | Ship as Python `platformctl` via pip/uv; no live clone of operator monorepo from customer env | +| [D005](D005-post-apply-edit-not-teardown.md) | Default lifecycle is edit→plan→apply; scoped destroy is exception | +| [D006](D006-delivery-mode-a.md) | Default customer landing = their git/CI/secrets; you keep blueprint | +| [D007](D007-wizard-reentrant.md) | Wizard is resumable guided editor, not one-shot intake | +| [D008](D008-v0-planner-graph.md) | v0 planner emits object graph + TF stubs; live snowflakedb provider wired later | diff --git a/docs/recipes/add-domain.md b/docs/recipes/add-domain.md new file mode 100644 index 0000000..ebc0c94 --- /dev/null +++ b/docs/recipes/add-domain.md @@ -0,0 +1,34 @@ +# How To Add a Domain + +Add a business domain to an existing Sky Lattice customer project after the platform is already applied. + +## Prerequisites + +- Customer project with `intent.yaml` +- `platformctl` installed + +## Steps + +1. Open the customer project directory. +2. Run: + + ```bash + platformctl add domain finance --access-profile restricted + ``` + +3. If this breaks a default (e.g. early prod curated read), record a decision: + + ```bash + platformctl decision add \ + --applies-to domains.finance \ + --rationale "Month-end needs prod curated read; revisit after SSO" \ + --expires 2026-10-01 + ``` + +4. Link an override in `intent.yaml` if needed (`overrides` with that `decision_id`). +5. Run `platformctl validate` then `platformctl plan`. +6. Review the plan summary and apply via `platformctl apply` or a PR. + +## Expected results + +Plan shows new database/schemas/roles for the domain across configured environments. diff --git a/docs/recipes/fix-drift.md b/docs/recipes/fix-drift.md new file mode 100644 index 0000000..d1d478c --- /dev/null +++ b/docs/recipes/fix-drift.md @@ -0,0 +1,20 @@ +# How To Fix Drift + +Compare live inventory to desired intent and decide what to adopt or leave unmanaged. + +## Steps + +1. Export or maintain `observed/inventory.yaml` with objects `{kind, name}`. +2. Run `platformctl drift --inventory observed/inventory.yaml`. +3. For leftovers you will not manage yet: + + ```bash + platformctl decision add --applies-to unmanaged.role.LEGACY_ANALYST --rationale "..." + ``` + + Add them under `intent.unmanaged` with that `decision_id`. +4. For gaps that should exist, `platformctl plan` and apply. + +## Expected results + +Discover report in `observed/discover-latest.yaml` with classifications. diff --git a/docs/recipes/resume-wizard.md b/docs/recipes/resume-wizard.md new file mode 100644 index 0000000..b1681d8 --- /dev/null +++ b/docs/recipes/resume-wizard.md @@ -0,0 +1,13 @@ +# How To Resume the Wizard Later + +Intake is incomplete — continue without re-init. + +## Steps + +1. `platformctl wizard --resume --path customers/` +2. Answer only remaining questions (or pass `--answers`). +3. `platformctl validate && platformctl plan` + +## Expected results + +`intent.wizard.answered` grows; prior domains/decisions remain intact. diff --git a/docs/wizard-intent-mapping.md b/docs/wizard-intent-mapping.md new file mode 100644 index 0000000..6172506 --- /dev/null +++ b/docs/wizard-intent-mapping.md @@ -0,0 +1,33 @@ +# Wizard catalog ↔ intent schema (deep dive) + +This documents the mapping locked for blueprint `0.1.0`. + +## Intent fields covered by greenfield catalog + +| Question id | Intent path / effect | Schema field | +|---|---|---| +| customer | `customer` | required string | +| env_strategy | `env_strategy` | enum | +| environments | `environments` | array of env enums | +| domains | `domains[]` | array of domain objects | +| domain_access | sets `domains[].access_profile` | enum standard/restricted | +| warehouse_profile | `warehouses.profile` | enum | +| identity_sso | `identity.sso` | enum | +| prod_curated_exceptions | `overrides[]` + decision file | overrides require decision_id | + +## Brownfield extras + +| Question id | Effect | +|---|---| +| adopt_domains | `domains[]` to manage now | +| keep_legacy_roles | `unmanaged[]` kind=role + decision | +| naming_conflicts | `unmanaged[]` kind=database + decision | + +## Gaps / follow-ups + +- Per-zone customization beyond default zone list +- Account-per-env provider alias wiring in Terraform +- Live Snowflake INFORMATION_SCHEMA discover connector +- JSON Schema `default` on nested domain zones not enforced by jsonschema without extending validator + +Wizard `mapping.yaml` helpers mirror these mutations in `wizard_cmd.py`. diff --git a/examples/answers/acme.greenfield.yaml b/examples/answers/acme.greenfield.yaml new file mode 100644 index 0000000..f0550b2 --- /dev/null +++ b/examples/answers/acme.greenfield.yaml @@ -0,0 +1,8 @@ +customer: acme +env_strategy: database_per_env +environments: [dev, test, prod] +domains: [sales, finance, ops] +domain_access: finance +warehouse_profile: standard_cost_saver +identity_sso: planned +prod_curated_exceptions: none diff --git a/examples/answers/globex.brownfield.yaml b/examples/answers/globex.brownfield.yaml new file mode 100644 index 0000000..51f0c38 --- /dev/null +++ b/examples/answers/globex.brownfield.yaml @@ -0,0 +1,8 @@ +customer: globex +environments: [prod] +adopt_domains: [sales, finance] +keep_legacy_roles: FINANCE_OLD +keep_legacy_roles_rationale: "Legacy finance role retained until SCIM cutover" +naming_conflicts: none +warehouse_profile: standard_cost_saver +identity_sso: planned diff --git a/examples/answers/globex.inventory.yaml b/examples/answers/globex.inventory.yaml new file mode 100644 index 0000000..de19d0e --- /dev/null +++ b/examples/answers/globex.inventory.yaml @@ -0,0 +1,9 @@ +objects: + - kind: database + name: GLOBEX_PROD_SALES + - kind: role + name: FINANCE_OLD + - kind: warehouse + name: GLOBEX_PROD_WH + - kind: database + name: SOME_LEGACY_DB diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9d3536d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "skylattice" +version = "0.1.0" +description = "Sky Lattice — intent + decisions + Terraform blueprint toolkit for Snowflake platforms" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Sky Lattice" }] +dependencies = [ + "typer>=0.12", + "rich>=13.0", + "pyyaml>=6.0", + "jsonschema>=4.20", + "python-dateutil>=2.8", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0"] + +[project.scripts] +platformctl = "skylattice.cli:app" +skylattice = "skylattice.cli:app" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +skylattice = ["py.typed"] diff --git a/src/skylattice/__init__.py b/src/skylattice/__init__.py new file mode 100644 index 0000000..4ec965e --- /dev/null +++ b/src/skylattice/__init__.py @@ -0,0 +1,5 @@ +"""Sky Lattice — platformctl CLI.""" + +from __future__ import annotations + +__version__ = "0.1.0" diff --git a/src/skylattice/cli.py b/src/skylattice/cli.py new file mode 100644 index 0000000..ed5a423 --- /dev/null +++ b/src/skylattice/cli.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import typer +from rich.console import Console + +from skylattice import __version__ +from skylattice.commands import ( + add_cmd, + apply_cmd, + decision_cmd, + destroy_cmd, + discover_cmd, + doctor_cmd, + drift_cmd, + explain_cmd, + init_cmd, + plan_cmd, + skills_cmd, + validate_cmd, + wizard_cmd, +) + +app = typer.Typer( + name="platformctl", + help="Sky Lattice: intent + decisions + Terraform for Snowflake platforms.", + no_args_is_help=True, + add_completion=False, +) +console = Console() + +app.command("init")(init_cmd.run) +app.command("wizard")(wizard_cmd.run) +app.command("add")(add_cmd.run) +app.command("validate")(validate_cmd.run) +app.command("plan")(plan_cmd.run) +app.command("apply")(apply_cmd.run) +app.command("discover")(discover_cmd.run) +app.command("drift")(drift_cmd.run) +app.add_typer(decision_cmd.app, name="decision") +app.command("doctor")(doctor_cmd.run) +app.command("explain")(explain_cmd.run) +app.command("destroy")(destroy_cmd.run) +app.add_typer(skills_cmd.app, name="skills") + + +def _version_callback(value: bool) -> None: + if value: + console.print(f"Sky Lattice platformctl {__version__}") + raise typer.Exit() + + +@app.callback() +def main( + version: bool = typer.Option( + False, + "--version", + help="Show version and exit.", + callback=_version_callback, + is_eager=True, + ), +) -> None: + """Sky Lattice platformctl.""" + + +if __name__ == "__main__": + app() diff --git a/src/skylattice/commands/__init__.py b/src/skylattice/commands/__init__.py new file mode 100644 index 0000000..dfed30a --- /dev/null +++ b/src/skylattice/commands/__init__.py @@ -0,0 +1 @@ +"""platformctl command modules.""" diff --git a/src/skylattice/commands/add_cmd.py b/src/skylattice/commands/add_cmd.py new file mode 100644 index 0000000..57804e4 --- /dev/null +++ b/src/skylattice/commands/add_cmd.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.console import Console + +from skylattice.core.intent import load_intent, save_intent +from skylattice.core.paths import find_customer_root + +console = Console() + + +def run( + what: str = typer.Argument(..., help="What to add: domain"), + name: str = typer.Argument(..., help="Name of the domain (or entity)"), + access_profile: str = typer.Option("standard", "--access-profile"), + path: Path | None = typer.Option(None, "--path"), +) -> None: + """Incremental edit helper (lifecycle: edit → plan → apply).""" + customer_root = path.resolve() if path else find_customer_root() + intent = load_intent(customer_root) + + if what != "domain": + console.print("[red]Only 'domain' is supported in v0.[/red]") + raise typer.Exit(1) + + domains = list(intent.get("domains") or []) + if any(d.get("name") == name for d in domains): + console.print(f"[yellow]Domain already present:[/yellow] {name}") + raise typer.Exit(0) + + domains.append( + { + "name": name, + "access_profile": access_profile, + "zones": ["landing", "raw", "curated", "marts"], + } + ) + intent["domains"] = domains + save_intent(customer_root, intent) + console.print(f"[green]Added domain[/green] {name} ({access_profile})") + console.print("Next: platformctl validate && platformctl plan") diff --git a/src/skylattice/commands/apply_cmd.py b/src/skylattice/commands/apply_cmd.py new file mode 100644 index 0000000..d22f5ef --- /dev/null +++ b/src/skylattice/commands/apply_cmd.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import typer +from rich.console import Console + +from skylattice.core.intent import load_intent +from skylattice.core.paths import find_customer_root +from skylattice.core.planner import build_plan, write_plan_artifacts +from skylattice.core.policy import run_policies + +console = Console() + + +def run( + path: Path | None = typer.Option(None, "--path"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"), + terraform: bool = typer.Option(False, "--terraform", help="Also run terraform apply if available"), +) -> None: + """Apply planned intent. Default records apply marker; optional terraform wrapper.""" + customer_root = path.resolve() if path else find_customer_root() + intent = load_intent(customer_root) + policy = run_policies(customer_root, intent) + if not policy.ok: + for e in policy.errors: + console.print(f"[red]ERROR[/red] {e}") + raise typer.Exit(1) + + plan = build_plan(intent) + write_plan_artifacts(customer_root, plan) + + console.print(f"About to apply [bold]{plan['object_count']}[/bold] planned objects for {plan['customer']}.") + if not yes and not typer.confirm("Continue?"): + raise typer.Exit(0) + + marker = customer_root / "plans" / "last-apply.yaml" + from skylattice.core.paths import dump_yaml + from datetime import datetime, timezone + + dump_yaml( + marker, + { + "applied_at": datetime.now(timezone.utc).isoformat(), + "object_count": plan["object_count"], + "creates": plan["creates"], + "note": "v0 apply records desired graph; wire snowflakedb provider for live apply.", + }, + ) + console.print(f"[green]Apply recorded[/green] → {marker}") + + if terraform: + tf = shutil.which("terraform") + if not tf: + console.print("[yellow]terraform not on PATH; skipped[/yellow]") + return + tf_dir = customer_root / "terraform" + subprocess.run([tf, "init", "-backend=false"], cwd=tf_dir, check=False) + subprocess.run([tf, "apply", "-auto-approve"], cwd=tf_dir, check=False) diff --git a/src/skylattice/commands/decision_cmd.py b/src/skylattice/commands/decision_cmd.py new file mode 100644 index 0000000..4c0c67a --- /dev/null +++ b/src/skylattice/commands/decision_cmd.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import re +from datetime import datetime, timezone +from pathlib import Path + +import typer +from rich.console import Console + +from skylattice.core.decisions import create_decision, list_decisions +from skylattice.core.paths import find_customer_root + +console = Console() + +app = typer.Typer(help="Manage decision records (why deviations exist).", no_args_is_help=True) + + +def _default_id(applies_to: str) -> str: + day = datetime.now(timezone.utc).strftime("%Y-%m-%d") + safe = re.sub(r"[^a-z0-9]+", "-", applies_to.lower()).strip("-")[:40] + return f"{day}-{safe}" + + +@app.command("add") +def add( + applies_to: str = typer.Option(..., "--applies-to"), + rationale: str = typer.Option(..., "--rationale"), + expires: str | None = typer.Option(None, "--expires", help="YYYY-MM-DD"), + decision_id: str | None = typer.Option(None, "--id"), + path: Path | None = typer.Option(None, "--path"), +) -> None: + """Record a durable rationale linked to an intent path.""" + customer_root = path.resolve() if path else find_customer_root() + did = decision_id or _default_id(applies_to) + create_decision( + customer_root, + decision_id=did, + applies_to=applies_to, + rationale=rationale, + expires_on=expires, + ) + console.print(f"[green]Decision recorded[/green] {did} → {customer_root / 'decisions' / (did + '.yaml')}") + + +@app.command("list") +def list_all( + path: Path | None = typer.Option(None, "--path"), +) -> None: + """List decision files for this customer.""" + customer_root = path.resolve() if path else find_customer_root() + decisions = list_decisions(customer_root) + if not decisions: + console.print("[dim]No decisions yet.[/dim]") + return + for d in decisions: + console.print(f"{d.get('id')} [{d.get('status')}] → {d.get('applies_to')}") + console.print(f" {d.get('rationale')}") diff --git a/src/skylattice/commands/destroy_cmd.py b/src/skylattice/commands/destroy_cmd.py new file mode 100644 index 0000000..51d9661 --- /dev/null +++ b/src/skylattice/commands/destroy_cmd.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.console import Console + +from skylattice.core.intent import load_intent, save_intent +from skylattice.core.paths import find_customer_root +from skylattice.core.planner import build_plan, write_plan_artifacts + +console = Console() + + +def run( + env: str = typer.Option(..., "--env", help="Environment to remove from intent (scoped teardown)"), + path: Path | None = typer.Option(None, "--path"), + yes: bool = typer.Option(False, "--yes", "-y"), +) -> None: + """Scoped teardown: remove an environment from intent and re-plan destroys.""" + customer_root = path.resolve() if path else find_customer_root() + intent = load_intent(customer_root) + envs = list(intent.get("environments") or []) + if env not in envs: + console.print(f"[yellow]Env not in intent:[/yellow] {env}") + raise typer.Exit(1) + + before = build_plan(intent) + if not yes and not typer.confirm(f"Remove env '{env}' from intent and plan destroys?"): + raise typer.Exit(0) + + intent["environments"] = [e for e in envs if e != env] + save_intent(customer_root, intent) + after = build_plan(intent) + before_set = set(before["creates"]) + after_set = set(after["creates"]) + destroys = sorted(before_set - after_set) + after["destroys"] = destroys + after["creates"] = sorted(after_set - before_set) + after["updates"] = [] + write_plan_artifacts(customer_root, after) + + console.print(f"[green]Removed env[/green] {env}. Planned destroys: {len(destroys)}") + for d in destroys[:30]: + console.print(f" - {d}") + console.print("Review plans/latest.yaml then platformctl apply (or terraform destroy for those addresses).") diff --git a/src/skylattice/commands/discover_cmd.py b/src/skylattice/commands/discover_cmd.py new file mode 100644 index 0000000..b0761c4 --- /dev/null +++ b/src/skylattice/commands/discover_cmd.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.console import Console + +from skylattice.core.discover import classify, load_inventory, write_discover_report +from skylattice.core.intent import load_intent +from skylattice.core.paths import find_customer_root + +console = Console() + + +def run( + path: Path | None = typer.Option(None, "--path"), + inventory: Path | None = typer.Option( + None, + "--inventory", + help="YAML inventory of live objects (kind/name). Optional demo file.", + ), +) -> None: + """Discover/classify live inventory vs desired intent (observe layer).""" + customer_root = path.resolve() if path else find_customer_root() + intent = load_intent(customer_root) + + inv_path = inventory + if inv_path is None: + candidate = customer_root / "observed" / "inventory.yaml" + inv_path = candidate if candidate.exists() else None + + inventory_objs = load_inventory(inv_path) if inv_path else [] + if not inventory_objs: + console.print( + "[yellow]No inventory provided.[/yellow] " + "Pass --inventory or create observed/inventory.yaml. " + "Live Snowflake connector can plug in here later." + ) + + report = classify(intent, inventory_objs) + out = write_discover_report(customer_root, report) + s = report["summary"] + console.print( + f"Discover: total={s['total']} matched={s['matched_pct']}% " + f"unmanaged={s['unmanaged']} conflicts={s['conflicts']} drifted={s['drifted']}" + ) + console.print(f"[dim]Wrote {out}[/dim]") diff --git a/src/skylattice/commands/doctor_cmd.py b/src/skylattice/commands/doctor_cmd.py new file mode 100644 index 0000000..062aa59 --- /dev/null +++ b/src/skylattice/commands/doctor_cmd.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +import typer +from rich.console import Console + +from skylattice import __version__ +from skylattice.core.paths import blueprint_version, find_customer_root, repo_root + +console = Console() + + +def run( + path: Path | None = typer.Option(None, "--path"), +) -> None: + """Health check and next-step hints (low-friction onboarding).""" + console.print(f"[bold]Sky Lattice doctor[/bold] platformctl={__version__} blueprint={blueprint_version()}") + + try: + root = repo_root() + console.print(f"[green]OK[/green] product root: {root}") + except FileNotFoundError as e: + console.print(f"[red]FAIL[/red] {e}") + raise typer.Exit(1) + + for tool in ("terraform",): + loc = shutil.which(tool) + if loc: + console.print(f"[green]OK[/green] {tool}: {loc}") + else: + console.print(f"[yellow]MISS[/yellow] {tool} not on PATH (optional for v0 graph apply)") + + try: + customer_root = path.resolve() if path else find_customer_root() + except FileNotFoundError: + console.print("[yellow]No customer intent.yaml in cwd.[/yellow] Next: platformctl init ") + return + + console.print(f"[green]OK[/green] customer project: {customer_root}") + intent = customer_root / "intent.yaml" + decisions = customer_root / "decisions" + skill = customer_root / ".cursor" / "skills" / "platform-ops" / "SKILL.md" + console.print(f" intent: {'yes' if intent.exists() else 'missing'}") + console.print(f" decisions dir: {'yes' if decisions.exists() else 'missing'}") + console.print(f" cursor skill: {'yes' if skill.exists() else 'missing — run platformctl skills install'}") + + plan = customer_root / "plans" / "latest.yaml" + if plan.exists(): + console.print("Next: review plans/latest.yaml or platformctl apply") + else: + console.print("Next: platformctl wizard --resume && platformctl validate && platformctl plan") diff --git a/src/skylattice/commands/drift_cmd.py b/src/skylattice/commands/drift_cmd.py new file mode 100644 index 0000000..c502473 --- /dev/null +++ b/src/skylattice/commands/drift_cmd.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.console import Console + +from skylattice.commands import discover_cmd +from skylattice.core.paths import find_customer_root, load_yaml + +console = Console() + + +def run( + path: Path | None = typer.Option(None, "--path"), + inventory: Path | None = typer.Option(None, "--inventory"), +) -> None: + """Re-discover and highlight drift vs last apply / intent.""" + customer_root = path.resolve() if path else find_customer_root() + discover_cmd.run(path=customer_root, inventory=inventory) + + report_path = customer_root / "observed" / "discover-latest.yaml" + if not report_path.exists(): + raise typer.Exit(1) + report = load_yaml(report_path) + drifted = [o for o in report.get("objects") or [] if o.get("classification") in ("drifted_managed", "unmanaged_live")] + missing = [o for o in report.get("objects") or [] if o.get("classification") == "blueprint_match"] + + console.print(f"[bold]Drift summary[/bold]: unmanaged_or_drifted={len(drifted)} desired_missing_live={len(missing)}") + for o in drifted[:20]: + console.print(f" ! {o.get('classification')} {o.get('kind')}:{o.get('name')}") + for o in missing[:20]: + console.print(f" + desired not in inventory: {o.get('kind')}:{o.get('name')}") diff --git a/src/skylattice/commands/explain_cmd.py b/src/skylattice/commands/explain_cmd.py new file mode 100644 index 0000000..024c8a0 --- /dev/null +++ b/src/skylattice/commands/explain_cmd.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.console import Console + +from skylattice.core.decisions import explain_path +from skylattice.core.intent import load_intent +from skylattice.core.paths import find_customer_root, get_by_path + +console = Console() + + +def run( + path_key: str = typer.Option(..., "--path", help="Intent path, e.g. domains.finance"), + project: Path | None = typer.Option(None, "--project", help="Customer project directory"), +) -> None: + """Explain why a path looks the way it does (intent value + decisions).""" + customer_root = project.resolve() if project else find_customer_root() + intent = load_intent(customer_root) + + parts = path_key.split(".") + value = get_by_path(intent, path_key) + if value is None and parts and parts[0] == "domains" and len(parts) >= 2: + for d in intent.get("domains") or []: + if d.get("name") == parts[1]: + value = d if len(parts) == 2 else d.get(parts[2]) + break + + console.print(f"[bold]{path_key}[/bold] = {value!r}") + + if parts and parts[0] == "domains" and len(parts) >= 2: + for d in intent.get("domains") or []: + if d.get("name") == parts[1]: + console.print(f"domain record: {d}") + + matches = explain_path(customer_root, path_key) + if not matches: + console.print("[dim]No linked decisions.[/dim]") + return + for d in matches: + console.print(f"[green]decision[/green] {d.get('id')} [{d.get('status')}] expires={d.get('expires_on')}") + console.print(f" {d.get('rationale')}") diff --git a/src/skylattice/commands/init_cmd.py b/src/skylattice/commands/init_cmd.py new file mode 100644 index 0000000..7105214 --- /dev/null +++ b/src/skylattice/commands/init_cmd.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +import typer +from rich.console import Console + +from skylattice.core.intent import default_intent, save_intent +from skylattice.core.paths import blueprint_dir, blueprint_version, repo_root + +console = Console() + + +def run( + customer: str = typer.Argument(..., help="Customer slug, e.g. acme"), + mode: str = typer.Option("greenfield", "--mode", help="greenfield|brownfield"), + path: Path | None = typer.Option(None, "--path", help="Parent dir for customer project"), +) -> None: + """Create a customer project from the blueprint template.""" + if mode not in ("greenfield", "brownfield"): + raise typer.BadParameter("mode must be greenfield or brownfield") + + parent = path or (repo_root() / "customers") + parent.mkdir(parents=True, exist_ok=True) + dest = parent / customer + if dest.exists(): + console.print(f"[red]Customer project already exists:[/red] {dest}") + raise typer.Exit(code=1) + + template = blueprint_dir() / "templates" / "customer-repo" + shutil.copytree(template, dest) + + intent = default_intent(mode, customer) + save_intent(dest, intent) + + # Fix terraform module path relative to monorepo layout + tf_main = dest / "terraform" / "main.tf" + if tf_main.exists(): + # customers//terraform -> ../../../blueprint/modules/environment + content = tf_main.read_text(encoding="utf-8") + content = content.replace( + 'source = "../../../blueprint/modules/environment"', + 'source = "../../../blueprint/modules/environment"', + ) + tf_main.write_text(content, encoding="utf-8") + + readme = dest / "README.md" + if readme.exists(): + text = readme.read_text(encoding="utf-8") + text = f"# {customer} — Sky Lattice customer project\n\nBlueprint pin: `{blueprint_version()}`\n\n" + text + readme.write_text(text, encoding="utf-8") + + console.print(f"[green]Created[/green] {dest}") + console.print("Next: [bold]platformctl wizard --path %s[/bold]" % dest) diff --git a/src/skylattice/commands/plan_cmd.py b/src/skylattice/commands/plan_cmd.py new file mode 100644 index 0000000..30c7586 --- /dev/null +++ b/src/skylattice/commands/plan_cmd.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.console import Console +from rich.table import Table + +from skylattice.core.intent import load_intent +from skylattice.core.paths import find_customer_root +from skylattice.core.planner import build_plan, write_plan_artifacts +from skylattice.core.policy import run_policies + +console = Console() + + +def run( + path: Path | None = typer.Option(None, "--path"), +) -> None: + """Compile intent → planned object graph (deterministic).""" + customer_root = path.resolve() if path else find_customer_root() + intent = load_intent(customer_root) + policy = run_policies(customer_root, intent) + if not policy.ok: + for e in policy.errors: + console.print(f"[red]ERROR[/red] {e}") + console.print("[red]Fix validation errors before planning.[/red]") + raise typer.Exit(1) + + plan = build_plan(intent) + write_plan_artifacts(customer_root, plan) + + console.print(f"[bold]Plan for {plan['customer']}[/bold] (blueprint {plan['blueprint']})") + console.print(f"Objects: {plan['object_count']}") + for line in plan["summary"]: + console.print(f" • {line}") + + table = Table(title="Creates") + table.add_column("Object") + for c in plan["creates"][:40]: + table.add_row(c) + if len(plan["creates"]) > 40: + table.add_row(f"... +{len(plan['creates']) - 40} more") + console.print(table) + console.print(f"[dim]Wrote {customer_root / 'plans' / 'latest.yaml'}[/dim]") + console.print("[dim]Terraform apply requires provider credentials; see docs/customer-landing.[/dim]") diff --git a/src/skylattice/commands/skills_cmd.py b/src/skylattice/commands/skills_cmd.py new file mode 100644 index 0000000..6a45feb --- /dev/null +++ b/src/skylattice/commands/skills_cmd.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +import typer +from rich.console import Console + +from skylattice.core.paths import blueprint_dir, find_customer_root + +console = Console() + +app = typer.Typer(help="Install Cursor skills into a customer project.", no_args_is_help=True) + + +@app.command("install") +def install( + path: Path | None = typer.Option(None, "--path"), +) -> None: + """Copy blueprint platform-ops skill into .cursor/skills/.""" + customer_root = path.resolve() if path else find_customer_root() + src = blueprint_dir() / "skills" / "platform-ops" + dest = customer_root / ".cursor" / "skills" / "platform-ops" + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(src, dest) + console.print(f"[green]Installed skill[/green] → {dest}") + console.print("Open this customer repo in Cursor and ask in plain language.") diff --git a/src/skylattice/commands/validate_cmd.py b/src/skylattice/commands/validate_cmd.py new file mode 100644 index 0000000..96405d2 --- /dev/null +++ b/src/skylattice/commands/validate_cmd.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.console import Console + +from skylattice.core.intent import load_intent +from skylattice.core.paths import find_customer_root +from skylattice.core.policy import run_policies + +console = Console() + + +def run( + path: Path | None = typer.Option(None, "--path"), +) -> None: + """Validate intent schema + policy pack.""" + customer_root = path.resolve() if path else find_customer_root() + intent = load_intent(customer_root) + result = run_policies(customer_root, intent) + + for w in result.warnings: + console.print(f"[yellow]WARN[/yellow] {w}") + for e in result.errors: + console.print(f"[red]ERROR[/red] {e}") + + if result.ok: + console.print("[green]Valid[/green]") + else: + raise typer.Exit(1) diff --git a/src/skylattice/commands/wizard_cmd.py b/src/skylattice/commands/wizard_cmd.py new file mode 100644 index 0000000..bbf8d41 --- /dev/null +++ b/src/skylattice/commands/wizard_cmd.py @@ -0,0 +1,164 @@ +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") diff --git a/src/skylattice/core/__init__.py b/src/skylattice/core/__init__.py new file mode 100644 index 0000000..19a1803 --- /dev/null +++ b/src/skylattice/core/__init__.py @@ -0,0 +1 @@ +"""Core libraries for Sky Lattice.""" diff --git a/src/skylattice/core/decisions.py b/src/skylattice/core/decisions.py new file mode 100644 index 0000000..fb4a2ad --- /dev/null +++ b/src/skylattice/core/decisions.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator + +from skylattice.core.paths import blueprint_dir, dump_yaml, load_json, load_yaml + + +def decision_schema() -> dict: + return load_json(blueprint_dir() / "schemas" / "decision.schema.json") + + +def decisions_dir(customer_root: Path) -> Path: + d = customer_root / "decisions" + d.mkdir(parents=True, exist_ok=True) + return d + + +def list_decisions(customer_root: Path) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for path in sorted(decisions_dir(customer_root).glob("*.yaml")): + if path.name.startswith("."): + continue + data = load_yaml(path) + if data: + out.append(data) + return out + + +def get_decision(customer_root: Path, decision_id: str) -> dict[str, Any] | None: + path = decisions_dir(customer_root) / f"{decision_id}.yaml" + if not path.exists(): + return None + return load_yaml(path) + + +def validate_decision(decision: dict[str, Any]) -> list[str]: + validator = Draft202012Validator(decision_schema()) + return [e.message for e in sorted(validator.iter_errors(decision), key=lambda e: e.path)] + + +def create_decision( + customer_root: Path, + *, + decision_id: str, + applies_to: str, + rationale: str, + expires_on: str | None = None, + alternatives_rejected: list[str] | None = None, + client_constraint: str | None = None, + created_by: str | None = None, +) -> dict[str, Any]: + decision = { + "id": decision_id, + "applies_to": applies_to, + "rationale": rationale, + "alternatives_rejected": alternatives_rejected or [], + "status": "active", + "expires_on": expires_on, + "client_constraint": client_constraint, + "created_at": datetime.now(timezone.utc).isoformat(), + "created_by": created_by, + "superseded_by": None, + } + errors = validate_decision(decision) + if errors: + raise ValueError("; ".join(errors)) + path = decisions_dir(customer_root) / f"{decision_id}.yaml" + dump_yaml(path, decision) + return decision + + +def active_decisions(customer_root: Path) -> list[dict[str, Any]]: + return [d for d in list_decisions(customer_root) if d.get("status") == "active"] + + +def explain_path(customer_root: Path, path: str) -> list[dict[str, Any]]: + matches = [] + for d in list_decisions(customer_root): + applies = d.get("applies_to") or "" + if applies == path or path.startswith(applies) or applies.startswith(path): + matches.append(d) + return matches diff --git a/src/skylattice/core/discover.py b/src/skylattice/core/discover.py new file mode 100644 index 0000000..73f00e6 --- /dev/null +++ b/src/skylattice/core/discover.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from skylattice.core.paths import dump_yaml, load_yaml +from skylattice.core.planner import build_plan + + +def load_inventory(path: Path) -> list[dict[str, Any]]: + data = load_yaml(path) + if isinstance(data, dict): + return list(data.get("objects") or []) + if isinstance(data, list): + return data + return [] + + +def classify( + intent: dict[str, Any], + inventory: list[dict[str, Any]], +) -> dict[str, Any]: + plan = build_plan(intent) + desired = {c.lower() for c in plan["creates"]} + live_keys: dict[str, dict[str, Any]] = {} + for obj in inventory: + key = f"{obj.get('kind')}:{obj.get('name')}".lower() + live_keys[key] = obj + + objects: list[dict[str, Any]] = [] + unmanaged = 0 + conflicts = 0 + drifted = 0 + matched = 0 + + for key, obj in live_keys.items(): + if key in desired: + classification = "in_sync" + matched += 1 + else: + classification = "unmanaged_live" + unmanaged += 1 + objects.append( + { + "kind": obj.get("kind"), + "name": obj.get("name"), + "classification": classification, + "tf_address": None, + "notes": obj.get("notes") or "", + } + ) + + for create in plan["creates"]: + if create.lower() not in live_keys: + kind, name = create.split(":", 1) + objects.append( + { + "kind": kind, + "name": name, + "classification": "blueprint_match", + "tf_address": None, + "notes": "desired by intent; not found in inventory", + } + ) + + total = max(len(live_keys), 1) + report = { + "customer": intent.get("customer"), + "generated_at": datetime.now(timezone.utc).isoformat(), + "source": "inventory_file", + "objects": objects, + "summary": { + "total": len(objects), + "matched_pct": round(100.0 * matched / total, 1), + "unmanaged": unmanaged, + "conflicts": conflicts, + "drifted": drifted, + }, + } + return report + + +def write_discover_report(customer_root: Path, report: dict[str, Any]) -> Path: + out = customer_root / "observed" / "discover-latest.yaml" + dump_yaml(out, report) + return out diff --git a/src/skylattice/core/intent.py b/src/skylattice/core/intent.py new file mode 100644 index 0000000..404ca63 --- /dev/null +++ b/src/skylattice/core/intent.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator + +from skylattice.core.paths import blueprint_dir, dump_yaml, load_json, load_yaml + + +def intent_schema() -> dict: + return load_json(blueprint_dir() / "schemas" / "intent.schema.json") + + +def load_intent(customer_root: Path) -> dict[str, Any]: + return load_yaml(customer_root / "intent.yaml") + + +def save_intent(customer_root: Path, intent: dict[str, Any]) -> None: + dump_yaml(customer_root / "intent.yaml", intent) + + +def validate_intent_schema(intent: dict[str, Any]) -> list[str]: + validator = Draft202012Validator(intent_schema()) + return [e.message for e in sorted(validator.iter_errors(intent), key=lambda e: e.path)] + + +def default_intent(mode: str, customer: str) -> dict[str, Any]: + name = "intent.greenfield.yaml" if mode == "greenfield" else "intent.brownfield.yaml" + data = load_yaml(blueprint_dir() / "defaults" / name) + data["customer"] = customer + data["blueprint"] = (blueprint_dir() / "VERSION").read_text(encoding="utf-8").strip() + data["mode"] = mode + data["wizard"] = {"answered": [], "last_run": None} + return data + + +def mark_wizard_answered(intent: dict[str, Any], question_id: str) -> None: + wizard = intent.setdefault("wizard", {"answered": []}) + answered = list(wizard.get("answered") or []) + if question_id not in answered: + answered.append(question_id) + wizard["answered"] = answered + wizard["last_run"] = datetime.now(timezone.utc).isoformat() diff --git a/src/skylattice/core/paths.py b/src/skylattice/core/paths.py new file mode 100644 index 0000000..9ffecbf --- /dev/null +++ b/src/skylattice/core/paths.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import yaml + + +def repo_root() -> Path: + """Locate Sky Lattice product root (contains blueprint/ and VERSION).""" + here = Path(__file__).resolve() + for p in [here, *here.parents]: + if (p / "blueprint" / "VERSION").exists() and (p / "VERSION").exists(): + return p + # Fallback: cwd walking up + cwd = Path.cwd().resolve() + for p in [cwd, *cwd.parents]: + if (p / "blueprint" / "VERSION").exists(): + return p + raise FileNotFoundError("Cannot find Sky Lattice root (blueprint/VERSION missing).") + + +def blueprint_dir() -> Path: + return repo_root() / "blueprint" + + +def blueprint_version() -> str: + return (blueprint_dir() / "VERSION").read_text(encoding="utf-8").strip() + + +def load_yaml(path: Path) -> Any: + with path.open(encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + +def dump_yaml(path: Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as f: + yaml.safe_dump(data, f, sort_keys=False, default_flow_style=False) + + +def load_json(path: Path) -> Any: + with path.open(encoding="utf-8") as f: + return json.load(f) + + +def find_customer_root(start: Path | None = None) -> Path: + """Find directory containing intent.yaml walking upward from start/cwd.""" + start = (start or Path.cwd()).resolve() + for p in [start, *start.parents]: + if (p / "intent.yaml").exists(): + return p + raise FileNotFoundError( + "No intent.yaml found. Run from a customer project or pass --path." + ) + + +def set_by_path(data: dict, dotted: str, value: Any) -> None: + parts = dotted.split(".") + cur: Any = data + for part in parts[:-1]: + if part not in cur or not isinstance(cur[part], dict): + cur[part] = {} + cur = cur[part] + cur[parts[-1]] = value + + +def get_by_path(data: dict, dotted: str, default: Any = None) -> Any: + cur: Any = data + for part in dotted.split("."): + if not isinstance(cur, dict) or part not in cur: + return default + cur = cur[part] + return cur diff --git a/src/skylattice/core/planner.py b/src/skylattice/core/planner.py new file mode 100644 index 0000000..d883950 --- /dev/null +++ b/src/skylattice/core/planner.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from skylattice.core.paths import blueprint_dir, dump_yaml, load_yaml + + +def warehouse_profiles() -> dict[str, Any]: + return load_yaml(blueprint_dir() / "defaults" / "warehouse_profiles.yaml") + + +def access_profiles() -> dict[str, Any]: + return load_yaml(blueprint_dir() / "defaults" / "access_profiles.yaml") + + +def build_plan(intent: dict[str, Any]) -> dict[str, Any]: + """Deterministic object graph from intent (v0 planner).""" + customer = intent["customer"] + envs = intent.get("environments") or ["dev"] + strategy = intent.get("env_strategy") or "database_per_env" + wh_profile_name = (intent.get("warehouses") or {}).get("profile") or "standard_cost_saver" + wh_cfg = warehouse_profiles().get(wh_profile_name, {}) + access = access_profiles() + + objects: list[dict[str, Any]] = [] + summary_lines: list[str] = [] + + for env in envs: + prefix = f"{customer.upper()}_{env.upper()}" if strategy == "database_per_env" else customer.upper() + objects.append( + { + "kind": "warehouse", + "name": f"{prefix}_WH", + "env": env, + "config": wh_cfg, + "module": "warehouses", + } + ) + objects.append( + { + "kind": "resource_monitor", + "name": f"{prefix}_MONITOR", + "env": env, + "module": "monitoring", + } + ) + if (intent.get("identity") or {}).get("service_users", True): + objects.append({"kind": "user", "name": f"{prefix}_TF_SVC", "env": env, "module": "service_principals"}) + objects.append({"kind": "user", "name": f"{prefix}_LOADER_SVC", "env": env, "module": "service_principals"}) + + for domain in intent.get("domains") or []: + dname = domain["name"] + zones = domain.get("zones") or ["landing", "raw", "curated", "marts"] + profile = domain.get("access_profile") or "standard" + db = f"{prefix}_{dname.upper()}" + objects.append( + { + "kind": "database", + "name": db, + "env": env, + "domain": dname, + "module": "database_zones", + } + ) + for z in zones: + objects.append( + { + "kind": "schema", + "name": f"{db}.{z.upper()}", + "env": env, + "domain": dname, + "module": "database_zones", + } + ) + for role_suffix in ("ANALYST", "LOADER", "ENGINEER"): + objects.append( + { + "kind": "role", + "name": f"{prefix}_{dname.upper()}_{role_suffix}", + "env": env, + "domain": dname, + "access_profile": profile, + "module": "rbac", + } + ) + profile_meta = access.get(profile) or {} + summary_lines.append( + f"[{env}] domain={dname} profile={profile}: " + f"db={db}, zones={zones}, analyst_read={profile_meta.get('analyst_zones_read')}" + ) + + for ov in intent.get("overrides") or []: + summary_lines.append(f"override {ov.get('path')} = {ov.get('value')} (decision {ov.get('decision_id')})") + + for um in intent.get("unmanaged") or []: + summary_lines.append(f"unmanaged {um.get('kind')}:{um.get('name')} ({um.get('reason') or 'deferred'})") + + return { + "customer": customer, + "blueprint": intent.get("blueprint"), + "object_count": len(objects), + "objects": objects, + "summary": summary_lines, + "creates": [f"{o['kind']}:{o['name']}" for o in objects], + "updates": [], + "destroys": [], + } + + +def write_plan_artifacts(customer_root: Path, plan: dict[str, Any]) -> None: + plans = customer_root / "plans" + plans.mkdir(parents=True, exist_ok=True) + dump_yaml(plans / "latest.yaml", plan) + + # Machine-readable planned objects for TF consumers / review + tf_dir = customer_root / "terraform" + tf_dir.mkdir(parents=True, exist_ok=True) + planned = { + "planned_objects": plan["creates"], + "summary": plan["summary"], + "object_count": plan["object_count"], + } + dump_yaml(tf_dir / "planned_objects.yaml", planned) diff --git a/src/skylattice/core/policy.py b/src/skylattice/core/policy.py new file mode 100644 index 0000000..ba1686b --- /dev/null +++ b/src/skylattice/core/policy.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from skylattice.core.decisions import active_decisions, get_decision +from skylattice.core.intent import validate_intent_schema +from skylattice.core.paths import blueprint_dir, load_yaml + + +@dataclass +class PolicyResult: + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + @property + def ok(self) -> bool: + return not self.errors + + +def run_policies(customer_root, intent: dict[str, Any]) -> PolicyResult: + result = PolicyResult() + result.errors.extend(validate_intent_schema(intent)) + + rules = load_yaml(blueprint_dir() / "policies" / "rules.yaml").get("rules", []) + checks = {r["check"]: r for r in rules} + + if "domains_non_empty" in checks and not intent.get("domains"): + result.errors.append(f"[{checks['domains_non_empty']['id']}] At least one domain is required.") + + if "blueprint_pinned" in checks and not intent.get("blueprint"): + result.errors.append(f"[{checks['blueprint_pinned']['id']}] intent.blueprint must be set.") + + if "overrides_have_decisions" in checks: + for ov in intent.get("overrides") or []: + did = ov.get("decision_id") + if not did: + result.errors.append( + f"[override_requires_decision] Override {ov.get('path')} missing decision_id." + ) + continue + dec = get_decision(customer_root, did) + if not dec: + result.errors.append( + f"[override_requires_decision] Decision '{did}' not found for override {ov.get('path')}." + ) + elif dec.get("status") != "active": + result.errors.append( + f"[override_requires_decision] Decision '{did}' is not active." + ) + + if "unmanaged_have_decisions" in checks: + for um in intent.get("unmanaged") or []: + if not um.get("decision_id"): + result.warnings.append( + f"[unmanaged_requires_decision] Unmanaged {um.get('kind')}:{um.get('name')} has no decision_id." + ) + + if "restricted_prod_curated_guard" in checks: + restricted = { + d["name"] for d in intent.get("domains") or [] if d.get("access_profile") == "restricted" + } + for ov in intent.get("overrides") or []: + path = ov.get("path") or "" + if "prod_curated_read" in path: + # path like domains.finance.prod_curated_read + parts = path.split(".") + domain = parts[1] if len(parts) > 1 else "" + if domain in restricted and not ov.get("decision_id"): + result.errors.append( + f"[restricted_prod_curated_read] {path} requires a decision." + ) + + # Ensure referenced active decisions exist for explainability coverage + _ = active_decisions(customer_root) + return result diff --git a/tests/test_planner.py b/tests/test_planner.py new file mode 100644 index 0000000..2671e0c --- /dev/null +++ b/tests/test_planner.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from pathlib import Path + +from skylattice.core.intent import load_intent, validate_intent_schema +from skylattice.core.planner import build_plan +from skylattice.core.policy import run_policies + + +def test_reference_intent_validates_and_plans(): + root = Path(__file__).resolve().parents[1] / "blueprint" / "tests" / "reference-customer" + # policy needs decisions dir; use reference as customer root + intent = load_intent(root) + errors = validate_intent_schema(intent) + assert errors == [], errors + plan = build_plan(intent) + assert plan["object_count"] > 0 + assert any(c.startswith("database:") for c in plan["creates"]) + + +def test_policy_ok_on_reference(tmp_path: Path): + src = Path(__file__).resolve().parents[1] / "blueprint" / "tests" / "reference-customer" / "intent.yaml" + (tmp_path / "intent.yaml").write_text(src.read_text(encoding="utf-8"), encoding="utf-8") + (tmp_path / "decisions").mkdir() + intent = load_intent(tmp_path) + result = run_policies(tmp_path, intent) + assert result.ok, result.errors