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