Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,14 @@ What's provider-specific is only the curation layer, shipped as **provider packs

- a palette pack (`packages/ui/src/resource-palette.ts`) — curated drag-and-drop types
- containment rules (`DEFAULT_CONTAINMENT_RULES` in `@stackcanvas/core`) — which
resources render as visual containers (AWS VPC/subnet today)
resources render as visual containers (AWS VPC/subnet, GCP network/subnetwork,
Azure subnet, Cloudflare zone today)
- icon patterns (`packages/ui/src/icons.tsx`)

AWS ships first. Adding a GCP/Azure/other pack is pure data and makes a great
first PR.
Four packs ship today: **AWS** (complete — the reference pack) and **GCP /
Azure / Cloudflare starter packs** covering the common resource types per
provider. Rounding out a starter pack, or adding a new provider entirely, is
pure data and makes a great first PR.

## OpenTofu

Expand Down
10 changes: 10 additions & 0 deletions e2e/canvas.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ test('nodes can be dragged', async ({ page }) => {
expect(moved).toBeGreaterThan(50)
})

test('GCP accordion palette item creates a draft node with provider google', async ({ page }) => {
await page.goto('/')
// GCP is collapsed by default (only AWS opens on load) — expand it first.
await page.locator('.palette-pack summary', { hasText: 'GCP' }).click()
await page.getByRole('button', { name: 'Compute instance' }).click()
const draft = page.locator('.resource-node.draft')
await expect(draft).toBeVisible()
await expect(draft.locator('.type')).toHaveText('google_compute_instance')
})

test('connecting two existing nodes draws a draft edge and Apply sends a modify', async ({ page }) => {
await page.goto('/')
const source = page.locator('.react-flow__node', { hasText: 'db' })
Expand Down
84 changes: 83 additions & 1 deletion packages/core/src/derive.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { readFileSync } from 'node:fs'
import { expect, test } from 'vitest'
import { parseState } from './parse-state.js'
import { deriveContainment, deriveEdges } from './derive.js'
import { DEFAULT_CONTAINMENT_RULES, deriveContainment, deriveEdges } from './derive.js'
import type { GraphModel } from './types.js'

const fixture = JSON.parse(
readFileSync(new URL('../test/fixtures/state.json', import.meta.url), 'utf8'),
Expand Down Expand Up @@ -33,3 +34,84 @@ test('vpc and subnet containment groups', () => {
expect(g.nodes.find(n => n.id === 'aws_instance.web')!.group).toBe('subnet:aws_subnet.a')
expect(g.nodes.find(n => n.id === 'module.data.aws_db_instance.db')!.group).toBe('vpc:aws_vpc.main')
})

test('gcp network/subnetwork containment keyed by self_link', () => {
const g: GraphModel = {
edges: [],
groups: [],
nodes: [
{
id: 'google_compute_network.main', type: 'google_compute_network', name: 'main',
provider: 'google', group: null, status: 'noop', dependsOn: [],
attributes: { id: 'projects/p/global/networks/main', self_link: 'https://www.googleapis.com/compute/v1/projects/p/global/networks/main' },
},
{
id: 'google_compute_subnetwork.a', type: 'google_compute_subnetwork', name: 'a',
provider: 'google', group: null, status: 'noop', dependsOn: [],
attributes: {
id: 'projects/p/regions/us/subnetworks/a',
self_link: 'https://www.googleapis.com/compute/v1/projects/p/regions/us/subnetworks/a',
network: 'https://www.googleapis.com/compute/v1/projects/p/global/networks/main',
},
},
{
id: 'google_compute_instance.web', type: 'google_compute_instance', name: 'web',
provider: 'google', group: null, status: 'noop', dependsOn: [],
attributes: { id: 'i-1', network: 'https://www.googleapis.com/compute/v1/projects/p/global/networks/main' },
},
],
}
const out = deriveContainment(g, DEFAULT_CONTAINMENT_RULES)
const netGroup = out.groups.find(x => x.id === 'vpc:google_compute_network.main')
expect(netGroup?.kind).toBe('vpc')
// the instance references the network directly (not via the subnetwork),
// so it's contained by the vpc group, not the subnet group.
expect(out.nodes.find(n => n.id === 'google_compute_instance.web')!.group).toBe('vpc:google_compute_network.main')
expect(out.nodes.find(n => n.id === 'google_compute_network.main')!.group).toBe('vpc:google_compute_network.main')
})

test('azurerm subnet containment (nic subnet_id references subnet.id)', () => {
const g: GraphModel = {
edges: [],
groups: [],
nodes: [
{
id: 'azurerm_subnet.internal', type: 'azurerm_subnet', name: 'internal',
provider: 'azurerm', group: null, status: 'noop', dependsOn: [],
attributes: { id: '/subscriptions/x/resourceGroups/rg/subnets/internal' },
},
{
id: 'azurerm_network_interface.nic', type: 'azurerm_network_interface', name: 'nic',
provider: 'azurerm', group: null, status: 'noop', dependsOn: [],
attributes: { id: 'nic-1', subnet_id: '/subscriptions/x/resourceGroups/rg/subnets/internal' },
},
],
}
const out = deriveContainment(g, DEFAULT_CONTAINMENT_RULES)
const subnetGroup = out.groups.find(x => x.id === 'subnet:azurerm_subnet.internal')
expect(subnetGroup?.kind).toBe('subnet')
expect(out.nodes.find(n => n.id === 'azurerm_network_interface.nic')!.group).toBe('subnet:azurerm_subnet.internal')
})

test('cloudflare zone containment (record zone_id references zone.id)', () => {
const g: GraphModel = {
edges: [],
groups: [],
nodes: [
{
id: 'cloudflare_zone.example', type: 'cloudflare_zone', name: 'example',
provider: 'cloudflare', group: null, status: 'noop', dependsOn: [],
attributes: { id: 'zone-1' },
},
{
id: 'cloudflare_dns_record.www', type: 'cloudflare_dns_record', name: 'www',
provider: 'cloudflare', group: null, status: 'noop', dependsOn: [],
attributes: { id: 'rec-1', zone_id: 'zone-1' },
},
],
}
const out = deriveContainment(g, DEFAULT_CONTAINMENT_RULES)
const zoneGroup = out.groups.find(x => x.id === 'vpc:cloudflare_zone.example')
expect(zoneGroup?.kind).toBe('vpc')
expect(out.nodes.find(n => n.id === 'cloudflare_dns_record.www')!.group).toBe('vpc:cloudflare_zone.example')
})
17 changes: 15 additions & 2 deletions packages/core/src/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,30 @@ export interface ContainmentRule {
memberAttr: string
/** Group kind; also the group-id prefix, e.g. 'vpc' -> 'vpc:<address>'. */
kind: string
/** Container attribute whose value members reference (default 'id'). Some
* providers don't expose a stable `id` members can point back to — e.g.
* GCP networks/subnetworks are referenced by `self_link`, not `id`. */
containerIdAttr?: string
}

/**
* Rule order matters: earlier rules' membership is assigned before later
* containers are created, so nested kinds (subnet inside vpc) inherit the
* right parent. Provider packs extend this table (PRs welcome) — the only
* requirement is that members reference the container by its physical id.
* requirement is that members reference the container by its physical id
* (or `containerIdAttr`, when the container isn't referenced by `id`).
*/
export const DEFAULT_CONTAINMENT_RULES: ContainmentRule[] = [
{ containerType: 'aws_vpc', memberAttr: 'vpc_id', kind: 'vpc' },
{ containerType: 'aws_subnet', memberAttr: 'subnet_id', kind: 'subnet' },
// GCP networks/subnetworks are referenced by `self_link`, not `id`.
{ containerType: 'google_compute_network', memberAttr: 'network', kind: 'vpc', containerIdAttr: 'self_link' },
{ containerType: 'google_compute_subnetwork', memberAttr: 'subnetwork', kind: 'subnet', containerIdAttr: 'self_link' },
{ containerType: 'azurerm_subnet', memberAttr: 'subnet_id', kind: 'subnet' },
// Cloudflare zones aren't a network container, but they group the
// resources scoped to them the same way a VPC groups its members — reuse
// the 'vpc' kind rather than introducing a one-off styling kind.
{ containerType: 'cloudflare_zone', memberAttr: 'zone_id', kind: 'vpc' },
]

export function deriveContainment(
Expand All @@ -53,7 +66,7 @@ export function deriveContainment(
for (const rule of rules) {
const groupByPhysicalId = new Map<string, string>()
for (const n of nodes.filter(n => n.type === rule.containerType)) {
const pid = n.attributes['id']
const pid = n.attributes[rule.containerIdAttr ?? 'id']
if (typeof pid !== 'string') continue
const gid = `${rule.kind}:${n.id}`
groups.push({ id: gid, label: n.name, kind: rule.kind, parent: n.group })
Expand Down
8 changes: 4 additions & 4 deletions packages/ui/src/Palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ export function Palette() {
return (
<aside className="palette">
<h4>Add resource</h4>
{PROVIDER_PACKS.map(pack => (
<section key={pack.provider} className="palette-pack">
<h5>{pack.label}</h5>
{PROVIDER_PACKS.map((pack, i) => (
<details key={pack.provider} className="palette-pack" open={i === 0}>
<summary>{pack.label} <span className="palette-pack-count">{pack.items.length}</span></summary>
{pack.items.map(p => (
<button key={p.type} className="palette-item" onClick={() => addDraft(p.type)}>
<ResourceIcon type={p.type} /> {p.label}
</button>
))}
</section>
</details>
))}
<div className="palette-custom">
<input
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/icons.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { JSX } from 'react'

const glyphFor = (type: string): 'network' | 'compute' | 'database' | 'storage' | 'security' | 'messaging' | 'generic' => {
if (/vpc|subnet|route53|cloudfront|lb|apigateway|nat|gateway|network|firewall|dns|cdn/.test(type)) return 'network'
if (/vpc|subnet|route53|cloudfront|lb|apigateway|nat|gateway|network|firewall|dns|cdn|zone/.test(type)) return 'network'
if (/db_|dynamodb|elasticache|rds|sql|spanner|bigtable|firestore|cosmos|redis/.test(type)) return 'database'
if (/instance$|autoscaling|ecs|eks|lambda|launch|virtual_machine|cloud_run|cloud_function|container|kubernetes|app_service/.test(type)) return 'compute'
if (/s3|ecr|log_group|efs|storage_bucket|blob|artifact_registry|filestore/.test(type)) return 'storage'
if (/instance$|autoscaling|ecs|eks|lambda|launch|virtual_machine|cloud_run|cloud_function|container|kubernetes|app_service|worker_script|workers_script|pages_project/.test(type)) return 'compute'
if (/s3|ecr|log_group|efs|storage_bucket|blob|artifact_registry|filestore|r2_bucket|workers_kv/.test(type)) return 'storage'
if (/iam|security_group|kms|key_vault|secret|service_account|role_/.test(type)) return 'security'
if (/sqs|sns|eventbridge|kinesis|pubsub|servicebus|eventhub|queue|topic/.test(type)) return 'messaging'
return 'generic'
Expand Down
65 changes: 65 additions & 0 deletions packages/ui/src/resource-palette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,71 @@ export const PROVIDER_PACKS: ProviderPack[] = [
{ type: 'aws_iam_policy', label: 'IAM policy' },
],
},
{
provider: 'google',
label: 'GCP',
items: [
{ type: 'google_compute_network', label: 'VPC network' },
{ type: 'google_compute_subnetwork', label: 'Subnetwork' },
{ type: 'google_compute_instance', label: 'Compute instance' },
{ type: 'google_compute_firewall', label: 'Firewall rule' },
{ type: 'google_container_cluster', label: 'GKE cluster' },
{ type: 'google_cloud_run_v2_service', label: 'Cloud Run service' },
{ type: 'google_sql_database_instance', label: 'Cloud SQL instance' },
{ type: 'google_storage_bucket', label: 'Storage bucket' },
{ type: 'google_pubsub_topic', label: 'Pub/Sub topic' },
{ type: 'google_pubsub_subscription', label: 'Pub/Sub subscription' },
{ type: 'google_cloudfunctions2_function', label: 'Cloud Function (2nd gen)' },
{ type: 'google_artifact_registry_repository', label: 'Artifact Registry repo' },
{ type: 'google_dns_managed_zone', label: 'Cloud DNS zone' },
{ type: 'google_service_account', label: 'Service account' },
{ type: 'google_project_iam_member', label: 'IAM member' },
{ type: 'google_redis_instance', label: 'Memorystore Redis' },
],
},
{
provider: 'azurerm',
label: 'Azure',
items: [
{ type: 'azurerm_virtual_network', label: 'Virtual network' },
{ type: 'azurerm_subnet', label: 'Subnet' },
{ type: 'azurerm_network_security_group', label: 'Network security group' },
{ type: 'azurerm_linux_virtual_machine', label: 'Virtual machine (Linux)' },
{ type: 'azurerm_kubernetes_cluster', label: 'AKS cluster' },
{ type: 'azurerm_container_app', label: 'Container App' },
{ type: 'azurerm_mssql_server', label: 'SQL server' },
{ type: 'azurerm_mssql_database', label: 'SQL database' },
{ type: 'azurerm_postgresql_flexible_server', label: 'PostgreSQL flexible server' },
{ type: 'azurerm_storage_account', label: 'Storage account' },
{ type: 'azurerm_servicebus_namespace', label: 'Service Bus namespace' },
{ type: 'azurerm_servicebus_queue', label: 'Service Bus queue' },
// azurerm_function_app_flex_consumption exists for the newer Flex
// Consumption plan, but azurerm_linux_function_app is the broadly
// compatible, long-established resource — safer as the curated default.
{ type: 'azurerm_linux_function_app', label: 'Function App (Linux)' },
{ type: 'azurerm_dns_zone', label: 'DNS zone' },
{ type: 'azurerm_key_vault', label: 'Key Vault' },
{ type: 'azurerm_redis_cache', label: 'Azure Cache for Redis' },
],
},
{
provider: 'cloudflare',
label: 'Cloudflare',
items: [
{ type: 'cloudflare_zone', label: 'Zone' },
// v5 of the provider renamed cloudflare_record -> cloudflare_dns_record;
// this pack targets the current (v5) resource name.
{ type: 'cloudflare_dns_record', label: 'DNS record' },
// v5 also renamed cloudflare_worker_script -> cloudflare_workers_script.
{ type: 'cloudflare_workers_script', label: 'Worker script' },
{ type: 'cloudflare_workers_kv_namespace', label: 'Workers KV namespace' },
{ type: 'cloudflare_r2_bucket', label: 'R2 bucket' },
{ type: 'cloudflare_pages_project', label: 'Pages project' },
{ type: 'cloudflare_ruleset', label: 'Ruleset' },
// Formerly cloudflare_tunnel; current name is the zero-trust-prefixed one.
{ type: 'cloudflare_zero_trust_tunnel_cloudflared', label: 'Tunnel' },
],
},
]

/** 'aws_vpc' -> 'aws', 'google_compute_instance' -> 'google', 'random_pet' -> 'random' */
Expand Down
9 changes: 7 additions & 2 deletions packages/ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,13 @@ body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; }
cursor: pointer; font-size: 13px; }
.context-menu button:hover { background: #f1f5f9; }
.palette-pack { display: flex; flex-direction: column; gap: 4px; }
.palette-pack h5 { margin: 6px 0 2px; font-size: 11px; color: #64748b;
text-transform: uppercase; letter-spacing: 0.05em; }
.palette-pack summary { margin: 6px 0 2px; font-size: 11px; color: #64748b;
text-transform: uppercase; letter-spacing: 0.05em; font-family: ui-monospace, monospace;
cursor: pointer; user-select: none; list-style: revert; }
.palette-pack summary:hover { color: #0f172a; }
.palette-pack-count { color: #94a3b8; font-weight: normal; }
.palette-pack[open] summary { margin-bottom: 4px; }
.palette-pack .palette-item { margin-left: 2px; }
.consent-banner { position: fixed; right: 16px; bottom: 16px; z-index: 40; width: 300px;
background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; box-shadow: 0 8px 24px #0002;
padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
Expand Down
Loading