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 <cursoragent@cursor.com>
This commit is contained in:
commit
611ad214fe
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@ -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/
|
||||
6
CHANGELOG.md
Normal file
6
CHANGELOG.md
Normal file
@ -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.
|
||||
73
README.md
Normal file
73
README.md
Normal file
@ -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.
|
||||
5
blueprint/CHANGELOG.md
Normal file
5
blueprint/CHANGELOG.md
Normal file
@ -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.
|
||||
1
blueprint/VERSION
Normal file
1
blueprint/VERSION
Normal file
@ -0,0 +1 @@
|
||||
0.1.0
|
||||
12
blueprint/defaults/access_profiles.yaml
Normal file
12
blueprint/defaults/access_profiles.yaml
Normal file
@ -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
|
||||
19
blueprint/defaults/intent.brownfield.yaml
Normal file
19
blueprint/defaults/intent.brownfield.yaml
Normal file
@ -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: []
|
||||
21
blueprint/defaults/intent.greenfield.yaml
Normal file
21
blueprint/defaults/intent.greenfield.yaml
Normal file
@ -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: []
|
||||
10
blueprint/defaults/warehouse_profiles.yaml
Normal file
10
blueprint/defaults/warehouse_profiles.yaml
Normal file
@ -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
|
||||
35
blueprint/modules/database_zones/main.tf
Normal file
35
blueprint/modules/database_zones/main.tf
Normal file
@ -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}"]
|
||||
)
|
||||
}
|
||||
24
blueprint/modules/environment/main.tf
Normal file
24
blueprint/modules/environment/main.tf
Normal file
@ -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
|
||||
}
|
||||
20
blueprint/modules/monitoring/main.tf
Normal file
20
blueprint/modules/monitoring/main.tf
Normal file
@ -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}"]
|
||||
}
|
||||
36
blueprint/modules/rbac/main.tf
Normal file
36
blueprint/modules/rbac/main.tf
Normal file
@ -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}"]
|
||||
}
|
||||
23
blueprint/modules/service_principals/main.tf
Normal file
23
blueprint/modules/service_principals/main.tf
Normal file
@ -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}"]
|
||||
}
|
||||
33
blueprint/modules/warehouses/main.tf
Normal file
33
blueprint/modules/warehouses/main.tf
Normal file
@ -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}"]
|
||||
}
|
||||
29
blueprint/policies/rules.yaml
Normal file
29
blueprint/policies/rules.yaml
Normal file
@ -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
|
||||
37
blueprint/schemas/decision.schema.json
Normal file
37
blueprint/schemas/decision.schema.json
Normal file
@ -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"] }
|
||||
}
|
||||
}
|
||||
46
blueprint/schemas/discover-report.schema.json
Normal file
46
blueprint/schemas/discover-report.schema.json
Normal file
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
131
blueprint/schemas/intent.schema.json
Normal file
131
blueprint/schemas/intent.schema.json
Normal file
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
48
blueprint/skills/platform-ops/SKILL.md
Normal file
48
blueprint/skills/platform-ops/SKILL.md
Normal file
@ -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 <name>
|
||||
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
|
||||
```
|
||||
18
blueprint/skills/platform-ops/examples.md
Normal file
18
blueprint/skills/platform-ops/examples.md
Normal file
@ -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.
|
||||
8
blueprint/skills/platform-ops/rbac-best-practices.md
Normal file
8
blueprint/skills/platform-ops/rbac-best-practices.md
Normal file
@ -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.
|
||||
@ -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 <name>
|
||||
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
|
||||
```
|
||||
@ -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.
|
||||
@ -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.
|
||||
26
blueprint/templates/customer-repo/.github/workflows/terraform.yml
vendored
Normal file
26
blueprint/templates/customer-repo/.github/workflows/terraform.yml
vendored
Normal file
@ -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."
|
||||
17
blueprint/templates/customer-repo/README.md
Normal file
17
blueprint/templates/customer-repo/README.md
Normal file
@ -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.
|
||||
1
blueprint/templates/customer-repo/decisions/.gitkeep
Normal file
1
blueprint/templates/customer-repo/decisions/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
# Keep decisions here as YAML files: <id>.yaml
|
||||
21
blueprint/templates/customer-repo/terraform/main.tf
Normal file
21
blueprint/templates/customer-repo/terraform/main.tf
Normal file
@ -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.
|
||||
18
blueprint/tests/reference-customer/intent.yaml
Normal file
18
blueprint/tests/reference-customer/intent.yaml
Normal file
@ -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]
|
||||
69
blueprint/wizard/catalog.brownfield.yaml
Normal file
69
blueprint/wizard/catalog.brownfield.yaml
Normal file
@ -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
|
||||
76
blueprint/wizard/catalog.greenfield.yaml
Normal file
76
blueprint/wizard/catalog.greenfield.yaml
Normal file
@ -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
|
||||
18
blueprint/wizard/mapping.yaml
Normal file
18
blueprint/wizard/mapping.yaml
Normal file
@ -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"
|
||||
0
customers/.gitkeep
Normal file
0
customers/.gitkeep
Normal file
48
customers/acme/.cursor/skills/platform-ops/SKILL.md
Normal file
48
customers/acme/.cursor/skills/platform-ops/SKILL.md
Normal file
@ -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 <name>
|
||||
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
|
||||
```
|
||||
18
customers/acme/.cursor/skills/platform-ops/examples.md
Normal file
18
customers/acme/.cursor/skills/platform-ops/examples.md
Normal file
@ -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.
|
||||
@ -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.
|
||||
26
customers/acme/.github/workflows/terraform.yml
vendored
Normal file
26
customers/acme/.github/workflows/terraform.yml
vendored
Normal file
@ -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."
|
||||
21
customers/acme/README.md
Normal file
21
customers/acme/README.md
Normal file
@ -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.
|
||||
1
customers/acme/decisions/.gitkeep
Normal file
1
customers/acme/decisions/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
# Keep decisions here as YAML files: <id>.yaml
|
||||
10
customers/acme/decisions/2026-07-15-domains-finance.yaml
Normal file
10
customers/acme/decisions/2026-07-15-domains-finance.yaml
Normal file
@ -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
|
||||
54
customers/acme/intent.yaml
Normal file
54
customers/acme/intent.yaml
Normal file
@ -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'
|
||||
21
customers/acme/terraform/main.tf
Normal file
21
customers/acme/terraform/main.tf
Normal file
@ -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.
|
||||
19
customers/acme/terraform/planned_objects.yaml
Normal file
19
customers/acme/terraform/planned_objects.yaml
Normal file
@ -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
|
||||
48
customers/globex/.cursor/skills/platform-ops/SKILL.md
Normal file
48
customers/globex/.cursor/skills/platform-ops/SKILL.md
Normal file
@ -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 <name>
|
||||
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
|
||||
```
|
||||
18
customers/globex/.cursor/skills/platform-ops/examples.md
Normal file
18
customers/globex/.cursor/skills/platform-ops/examples.md
Normal file
@ -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.
|
||||
@ -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.
|
||||
26
customers/globex/.github/workflows/terraform.yml
vendored
Normal file
26
customers/globex/.github/workflows/terraform.yml
vendored
Normal file
@ -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."
|
||||
21
customers/globex/README.md
Normal file
21
customers/globex/README.md
Normal file
@ -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.
|
||||
1
customers/globex/decisions/.gitkeep
Normal file
1
customers/globex/decisions/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
# Keep decisions here as YAML files: <id>.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
|
||||
42
customers/globex/intent.yaml
Normal file
42
customers/globex/intent.yaml
Normal file
@ -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'
|
||||
9
customers/globex/observed/inventory.yaml
Normal file
9
customers/globex/observed/inventory.yaml
Normal file
@ -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
|
||||
21
customers/globex/terraform/main.tf
Normal file
21
customers/globex/terraform/main.tf
Normal file
@ -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.
|
||||
28
customers/globex/terraform/planned_objects.yaml
Normal file
28
customers/globex/terraform/planned_objects.yaml
Normal file
@ -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
|
||||
11
docs/customer-landing/CHECKLIST.md
Normal file
11
docs/customer-landing/CHECKLIST.md
Normal file
@ -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
|
||||
57
docs/customer-landing/README.md
Normal file
57
docs/customer-landing/README.md
Normal file
@ -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 <customer> --mode greenfield # or brownfield
|
||||
platformctl wizard --path customers/<customer>
|
||||
platformctl validate --path customers/<customer>
|
||||
platformctl plan --path customers/<customer>
|
||||
```
|
||||
|
||||
4. **Push to customer git**
|
||||
Push `customers/<customer>/` 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/`
|
||||
18
docs/decisions/D001-product-name-and-scope.md
Normal file
18
docs/decisions/D001-product-name-and-scope.md
Normal file
@ -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
|
||||
17
docs/decisions/D002-llm-as-front-door.md
Normal file
17
docs/decisions/D002-llm-as-front-door.md
Normal file
@ -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)
|
||||
17
docs/decisions/D003-blueprint-vs-customer-repo.md
Normal file
17
docs/decisions/D003-blueprint-vs-customer-repo.md
Normal file
@ -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
|
||||
17
docs/decisions/D004-packaging-python.md
Normal file
17
docs/decisions/D004-packaging-python.md
Normal file
@ -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)
|
||||
17
docs/decisions/D005-post-apply-edit-not-teardown.md
Normal file
17
docs/decisions/D005-post-apply-edit-not-teardown.md
Normal file
@ -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
|
||||
17
docs/decisions/D006-delivery-mode-a.md
Normal file
17
docs/decisions/D006-delivery-mode-a.md
Normal file
@ -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
|
||||
16
docs/decisions/D007-wizard-reentrant.md
Normal file
16
docs/decisions/D007-wizard-reentrant.md
Normal file
@ -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
|
||||
17
docs/decisions/D008-v0-planner-graph.md
Normal file
17
docs/decisions/D008-v0-planner-graph.md
Normal file
@ -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
|
||||
14
docs/decisions/README.md
Normal file
14
docs/decisions/README.md
Normal file
@ -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 |
|
||||
34
docs/recipes/add-domain.md
Normal file
34
docs/recipes/add-domain.md
Normal file
@ -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.
|
||||
20
docs/recipes/fix-drift.md
Normal file
20
docs/recipes/fix-drift.md
Normal file
@ -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.
|
||||
13
docs/recipes/resume-wizard.md
Normal file
13
docs/recipes/resume-wizard.md
Normal file
@ -0,0 +1,13 @@
|
||||
# How To Resume the Wizard Later
|
||||
|
||||
Intake is incomplete — continue without re-init.
|
||||
|
||||
## Steps
|
||||
|
||||
1. `platformctl wizard --resume --path customers/<customer>`
|
||||
2. Answer only remaining questions (or pass `--answers`).
|
||||
3. `platformctl validate && platformctl plan`
|
||||
|
||||
## Expected results
|
||||
|
||||
`intent.wizard.answered` grows; prior domains/decisions remain intact.
|
||||
33
docs/wizard-intent-mapping.md
Normal file
33
docs/wizard-intent-mapping.md
Normal file
@ -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`.
|
||||
8
examples/answers/acme.greenfield.yaml
Normal file
8
examples/answers/acme.greenfield.yaml
Normal file
@ -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
|
||||
8
examples/answers/globex.brownfield.yaml
Normal file
8
examples/answers/globex.brownfield.yaml
Normal file
@ -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
|
||||
9
examples/answers/globex.inventory.yaml
Normal file
9
examples/answers/globex.inventory.yaml
Normal file
@ -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
|
||||
31
pyproject.toml
Normal file
31
pyproject.toml
Normal file
@ -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"]
|
||||
5
src/skylattice/__init__.py
Normal file
5
src/skylattice/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Sky Lattice — platformctl CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "0.1.0"
|
||||
66
src/skylattice/cli.py
Normal file
66
src/skylattice/cli.py
Normal file
@ -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()
|
||||
1
src/skylattice/commands/__init__.py
Normal file
1
src/skylattice/commands/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""platformctl command modules."""
|
||||
43
src/skylattice/commands/add_cmd.py
Normal file
43
src/skylattice/commands/add_cmd.py
Normal file
@ -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")
|
||||
61
src/skylattice/commands/apply_cmd.py
Normal file
61
src/skylattice/commands/apply_cmd.py
Normal file
@ -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)
|
||||
57
src/skylattice/commands/decision_cmd.py
Normal file
57
src/skylattice/commands/decision_cmd.py
Normal file
@ -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')}")
|
||||
46
src/skylattice/commands/destroy_cmd.py
Normal file
46
src/skylattice/commands/destroy_cmd.py
Normal file
@ -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).")
|
||||
47
src/skylattice/commands/discover_cmd.py
Normal file
47
src/skylattice/commands/discover_cmd.py
Normal file
@ -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]")
|
||||
53
src/skylattice/commands/doctor_cmd.py
Normal file
53
src/skylattice/commands/doctor_cmd.py
Normal file
@ -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 <customer>")
|
||||
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")
|
||||
33
src/skylattice/commands/drift_cmd.py
Normal file
33
src/skylattice/commands/drift_cmd.py
Normal file
@ -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')}")
|
||||
44
src/skylattice/commands/explain_cmd.py
Normal file
44
src/skylattice/commands/explain_cmd.py
Normal file
@ -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')}")
|
||||
55
src/skylattice/commands/init_cmd.py
Normal file
55
src/skylattice/commands/init_cmd.py
Normal file
@ -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/<name>/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)
|
||||
46
src/skylattice/commands/plan_cmd.py
Normal file
46
src/skylattice/commands/plan_cmd.py
Normal file
@ -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]")
|
||||
28
src/skylattice/commands/skills_cmd.py
Normal file
28
src/skylattice/commands/skills_cmd.py
Normal file
@ -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.")
|
||||
31
src/skylattice/commands/validate_cmd.py
Normal file
31
src/skylattice/commands/validate_cmd.py
Normal file
@ -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)
|
||||
164
src/skylattice/commands/wizard_cmd.py
Normal file
164
src/skylattice/commands/wizard_cmd.py
Normal file
@ -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")
|
||||
1
src/skylattice/core/__init__.py
Normal file
1
src/skylattice/core/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Core libraries for Sky Lattice."""
|
||||
86
src/skylattice/core/decisions.py
Normal file
86
src/skylattice/core/decisions.py
Normal file
@ -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
|
||||
87
src/skylattice/core/discover.py
Normal file
87
src/skylattice/core/discover.py
Normal file
@ -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
|
||||
45
src/skylattice/core/intent.py
Normal file
45
src/skylattice/core/intent.py
Normal file
@ -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()
|
||||
75
src/skylattice/core/paths.py
Normal file
75
src/skylattice/core/paths.py
Normal file
@ -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
|
||||
124
src/skylattice/core/planner.py
Normal file
124
src/skylattice/core/planner.py
Normal file
@ -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)
|
||||
76
src/skylattice/core/policy.py
Normal file
76
src/skylattice/core/policy.py
Normal file
@ -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
|
||||
27
tests/test_planner.py
Normal file
27
tests/test_planner.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user