diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 4a831934..ccc6ac92 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -184,4 +184,5 @@ See [`../../SECURITY.md`](../../SECURITY.md) for the disclosure process. ## See also - [core-engine.md](core-engine.md), [frontend.md](frontend.md), [services.md](services.md), [database.md](database.md), [desktop.md](desktop.md). +- [connections-to-cloud.md](connections-to-cloud.md) — how a canvas edge collapses into env vars, IAM, and network policy at deploy time. - [`packages/core/src/`](../../packages/core/src/) — the canonical implementation of everything on this page. diff --git a/docs/architecture/connections-to-cloud.md b/docs/architecture/connections-to-cloud.md new file mode 100644 index 00000000..70b260b6 --- /dev/null +++ b/docs/architecture/connections-to-cloud.md @@ -0,0 +1,250 @@ +# Connections → cloud infra + +How a line drawn between two blocks on the canvas becomes real cloud +resources, IAM bindings, and network policy. Worked examples on GCP at +the end. + +## The mental model + +A canvas edge is **not** a cloud resource. The cloud sees only resources +plus IAM plus network policy — there is no "edge" object in any cloud +SDK. So every line you draw must collapse into some mix of three things +on the endpoint nodes: + +1. **Property propagation** — env vars, URLs, connection strings, etc. + written onto a block's properties because of the connection. +2. **IAM binding** — the source node's identity gets a role on the + target's resource (and vice versa). +3. **Network policy** — firewall / ingress allow-list entries, VPC + peering, custom domain routes. + +Which of the three an edge produces is decided by its +`connectionCategory` (`traffic` | `config` | `source` | …) plus the +roles of the two endpoint blocks (`backend`, `database`, `storage`, +`secrets`, `repo`, …). Roles come from a single table in +`@ice/constants/block-classifiers.ts` so the connection-rules predicates +and the propagation predicates can never drift apart on what "is a +database" means. + +## The pipeline + +``` +canvas edge (drawn in the UI) + ↓ +connection rules packages/types/connection-rules + Shape check: is this combo legal at all? + ↓ +propagation rules packages/core/src/compute/propagation-rules.ts + Mutate node data based on the edge. + ↓ +type-maps packages/core/src/deploy/type-maps.ts + iceType → provider resource type (per cloud). + ↓ +extractors packages/core/src/deploy/extractors/* + Block properties → provider resource properties. + ↓ +handlers packages/core/src/deploy/providers//handlers/* + Resource properties → cloud SDK call. +``` + +Each layer is schema-driven; per-provider behaviour lives in +per-provider files only. The cardinal rule: no hardcoded `iceType ===` +branches in cross-cutting code. See +[refactoring-patterns.md](../refactoring-patterns.md) for the rationale. + +### Layer 1 — connection rules + +`packages/types/connection-rules` holds the **legality** table: which +(source iceType, target iceType) pairs are even valid edges. It runs in +the UI as you drag a connection — incompatible combos are rejected +before the edge can land. Predicates here use `hasBlockRole` so +`isDatabase(t)` works the same way on both sides of the layer split. + +### Layer 2 — propagation rules + +`packages/core/src/compute/propagation-rules.ts` is a declarative array. +Each `PropagationRule` says: "when source role × target role match +this pattern AND this edge has these data fields, write these derived +properties onto the receiving node." + +There are two flavours: + +- **Per-edge rules** (`PROPAGATION_RULES`) — fire on a single edge, + compute a property patch for the source or target depending on + `direction`. E.g. `Backend → DataStore: connection string +propagation` writes `envVarName: 'BUCKET_NAME'` onto the edge so + downstream the backend knows what env var to read. +- **Aggregate rules** (`AGGREGATE_RULES`) — fire on a node, scan all + inbound or outbound edges, compute a property patch from the + collection. E.g. `DataStore: derive allowedClients from inbound +traffic edges` populates the bucket's `allowedClients` array, which + the handler turns into an IAM policy binding. + +These run live in the UI (so the properties panel reacts as soon as +you draw an edge) and again pre-deploy (so the translator sees the +fully-propagated graph). + +### Layer 3 — type-maps + +`packages/core/src/deploy/type-maps.ts` is the provider-specific +collapse: a single `Record` per cloud. +`Compute.BackendAPI` becomes `gcp.run.service` on GCP, +`aws.ecs.service` on AWS, `azure.containerapps.app` on Azure. One iceType +can map to different resources per provider because the schema-driven +extractor dispatch sits behind it. + +Some iceTypes deliberately compile differently on different clouds — +`Compute.StaticSite` → `gcp.firebase.hosting` on GCP (bypasses the +public-bucket org policy) but → `aws.s3.bucket` + CloudFront on AWS. +That's documented inline in `type-maps.ts`. + +### Layer 4 — extractors + +`packages/core/src/deploy/extractors/*` transform a canvas block's +**user-facing properties** (`data.schedule = 'daily'`, +`data.bucket_name = 'photos'`) into the **provider resource +properties** the handler expects (`schedule: '0 0 * * *'`, +`bucket_name: 'ice-photos-abc123'`). One extractor per provider +resource type. The dispatcher (`extractors/dispatch.ts`) routes by the +type-map output, not by iceType. + +### Layer 5 — handlers + +`packages/core/src/deploy/providers//handlers/*` make the actual +SDK call. Handlers are dumb — they assume the extractor has already +filled every required property correctly, so the only branching is +between create / update / delete. See +[`providers/aws/README.md`](../../packages/core/src/deploy/providers/aws/README.md) +and [deploying-to-gcp.md](../deploying-to-gcp.md) for the per-provider +quirks each layer handles. + +## Worked example 1 — `Storage.Bucket` connected to `Compute.BackendAPI` (GCP) + +You drag a Backend onto the canvas, drop a Storage Bucket next to it, +draw an edge Backend → Bucket. Here's what each layer does. + +**Type-map** (`type-maps.ts:31,39`): + +- `Compute.BackendAPI` → `gcp.run.service` +- `Storage.Bucket` → `gcp.storage.bucket` + +**Propagation fires twice as you draw the edge:** + +1. `Backend → DataStore: connection string propagation` + (`propagation-rules.ts:166`) — `hasBlockRole('storage')` matches the + bucket, so the rule resolves `envVarName: 'BUCKET_NAME'` from + `DEFAULT_ENV_VARS['Storage.Bucket']` (in `@ice/constants/derived`) + and stamps it onto the **edge data**. +2. `DataStore: derive allowedClients from inbound traffic edges` + (`propagation-rules.ts:218`) — runs on the bucket node, looks at + every inbound `traffic` edge, writes + `allowedClients: [{ nodeId, iceType, label }]` onto the bucket. This + array is what the IAM-binding pass reads. +3. `Service: derive allowedTargets from outbound traffic edges` + (`propagation-rules.ts:263`) — the symmetric view on the backend, so + it knows what it's allowed to reach. + +**Extractors then run:** + +- `extract_cloud_run_properties` for the backend — picks up + `injectedEnvVars: { BUCKET_NAME: }` (resolved from the + edge's `envVarName` plus the target bucket's name) and merges it into + the Cloud Run service's `env` array. +- `extract_storage_bucket_properties` for the bucket — passes through + bucket name, location, uniform-access-level, plus the + `allowedClients` from the aggregate rule. + +**Handlers call GCP:** + +- The Cloud Run handler creates a `google.cloud.run.v2.Service` with + `env: [{ name: 'BUCKET_NAME', value: 'ice-myapp-photos' }]`. +- The Cloud Storage handler creates a private + `google.cloud.storage.Bucket` (no `allUsers` binding — that's + reserved for `Compute.StaticSite`; see + [`cloud-storage/public-access-granter.ts`](../../packages/core/src/deploy/providers/gcp/handlers/cloud-storage/public-access-granter.ts)). +- After both exist, an IAM pass grants the Cloud Run service's + identity `roles/storage.objectUser` on the bucket. The bucket-side + `allowedClients` is the input list. + +**Cloud-side result of one canvas edge:** + +- ✅ env var injection on the Cloud Run service +- ✅ IAM policy binding on the bucket +- ❌ no "edge resource" — none exists in GCP + +## Worked example 2 — `Compute.CronJob` connected to `Compute.BackendAPI` (GCP) + +You drop a Cron block, set schedule to `daily`, draw an edge Cron → +Backend. + +**Type-map** (`type-maps.ts:31,33`): + +- `Compute.CronJob` → `gcp.cloudscheduler.job` +- `Compute.BackendAPI` → `gcp.run.service` + +**Propagation:** a translator pass reads the cron's outbound edge to +the Backend and writes the Backend's URL onto `cron.data.targetUri`. +Because cron is a job-like trigger (not a data store), it doesn't +participate in the `Backend → DataStore` rule — its outbound edge is +the propagation source. + +**Extractor** (`extractors/compute.ts:135`, +`extract_cloud_scheduler_properties`) turns `data.schedule = 'daily'` +into a real cron expression `'0 0 * * *'` via a built-in map, then +emits: + +```ts +{ + region, + schedule: '0 0 * * *', + timezone: 'UTC', + target_type: 'http', + target_uri: , + labels: {}, +} +``` + +**Handler** (`gcp/handlers/cloud-scheduler.ts:62`) calls +`projects.locations.jobs.create` with: + +```ts +httpTarget: { uri: properties.target_uri, httpMethod: 'POST', ... } +``` + +If the Cloud Run service is private (not public-traffic), the IAM pass +grants the scheduler's service account `roles/run.invoker` on the +service. That binding is applied by +[`gcp/handlers/cloud-run/iam.ts:19`](../../packages/core/src/deploy/providers/gcp/handlers/cloud-run/iam.ts). + +**Cloud-side result of one canvas edge:** + +- ✅ a `google.cloud.scheduler.v1.Job` that POSTs the backend URL on + schedule +- ✅ optional `run.invoker` IAM binding scoped to the scheduler's SA +- ❌ no "edge resource" + +## Why this layering matters + +- **Property propagation runs both live (UI) and pre-deploy.** Drawing + an edge updates the properties panel before you save — same rules, + same code. The deploy never sees an unpropagated graph. +- **Layers can be swapped per provider without touching the others.** + AWS and Azure plug in by registering their own type-maps, + extractors, and handlers; the propagation rules and connection rules + are provider-agnostic because they only read roles (`backend`, + `database`, …) from the shared classifier table. +- **No edges in the cloud.** If your debugging instinct is "find the + connection in the cloud console" — stop. Look at the env vars on the + consumer, the IAM bindings on the producer, and the firewall rules + on the network. + +## See also + +- [core-engine.md](core-engine.md) — graph engine, plan/apply scheduler, + state store. +- [extending-providers.md](../extending-providers.md) — adding a new + provider goes through these same layers. +- [blocks-reference.md](../blocks-reference.md) — every iceType and the + roles it carries. +- [`packages/core/src/compute/propagation-rules.ts`](../../packages/core/src/compute/propagation-rules.ts) — the full rules table. +- [`packages/core/src/deploy/type-maps.ts`](../../packages/core/src/deploy/type-maps.ts) — every iceType → provider resource mapping. diff --git a/docs/architecture/core-engine.md b/docs/architecture/core-engine.md index 97bb1620..3c4bb9a5 100644 --- a/docs/architecture/core-engine.md +++ b/docs/architecture/core-engine.md @@ -161,6 +161,8 @@ A clean clone of ICE with no deploys has an empty state store; every node in the Some block properties are derived from others (`derived`), aggregated across connected nodes (`aggregate`), or propagated along edges (`propagation`). Rules live in `packages/core/src/compute/propagation-rules.ts` and similar. The UI applies these live so that, e.g., a Static Site's "CDN" toggle automatically suggests a Custom Domain requirement. +The full canvas-edge → cloud-resource pipeline (propagation → type-maps → extractors → handlers, with worked GCP examples for Storage→Backend and Cron→Backend) is documented separately in [connections-to-cloud.md](connections-to-cloud.md). + ## Entry points worth reading - [`packages/core/src/index.ts`](../../packages/core/src/index.ts) - the export surface. diff --git a/package.json b/package.json index 302cb200..812f8c14 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ice", "license": "Apache-2.0", - "version": "0.1.720", + "version": "0.1.769", "description": "ICE - Integrated Cloud Environment (Web + Backend)", "private": true, "type": "module", diff --git a/packages/constants/src/__tests__/block-classifiers.test.ts b/packages/constants/src/__tests__/block-classifiers.test.ts new file mode 100644 index 00000000..f7bac097 --- /dev/null +++ b/packages/constants/src/__tests__/block-classifiers.test.ts @@ -0,0 +1,130 @@ +/** + * Tests for `block-classifiers` — the shared role table that drives + * block-type classification across `@ice/types` connection-rules AND + * `@ice/core` propagation-rules. + * + * Cardinal rule check: the table is the single declarative fact for + * "what role does this iceType play?". Both packages read from it via + * `hasBlockRole(t, role)`; no iceType strings appear in classifier + * code on either side. + * + * These tests pin the role semantics so any future drift between the + * canonical iceTypes (Compute.X, Database.X, Storage.X) and the + * provider-specific iceTypes (raw blueprint iceTypes under varied + * namespaces) is caught. + */ + +import { describe, it, expect } from 'vitest'; +import { hasBlockRole, BLOCK_ROLES_BY_ICE_TYPE, BLOCK_ROLES_BY_PREFIX } from '../block-classifiers'; + +describe('hasBlockRole — exact iceType matches', () => { + it('Source.Repository → repo', () => { + expect(hasBlockRole('Source.Repository', 'repo')).toBe(true); + expect(hasBlockRole('Source.Repository', 'backend')).toBe(false); + }); + + it('Config.Environment → envConfig', () => { + expect(hasBlockRole('Config.Environment', 'envConfig')).toBe(true); + }); + + it('Security.Secret → secrets', () => { + expect(hasBlockRole('Security.Secret', 'secrets')).toBe(true); + }); + + it('Network.CustomDomain → customDomain + domain', () => { + expect(hasBlockRole('Network.CustomDomain', 'customDomain')).toBe(true); + expect(hasBlockRole('Network.CustomDomain', 'domain')).toBe(true); + }); + + it('Network.PrivateNetwork → privateNetwork', () => { + expect(hasBlockRole('Network.PrivateNetwork', 'privateNetwork')).toBe(true); + }); + + it('Util.Reroute → reroute', () => { + expect(hasBlockRole('Util.Reroute', 'reroute')).toBe(true); + }); +}); + +describe('hasBlockRole — category-prefix inheritance', () => { + it('any Compute.* → backend', () => { + expect(hasBlockRole('Compute.Container', 'backend')).toBe(true); + expect(hasBlockRole('Compute.NewMadeUpType', 'backend')).toBe(true); + }); + + it('any Database.* → database', () => { + expect(hasBlockRole('Database.PostgreSQL', 'database')).toBe(true); + expect(hasBlockRole('Database.NewMadeUpType', 'database')).toBe(true); + }); + + it('any Storage.* → storage', () => { + expect(hasBlockRole('Storage.Bucket', 'storage')).toBe(true); + }); + + it('any Messaging.* → queue', () => { + expect(hasBlockRole('Messaging.Queue', 'queue')).toBe(true); + expect(hasBlockRole('Messaging.EventStream', 'queue')).toBe(true); + }); + + it('any Monitoring.* or Log.* → monitoring', () => { + expect(hasBlockRole('Monitoring.Log', 'monitoring')).toBe(true); + expect(hasBlockRole('Log.Sink', 'monitoring')).toBe(true); + }); +}); + +describe('hasBlockRole — regex matches for provider-specific iceTypes', () => { + it('iceTypes containing PostgreSQL / MySQL / MongoDB → database', () => { + expect(hasBlockRole('aws.rds.PostgreSQL', 'database')).toBe(true); + expect(hasBlockRole('gcp.sql.MySQL', 'database')).toBe(true); + }); + + it('iceTypes containing Redis / Cache → cache', () => { + expect(hasBlockRole('Cache.Redis', 'cache')).toBe(true); + expect(hasBlockRole('aws.elasticache.Cache', 'cache')).toBe(true); + }); + + it('iceTypes containing Bucket / S3 / GCS → storage', () => { + expect(hasBlockRole('aws.s3.Bucket', 'storage')).toBe(true); + }); + + it('iceTypes containing Container / Function / Worker → backend', () => { + expect(hasBlockRole('aws.ecs.Container', 'backend')).toBe(true); + expect(hasBlockRole('gcp.cloudfunctions.Function', 'backend')).toBe(true); + }); +}); + +describe('hasBlockRole — composite domain role', () => { + it('PublicEndpoint → domain (composite, not just customDomain)', () => { + expect(hasBlockRole('Network.PublicEndpoint', 'domain')).toBe(true); + expect(hasBlockRole('Network.PublicEndpoint', 'customDomain')).toBe(false); + }); + + it('CustomDomain → domain AND customDomain', () => { + expect(hasBlockRole('Network.CustomDomain', 'domain')).toBe(true); + expect(hasBlockRole('Network.CustomDomain', 'customDomain')).toBe(true); + }); +}); + +describe('hasBlockRole — negative cases', () => { + it('returns false for unknown iceTypes', () => { + expect(hasBlockRole('Totally.Made.Up', 'database')).toBe(false); + expect(hasBlockRole('', 'backend')).toBe(false); + }); + + it('returns false for the wrong role', () => { + expect(hasBlockRole('Source.Repository', 'database')).toBe(false); + }); +}); + +describe('table integrity', () => { + it('every exact-match entry uses at least one role', () => { + for (const [iceType, roles] of Object.entries(BLOCK_ROLES_BY_ICE_TYPE)) { + expect(roles.length, `${iceType} has no roles`).toBeGreaterThan(0); + } + }); + + it('every prefix entry ends with a dot (category separator)', () => { + for (const entry of BLOCK_ROLES_BY_PREFIX) { + expect(entry.prefix.endsWith('.'), `prefix ${entry.prefix} should end with .`).toBe(true); + } + }); +}); diff --git a/packages/constants/src/block-classifiers.ts b/packages/constants/src/block-classifiers.ts new file mode 100644 index 00000000..80064c45 --- /dev/null +++ b/packages/constants/src/block-classifiers.ts @@ -0,0 +1,168 @@ +/** + * Block role classification — the single source of truth for "what + * kind of block is this iceType?" across the whole monorepo. + * + * The cardinal rule (see CLAUDE memory / feedback): cross-cutting + * layers MUST NOT scatter `if (iceType === 'X')` or `t.startsWith('Y')` + * branches throughout the codebase. They consult the schema-shaped + * tables here and the connection-rules + propagation-rules engines + * stay generic. + * + * Lives in `@ice/constants` because BOTH `@ice/types/connection-rules/ + * predicates.ts` AND `@ice/core/compute/propagation-rules.ts` need + * identical block classification. Previously both files duplicated + * the same predicate bodies (regex + prefix matches); this module + * collapses them into one declaration. + * + * Three lookup tiers, evaluated in order — fastest first: + * 1. `BLOCK_ROLES_BY_ICE_TYPE`: exact iceType → roles. Use this for + * narrow, one-off bindings (Source.Repository, Config.Environment). + * 2. `BLOCK_ROLES_BY_PREFIX`: category prefix → role. Use this for + * auto-inheritance (every `Database.*` is a `database` for free, + * so a new MySQL variant doesn't need a table edit). + * 3. `BLOCK_ROLES_BY_REGEX`: regex pattern → role. Use this ONLY for + * provider-specific iceTypes that don't fit a clean prefix + * (e.g. `PostgreSQL`, `Redis`, `Kafka` engines authored under + * varied namespaces by per-provider blueprints). + */ + +export type BlockRole = + // Compute + | 'backend' + | 'frontend' + /** + * Compute backend that compiles to a Cloud Run / ECS / Container App + * service behind a Serverless NEG when wired through a public-ingress + * endpoint. Narrower than `backend` — excludes StaticSite (which + * compiles to a backendBucket instead). Used by: + * - the LB-wiring pass (which serverless-NEG vs backendBucket dispatch) + * - the network-isolation ingress override on the translator + */ + | 'serviceBackend' + // Data + | 'database' + | 'cache' + | 'storage' + | 'queue' + | 'search' + | 'vectorDb' + | 'llm' + | 'dataWarehouse' + // Ops / Config + | 'repo' + | 'envConfig' + | 'secrets' + // Network / Auth + | 'gateway' + | 'auth' + | 'monitoring' + // Specialised network blocks + | 'customDomain' + | 'privateNetwork' + | 'reroute' + // Composite — anything that owns / propagates a public host + | 'domain' + /** + * The block represents a publicly-served static website. When it + * compiles to a bucket-style resource on the active provider (e.g. + * AWS `Compute.StaticSite` → `aws.s3.bucket`), the bucket extractor + * flips `public_access` + `website_hosting` so the asset can serve + * traffic directly. Providers where it compiles to a self-serving + * resource (e.g. GCP Firebase Hosting) don't need this — the bucket + * extractor isn't invoked at all. + */ + | 'publicWebsiteSource'; + +export const BLOCK_ROLES_BY_ICE_TYPE: Record> = { + // Service backends — compile to Cloud Run / ECS service with a + // Serverless NEG when wired behind a public-ingress endpoint. + // Compute.StaticSite is INTENTIONALLY omitted: it compiles to a + // backendBucket via Firebase Hosting, not a NEG. + 'Compute.Container': ['serviceBackend'], + 'Compute.BackendAPI': ['serviceBackend'], + 'Compute.SSRSite': ['serviceBackend'], + 'Compute.Worker': ['serviceBackend'], + 'Compute.ServerlessFunction': ['serviceBackend'], + // Frontend block representing a public static site. On providers + // where it compiles to a bucket (AWS S3) the storage extractor + // flips public + website-hosting based on this role. + 'Compute.StaticSite': ['publicWebsiteSource'], + // Ops / Config blocks — narrow exact matches. + 'Source.Repository': ['repo'], + 'Config.Environment': ['envConfig'], + 'Security.Secret': ['secrets'], + 'Security.Identity': ['auth'], + // Specialised network blocks. + 'Network.Gateway': ['gateway'], + 'Network.CustomDomain': ['customDomain', 'domain'], + 'Network.PublicEndpoint': ['domain'], + 'Network.PrivateNetwork': ['privateNetwork'], + 'Util.Reroute': ['reroute'], + // High-level analytics + AI blocks (no clean prefix; exact iceType). + 'Analytics.Search': ['search'], + 'Analytics.DataWarehouse': ['dataWarehouse'], + 'AI.VectorDB': ['vectorDb'], + 'AI.LLMGateway': ['llm'], + 'AI.ModelServing': ['llm'], +}; + +export const BLOCK_ROLES_BY_PREFIX: ReadonlyArray<{ prefix: string; role: BlockRole }> = [ + { prefix: 'Compute.', role: 'backend' }, + { prefix: 'Database.', role: 'database' }, + { prefix: 'Storage.', role: 'storage' }, + { prefix: 'Messaging.', role: 'queue' }, + { prefix: 'Monitoring.', role: 'monitoring' }, + { prefix: 'Log.', role: 'monitoring' }, +]; + +export const BLOCK_ROLES_BY_REGEX: ReadonlyArray<{ pattern: RegExp; role: BlockRole }> = [ + // Backend — provider-specific compute iceTypes that don't start with `Compute.`. + { pattern: /Backend|Container|Worker|Function|CronJob|Scheduled|AppPlatform|OCIFunctions/i, role: 'backend' }, + // Frontend — static / SSR sites under varied namespaces. + { pattern: /StaticSite|SSRSite|Frontend/i, role: 'frontend' }, + // Database engines. + { + pattern: /PostgreSQL|MySQL|MongoDB|DynamoDB|Firestore|CosmosDB|AutonomousDB|Tablestore|ManagedDB/i, + role: 'database', + }, + // Cache engines. + { pattern: /Redis|Cache|Memcache/i, role: 'cache' }, + // Storage engines. + { pattern: /Bucket|S3|GCS|Blob|ObjectStorage|Spaces/i, role: 'storage' }, + // Queue / messaging engines. + { pattern: /Queue|SQS|SNS|PubSub|ServiceBus|RabbitMQ|Kafka|Event/i, role: 'queue' }, + // Search engines. + { pattern: /Search|Elasticsearch/i, role: 'search' }, + // Vector DBs. + { pattern: /VectorDB|Vector/i, role: 'vectorDb' }, + // LLM gateways / model serving. + { pattern: /LLM|ModelServing/i, role: 'llm' }, + // Data warehouses. + { pattern: /Warehouse|BigQuery|Redshift|Synapse/i, role: 'dataWarehouse' }, + // Secret stores (Vault, KMS, etc. authored under varied namespaces). + { pattern: /Secret|Vault|Certificate/i, role: 'secrets' }, + // API gateways / load balancers / WAF authored under varied namespaces. + { pattern: /Gateway|LoadBalancer|Internet|WAF/i, role: 'gateway' }, + // Auth / IAM / identity providers. + { pattern: /Auth|Identity|IAM/i, role: 'auth' }, + // Monitoring sinks authored under varied namespaces. + { pattern: /Log|Monitor|Observability|Terminal/i, role: 'monitoring' }, + // Domain blocks under varied namespaces. + { pattern: /Domain|DNS/i, role: 'domain' }, +]; + +/** + * True iff `iceType` carries the given role (per any of the three + * lookup tiers). Replaces the per-package classifier functions — + * connection-rules and propagation-rules now both call this. + */ +export function hasBlockRole(iceType: string, role: BlockRole): boolean { + if (BLOCK_ROLES_BY_ICE_TYPE[iceType]?.includes(role)) return true; + for (const e of BLOCK_ROLES_BY_PREFIX) { + if (e.role === role && iceType.startsWith(e.prefix)) return true; + } + for (const e of BLOCK_ROLES_BY_REGEX) { + if (e.role === role && e.pattern.test(iceType)) return true; + } + return false; +} diff --git a/packages/constants/src/feature-flags.ts b/packages/constants/src/feature-flags.ts index c42ee09c..b59ade58 100644 --- a/packages/constants/src/feature-flags.ts +++ b/packages/constants/src/feature-flags.ts @@ -31,10 +31,25 @@ function allCategoriesOn(): Record { return Object.fromEntries(CATEGORY_IDS.map((c) => [c, true])) as Record; } +function categoriesFromOnList(on: CategoryId[]): Record { + const set = new Set(on); + return Object.fromEntries(CATEGORY_IDS.map((c) => [c, set.has(c)])) as Record; +} + export const PROVIDER_FLAGS: Record = { + // AWS staged rollout. Categories left off have a concrete unblocker; + // see `packages/core/src/deploy/providers/aws/README.md` → Rollout state. + // off — Compute: ECS needs canvas-driven VPC/subnet/SG blocks (deferred). + // off — Frontend: StaticSite+CloudFront combo needs us-east-1 cert validation flow. + // off — Scheduler: EventBridge schedule expression wiring not finished. + // off — Network: ELBv2 needs VPC blocks; CloudFront is create-only. + // off — Database: RDS works but slow + no update path; DynamoDB-only deploys are fine + // once a sub-category gate exists, today the whole bucket stays off. + // off — AI: Bedrock is a no-op; SageMaker only has mocked-SDK coverage. + // off — Analytics: Redshift + OpenSearch are create-only. aws: { - enabled: false, - categories: allCategoriesOff(), + enabled: true, + categories: categoriesFromOnList(['Storage', 'Messaging', 'Cache', 'Monitoring', 'Security', 'Source', 'Config']), }, gcp: { enabled: true, diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 97efcee2..d62905f0 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -127,6 +127,14 @@ export { SECURITY_LEVEL_COLORS, } from './node-traits'; +export { + type BlockRole, + BLOCK_ROLES_BY_ICE_TYPE, + BLOCK_ROLES_BY_PREFIX, + BLOCK_ROLES_BY_REGEX, + hasBlockRole, +} from './block-classifiers'; + export { type TemplateCategory, type TemplateCategoryMeta, diff --git a/packages/core/src/__tests__/card-translator.test.ts b/packages/core/src/__tests__/card-translator.test.ts index edeb6077..a14fb163 100644 --- a/packages/core/src/__tests__/card-translator.test.ts +++ b/packages/core/src/__tests__/card-translator.test.ts @@ -25,6 +25,9 @@ describe('Card Translator Type Maps', () => { it('should map all standard GCP iceTypes', async () => { const mod = await import('../deploy/card-translator'); + // Security.Secret is intentionally absent here — it now expands per + // binding, so a block with no bindings produces zero deployables and + // a warning. Covered separately below. const gcpTypes = [ 'Compute.StaticSite', 'Compute.Container', @@ -32,7 +35,6 @@ describe('Card Translator Type Maps', () => { 'Database.PostgreSQL', 'Storage.Bucket', 'Messaging.CloudPubSub', - 'Security.Secret', 'AI.VectorDB', ]; @@ -46,19 +48,113 @@ describe('Card Translator Type Maps', () => { expect(result.deployable_count).toBeGreaterThan(0); } }); + + it('expands a Security.Secret block into one resource per unique binding', async () => { + const mod = await import('../deploy/card-translator'); + const result = mod.translate_card_to_graph({ + nodes: [ + { + id: 'sec1', + type: 'resource', + data: { + iceType: 'Security.Secret', + label: 'app-secrets', + secrets: [ + { key: 'STRIPE_API_KEY', ref: 'prod-stripe-key' }, + { key: 'JWT_SECRET' }, // ref blank → falls back to key + { key: 'STRIPE_API_KEY', ref: 'prod-stripe-key' }, // dup + ], + }, + }, + ], + edges: [], + provider: 'gcp', + projectName: 'test', + }); + // Dedup by `ref || key` collapses the duplicate. + expect(result.deployable_count).toBe(2); + const refs = result.deployables.map((d) => d.resource_name).sort(); + expect(refs).toEqual(['jwt-secret', 'prod-stripe-key']); + // Every emitted deployable still attributes back to the source block. + expect(result.deployables.every((d) => d.node_id === 'sec1')).toBe(true); + // Each deployable label carries the binding key for plan-UI clarity. + expect(result.deployables.some((d) => d.label.includes('STRIPE_API_KEY'))).toBe(true); + expect(result.deployables.some((d) => d.label.includes('JWT_SECRET'))).toBe(true); + }); + + it('warns and skips when a Security.Secret block has no bindings', async () => { + const mod = await import('../deploy/card-translator'); + const result = mod.translate_card_to_graph({ + nodes: [{ id: 'sec1', type: 'resource', data: { iceType: 'Security.Secret', label: 'empty-store' } }], + edges: [], + provider: 'gcp', + projectName: 'test', + }); + expect(result.deployable_count).toBe(0); + expect(result.warnings.some((w) => w.includes('empty-store'))).toBe(true); + expect(result.skipped.some((s) => s.nodeId === 'sec1')).toBe(true); + }); + + it('dedupes shared refs across multiple Security.Secret blocks', async () => { + const mod = await import('../deploy/card-translator'); + const result = mod.translate_card_to_graph({ + nodes: [ + { + id: 'sec1', + type: 'resource', + data: { + iceType: 'Security.Secret', + label: 'app', + secrets: [{ key: 'DB_PASSWORD', ref: 'shared-db' }], + }, + }, + { + id: 'sec2', + type: 'resource', + data: { + iceType: 'Security.Secret', + label: 'worker', + secrets: [{ key: 'DB_PASSWORD', ref: 'shared-db' }], + }, + }, + ], + edges: [], + provider: 'gcp', + projectName: 'test', + }); + // One cloud secret, attributed to whichever block emitted it first. + expect(result.deployable_count).toBe(1); + expect(result.deployables[0].resource_name).toBe('shared-db'); + }); }); - describe.skip('AWS Type Map', () => { - // AWS deploy path is not yet wired up — PROPERTY_EXTRACTORS only covers - // GCP resource types today. Unskip when AWS extractors land. + describe('AWS Type Map', () => { + // Phase 1 extractors landed across commits #2–#6 (compute, + // database, network, ancillary, ai). Every aws.* resource type in + // AWS_TYPE_MAP now has a registered PROPERTY_EXTRACTORS entry, so + // these used-to-be-skipped tests turn green. it('should map AWS iceTypes (ENGINE-1)', async () => { const mod = await import('../deploy/card-translator'); const awsTypes = [ 'Compute.Container', 'Compute.ServerlessFunction', + 'Compute.CronJob', + 'Compute.SSRSite', 'Database.PostgreSQL', + 'Database.DynamoDB', + 'Database.Redis', 'Storage.Bucket', 'Messaging.Queue', + 'Messaging.Topic', + 'Network.Gateway', + 'Network.PublicEndpoint', + 'Network.LoadBalancer', + 'Security.Identity', + 'Monitoring.Log', + 'AI.VectorDB', + 'AI.LLMGateway', + 'AI.ModelServing', + 'Analytics.DataWarehouse', ]; for (const iceType of awsTypes) { @@ -68,7 +164,7 @@ describe('Card Translator Type Maps', () => { provider: 'aws', projectName: 'test', }); - expect(result.deployable_count).toBeGreaterThan(0); + expect(result.deployable_count, `${iceType} should produce a deployable`).toBeGreaterThan(0); } }); @@ -82,6 +178,47 @@ describe('Card Translator Type Maps', () => { }); expect(result.deployable_count).toBe(1); }); + + it('Compute.StaticSite + Security.Secret + Database.PostgreSQL deploys to AWS', async () => { + // End-to-end multi-block scenario that covers extractor + deploy- + // expansion + provider type resolution all in one go. + const mod = await import('../deploy/card-translator'); + const result = mod.translate_card_to_graph({ + nodes: [ + { + id: 'site', + type: 'resource', + data: { iceType: 'Compute.StaticSite', label: 'web' }, + }, + { + id: 'sec', + type: 'resource', + data: { + iceType: 'Security.Secret', + label: 'app-secrets', + secrets: [{ key: 'STRIPE_API_KEY', ref: 'prod-stripe' }, { key: 'JWT_SECRET' }], + }, + }, + { + id: 'db', + type: 'resource', + data: { iceType: 'Database.PostgreSQL', label: 'main', master_user_password: 'set-later' }, + }, + ], + edges: [], + provider: 'aws', + projectName: 'demo', + }); + // StaticSite (1) + Secret expansion (2 unique refs) + Postgres (1) = 4 deployables + expect(result.deployable_count).toBe(4); + const types = result.deployables.map((d) => d.resource_type).sort(); + expect(types).toEqual([ + 'aws.rds.dbInstance', + 'aws.s3.bucket', + 'aws.secretsmanager.secret', + 'aws.secretsmanager.secret', + ]); + }); }); describe.skip('Azure Type Map', () => { diff --git a/packages/core/src/compute/propagation-rules.ts b/packages/core/src/compute/propagation-rules.ts index 3fc4a5b8..c2ab41d1 100644 --- a/packages/core/src/compute/propagation-rules.ts +++ b/packages/core/src/compute/propagation-rules.ts @@ -8,62 +8,61 @@ * that is the single source of truth for all reactive property propagation. */ -import { DEFAULT_PORTS, DEFAULT_ENV_VARS } from '@ice/constants'; +import { DEFAULT_PORTS, DEFAULT_ENV_VARS, hasBlockRole } from '@ice/constants'; import type { PropagationRule, AggregateRule, PropagationNode } from './types'; // ─── Block Type Classifiers ───────────────────────────────────────────────── -// Minimal copies of the classifiers from @ice/types/connection-rules. -// Kept local to avoid cross-package moduleResolution conflicts. +// Cardinal-rule schema-driven: every predicate body is a one-line +// lookup against `hasBlockRole` (defined in `@ice/constants/block- +// classifiers.ts`). The shared role tables there are the single source +// of truth shared with `@ice/types/connection-rules/predicates.ts`, so +// the two packages can no longer drift apart on what "is a database" +// or "is a backend" means. function isBackend(t: string): boolean { - return ( - /Backend|Container|Worker|Function|CronJob|Scheduled|AppPlatform|OCIFunctions/i.test(t) || t.startsWith('Compute.') - ); + return hasBlockRole(t, 'backend'); } function isFrontend(t: string): boolean { - return /StaticSite|SSRSite|Frontend/i.test(t); + return hasBlockRole(t, 'frontend'); } function isService(t: string): boolean { return isBackend(t) || isFrontend(t); } function isDatabase(t: string): boolean { - return ( - t.startsWith('Database.') || - /PostgreSQL|MySQL|MongoDB|DynamoDB|Firestore|CosmosDB|AutonomousDB|Tablestore|ManagedDB/i.test(t) - ); + return hasBlockRole(t, 'database'); } function isCache(t: string): boolean { - return /Redis|Cache|Memcache/i.test(t); + return hasBlockRole(t, 'cache'); } function isStorage(t: string): boolean { - return t.startsWith('Storage.') || /Bucket|S3|GCS|Blob|ObjectStorage|Spaces/i.test(t); + return hasBlockRole(t, 'storage'); } function isQueue(t: string): boolean { - return t.startsWith('Messaging.') || /Queue|SQS|SNS|PubSub|ServiceBus|RabbitMQ|Kafka|Event/i.test(t); + return hasBlockRole(t, 'queue'); } function isSearch(t: string): boolean { - return /Search|Elasticsearch/i.test(t) || t === 'Analytics.Search'; + return hasBlockRole(t, 'search'); } function isVectorDb(t: string): boolean { - return /VectorDB|Vector/i.test(t) || t === 'AI.VectorDB'; + return hasBlockRole(t, 'vectorDb'); } function isLLM(t: string): boolean { - return /LLM|ModelServing/i.test(t) || t === 'AI.LLMGateway' || t === 'AI.ModelServing'; + return hasBlockRole(t, 'llm'); } function isDataWarehouse(t: string): boolean { - return /Warehouse|BigQuery|Redshift|Synapse/i.test(t) || t === 'Analytics.DataWarehouse'; + return hasBlockRole(t, 'dataWarehouse'); } function isRepo(t: string): boolean { - return t === 'Source.Repository'; + return hasBlockRole(t, 'repo'); } function isEnvConfig(t: string): boolean { - return t === 'Config.Environment'; + return hasBlockRole(t, 'envConfig'); } function isSecrets(t: string): boolean { - return /Secret|Vault|Certificate/i.test(t) || t === 'Security.Secret'; + return hasBlockRole(t, 'secrets'); } function isCustomDomain(t: string): boolean { - return t === 'Network.CustomDomain'; + return hasBlockRole(t, 'customDomain'); } /** Anything that stores data and should restrict network access */ diff --git a/packages/core/src/deploy/__tests__/block-deploy-classifiers.test.ts b/packages/core/src/deploy/__tests__/block-deploy-classifiers.test.ts new file mode 100644 index 00000000..85721bfa --- /dev/null +++ b/packages/core/src/deploy/__tests__/block-deploy-classifiers.test.ts @@ -0,0 +1,45 @@ +/** + * Tests for `block-deploy-classifiers` — the per-iceType flag table + * the deploy-side classifiers read. + * + * Cardinal rule check: the table is the single declarative fact for + * "this iceType isolates network context" and "this iceType has a + * standalone/nested duality". Classifier code reads these flags + * generically; it MUST NOT name a specific iceType. + */ + +import { describe, it, expect } from 'vitest'; +import { BLOCK_DEPLOY_CLASSIFIERS, getBlockDeployClassifiers } from '../block-deploy-classifiers'; + +describe('BLOCK_DEPLOY_CLASSIFIERS', () => { + it('marks Network.PrivateNetwork as a network-isolation container', () => { + expect(BLOCK_DEPLOY_CLASSIFIERS['Network.PrivateNetwork'].isolatesNetworkContext).toBe(true); + }); + + it('marks Network.CustomDomain as having standalone/nested duality', () => { + expect(BLOCK_DEPLOY_CLASSIFIERS['Network.CustomDomain'].metadataOnlyWhenStandalone).toBe(true); + }); + + it('marks Network.PublicEndpoint as an always-public-ingress block', () => { + expect(BLOCK_DEPLOY_CLASSIFIERS['Network.PublicEndpoint'].publicIngressMode).toBe('always'); + }); + + it('marks Network.CustomDomain as ingress only when nested in an isolation container', () => { + expect(BLOCK_DEPLOY_CLASSIFIERS['Network.CustomDomain'].publicIngressMode).toBe('when-nested-in-isolated-network'); + }); + + it('marks Network.CustomDomain as the domain propagator', () => { + expect(BLOCK_DEPLOY_CLASSIFIERS['Network.CustomDomain'].isDomainPropagator).toBe(true); + }); +}); + +describe('getBlockDeployClassifiers', () => { + it('returns the registered entry when present', () => { + expect(getBlockDeployClassifiers('Network.PrivateNetwork').isolatesNetworkContext).toBe(true); + }); + + it('returns an empty object for unknown iceTypes (safe to read flags)', () => { + expect(getBlockDeployClassifiers('Wholly.Unknown')).toEqual({}); + expect(getBlockDeployClassifiers('').isolatesNetworkContext).toBeUndefined(); + }); +}); diff --git a/packages/core/src/deploy/__tests__/edge-classifier.test.ts b/packages/core/src/deploy/__tests__/edge-classifier.test.ts index 9112c174..22b6ab82 100644 --- a/packages/core/src/deploy/__tests__/edge-classifier.test.ts +++ b/packages/core/src/deploy/__tests__/edge-classifier.test.ts @@ -19,9 +19,38 @@ import { EXTERNAL_TYPES, hasPrivateNetworkAncestor, isCustomDomainStandalone, + isPublicIngressNode, map_edge_relationship, } from '../edge-classifier'; +describe('isPublicIngressNode', () => { + it('returns true for Network.PublicEndpoint regardless of nesting', () => { + const node = { data: { iceType: 'Network.PublicEndpoint' } }; + expect(isPublicIngressNode(node, [])).toBe(true); + }); + + it('returns false for standalone Network.CustomDomain', () => { + const node = { data: { iceType: 'Network.CustomDomain' } }; + expect(isPublicIngressNode(node, [])).toBe(false); + }); + + it('returns true for Network.CustomDomain nested inside Network.PrivateNetwork', () => { + const parent = { id: 'pn1', data: { iceType: 'Network.PrivateNetwork' } }; + const node = { parentId: 'pn1', data: { iceType: 'Network.CustomDomain' } }; + expect(isPublicIngressNode(node, [parent])).toBe(true); + }); + + it('returns false for Network.CustomDomain nested inside a non-isolation parent', () => { + const parent = { id: 'g1', data: { iceType: 'Group.Network' } }; + const node = { parentId: 'g1', data: { iceType: 'Network.CustomDomain' } }; + expect(isPublicIngressNode(node, [parent])).toBe(false); + }); + + it('returns false for plain compute nodes (no publicIngressMode flag)', () => { + expect(isPublicIngressNode({ data: { iceType: 'Compute.Container' } }, [])).toBe(false); + }); +}); + describe('UI_ONLY_TYPES', () => { it('contains exactly 3 entries', () => { expect(UI_ONLY_TYPES.size).toBe(3); diff --git a/packages/core/src/deploy/__tests__/internal-ingress-overrides.test.ts b/packages/core/src/deploy/__tests__/internal-ingress-overrides.test.ts new file mode 100644 index 00000000..514423e7 --- /dev/null +++ b/packages/core/src/deploy/__tests__/internal-ingress-overrides.test.ts @@ -0,0 +1,58 @@ +/** + * Tests for `internal-ingress-overrides` — per-provider mutations + * applied when a service backend is nested inside a network-isolation + * container. + * + * Cardinal-rule check: the table is the schema-shaped fact for + * "how does provider X make a service internal?". Callers iterate + * `applyInternalIngressOverride(resourceType, props)` generically; + * no provider-specific branches in the translator remain. + */ + +import { describe, it, expect } from 'vitest'; +import { INTERNAL_INGRESS_OVERRIDES, applyInternalIngressOverride } from '../internal-ingress-overrides'; + +describe('INTERNAL_INGRESS_OVERRIDES', () => { + it('registers the three providers that today have a service-backend type', () => { + expect(Object.keys(INTERNAL_INGRESS_OVERRIDES).sort()).toEqual([ + 'aws.ecs.service', + 'azure.containerapp.containerApp', + 'gcp.run.service', + ]); + }); + + it('GCP Cloud Run override: ingress="internal-and-cloud-load-balancing" + allow_unauthenticated=false', () => { + const props: Record = {}; + INTERNAL_INGRESS_OVERRIDES['gcp.run.service'](props); + expect(props.allow_unauthenticated).toBe(false); + expect(props.ingress).toBe('internal-and-cloud-load-balancing'); + }); + + it('AWS ECS override: assign_public_ip=false + internal=true', () => { + const props: Record = {}; + INTERNAL_INGRESS_OVERRIDES['aws.ecs.service'](props); + expect(props.assign_public_ip).toBe(false); + expect(props.internal).toBe(true); + }); + + it('Azure Container App override: ingress_external=false', () => { + const props: Record = {}; + INTERNAL_INGRESS_OVERRIDES['azure.containerapp.containerApp'](props); + expect(props.ingress_external).toBe(false); + }); +}); + +describe('applyInternalIngressOverride', () => { + it('applies the GCP override in place', () => { + const props: Record = { cpu: 2 }; + applyInternalIngressOverride('gcp.run.service', props); + expect(props).toMatchObject({ cpu: 2, allow_unauthenticated: false }); + }); + + it('is a no-op for resource types without a registered override', () => { + const props: Record = { foo: 'bar' }; + applyInternalIngressOverride('gcp.storage.bucket', props); + applyInternalIngressOverride('totally.unknown.type', props); + expect(props).toEqual({ foo: 'bar' }); + }); +}); diff --git a/packages/core/src/deploy/__tests__/self-serving-resources.test.ts b/packages/core/src/deploy/__tests__/self-serving-resources.test.ts new file mode 100644 index 00000000..8a6d6e90 --- /dev/null +++ b/packages/core/src/deploy/__tests__/self-serving-resources.test.ts @@ -0,0 +1,35 @@ +/** + * Tests for `self-serving-resources` — the set of provider resource + * types that serve public traffic on their own and need no LB chain. + * + * Cardinal-rule check: the set is the schema-shaped fact. The + * endpoint-wiring pass iterates it generically — there are no + * iceType strings naming any specific block. + */ + +import { describe, it, expect } from 'vitest'; +import { SELF_SERVING_PUBLIC_RESOURCES, isSelfServingPublicResource } from '../self-serving-resources'; + +describe('SELF_SERVING_PUBLIC_RESOURCES', () => { + it("includes GCP Firebase Hosting (today's only self-serving target)", () => { + expect(SELF_SERVING_PUBLIC_RESOURCES.has('gcp.firebase.hosting')).toBe(true); + }); + + it('does NOT include Cloud Run, S3, or other "needs an LB" resources', () => { + expect(SELF_SERVING_PUBLIC_RESOURCES.has('gcp.run.service')).toBe(false); + expect(SELF_SERVING_PUBLIC_RESOURCES.has('aws.s3.bucket')).toBe(false); + expect(SELF_SERVING_PUBLIC_RESOURCES.has('azure.containerapp.containerApp')).toBe(false); + }); +}); + +describe('isSelfServingPublicResource', () => { + it('returns true for registered resource types', () => { + expect(isSelfServingPublicResource('gcp.firebase.hosting')).toBe(true); + }); + + it('returns false for unregistered types', () => { + expect(isSelfServingPublicResource('gcp.run.service')).toBe(false); + expect(isSelfServingPublicResource('totally.unknown.type')).toBe(false); + expect(isSelfServingPublicResource('')).toBe(false); + }); +}); diff --git a/packages/core/src/deploy/__tests__/type-maps.test.ts b/packages/core/src/deploy/__tests__/type-maps.test.ts index eea3b1bc..c3556733 100644 --- a/packages/core/src/deploy/__tests__/type-maps.test.ts +++ b/packages/core/src/deploy/__tests__/type-maps.test.ts @@ -15,8 +15,12 @@ import { GCP_TYPE_MAP, AWS_TYPE_MAP, AZURE_TYPE_MAP, DESIGN_ONLY_PROVIDERS, get_ import type { DeployProvider } from '../card-translator'; describe('GCP_TYPE_MAP', () => { - it('exposes 32 iceType entries', () => { - expect(Object.keys(GCP_TYPE_MAP)).toHaveLength(32); + it('exposes 33 iceType entries', () => { + expect(Object.keys(GCP_TYPE_MAP)).toHaveLength(33); + }); + + it('maps nested Network.CustomDomain → gcp.compute.globalForwardingRule (mirrors PublicEndpoint)', () => { + expect(GCP_TYPE_MAP['Network.CustomDomain']).toBe('gcp.compute.globalForwardingRule'); }); it('maps Compute.StaticSite → gcp.firebase.hosting (Firebase Hosting choice)', () => { @@ -43,8 +47,12 @@ describe('GCP_TYPE_MAP', () => { }); describe('AWS_TYPE_MAP', () => { - it('exposes 27 iceType entries', () => { - expect(Object.keys(AWS_TYPE_MAP)).toHaveLength(27); + it('exposes 28 iceType entries', () => { + expect(Object.keys(AWS_TYPE_MAP)).toHaveLength(28); + }); + + it('maps nested Network.CustomDomain → aws.cloudfront.distribution (mirrors PublicEndpoint)', () => { + expect(AWS_TYPE_MAP['Network.CustomDomain']).toBe('aws.cloudfront.distribution'); }); it('maps Compute.Container → aws.ecs.service', () => { @@ -67,8 +75,12 @@ describe('AWS_TYPE_MAP', () => { }); describe('AZURE_TYPE_MAP', () => { - it('exposes 26 iceType entries', () => { - expect(Object.keys(AZURE_TYPE_MAP)).toHaveLength(26); + it('exposes 27 iceType entries', () => { + expect(Object.keys(AZURE_TYPE_MAP)).toHaveLength(27); + }); + + it('maps nested Network.CustomDomain → azure.cdn.profile (mirrors PublicEndpoint)', () => { + expect(AZURE_TYPE_MAP['Network.CustomDomain']).toBe('azure.cdn.profile'); }); it('maps Storage.Bucket → azure.storage.storageAccount', () => { diff --git a/packages/core/src/deploy/block-deploy-classifiers.ts b/packages/core/src/deploy/block-deploy-classifiers.ts new file mode 100644 index 00000000..6d3faaf5 --- /dev/null +++ b/packages/core/src/deploy/block-deploy-classifiers.ts @@ -0,0 +1,75 @@ +/** + * Per-iceType deploy classification flags. + * + * Cardinal-rule schema-driven dispatch. The edge classifier reads from + * this table generically — no `if (iceType === 'X')` branches in the + * classifier functions. Adding a new block whose iceType changes deploy + * shape based on context (network isolation, parent nesting, metadata- + * only behaviour) adds an entry here; classifier code stays unchanged. + * + * Why this lives in core/deploy and not on HighLevelResource: the + * `Network.PrivateNetwork` and `Network.CustomDomain` iceTypes aren't + * declared in `HIGH_LEVEL_CATEGORIES` (they're authored as blueprints + * in `@ice/blocks`). Promoting them into HIGH_LEVEL_CATEGORIES is a + * larger change with palette/properties side-effects; for the + * classifier's narrow needs a sibling deploy-side table is the + * smallest correct unit of schema declaration. + */ + +export interface BlockDeployClassifiers { + /** + * The block represents a network-isolation boundary (a VPC, Private + * Network, etc.). Services nested inside it should compile to the + * internal-only variant of their underlying compute resource — see + * the card-translator's ingress-override branch. + */ + isolatesNetworkContext?: boolean; + /** + * The block has TWO deploy modes depending on parent context: + * - STANDALONE (no parent in an isolating container): metadata-only, + * consumed by downstream propagation passes but emits no cloud + * resource of its own. + * - NESTED inside an isolating container: compiles to a real cloud + * resource (its provider type-map entry). + * Example: Network.CustomDomain — standalone = host propagation only, + * nested inside Network.PrivateNetwork = LB ingress chain. + */ + metadataOnlyWhenStandalone?: boolean; + /** + * Whether this block acts as a public ingress endpoint (the head of + * a load-balancer chain backed by compute services). + * - 'always': the block is ALWAYS a public ingress (e.g. + * Network.PublicEndpoint). + * - 'when-nested-in-isolated-network': only counts as ingress when + * nested inside a block with `isolatesNetworkContext` (e.g. + * Network.CustomDomain inside Network.PrivateNetwork acts as the + * network's gateway; standalone CD stays DNS-only). + */ + publicIngressMode?: 'always' | 'when-nested-in-isolated-network'; + /** + * The block carries a `domain` (root) + `routes` (per-subdomain rows) + * and propagates the full host onto every connected compute target. + * Read by the domain-propagation pass; iterating this flag lets new + * domain-source blocks slot in without touching the pass code. + */ + isDomainPropagator?: boolean; +} + +export const BLOCK_DEPLOY_CLASSIFIERS: Record = { + 'Network.PrivateNetwork': { + isolatesNetworkContext: true, + }, + 'Network.PublicEndpoint': { + publicIngressMode: 'always', + }, + 'Network.CustomDomain': { + metadataOnlyWhenStandalone: true, + publicIngressMode: 'when-nested-in-isolated-network', + isDomainPropagator: true, + }, +}; + +/** Convenience accessor — empty object for unknown iceTypes. */ +export function getBlockDeployClassifiers(iceType: string): BlockDeployClassifiers { + return BLOCK_DEPLOY_CLASSIFIERS[iceType] ?? {}; +} diff --git a/packages/core/src/deploy/card-translator.ts b/packages/core/src/deploy/card-translator.ts index 0ab58e13..3e7dd49c 100644 --- a/packages/core/src/deploy/card-translator.ts +++ b/packages/core/src/deploy/card-translator.ts @@ -5,20 +5,24 @@ * with GCP-typed nodes that the deploy pipeline understands. */ +import { hasBlockRole } from '@ice/constants'; import { UI_ONLY_TYPES, - SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS, EXTERNAL_TYPES, - hasPrivateNetworkAncestor, - isCustomDomainStandalone, + hasNetworkIsolatingAncestor, + isStandaloneMetadataOnly, map_edge_relationship, } from './edge-classifier'; import { create_mutable_graph } from '../graph/mutable-graph'; import { PROPERTY_EXTRACTORS } from './extractors/dispatch'; +import { applyInternalIngressOverride } from './internal-ingress-overrides'; +import { expand_deployable_per_entry } from './passes/deploy-expansion'; import { wire_source_repositories } from './passes/pass-1-4-repo-wiring'; import { propagate_custom_domain_hosts } from './passes/pass-1-45-domain-propagation'; +import { propagate_socket_port_targets } from './passes/pass-1-46-socket-port-targeting'; import { wire_public_endpoints } from './passes/pass-1-5-endpoint-wiring'; import { DESIGN_ONLY_PROVIDERS, get_type_map } from './type-maps'; +import { getHighLevelResourceByIceType } from '../resources/high-level-resources'; import { sanitize_name, sanitize_label_value } from './utils/name-utils'; import { generate_stable_name } from './utils/stable-name'; import type { Graph } from '../types/graph'; @@ -193,14 +197,16 @@ export function translate_card_to_graph(input: CardTranslationInput): CardTransl continue; } - // Standalone Network.CustomDomain is UI-only (metadata for Pass 1.6 - // propagation). Nested inside a PrivateNetwork it becomes deployable - // — see isCustomDomainStandalone + the dynamic type lookup below. - if (isCustomDomainStandalone(node, nodes)) { + // Blocks declared with `metadataOnlyWhenStandalone` (today: Network. + // CustomDomain) are UI-only when not nested in an isolation container + // — downstream propagation passes consume them, no cloud resource + // emitted. Nested inside an isolation container they become + // deployable via the dynamic type lookup below. + if (isStandaloneMetadataOnly(node, nodes)) { skipped.push({ nodeId: node.id, label: (node.data.label as string) || node.id, - reason: 'Standalone Network.CustomDomain is metadata-only (handled by Pass 1.6)', + reason: `Standalone ${ice_type} is metadata-only (handled by downstream propagation passes)`, }); continue; } @@ -215,11 +221,12 @@ export function translate_card_to_graph(input: CardTranslationInput): CardTransl continue; } - // Look up the deployer type. Nested Network.CustomDomain inside a - // PrivateNetwork compiles to the global forwarding rule (same as - // Network.PublicEndpoint) — the nested case isn't in the type map - // because standalone CDs are UI-only, so we resolve it inline here. - const gcp_type = ice_type === 'Network.CustomDomain' ? 'gcp.compute.globalForwardingRule' : type_map[ice_type]; + // Look up the deployer type. Network.CustomDomain has its own + // per-provider entry in each type-map (nested CDs compile to the + // same ingress chain as Network.PublicEndpoint on every provider; + // standalone CDs are filtered out above by isStandaloneMetadataOnly). + // No iceType-specific branches here — pure table lookup. + const gcp_type = type_map[ice_type]; if (!gcp_type) { warnings.push(`No ${provider} mapping for iceType "${ice_type}" (node: ${node.data.label || node.id}). Skipped.`); skipped.push({ @@ -252,26 +259,62 @@ export function translate_card_to_graph(input: CardTranslationInput): CardTransl } const properties = extractor(node.data, region, node.id); - // Private Network ingress override. + // ─── Schema-declared 1→N deploy expansion ───────────────────────── // - // When a service backend (Scalable Backend / SSR Site / Worker / - // Serverless Function) is nested inside a Network.PrivateNetwork, - // emit the internal-only variant of the underlying compute resource. - // A nested Custom Domain (if present) remains the sole external - // entry point via its own LB chain; see isCustomDomainStandalone + - // the backend-wiring at ~line 1100. - if (SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS.has(ice_type) && hasPrivateNetworkAncestor(node, nodes)) { - const props = properties as Record; - if (gcp_type === 'gcp.run.service') { - // Internal Cloud Run — only reachable via VPC or internal LB. - props.allow_unauthenticated = false; - props.ingress = 'internal-and-cloud-load-balancing'; - } else if (gcp_type === 'aws.ecs.service') { - props.assign_public_ip = false; - props.internal = true; - } else if (gcp_type === 'azure.containerapp.containerApp') { - props.ingress_external = false; - } + // When the canonical schema sets `deployExpansion`, partition the + // extractor's output and emit one cloud resource per entry instead + // of one per block. This branch is iceType-agnostic — Secret Store + // happens to be the first user, but ANY future block whose schema + // declares expansion goes through the same code path. + // + // No edge connections back to the source block — the canvas-side + // propagation rules carry per-entry refs onto consumer nodes + // (e.g. service `secretRefs`), and leaving the block out of + // `card_id_to_name` makes the deferred edge pass drop any orphan + // edges naturally. + const schemaResource = getHighLevelResourceByIceType(ice_type); + if (schemaResource?.deployExpansion) { + const blockLabel = (node.data.label as string) || ice_type.split('.').pop() || 'resource'; + const baseLabels: Record = { + 'ice-managed': 'true', + 'ice-source-id': sanitize_label_value(node.id), + 'ice-type': sanitize_label_value(ice_type), + 'ice-project': sanitize_label_value(projectName), + }; + if (input.environment) baseLabels['ice-environment'] = sanitize_label_value(input.environment); + if (cardId) baseLabels['ice-card-id'] = sanitize_label_value(cardId); + + const expansionResult = expand_deployable_per_entry({ + expansion: schemaResource.deployExpansion, + nodeId: node.id, + blockLabel, + iceType: ice_type, + // `gcp_type` is the provider-resolved type (legacy variable name + // — covers AWS / Azure / GCP / K8s); it just gets forwarded. + resourceType: gcp_type, + properties: properties as Record, + baseLabels, + graph, + deployables, + skipped, + warnings, + provider, + }); + deployable_count += expansionResult.added; + continue; + } + + // Network-isolation ingress override. + // + // When a `serviceBackend`-role block (Scalable Backend / SSR Site / + // Worker / Serverless Function) is nested inside a network-isolation + // container (any iceType with BLOCK_DEPLOY_CLASSIFIERS.isolatesNetworkContext), + // ask the provider's registered override to mutate the property dict + // so the resource serves traffic internally. The provider-specific + // logic lives in `internal-ingress-overrides.ts`; the translator + // stays provider-agnostic. + if (hasBlockRole(ice_type, 'serviceBackend') && hasNetworkIsolatingAncestor(node, nodes)) { + applyInternalIngressOverride(gcp_type, properties as Record); } // Phase 1 — stable resource identity. @@ -358,6 +401,14 @@ export function translate_card_to_graph(input: CardTranslationInput): CardTransl // ─── Pass 1.45 — Network.CustomDomain → target host propagation ──────── propagate_custom_domain_hosts(edges, nodes, card_id_to_name, graph); + // ─── Pass 1.46 — Socket-driven target-port routing ───────────────────── + // Reads `edge.data.targetSocket` / `sourceSocket` ids of shape + // `port--(in|out)` and writes the encoded port onto the compute + // node's `target_port` (and `port` if not user-set). Makes multi-port + // containers' typed-socket choices actually drive what the LB + // targets at deploy time. + propagate_socket_port_targets(edges, nodes, card_id_to_name, graph); + // ─── Pass 1.5 — PublicEndpoint semantic wiring ───────────────────────── const { deployable_count_delta } = wire_public_endpoints({ edges, diff --git a/packages/core/src/deploy/edge-classifier.ts b/packages/core/src/deploy/edge-classifier.ts index 88b87a58..c5661b68 100644 --- a/packages/core/src/deploy/edge-classifier.ts +++ b/packages/core/src/deploy/edge-classifier.ts @@ -3,10 +3,18 @@ * * Bundles the predicates and constants the translator uses to decide * which canvas nodes compile to real cloud resources, which act as - * backends behind a Private Network override, and how raw edge + * backends behind a network-isolation override, and how raw edge * relationship strings resolve to typed `EdgeRelationship` values. + * + * Cardinal rule: the ancestor walk + standalone-mode predicates read + * `BLOCK_DEPLOY_CLASSIFIERS` (a per-iceType flag table) instead of + * naming specific iceTypes. Adding a new isolation container or a new + * block with standalone/nested duality adds a table entry; the + * classifier functions stay unchanged. */ +import { BLOCK_ROLES_BY_ICE_TYPE } from '@ice/constants'; +import { getBlockDeployClassifiers } from './block-deploy-classifiers'; import type { EdgeRelationship } from '../types/graph'; // iceTypes that are UI-only and should not be deployed @@ -27,32 +35,37 @@ import type { EdgeRelationship } from '../types/graph'; export const UI_ONLY_TYPES = new Set(['Source.Repository', 'Config.Environment', 'Network.PublicTraffic']); /** - * iceTypes whose compute is treated as a service backend. Shared between - * the LB-wiring path (line ~1059) and the Private Network ingress-override - * logic below. + * iceTypes whose compute is treated as a service backend (compiles to + * Cloud Run / ECS service with a Serverless NEG when wired through a + * public-ingress endpoint). + * + * Cardinal-rule schema-driven: the contents come from + * `BLOCK_ROLES_BY_ICE_TYPE` via the `serviceBackend` role. This Set is + * a thin materialisation kept for back-compat with callers that want + * `.has(iceType)`. New iceTypes register the role in the table; the + * Set picks them up automatically. The previous in-file inline list + + * the duplicate inside `pass-1-5-endpoint-wiring.ts` have both been + * replaced with a single role lookup. */ -export const SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS = new Set([ - 'Compute.Container', - 'Compute.BackendAPI', - 'Compute.SSRSite', - 'Compute.Worker', - 'Compute.ServerlessFunction', -]); +export const SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS: ReadonlySet = new Set( + Object.entries(BLOCK_ROLES_BY_ICE_TYPE) + .filter(([, roles]) => roles.includes('serviceBackend')) + .map(([iceType]) => iceType), +); /** - * Walk the parent chain to check whether any ancestor is a Private Network. + * Walk the parent chain to check whether any ancestor is a network- + * isolation container (today: Network.PrivateNetwork; tomorrow: any + * iceType the schema-shaped table declares with + * `isolatesNetworkContext: true`). * - * When a service backend (Compute.Container / SSR / Worker / etc.) is nested - * inside a Network.PrivateNetwork, the compiler should emit the internal-only - * variant of the underlying compute resource: - * - GCP Cloud Run: ingress = 'internal-and-cloud-load-balancing' - * - AWS ECS: no public ALB; rely on nested Custom Domain for ingress + * When a service backend is nested inside one, the compiler emits the + * internal-only variant of the underlying compute resource: + * - GCP Cloud Run: ingress = 'internal-and-cloud-load-balancing' + * - AWS ECS: no public ALB; rely on nested ingress block * - Azure Container App: internal ingress - * - * The nested Custom Domain (if present) acts as the sole external entry - * point via its own LB chain — see lines 957-970 for that path. */ -export function hasPrivateNetworkAncestor( +export function hasNetworkIsolatingAncestor( node: { id: string; parentId?: string | null }, allNodes: Array<{ id: string; parentId?: string | null; data: Record }>, ): boolean { @@ -62,37 +75,78 @@ export function hasPrivateNetworkAncestor( visited.add(currentParentId); const parent = allNodes.find((n) => n.id === currentParentId); if (!parent) return false; - if (parent.data?.iceType === 'Network.PrivateNetwork') return true; + const parentIceType = (parent.data?.iceType as string) || ''; + if (getBlockDeployClassifiers(parentIceType).isolatesNetworkContext) return true; currentParentId = parent.parentId; } return false; } /** - * Network.CustomDomain has two modes: + * Blocks declared with `metadataOnlyWhenStandalone: true` have TWO + * deploy modes: + * + * 1. STANDALONE (no parent, or parent is NOT a network-isolation + * container): metadata-only. The node is consumed by downstream + * propagation passes but does NOT emit a cloud resource. * - * 1. STANDALONE (no parent, or parent is not a PrivateNetwork): - * metadata-only — it carries a root domain + per-edge subdomains - * and is consumed by Pass 1.6 to propagate the full host onto - * each connected target's `domain` property. Firebase Hosting - * (et al.) then registers the custom domain via its native API. - * NO dedicated resource is deployed. + * 2. NESTED inside an isolation container: compiles to the real + * cloud resource (full LB chain in the Custom-Domain-in-Private- + * Network case). * - * 2. NESTED inside a Network.PrivateNetwork: the CD is that - * network's public ingress gateway. It compiles to the full LB - * chain (forwarding rule + URL map + backend services) targeting - * sibling services inside the parent VPC. + * Returns true ONLY when both conditions hold: the node's iceType + * has the duality flag AND there is no isolation-container parent. */ -export function isCustomDomainStandalone( +export function isStandaloneMetadataOnly( node: { data: Record; parentId?: string | null }, allNodes: Array<{ id: string; data: Record }>, ): boolean { - if (node.data?.iceType !== 'Network.CustomDomain') return false; + const iceType = (node.data?.iceType as string) || ''; + if (!getBlockDeployClassifiers(iceType).metadataOnlyWhenStandalone) return false; if (!node.parentId) return true; const parent = allNodes.find((n) => n.id === node.parentId); - return parent?.data?.iceType !== 'Network.PrivateNetwork'; + if (!parent) return true; + const parentIceType = (parent.data?.iceType as string) || ''; + return !getBlockDeployClassifiers(parentIceType).isolatesNetworkContext; +} + +/** + * Whether a node is a public-ingress endpoint (the head of a load + * balancer chain). Schema-shaped: reads `publicIngressMode` from + * `BLOCK_DEPLOY_CLASSIFIERS`. + * + * - `publicIngressMode === 'always'`: this iceType is always ingress + * (e.g. Network.PublicEndpoint). + * - `publicIngressMode === 'when-nested-in-isolated-network'`: ingress + * only when nested inside an isolation container (Network. + * CustomDomain inside Network.PrivateNetwork acts as the network's + * gateway; standalone CD stays DNS-only). + * - any other value (or unset): never an ingress endpoint. + */ +export function isPublicIngressNode( + node: { data: Record; parentId?: string | null }, + allNodes: Array<{ id: string; data: Record }>, +): boolean { + const iceType = (node.data?.iceType as string) || ''; + const mode = getBlockDeployClassifiers(iceType).publicIngressMode; + if (mode === 'always') return true; + if (mode === 'when-nested-in-isolated-network') { + if (!node.parentId) return false; + const parent = allNodes.find((n) => n.id === node.parentId); + if (!parent) return false; + const parentIceType = (parent.data?.iceType as string) || ''; + return !!getBlockDeployClassifiers(parentIceType).isolatesNetworkContext; + } + return false; } +// Kept temporarily for callers that haven't switched names — both +// re-export the same body so the rename is risk-free. +/** @deprecated Use `hasNetworkIsolatingAncestor`. */ +export const hasPrivateNetworkAncestor = hasNetworkIsolatingAncestor; +/** @deprecated Use `isStandaloneMetadataOnly`. */ +export const isCustomDomainStandalone = isStandaloneMetadataOnly; + // iceTypes that are external services (not GCP-managed) export const EXTERNAL_TYPES = new Set(['Database.MongoDB']); diff --git a/packages/core/src/deploy/extractors/__tests__/ancillary.test.ts b/packages/core/src/deploy/extractors/__tests__/ancillary.test.ts index 3ff92c93..94232fde 100644 --- a/packages/core/src/deploy/extractors/__tests__/ancillary.test.ts +++ b/packages/core/src/deploy/extractors/__tests__/ancillary.test.ts @@ -42,6 +42,7 @@ describe('extract_secret_manager_properties', () => { it('returns defaults for an empty data object', () => { expect(extract_secret_manager_properties({}, 'us-central1')).toEqual({ replication_type: 'automatic', + bindings: [], labels: {}, }); }); @@ -61,6 +62,19 @@ describe('extract_secret_manager_properties', () => { const b = extract_secret_manager_properties({}, 'europe-west2'); expect(a).toEqual(b); }); + + it('passes bindings through verbatim from data.secrets', () => { + const result = extract_secret_manager_properties( + { secrets: [{ key: 'API_KEY', ref: 'prod-api-key' }, { key: 'TOKEN' }] }, + 'us-central1', + ); + expect(result.bindings).toEqual([{ key: 'API_KEY', ref: 'prod-api-key' }, { key: 'TOKEN' }]); + }); + + it('coerces missing or non-array secrets to []', () => { + expect(extract_secret_manager_properties({ secrets: 'oops' }, 'us-central1').bindings).toEqual([]); + expect(extract_secret_manager_properties({}, 'us-central1').bindings).toEqual([]); + }); }); describe('extract_identity_platform_properties', () => { diff --git a/packages/core/src/deploy/extractors/__tests__/compute.test.ts b/packages/core/src/deploy/extractors/__tests__/compute.test.ts index 92c08063..7a7881ba 100644 --- a/packages/core/src/deploy/extractors/__tests__/compute.test.ts +++ b/packages/core/src/deploy/extractors/__tests__/compute.test.ts @@ -18,6 +18,7 @@ import { extract_cloud_run_job_properties, extract_cloud_functions_properties, extract_cloud_scheduler_properties, + parse_exposed_ports, } from '../compute'; describe('extract_cloud_run_properties', () => { @@ -85,6 +86,81 @@ describe('extract_cloud_run_properties', () => { const result = extract_cloud_run_properties({ labels: { keep: 'me' } }, 'us-central1'); expect(result.labels).toEqual({}); }); + + it('honors exposed_ports — primary port is the first entry, full list in additional_ports', () => { + const result = extract_cloud_run_properties( + { + exposed_ports: [ + JSON.stringify({ port: 8080, protocol: 'http', label: 'api' }), + JSON.stringify({ port: 8443, protocol: 'https' }), + ], + }, + 'us-central1', + ); + expect(result.port).toBe(8080); + expect(result.additional_ports).toEqual([ + { port: 8080, protocol: 'http', label: 'api' }, + { port: 8443, protocol: 'https' }, + ]); + }); + + it('exposed_ports wins over the legacy data.port scalar when both are set', () => { + const result = extract_cloud_run_properties( + { + port: 3000, + exposed_ports: [JSON.stringify({ port: 8080, protocol: 'http' })], + }, + 'us-central1', + ); + expect(result.port).toBe(8080); + }); + + it('falls back to data.port when exposed_ports is missing', () => { + const result = extract_cloud_run_properties({ port: 3000 }, 'us-central1'); + expect(result.port).toBe(3000); + expect(result.additional_ports).toBeUndefined(); + }); +}); + +describe('parse_exposed_ports', () => { + it('returns [] for missing or non-array input', () => { + expect(parse_exposed_ports({})).toEqual([]); + expect(parse_exposed_ports({ exposed_ports: 'nope' })).toEqual([]); + expect(parse_exposed_ports({ exposed_ports: null })).toEqual([]); + }); + + it('parses JSON-stringified entries', () => { + const out = parse_exposed_ports({ + exposed_ports: [JSON.stringify({ port: 8080, protocol: 'http', label: 'api' })], + }); + expect(out).toEqual([{ port: 8080, protocol: 'http', label: 'api' }]); + }); + + it('parses compact text entries like "https:443:web"', () => { + expect(parse_exposed_ports({ exposed_ports: ['https:443:web'] })).toEqual([ + { port: 443, protocol: 'https', label: 'web' }, + ]); + }); + + it('parses plain object entries', () => { + expect(parse_exposed_ports({ exposed_ports: [{ port: 9000, protocol: 'tcp' }] })).toEqual([ + { port: 9000, protocol: 'tcp' }, + ]); + }); + + it('defaults to http when protocol is unknown', () => { + expect(parse_exposed_ports({ exposed_ports: [{ port: 8080, protocol: 'weird' }] })).toEqual([ + { port: 8080, protocol: 'http' }, + ]); + }); + + it('drops malformed entries silently', () => { + expect( + parse_exposed_ports({ + exposed_ports: ['', 'garbage', JSON.stringify({ port: 0 }), JSON.stringify({ port: 5000 })], + }), + ).toEqual([{ port: 5000, protocol: 'http' }]); + }); }); describe('extract_cloud_run_job_properties', () => { diff --git a/packages/core/src/deploy/extractors/__tests__/dispatch.test.ts b/packages/core/src/deploy/extractors/__tests__/dispatch.test.ts index 78273a35..2e607d07 100644 --- a/packages/core/src/deploy/extractors/__tests__/dispatch.test.ts +++ b/packages/core/src/deploy/extractors/__tests__/dispatch.test.ts @@ -55,14 +55,21 @@ import { } from '../network'; describe('PROPERTY_EXTRACTORS table shape', () => { - it('has exactly 27 entries (matches the 27 resolved GCP types the deployer supports)', () => { - expect(Object.keys(PROPERTY_EXTRACTORS)).toHaveLength(27); + it('counts GCP entries (27) + AWS entries — the latter grows with each AWS handler commit', () => { + const keys = Object.keys(PROPERTY_EXTRACTORS); + const gcpKeys = keys.filter((k) => k.startsWith('gcp.')); + const awsKeys = keys.filter((k) => k.startsWith('aws.')); + expect(gcpKeys).toHaveLength(27); + // AWS compute (commit #2): ecs.service, lambda.function, events.rule. + // Subsequent commits add database / network / ancillary / ai + // extractors — this assertion bumps when each lands. + expect(awsKeys.length).toBeGreaterThanOrEqual(3); }); - it('every key matches the gcp.{service}.{kind} shape', () => { - const pattern = /^gcp\.[a-z]+\.[a-zA-Z]+$/; + it('every key matches the {provider}.{service}.{kind} shape', () => { + const pattern = /^(gcp|aws|azure)\.[a-z0-9]+\.[a-zA-Z]+$/; for (const key of Object.keys(PROPERTY_EXTRACTORS)) { - expect(key, `key "${key}" should be gcp.{service}.{kind}`).toMatch(pattern); + expect(key, `key "${key}" should be {provider}.{service}.{kind}`).toMatch(pattern); } }); @@ -75,7 +82,7 @@ describe('PROPERTY_EXTRACTORS table shape', () => { it('returns undefined for an unknown key (orchestrator falls through to the error path)', () => { expect(PROPERTY_EXTRACTORS['gcp.unknown.thing']).toBeUndefined(); expect(PROPERTY_EXTRACTORS['']).toBeUndefined(); - expect(PROPERTY_EXTRACTORS['aws.s3.bucket']).toBeUndefined(); + expect(PROPERTY_EXTRACTORS['aws.unknown.thing']).toBeUndefined(); }); }); diff --git a/packages/core/src/deploy/extractors/ancillary.ts b/packages/core/src/deploy/extractors/ancillary.ts index 88cb1ad7..4f756fd8 100644 --- a/packages/core/src/deploy/extractors/ancillary.ts +++ b/packages/core/src/deploy/extractors/ancillary.ts @@ -13,8 +13,15 @@ export function extract_secret_manager_properties( data: Record, _region: string, ): Record { + // Pass the bindings through verbatim. The handler currently creates + // a single parent `Secret` resource named after the block; each row + // here describes the upstream entry an env var should resolve to + // (`{ key: ENV_VAR, ref: secret-id }`). Wiring each binding to its + // own provider resource is a translator-level expansion (one block → + // N resources) — left for a follow-up. return { replication_type: data.replicationType || 'automatic', + bindings: Array.isArray(data.secrets) ? data.secrets : [], labels: {}, }; } diff --git a/packages/core/src/deploy/extractors/aws/__tests__/ai.test.ts b/packages/core/src/deploy/extractors/aws/__tests__/ai.test.ts new file mode 100644 index 00000000..93bd7382 --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/__tests__/ai.test.ts @@ -0,0 +1,114 @@ +/** + * Tests for AWS AI / analytics extractors. + */ + +import { describe, it, expect } from 'vitest'; +import { + extract_opensearch_domain_properties, + extract_bedrock_endpoint_properties, + extract_sagemaker_endpoint_properties, + extract_redshift_cluster_properties, +} from '../ai'; + +describe('extract_opensearch_domain_properties', () => { + it('defaults to OpenSearch 2.13 on a single t3.small.search instance', () => { + expect(extract_opensearch_domain_properties({}, 'us-east-1')).toMatchObject({ + region: 'us-east-1', + engine_version: 'OpenSearch_2.13', + instance_type: 't3.small.search', + instance_count: 1, + dedicated_master_enabled: false, + ebs_enabled: true, + ebs_volume_type: 'gp3', + ebs_volume_size_gb: 10, + encryption_at_rest: true, + node_to_node_encryption: true, + }); + }); + + it('honours production-sized overrides (3 nodes + dedicated master)', () => { + const result = extract_opensearch_domain_properties( + { + instance_count: 3, + instance_type: 'r6g.large.search', + dedicated_master_enabled: true, + dedicated_master_type: 'r6g.large.search', + dedicated_master_count: 3, + }, + 'eu-west-1', + ); + expect(result.instance_count).toBe(3); + expect(result.dedicated_master_enabled).toBe(true); + expect(result.dedicated_master_count).toBe(3); + }); +}); + +describe('extract_bedrock_endpoint_properties', () => { + it('defaults to Claude 3 Haiku, on-demand (zero model units)', () => { + expect(extract_bedrock_endpoint_properties({}, 'us-east-1')).toMatchObject({ + region: 'us-east-1', + model_id: 'anthropic.claude-3-haiku-20240307-v1:0', + model_units: 0, + commitment_duration: 'OneMonth', + }); + }); + + it('emits provisioned-throughput config when model_units > 0', () => { + const result = extract_bedrock_endpoint_properties( + { model_units: 5, commitment_duration: 'SixMonths' }, + 'us-east-1', + ); + expect(result.model_units).toBe(5); + expect(result.commitment_duration).toBe('SixMonths'); + }); +}); + +describe('extract_sagemaker_endpoint_properties', () => { + it('defaults to a real-time ml.t2.medium endpoint', () => { + expect(extract_sagemaker_endpoint_properties({}, 'us-east-1')).toMatchObject({ + region: 'us-east-1', + model_name: '', + instance_type: 'ml.t2.medium', + initial_instance_count: 1, + initial_variant_weight: 1.0, + endpoint_mode: 'real-time', + }); + }); + + it('passes endpoint_mode + instance_type overrides through', () => { + const result = extract_sagemaker_endpoint_properties( + { endpoint_mode: 'serverless', instance_type: 'ml.g4dn.xlarge', initial_instance_count: 2 }, + 'us-east-1', + ); + expect(result.endpoint_mode).toBe('serverless'); + expect(result.instance_type).toBe('ml.g4dn.xlarge'); + expect(result.initial_instance_count).toBe(2); + }); +}); + +describe('extract_redshift_cluster_properties', () => { + it('defaults to a single-node dc2.large with no password', () => { + expect(extract_redshift_cluster_properties({}, 'us-east-1')).toMatchObject({ + region: 'us-east-1', + node_type: 'dc2.large', + cluster_type: 'single-node', + number_of_nodes: 1, + db_name: 'analytics', + master_username: 'admin', + master_user_password: '', + publicly_accessible: false, + encrypted: true, + port: 5439, + }); + }); + + it('honours production-sized overrides (ra3 multi-node)', () => { + const result = extract_redshift_cluster_properties( + { node_type: 'ra3.xlplus', cluster_type: 'multi-node', number_of_nodes: 3 }, + 'us-east-1', + ); + expect(result.node_type).toBe('ra3.xlplus'); + expect(result.cluster_type).toBe('multi-node'); + expect(result.number_of_nodes).toBe(3); + }); +}); diff --git a/packages/core/src/deploy/extractors/aws/__tests__/ancillary.test.ts b/packages/core/src/deploy/extractors/aws/__tests__/ancillary.test.ts new file mode 100644 index 00000000..07ae71a0 --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/__tests__/ancillary.test.ts @@ -0,0 +1,120 @@ +/** + * Tests for AWS ancillary extractors. + */ + +import { describe, it, expect } from 'vitest'; +import { + extract_sqs_queue_properties, + extract_sns_topic_properties, + extract_cognito_user_pool_properties, + extract_secrets_manager_secret_properties, + extract_cloudwatch_log_group_properties, +} from '../ancillary'; + +describe('extract_sqs_queue_properties', () => { + it('defaults to a standard 4-day-retention queue', () => { + expect(extract_sqs_queue_properties({}, 'us-east-1')).toEqual({ + region: 'us-east-1', + fifo: false, + message_retention_seconds: 345600, + visibility_timeout_seconds: 30, + delay_seconds: 0, + tags: {}, + }); + }); + + it('emits FIFO + content_based_deduplication only on FIFO queues', () => { + const fifo = extract_sqs_queue_properties({ fifo: true, content_based_dedup: true }, 'us-east-1'); + expect(fifo.fifo).toBe(true); + expect(fifo.content_based_deduplication).toBe(true); + + const std = extract_sqs_queue_properties({ content_based_dedup: true }, 'us-east-1'); + expect(std.content_based_deduplication).toBeUndefined(); + }); +}); + +describe('extract_sns_topic_properties', () => { + it('defaults to standard topic with no display name + no KMS key', () => { + expect(extract_sns_topic_properties({}, 'us-east-1')).toEqual({ + region: 'us-east-1', + fifo: false, + display_name: '', + kms_master_key_id: undefined, + tags: {}, + }); + }); + + it('honours FIFO flag', () => { + expect(extract_sns_topic_properties({ fifo: true }, 'us-east-1').fifo).toBe(true); + }); +}); + +describe('extract_cognito_user_pool_properties', () => { + it('defaults to email auto-verification + email/google sign-in + MFA off', () => { + const result = extract_cognito_user_pool_properties({}, 'us-east-1'); + expect(result).toMatchObject({ + region: 'us-east-1', + auto_verified_attributes: ['email'], + sign_in_providers: ['email', 'google'], + mfa_configuration: 'OFF', + }); + expect(result.password_policy).toEqual({ + minimum_length: 8, + require_uppercase: true, + require_lowercase: true, + require_numbers: true, + require_symbols: false, + }); + }); + + it('flips MFA to ON when mfaEnabled=true', () => { + expect(extract_cognito_user_pool_properties({ mfaEnabled: true }, 'us-east-1').mfa_configuration).toBe('ON'); + }); + + it('reads signInProviders (camelCase canvas field) or snake variant', () => { + expect( + extract_cognito_user_pool_properties({ signInProviders: ['phone', 'github'] }, 'us-east-1').sign_in_providers, + ).toEqual(['phone', 'github']); + }); +}); + +describe('extract_secrets_manager_secret_properties', () => { + it('forwards data.secrets as bindings (parallel to GCP secret_manager)', () => { + const result = extract_secrets_manager_secret_properties( + { secrets: [{ key: 'API_KEY', ref: 'prod-api-key' }, { key: 'TOKEN' }] }, + 'us-east-1', + ); + expect(result.bindings).toEqual([{ key: 'API_KEY', ref: 'prod-api-key' }, { key: 'TOKEN' }]); + }); + + it('coerces missing or non-array secrets to []', () => { + expect(extract_secrets_manager_secret_properties({ secrets: 'oops' }, 'us-east-1').bindings).toEqual([]); + expect(extract_secrets_manager_secret_properties({}, 'us-east-1').bindings).toEqual([]); + }); + + it('emits undefined for rotation_lambda_arn + kms_key_id by default', () => { + const result = extract_secrets_manager_secret_properties({}, 'us-east-1'); + expect(result.rotation_lambda_arn).toBeUndefined(); + expect(result.kms_key_id).toBeUndefined(); + expect(result.rotation_days).toBe(0); + }); +}); + +describe('extract_cloudwatch_log_group_properties', () => { + it('defaults to 30-day retention', () => { + expect(extract_cloudwatch_log_group_properties({}, 'us-east-1')).toEqual({ + region: 'us-east-1', + retention_in_days: 30, + kms_key_id: undefined, + tags: {}, + }); + }); + + it('honours retention_in_days override', () => { + expect(extract_cloudwatch_log_group_properties({ retention_in_days: 14 }, 'us-east-1').retention_in_days).toBe(14); + }); + + it('falls through to retention_days (alternate canvas field)', () => { + expect(extract_cloudwatch_log_group_properties({ retention_days: 90 }, 'us-east-1').retention_in_days).toBe(90); + }); +}); diff --git a/packages/core/src/deploy/extractors/aws/__tests__/compute.test.ts b/packages/core/src/deploy/extractors/aws/__tests__/compute.test.ts new file mode 100644 index 00000000..cc48e623 --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/__tests__/compute.test.ts @@ -0,0 +1,171 @@ +/** + * Tests for AWS compute extractors. + * + * Mirrors the assertion style used by the GCP extractor tests: pin + * defaults, exercise passthrough fields, lock in the multi-port + + * cron-preset normalisations. Provider-specific defaults that differ + * from GCP (Fargate CPU/memory units, EventBridge 6-field cron) are + * called out in their own describe blocks so a future change to those + * defaults trips the test instead of silently shifting deployed + * resources. + */ + +import { describe, it, expect } from 'vitest'; +import { + extract_ecs_service_properties, + extract_lambda_function_properties, + extract_events_rule_properties, +} from '../compute'; + +describe('extract_ecs_service_properties', () => { + it('returns Fargate-shaped defaults for an empty data object', () => { + expect(extract_ecs_service_properties({}, 'us-east-1')).toEqual({ + region: 'us-east-1', + image: '', + repository: '', + branch: 'main', + port: 8080, + desired_count: 1, + min_capacity: 1, + max_capacity: 3, + cpu: '256', + memory: '512', + assign_public_ip: true, + internal: false, + env_vars: {}, + tags: {}, + }); + }); + + it('honours user-supplied image / port / branch / cpu / memory', () => { + const result = extract_ecs_service_properties( + { + image: 'my-org/api:v1.2', + port: 3000, + branch: 'release', + cpu: '512', + memory: '1024', + }, + 'eu-west-1', + ); + expect(result.image).toBe('my-org/api:v1.2'); + expect(result.port).toBe(3000); + expect(result.branch).toBe('release'); + expect(result.cpu).toBe('512'); + expect(result.memory).toBe('1024'); + expect(result.region).toBe('eu-west-1'); + }); + + it('parses exposed_ports and forwards additional_ports + primary port', () => { + const result = extract_ecs_service_properties( + { + exposed_ports: [ + { port: 443, protocol: 'https' }, + { port: 8080, protocol: 'http', label: 'admin' }, + ], + }, + 'us-east-1', + ); + expect(result.port).toBe(443); + expect(result.additional_ports).toEqual([ + { port: 443, protocol: 'https' }, + { port: 8080, protocol: 'http', label: 'admin' }, + ]); + }); + + it('maps minInstances/maxInstances onto ECS desired_count + capacity', () => { + const result = extract_ecs_service_properties({ minInstances: 2, maxInstances: 10 }, 'us-east-1'); + expect(result.desired_count).toBe(2); + expect(result.min_capacity).toBe(2); + expect(result.max_capacity).toBe(10); + }); +}); + +describe('extract_lambda_function_properties', () => { + it('returns nodejs20.x defaults + empty S3 ref for an empty data object', () => { + expect(extract_lambda_function_properties({}, 'us-east-1')).toEqual({ + region: 'us-east-1', + runtime: 'nodejs20.x', + handler: 'index.handler', + memory_size: 128, + timeout: 30, + s3_bucket: '', + s3_key: '', + role: '', + description: '', + repository: '', + branch: 'main', + environment: {}, + tags: {}, + }); + }); + + it('reads code from the nested code.{s3Bucket,s3Key} shape', () => { + const result = extract_lambda_function_properties( + { code: { s3Bucket: 'build-artifacts', s3Key: 'app/v3.zip' } }, + 'us-east-1', + ); + expect(result.s3_bucket).toBe('build-artifacts'); + expect(result.s3_key).toBe('app/v3.zip'); + }); + + it('falls back to top-level s3_bucket/s3_key when nested code is absent', () => { + const result = extract_lambda_function_properties({ s3_bucket: 'legacy', s3_key: 'fn.zip' }, 'us-east-1'); + expect(result.s3_bucket).toBe('legacy'); + expect(result.s3_key).toBe('fn.zip'); + }); + + it('passes runtime/handler/memory/timeout through', () => { + const result = extract_lambda_function_properties( + { runtime: 'python3.12', handler: 'main.lambda_handler', memory: 512, timeout: 120 }, + 'us-east-1', + ); + expect(result.runtime).toBe('python3.12'); + expect(result.handler).toBe('main.lambda_handler'); + expect(result.memory_size).toBe(512); + expect(result.timeout).toBe(120); + }); + + it('renames envVars → environment (Lambda SDK shape)', () => { + const result = extract_lambda_function_properties({ envVars: { LOG_LEVEL: 'debug' } }, 'us-east-1'); + expect(result.environment).toEqual({ LOG_LEVEL: 'debug' }); + }); +}); + +describe('extract_events_rule_properties', () => { + it('returns ENABLED-by-default cron(0 0 * * ? *) for an empty data object', () => { + expect(extract_events_rule_properties({}, 'us-east-1')).toEqual({ + region: 'us-east-1', + schedule_expression: 'cron(0 0 * * ? *)', + description: '', + state: 'ENABLED', + target_type: 'lambda', + target_arn: '', + tags: {}, + }); + }); + + it('maps named presets to EventBridge 6-field cron expressions', () => { + expect(extract_events_rule_properties({ schedule: 'daily' }, 'us-east-1').schedule_expression).toBe( + 'cron(0 0 * * ? *)', + ); + expect(extract_events_rule_properties({ schedule: 'hourly' }, 'us-east-1').schedule_expression).toBe( + 'cron(0 * * * ? *)', + ); + expect(extract_events_rule_properties({ schedule: 'weekly' }, 'us-east-1').schedule_expression).toBe( + 'cron(0 0 ? * SUN *)', + ); + expect(extract_events_rule_properties({ schedule: 'monthly' }, 'us-east-1').schedule_expression).toBe( + 'cron(0 0 1 * ? *)', + ); + }); + + it('passes a custom cron expression through verbatim', () => { + const custom = 'cron(15 10 ? * MON-FRI *)'; + expect(extract_events_rule_properties({ schedule: custom }, 'us-east-1').schedule_expression).toBe(custom); + }); + + it('honours enabled=false → state DISABLED', () => { + expect(extract_events_rule_properties({ enabled: false }, 'us-east-1').state).toBe('DISABLED'); + }); +}); diff --git a/packages/core/src/deploy/extractors/aws/__tests__/database.test.ts b/packages/core/src/deploy/extractors/aws/__tests__/database.test.ts new file mode 100644 index 00000000..8be2232a --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/__tests__/database.test.ts @@ -0,0 +1,146 @@ +/** + * Tests for AWS database extractors. + * + * Locks in engine selection (PostgreSQL vs MySQL via iceType), + * size-enum translation (M-series → ElastiCache node types), DynamoDB + * billing-mode branching, and the "no default password" invariant on + * RDS + DocDB. + */ + +import { describe, it, expect } from 'vitest'; +import { + extract_rds_db_instance_properties, + extract_dynamodb_table_properties, + extract_elasticache_cluster_properties, + extract_docdb_cluster_properties, + ELASTICACHE_REDIS_SIZE_MAP, +} from '../database'; + +describe('extract_rds_db_instance_properties', () => { + it('defaults to PostgreSQL 16 when iceType is Database.PostgreSQL', () => { + const result = extract_rds_db_instance_properties({ iceType: 'Database.PostgreSQL' }, 'us-east-1'); + expect(result.engine).toBe('postgres'); + expect(result.engine_version).toBe('16'); + expect(result.port).toBe(5432); + expect(result.master_username).toBe('postgres'); + }); + + it('defaults to MySQL 8.0 when iceType is Database.MySQL', () => { + const result = extract_rds_db_instance_properties({ iceType: 'Database.MySQL' }, 'us-east-1'); + expect(result.engine).toBe('mysql'); + expect(result.engine_version).toBe('8.0'); + expect(result.port).toBe(3306); + expect(result.master_username).toBe('admin'); + }); + + it('extracts version from runtime string (e.g. "PostgreSQL 14.5" → 14.5)', () => { + const result = extract_rds_db_instance_properties( + { iceType: 'Database.PostgreSQL', runtime: 'PostgreSQL 14.5' }, + 'us-east-1', + ); + expect(result.engine_version).toBe('14.5'); + }); + + it('defaults to db.t3.micro instance class', () => { + expect(extract_rds_db_instance_properties({}, 'us-east-1').db_instance_class).toBe('db.t3.micro'); + }); + + it('honours user-supplied size + storage', () => { + const result = extract_rds_db_instance_properties({ size: 'db.r5.large', storage: '100GB' }, 'us-east-1'); + expect(result.db_instance_class).toBe('db.r5.large'); + expect(result.allocated_storage).toBe(100); + }); + + it('leaves master_user_password empty by default (handler must error)', () => { + expect(extract_rds_db_instance_properties({}, 'us-east-1').master_user_password).toBe(''); + }); +}); + +describe('extract_dynamodb_table_properties', () => { + it('defaults to PAY_PER_REQUEST billing mode + string id partition key', () => { + expect(extract_dynamodb_table_properties({}, 'us-east-1')).toMatchObject({ + region: 'us-east-1', + billing_mode: 'PAY_PER_REQUEST', + partition_key: 'id', + partition_key_type: 'S', + point_in_time_recovery: true, + }); + }); + + it('emits RCU/WCU only when billing_mode === PROVISIONED', () => { + const onDemand = extract_dynamodb_table_properties({}, 'us-east-1'); + expect(onDemand.read_capacity).toBeUndefined(); + expect(onDemand.write_capacity).toBeUndefined(); + + const provisioned = extract_dynamodb_table_properties({ billing_mode: 'PROVISIONED' }, 'us-east-1'); + expect(provisioned.read_capacity).toBe(5); + expect(provisioned.write_capacity).toBe(5); + }); + + it('honours user-supplied partition/sort key shape', () => { + const result = extract_dynamodb_table_properties( + { partition_key: 'pk', partition_key_type: 'N', sort_key: 'sk', sort_key_type: 'S' }, + 'us-east-1', + ); + expect(result).toMatchObject({ + partition_key: 'pk', + partition_key_type: 'N', + sort_key: 'sk', + sort_key_type: 'S', + }); + }); +}); + +describe('extract_elasticache_cluster_properties', () => { + it('defaults to Redis 7.0 on cache.t3.micro', () => { + expect(extract_elasticache_cluster_properties({}, 'us-east-1')).toMatchObject({ + region: 'us-east-1', + engine: 'redis', + engine_version: '7.0', + cache_node_type: 'cache.t3.micro', + num_cache_nodes: 1, + port: 6379, + }); + }); + + it('translates the M-series size enum to a known node type', () => { + for (const [size, mapped] of Object.entries(ELASTICACHE_REDIS_SIZE_MAP)) { + const result = extract_elasticache_cluster_properties({ size }, 'us-east-1'); + expect(result.cache_node_type, `${size} → node type`).toBe(mapped.node_type); + expect(result.num_cache_nodes, `${size} → num nodes`).toBe(mapped.num_nodes); + } + }); + + it('falls through to cache_node_type when size is not a known M-tier', () => { + const result = extract_elasticache_cluster_properties( + { cache_node_type: 'cache.r5.xlarge', num_cache_nodes: 3 }, + 'us-east-1', + ); + expect(result.cache_node_type).toBe('cache.r5.xlarge'); + expect(result.num_cache_nodes).toBe(3); + }); +}); + +describe('extract_docdb_cluster_properties', () => { + it('defaults to engine_version 5.0.0 + db.t3.medium', () => { + expect(extract_docdb_cluster_properties({}, 'us-east-1')).toMatchObject({ + region: 'us-east-1', + engine: 'docdb', + engine_version: '5.0.0', + db_instance_class: 'db.t3.medium', + instance_count: 1, + port: 27017, + storage_encrypted: true, + }); + }); + + it('leaves master_user_password empty by default (handler must error)', () => { + expect(extract_docdb_cluster_properties({}, 'us-east-1').master_user_password).toBe(''); + }); + + it('honours instance_count + master_username overrides', () => { + const result = extract_docdb_cluster_properties({ instance_count: 3, master_username: 'mongo' }, 'us-east-1'); + expect(result.instance_count).toBe(3); + expect(result.master_username).toBe('mongo'); + }); +}); diff --git a/packages/core/src/deploy/extractors/aws/__tests__/network.test.ts b/packages/core/src/deploy/extractors/aws/__tests__/network.test.ts new file mode 100644 index 00000000..401db82e --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/__tests__/network.test.ts @@ -0,0 +1,102 @@ +/** + * Tests for AWS network extractors. + */ + +import { describe, it, expect } from 'vitest'; +import { + extract_s3_bucket_properties, + extract_api_gateway_rest_api_properties, + extract_cloudfront_distribution_properties, + extract_elbv2_load_balancer_properties, +} from '../network'; + +describe('extract_s3_bucket_properties', () => { + it('returns private-bucket defaults for an empty data object', () => { + expect(extract_s3_bucket_properties({}, 'us-east-1')).toEqual({ + storage_class: 'STANDARD', + versioning: false, + public_access: false, + website_hosting: false, + index_page: 'index.html', + not_found_page: '404.html', + block_public_acls: true, + encryption: 'AES256', + tags: {}, + }); + }); + + it('flips public + website hosting when iceType has publicWebsiteSource role (Compute.StaticSite)', () => { + const result = extract_s3_bucket_properties({ iceType: 'Compute.StaticSite' }, 'us-east-1'); + expect(result.public_access).toBe(true); + expect(result.website_hosting).toBe(true); + expect(result.block_public_acls).toBe(false); + }); + + it('plain Storage.Bucket stays private', () => { + const result = extract_s3_bucket_properties({ iceType: 'Storage.Bucket' }, 'us-east-1'); + expect(result.public_access).toBe(false); + expect(result.website_hosting).toBe(false); + }); +}); + +describe('extract_api_gateway_rest_api_properties', () => { + it('defaults to REGIONAL + stage "prod"', () => { + expect(extract_api_gateway_rest_api_properties({}, 'eu-west-1')).toEqual({ + region: 'eu-west-1', + endpoint_type: 'REGIONAL', + description: '', + api_key_required: false, + stage_name: 'prod', + binary_media_types: [], + tags: {}, + }); + }); + + it('honours endpoint_type=EDGE override', () => { + expect(extract_api_gateway_rest_api_properties({ endpoint_type: 'EDGE' }, 'us-east-1').endpoint_type).toBe('EDGE'); + }); +}); + +describe('extract_cloudfront_distribution_properties', () => { + it('defaults to HTTPS + auto-cert + PriceClass_100', () => { + expect(extract_cloudfront_distribution_properties({}, 'us-east-1')).toMatchObject({ + enableHttps: true, + auto_provision_cert: true, + redirect_http_to_https: true, + domain: '', + cache_policy_name: 'CachingOptimized', + origin_request_policy_name: 'CORS-S3Origin', + price_class: 'PriceClass_100', + }); + }); + + it('passes the user-supplied root domain through', () => { + expect(extract_cloudfront_distribution_properties({ domain: 'example.com' }, 'us-east-1').domain).toBe( + 'example.com', + ); + }); +}); + +describe('extract_elbv2_load_balancer_properties', () => { + it('defaults to internet-facing ALB on HTTPS:443', () => { + expect(extract_elbv2_load_balancer_properties({}, 'us-east-1')).toMatchObject({ + region: 'us-east-1', + scheme: 'internet-facing', + type: 'application', + listener_port: 443, + listener_protocol: 'HTTPS', + target_group_port: 80, + target_group_protocol: 'HTTP', + }); + }); + + it('flips to internal scheme when internal=true', () => { + expect(extract_elbv2_load_balancer_properties({ internal: true }, 'us-east-1').scheme).toBe('internal'); + }); + + it('drops to HTTP:80 listener when enable_https=false', () => { + const result = extract_elbv2_load_balancer_properties({ enable_https: false }, 'us-east-1'); + expect(result.listener_port).toBe(80); + expect(result.listener_protocol).toBe('HTTP'); + }); +}); diff --git a/packages/core/src/deploy/extractors/aws/ai.ts b/packages/core/src/deploy/extractors/aws/ai.ts new file mode 100644 index 00000000..4257fc8d --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/ai.ts @@ -0,0 +1,117 @@ +/** + * Property extractors for AWS AI / analytics services. + * + * Resources covered: + * - aws.opensearch.domain (AI.VectorDB) + * - aws.bedrock.endpoint (AI.LLMGateway) + * - aws.sagemaker.endpoint (AI.ModelServing) + * - aws.redshift.cluster (Analytics.DataWarehouse) + * + * Both Bedrock and SageMaker iceTypes carry the same `llm` role in + * the shared classifier table, but AWS gives them distinct managed + * surfaces so each gets its own extractor. + */ + +/** + * OpenSearch domain — backs AI.VectorDB. Defaults to a single-node + * t3.small.search instance for cost-conscious dev/test; production + * users set `instance_count` ≥ 3 + `dedicated_master_enabled`. + */ +export function extract_opensearch_domain_properties( + data: Record, + region: string, +): Record { + return { + region, + engine_version: (data.engine_version as string) || 'OpenSearch_2.13', + instance_type: (data.instance_type as string) || 't3.small.search', + instance_count: (data.instance_count as number) ?? 1, + dedicated_master_enabled: data.dedicated_master_enabled ?? false, + dedicated_master_type: (data.dedicated_master_type as string) || undefined, + dedicated_master_count: (data.dedicated_master_count as number) ?? 0, + ebs_enabled: data.ebs_enabled ?? true, + ebs_volume_type: (data.ebs_volume_type as string) || 'gp3', + ebs_volume_size_gb: (data.ebs_volume_size_gb as number) ?? 10, + encryption_at_rest: data.encryption_at_rest ?? true, + node_to_node_encryption: data.node_to_node_encryption ?? true, + tags: {}, + }; +} + +/** + * Bedrock — backs AI.LLMGateway. Bedrock is mostly a foundation-model + * surface (on-demand calls don't need provisioning), but provisioned + * throughput + guardrails are the resources operators actually deploy. + * The extractor focuses on the provisioned-throughput shape; if no + * model is set the handler returns a no-op create (Bedrock on-demand + * access is account-level, not resource-level). + */ +export function extract_bedrock_endpoint_properties( + data: Record, + region: string, +): Record { + return { + region, + // Foundation model id — defaults to the most-common Claude model + // available on Bedrock. Operators override to pin a specific model. + model_id: (data.model_id as string) || 'anthropic.claude-3-haiku-20240307-v1:0', + // Provisioned throughput in `model units` (Bedrock's pricing unit). + // 0 = on-demand only (no resource is created at deploy time). + model_units: (data.model_units as number) ?? 0, + commitment_duration: (data.commitment_duration as string) || 'OneMonth', + // Optional guardrail attached to invocations. + guardrail_id: (data.guardrail_id as string) || undefined, + guardrail_version: (data.guardrail_version as string) || undefined, + tags: {}, + }; +} + +/** + * SageMaker endpoint — backs AI.ModelServing. Real-time inference + * endpoint over a previously-registered model. The model itself + * (training, registration) is operator-side; the extractor focuses + * on the endpoint config (instance class + count). + */ +export function extract_sagemaker_endpoint_properties( + data: Record, + region: string, +): Record { + return { + region, + // Model name resolved by the handler from the connected canvas + // node OR set explicitly. Empty = handler fails loudly. + model_name: (data.model_name as string) || '', + instance_type: (data.instance_type as string) || 'ml.t2.medium', + initial_instance_count: (data.initial_instance_count as number) ?? 1, + initial_variant_weight: (data.initial_variant_weight as number) ?? 1.0, + // Async / serverless / real-time endpoint mode. Defaults to + // real-time (the most common). + endpoint_mode: (data.endpoint_mode as string) || 'real-time', + tags: {}, + }; +} + +/** + * Redshift cluster — backs Analytics.DataWarehouse. Like RDS, Redshift + * needs an admin password supplied by the operator. + */ +export function extract_redshift_cluster_properties( + data: Record, + region: string, +): Record { + return { + region, + // Smallest dc2.large default — fits dev/test, cheap. Production + // workloads override to ra3.* node types. + node_type: (data.node_type as string) || 'dc2.large', + cluster_type: (data.cluster_type as string) || 'single-node', + number_of_nodes: (data.number_of_nodes as number) ?? 1, + db_name: (data.db_name as string) || 'analytics', + master_username: (data.master_username as string) || 'admin', + master_user_password: (data.master_user_password as string) || '', + publicly_accessible: data.publicly_accessible ?? false, + encrypted: data.encrypted ?? true, + port: data.port || 5439, + tags: {}, + }; +} diff --git a/packages/core/src/deploy/extractors/aws/ancillary.ts b/packages/core/src/deploy/extractors/aws/ancillary.ts new file mode 100644 index 00000000..bd7de7d4 --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/ancillary.ts @@ -0,0 +1,117 @@ +/** + * Property extractors for AWS ancillary services. + * + * Resources covered: + * - aws.sqs.queue (Messaging.Queue) + * - aws.sns.topic (Messaging.Topic, Messaging.CloudPubSub) + * - aws.cognito.userPool (Security.Identity) + * - aws.secretsmanager.secret (Security.Secret) + * - aws.cloudwatch.logGroup (Monitoring.Log) + */ + +/** + * SQS queue. FIFO vs Standard is inferred from `data.fifo` (which + * the canvas Messaging.Queue editor sets) — handler appends `.fifo` + * suffix to the queue name when FIFO is on (AWS requirement). + */ +export function extract_sqs_queue_properties(data: Record, region: string): Record { + const fifo = data.fifo === true; + return { + region, + fifo, + // SQS defaults — 4-day retention, 30s visibility timeout, no delay. + message_retention_seconds: (data.message_retention as number) ?? 345600, + visibility_timeout_seconds: (data.visibility_timeout as number) ?? 30, + delay_seconds: (data.delay as number) ?? 0, + // Content-based dedup is only valid on FIFO queues; standard SQS + // ignores the field. The handler enforces the constraint. + ...(fifo && { content_based_deduplication: data.content_based_dedup ?? false }), + tags: {}, + }; +} + +/** + * SNS topic. Standard topics by default; FIFO topics need the `.fifo` + * suffix that the handler adds. + */ +export function extract_sns_topic_properties(data: Record, region: string): Record { + const fifo = data.fifo === true; + return { + region, + fifo, + display_name: (data.display_name as string) || '', + // KMS at-rest encryption — opt-in (operators provide a KMS key ID + // or accept the AWS-managed alias). + kms_master_key_id: (data.kms_master_key_id as string) || undefined, + tags: {}, + }; +} + +/** + * Cognito User Pool. Mirrors the GCP Identity Platform extractor's + * sign-in / MFA shape so cards stay portable. + */ +export function extract_cognito_user_pool_properties( + data: Record, + region: string, +): Record { + return { + region, + // Default to email auto-verification + password sign-in (the + // minimum viable Cognito setup). + auto_verified_attributes: (data.auto_verified_attributes as string[]) || ['email'], + sign_in_providers: (data.signInProviders as string[]) || + (data.sign_in_providers as string[]) || ['email', 'google'], + mfa_configuration: data.mfaEnabled === true ? 'ON' : (data.mfa_configuration as string) || 'OFF', + password_policy: { + minimum_length: (data.password_min_length as number) ?? 8, + require_uppercase: data.password_require_uppercase ?? true, + require_lowercase: data.password_require_lowercase ?? true, + require_numbers: data.password_require_numbers ?? true, + require_symbols: data.password_require_symbols ?? false, + }, + tags: {}, + }; +} + +/** + * Secrets Manager secret. Parallel to the GCP secret_manager extractor: + * the canvas `secrets` array (each row a `{key, ref}` binding) is + * forwarded as `bindings` so the schema-declared deploy-expansion + * pass can emit one cloud resource per unique ref. Adding AWS Secrets + * Manager doesn't require translator changes — the same expansion + * branch fires for any iceType that declares `deployExpansion`. + */ +export function extract_secrets_manager_secret_properties( + data: Record, + region: string, +): Record { + return { + region, + bindings: Array.isArray(data.secrets) ? data.secrets : [], + // Operators wire automatic rotation via a Lambda ARN. Disabled by + // default — the canvas doesn't expose rotation today. + rotation_lambda_arn: (data.rotation_lambda_arn as string) || undefined, + rotation_days: (data.rotation_days as number) ?? 0, + // KMS at-rest encryption (defaults to the AWS-managed alias). + kms_key_id: (data.kms_key_id as string) || undefined, + tags: {}, + }; +} + +/** + * CloudWatch Log Group. Retention is the field most operators care + * about — 30 days strikes the cost vs. visibility balance most + * teams ship with. + */ +export function extract_cloudwatch_log_group_properties( + data: Record, + region: string, +): Record { + return { + region, + retention_in_days: (data.retention_in_days as number) ?? (data.retention_days as number) ?? 30, + kms_key_id: (data.kms_key_id as string) || undefined, + tags: {}, + }; +} diff --git a/packages/core/src/deploy/extractors/aws/compute.ts b/packages/core/src/deploy/extractors/aws/compute.ts new file mode 100644 index 00000000..e73f0be8 --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/compute.ts @@ -0,0 +1,119 @@ +/** + * Property extractors for AWS compute services on the card-to-graph + * translator. + * + * Each extractor maps a canvas node's `data` payload to the + * deployer-handler input shape for a specific AWS compute resource + * type. The translator's dispatch table looks up the right extractor + * by resolved `resource_type`. + * + * Resources covered: + * - aws.ecs.service (Compute.Container, BackendAPI, SSRSite, Worker) + * - aws.lambda.function (Compute.ServerlessFunction) + * - aws.events.rule (Compute.CronJob) + * + * Loose `Record` types on the parameter and return + * value are intentional — handlers further down the pipeline coerce + * per-resource. The extractor lays down everything the handler needs + * to drive the AWS SDK call; provider-specific defaults that vary + * per resource (instance class, runtime, etc.) live here, not in the + * handler. + */ + +import { parse_exposed_ports } from '../compute'; + +/** + * ECS service — backs Compute.Container / Compute.BackendAPI / + * Compute.SSRSite / Compute.Worker on AWS. The handler will create a + * task definition + service. Multi-port `exposed_ports` is parsed via + * the shared compute helper so the shape matches what GCP Cloud Run + * sees today. + */ +export function extract_ecs_service_properties(data: Record, region: string): Record { + const ports = parse_exposed_ports(data); + const primaryPort = ports[0]?.port ?? (data.port as number | undefined) ?? 8080; + return { + region, + image: (data.image as string) || '', + repository: (data.repository as string) || '', + branch: (data.branch as string) || 'main', + port: primaryPort, + ...(ports.length > 0 && { additional_ports: ports }), + // ECS scaling — desired_count + min/max capacity. Mirrors GCP's + // `min_instances`/`max_instances` semantics; auto-scaling policy + // creation is the handler's job. + desired_count: data.minInstances ?? 1, + min_capacity: data.minInstances ?? 1, + max_capacity: data.maxInstances ?? 3, + // Fargate uses CPU/memory as integers (CPU units, MiB). Defaults + // match the smallest Fargate task size — 256 CPU + 512 MiB. + cpu: data.cpu || '256', + memory: data.memory || '512', + // Service-level network mode. Public assignment is decided by the + // INTERNAL_INGRESS_OVERRIDES table at translator time when nested + // in an isolation container. + assign_public_ip: data.assign_public_ip ?? true, + internal: data.internal ?? false, + env_vars: data.envVars || {}, + tags: {}, + }; +} + +/** + * Lambda function. The handler accepts the S3-ref code source today + * (`code: { s3Bucket, s3Key }`); auto-build from a connected + * Source.Repository ships in commit #28 (Phase 3). + */ +export function extract_lambda_function_properties( + data: Record, + region: string, +): Record { + const code = (data.code as { s3Bucket?: string; s3Key?: string } | undefined) ?? {}; + return { + region, + runtime: (data.runtime as string) || 'nodejs20.x', + handler: (data.handler as string) || 'index.handler', + memory_size: (data.memory as number) || 128, + timeout: (data.timeout as number) || 30, + // S3-ref code source — handler reads `s3_bucket` + `s3_key`. + // Auto-build flow (commit #28) sets these after uploading the zip. + s3_bucket: code.s3Bucket || (data.s3_bucket as string) || '', + s3_key: code.s3Key || (data.s3_key as string) || '', + // IAM execution role; ECS-style auto-provisioning of a default + // role is not yet wired for Lambda — operators supply the ARN. + role: (data.role as string) || '', + description: (data.description as string) || '', + repository: (data.repository as string) || '', + branch: (data.branch as string) || 'main', + environment: data.envVars || {}, + tags: {}, + }; +} + +/** + * EventBridge rule — backs Compute.CronJob on AWS. The cron expression + * is normalised the same way GCP's cloud_scheduler extractor handles + * the named "daily" / "hourly" / "weekly" / "monthly" presets, so + * project portability is preserved. + */ +export function extract_events_rule_properties(data: Record, region: string): Record { + // EventBridge uses `cron(min hour day-of-month month day-of-week year)` + // (6 fields, not the 5-field unix cron). The named presets map to + // EventBridge expressions directly. + const schedule_map: Record = { + daily: 'cron(0 0 * * ? *)', + hourly: 'cron(0 * * * ? *)', + weekly: 'cron(0 0 ? * SUN *)', + monthly: 'cron(0 0 1 * ? *)', + }; + const schedule = (data.schedule as string) || 'daily'; + return { + region, + schedule_expression: schedule_map[schedule] || schedule, + description: (data.description as string) || '', + state: data.enabled === false ? 'DISABLED' : 'ENABLED', + target_type: (data.targetType as string) || 'lambda', + target_arn: (data.targetArn as string) || '', + tags: {}, + }; +} diff --git a/packages/core/src/deploy/extractors/aws/database.ts b/packages/core/src/deploy/extractors/aws/database.ts new file mode 100644 index 00000000..dd54ec56 --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/database.ts @@ -0,0 +1,145 @@ +/** + * Property extractors for AWS database services. + * + * Resources covered: + * - aws.rds.dbInstance (Database.PostgreSQL, Database.MySQL) + * - aws.dynamodb.table (Database.DynamoDB) + * - aws.elasticache.cluster (Database.Redis) + * - aws.docdb.cluster (Database.MongoDB) + * + * Each extractor lays down the AWS SDK-shaped property dict the + * handler will hand to the SDK. Provider-specific defaults (instance + * class, engine version, billing mode) live here. + */ + +import { parse_storage_gb } from '../../utils/name-utils'; + +/** + * RDS dbInstance — backs both Database.PostgreSQL and Database.MySQL. + * Engine + version are inferred from `iceType` and `runtime` the same + * way the GCP Cloud SQL extractor does, so the canvas contract stays + * provider-agnostic. + * + * Note: the master_user_password field is intentionally a literal + * placeholder — operators must supply a real secret via the + * connected Security.Secret block (or set the field explicitly). + * The handler will reject empty passwords loudly rather than create + * an RDS instance with a default credential. + */ +export function extract_rds_db_instance_properties( + data: Record, + region: string, +): Record { + const ice_type = data.iceType as string; + const is_postgres = ice_type === 'Database.PostgreSQL'; + const runtime = (data.runtime as string) || (is_postgres ? 'PostgreSQL 16' : 'MySQL 8.0'); + const version_match = runtime.match(/(\d+(\.\d+)?)/); + const version_num = version_match?.[1] ?? (is_postgres ? '16' : '8.0'); + + return { + region, + engine: is_postgres ? 'postgres' : 'mysql', + engine_version: version_num, + // RDS uses `db..` instance classes — db.t3.micro is + // the smallest Burstable option, mirrors db-f1-micro on Cloud SQL. + db_instance_class: (data.size as string) || 'db.t3.micro', + allocated_storage: parse_storage_gb(data.storage as string) || 20, + storage_type: (data.storageType as string) || 'gp3', + backup_retention_period: data.backup_retention ?? 7, + publicly_accessible: data.publicly_accessible ?? false, + multi_az: data.multi_az ?? false, + master_username: (data.master_username as string) || (is_postgres ? 'postgres' : 'admin'), + // Empty string forces the handler to error rather than ship a + // resource with no credential. + master_user_password: (data.master_user_password as string) || '', + port: data.port || (is_postgres ? 5432 : 3306), + tags: {}, + }; +} + +/** + * DynamoDB table — pay-per-request by default (the AWS recommended + * mode for new workloads). Operators can switch to provisioned by + * setting `billing_mode: 'PROVISIONED'` and supplying RCU/WCU values. + */ +export function extract_dynamodb_table_properties( + data: Record, + region: string, +): Record { + const billing_mode = (data.billing_mode as string) || 'PAY_PER_REQUEST'; + return { + region, + billing_mode, + // Hash key defaults to a string `id` — the most common DynamoDB + // shape. Operators override via `partition_key` / `sort_key`. + partition_key: (data.partition_key as string) || 'id', + partition_key_type: (data.partition_key_type as string) || 'S', + sort_key: (data.sort_key as string) || undefined, + sort_key_type: (data.sort_key_type as string) || undefined, + // Provisioned-mode capacity. Ignored by the handler when + // billing_mode === 'PAY_PER_REQUEST'. + ...(billing_mode === 'PROVISIONED' && { + read_capacity: data.read_capacity ?? 5, + write_capacity: data.write_capacity ?? 5, + }), + point_in_time_recovery: data.point_in_time_recovery ?? true, + tags: {}, + }; +} + +/** + * ElastiCache cluster — Redis. The canvas exposes the same M-series + * size enum that GCP Memorystore uses; we translate to the AWS + * `cache..` node type so blocks remain portable. + */ +export const ELASTICACHE_REDIS_SIZE_MAP: Record = { + M1: { node_type: 'cache.t3.micro', num_nodes: 1 }, + M2: { node_type: 'cache.t3.small', num_nodes: 1 }, + M3: { node_type: 'cache.t3.medium', num_nodes: 1 }, + M4: { node_type: 'cache.m5.large', num_nodes: 1 }, + // M5 is the HA tier on GCP; on AWS we approximate by spinning up + // multi-az replicas. The handler will set ReplicationGroup mode. + M5: { node_type: 'cache.m5.xlarge', num_nodes: 2 }, +}; + +export function extract_elasticache_cluster_properties( + data: Record, + region: string, +): Record { + const size = typeof data.size === 'string' ? data.size : null; + const mapped = size && ELASTICACHE_REDIS_SIZE_MAP[size] ? ELASTICACHE_REDIS_SIZE_MAP[size] : null; + return { + region, + engine: 'redis', + engine_version: (data.redisVersion as string) || '7.0', + cache_node_type: mapped?.node_type ?? (data.cache_node_type as string) ?? 'cache.t3.micro', + num_cache_nodes: mapped?.num_nodes ?? (data.num_cache_nodes as number) ?? 1, + port: data.port || 6379, + parameter_group_name: (data.parameter_group_name as string) || 'default.redis7', + tags: {}, + }; +} + +/** + * DocumentDB cluster — MongoDB-compatible managed engine. Like RDS, + * DocDB needs an admin password supplied by the operator (no default). + */ +export function extract_docdb_cluster_properties( + data: Record, + region: string, +): Record { + return { + region, + engine: 'docdb', + engine_version: (data.engineVersion as string) || '5.0.0', + db_cluster_identifier: (data.cluster_identifier as string) || '', + db_instance_class: (data.size as string) || 'db.t3.medium', + instance_count: (data.instance_count as number) ?? 1, + master_username: (data.master_username as string) || 'admin', + master_user_password: (data.master_user_password as string) || '', + backup_retention_period: data.backup_retention ?? 7, + storage_encrypted: data.storage_encrypted ?? true, + port: data.port || 27017, + tags: {}, + }; +} diff --git a/packages/core/src/deploy/extractors/aws/network.ts b/packages/core/src/deploy/extractors/aws/network.ts new file mode 100644 index 00000000..f83b6af4 --- /dev/null +++ b/packages/core/src/deploy/extractors/aws/network.ts @@ -0,0 +1,109 @@ +/** + * Property extractors for AWS network services. + * + * Resources covered: + * - aws.s3.bucket (Storage.Bucket, Storage.ObjectStorage, Compute.StaticSite) + * - aws.apigateway.restApi (Network.Gateway) + * - aws.cloudfront.distribution (Network.PublicEndpoint, Network.CustomDomain) + * - aws.elbv2.loadBalancer (Network.LoadBalancer) + */ + +import { hasBlockRole } from '@ice/constants'; + +/** + * S3 bucket. Compute.StaticSite carries the `publicWebsiteSource` + * role so the handler flips bucket policy + website hosting (mirrors + * the GCP storage extractor's matching branch). Plain Storage.Bucket + * stays private. + */ +export function extract_s3_bucket_properties(data: Record, _region: string): Record { + const iceType = String(data.iceType || ''); + const isPublicWebsite = hasBlockRole(iceType, 'publicWebsiteSource'); + return { + // No `region` field — S3 buckets are technically global names with + // a region attribute, set by the handler via LocationConstraint. + storage_class: (data.storageClass as string) || 'STANDARD', + versioning: data.versioning ?? false, + public_access: isPublicWebsite || data.public_access === true, + website_hosting: isPublicWebsite || data.website_hosting === true, + index_page: (data.index_page as string) || 'index.html', + not_found_page: (data.not_found_page as string) || '404.html', + block_public_acls: !isPublicWebsite && (data.block_public_acls ?? true), + encryption: (data.encryption as string) || 'AES256', + tags: {}, + }; +} + +/** + * API Gateway REST API. Defaults to a regional endpoint (cheaper + + * lower latency than EDGE). Operators wanting a CloudFront-fronted + * edge endpoint set `endpoint_type: 'EDGE'`. + */ +export function extract_api_gateway_rest_api_properties( + data: Record, + region: string, +): Record { + return { + region, + endpoint_type: (data.endpoint_type as string) || 'REGIONAL', + description: (data.description as string) || '', + api_key_required: data.api_key_required ?? false, + // Stage / deployment created lazily by the handler when a backing + // Lambda or ECS service is wired in via outgoing edges. + stage_name: (data.stage_name as string) || 'prod', + binary_media_types: (data.binary_media_types as string[]) || [], + tags: {}, + }; +} + +/** + * CloudFront distribution — backs both Network.PublicEndpoint AND + * Network.CustomDomain (when nested inside a PrivateNetwork). The + * extractor lays down origins + cache behaviours; the handler + * synthesises the CloudFront-required ACM cert in us-east-1 and + * wires it onto the distribution. + */ +export function extract_cloudfront_distribution_properties( + data: Record, + _region: string, +): Record { + return { + enableHttps: data.enableHttps ?? true, + auto_provision_cert: data.autoProvisionCert ?? true, + redirect_http_to_https: data.redirectHttpToHttps ?? true, + // Single root domain on this block — per-subdomain mapping comes + // from outgoing-edge propagation (see pass-1-5-endpoint-wiring). + domain: (data.domain as string) || '', + // Cache + origin policy presets. Most users stay on the defaults + // (CachingOptimized + CORS-S3Origin); the handler resolves the + // managed-policy IDs by name. + cache_policy_name: (data.cache_policy_name as string) || 'CachingOptimized', + origin_request_policy_name: (data.origin_request_policy_name as string) || 'CORS-S3Origin', + price_class: (data.price_class as string) || 'PriceClass_100', + tags: {}, + }; +} + +/** + * Application Load Balancer (ELBv2). Sized for HTTPS by default; the + * handler attaches a default target group when no compute backend is + * wired (silent until the user connects something). + */ +export function extract_elbv2_load_balancer_properties( + data: Record, + region: string, +): Record { + return { + region, + scheme: data.internal === true ? 'internal' : 'internet-facing', + type: (data.lb_type as string) || 'application', + ip_address_type: (data.ip_address_type as string) || 'ipv4', + enable_deletion_protection: data.enable_deletion_protection ?? false, + // Listener port (HTTPS by default, HTTP fallback when cert not set). + listener_port: data.listener_port ?? (data.enable_https !== false ? 443 : 80), + listener_protocol: data.listener_protocol ?? (data.enable_https !== false ? 'HTTPS' : 'HTTP'), + target_group_port: data.target_group_port ?? 80, + target_group_protocol: data.target_group_protocol ?? 'HTTP', + tags: {}, + }; +} diff --git a/packages/core/src/deploy/extractors/compute.ts b/packages/core/src/deploy/extractors/compute.ts index 819044a3..69ad1030 100644 --- a/packages/core/src/deploy/extractors/compute.ts +++ b/packages/core/src/deploy/extractors/compute.ts @@ -11,13 +11,83 @@ import { normalize_runtime } from '../utils/name-utils'; +/** + * Parses the `exposed_ports` array from `data` into typed entries. + * Each entry is either a JSON string (`{port, protocol, label?}`) or + * a compact text form (`"https:443"`, `"https:443:api"`) — matches + * `port-spec.ts` in the UI package. Returns `[]` for absent / malformed + * data so callers can safely default. + */ +export interface ExposedPort { + port: number; + protocol: 'http' | 'https' | 'tcp'; + label?: string; +} + +export function parse_exposed_ports(data: Record): ExposedPort[] { + const raw = data.exposed_ports; + if (!Array.isArray(raw)) return []; + const out: ExposedPort[] = []; + for (const entry of raw) { + if (typeof entry === 'string') { + try { + const parsed = JSON.parse(entry) as { port?: unknown; protocol?: unknown; label?: unknown }; + if (parsed && typeof parsed.port === 'number' && parsed.port > 0) { + const protocol: ExposedPort['protocol'] = + parsed.protocol === 'https' || parsed.protocol === 'tcp' ? parsed.protocol : 'http'; + out.push({ + port: parsed.port, + protocol, + ...(typeof parsed.label === 'string' && parsed.label ? { label: parsed.label } : {}), + }); + continue; + } + } catch { + /* fall through to compact form */ + } + const parts = entry.split(':'); + if (parts.length >= 2 && (parts[0] === 'http' || parts[0] === 'https' || parts[0] === 'tcp')) { + const p = Number(parts[1]); + if (Number.isFinite(p) && p > 0) { + out.push({ + port: p, + protocol: parts[0] as ExposedPort['protocol'], + ...(parts[2] ? { label: parts[2] } : {}), + }); + } + } + } else if (entry && typeof entry === 'object') { + const obj = entry as { port?: unknown; protocol?: unknown; label?: unknown }; + if (typeof obj.port === 'number' && obj.port > 0) { + const protocol: ExposedPort['protocol'] = + obj.protocol === 'https' || obj.protocol === 'tcp' ? obj.protocol : 'http'; + out.push({ + port: obj.port, + protocol, + ...(typeof obj.label === 'string' && obj.label ? { label: obj.label } : {}), + }); + } + } + } + return out; +} + export function extract_cloud_run_properties(data: Record, region: string): Record { + // Multi-port: when the user declares `exposed_ports` on a Container / + // BackendAPI block, the first entry becomes the primary listener and + // the full list is forwarded as `additional_ports` so the deployer + // can configure all of them (e.g. Container App ingress / ECS + // listener rules). Legacy `data.port` scalar is the back-compat + // fallback for blocks that haven't set `exposed_ports`. + const ports = parse_exposed_ports(data); + const primaryPort = ports[0]?.port ?? (data.port as number | undefined) ?? 8080; return { region, image: (data.image as string) || '', repository: (data.repository as string) || '', branch: (data.branch as string) || 'main', - port: data.port || 8080, + port: primaryPort, + ...(ports.length > 0 && { additional_ports: ports }), min_instances: data.minInstances ?? 0, max_instances: data.maxInstances ?? 3, cpu: data.cpu || '1', diff --git a/packages/core/src/deploy/extractors/dispatch.ts b/packages/core/src/deploy/extractors/dispatch.ts index 187757b1..b816044e 100644 --- a/packages/core/src/deploy/extractors/dispatch.ts +++ b/packages/core/src/deploy/extractors/dispatch.ts @@ -31,6 +31,36 @@ import { extract_backend_bucket_properties, extract_firebase_hosting_properties, } from './ancillary'; +import { + extract_opensearch_domain_properties, + extract_bedrock_endpoint_properties, + extract_sagemaker_endpoint_properties, + extract_redshift_cluster_properties, +} from './aws/ai'; +import { + extract_sqs_queue_properties, + extract_sns_topic_properties, + extract_cognito_user_pool_properties, + extract_secrets_manager_secret_properties, + extract_cloudwatch_log_group_properties, +} from './aws/ancillary'; +import { + extract_ecs_service_properties, + extract_lambda_function_properties, + extract_events_rule_properties, +} from './aws/compute'; +import { + extract_rds_db_instance_properties, + extract_dynamodb_table_properties, + extract_elasticache_cluster_properties, + extract_docdb_cluster_properties, +} from './aws/database'; +import { + extract_s3_bucket_properties, + extract_api_gateway_rest_api_properties, + extract_cloudfront_distribution_properties, + extract_elbv2_load_balancer_properties, +} from './aws/network'; import { extract_cloud_run_properties, extract_cloud_run_job_properties, @@ -79,4 +109,34 @@ export const PROPERTY_EXTRACTORS: Record< 'gcp.compute.subnetwork': extract_subnet_properties, 'gcp.compute.securityPolicy': extract_cloud_armor_properties, 'gcp.firebase.hosting': extract_firebase_hosting_properties, + + // ─── AWS — compute ───────────────────────────────────────────────── + 'aws.ecs.service': extract_ecs_service_properties, + 'aws.lambda.function': extract_lambda_function_properties, + 'aws.events.rule': extract_events_rule_properties, + + // ─── AWS — database ──────────────────────────────────────────────── + 'aws.rds.dbInstance': extract_rds_db_instance_properties, + 'aws.dynamodb.table': extract_dynamodb_table_properties, + 'aws.elasticache.cluster': extract_elasticache_cluster_properties, + 'aws.docdb.cluster': extract_docdb_cluster_properties, + + // ─── AWS — network ───────────────────────────────────────────────── + 'aws.s3.bucket': extract_s3_bucket_properties, + 'aws.apigateway.restApi': extract_api_gateway_rest_api_properties, + 'aws.cloudfront.distribution': extract_cloudfront_distribution_properties, + 'aws.elbv2.loadBalancer': extract_elbv2_load_balancer_properties, + + // ─── AWS — ancillary (messaging, auth, secrets, logging) ─────────── + 'aws.sqs.queue': extract_sqs_queue_properties, + 'aws.sns.topic': extract_sns_topic_properties, + 'aws.cognito.userPool': extract_cognito_user_pool_properties, + 'aws.secretsmanager.secret': extract_secrets_manager_secret_properties, + 'aws.cloudwatch.logGroup': extract_cloudwatch_log_group_properties, + + // ─── AWS — AI / analytics ────────────────────────────────────────── + 'aws.opensearch.domain': extract_opensearch_domain_properties, + 'aws.bedrock.endpoint': extract_bedrock_endpoint_properties, + 'aws.sagemaker.endpoint': extract_sagemaker_endpoint_properties, + 'aws.redshift.cluster': extract_redshift_cluster_properties, }; diff --git a/packages/core/src/deploy/extractors/network.ts b/packages/core/src/deploy/extractors/network.ts index 3a2f8b64..f1bda30a 100644 --- a/packages/core/src/deploy/extractors/network.ts +++ b/packages/core/src/deploy/extractors/network.ts @@ -11,24 +11,28 @@ */ import { createHash } from 'crypto'; +import { hasBlockRole } from '@ice/constants'; export function extract_storage_bucket_properties( data: Record, region: string, ): Record { - // Phase 8 — when the bucket backs a Compute.StaticSite block we need the - // handler to make it publicly readable and enable static website hosting - // (index.html / 404.html) so the load balancer's backend bucket can serve - // it to the internet. Users who drag a plain Storage.Bucket block don't - // get this treatment — private bucket, no website config. + // Phase 8 — when the source block is flagged with `publicWebsiteSource` + // (today: Compute.StaticSite on providers that compile it to a bucket + // such as AWS S3), the handler needs to make the bucket publicly + // readable and enable static website hosting (index.html / 404.html) + // so the LB backend bucket can serve it to the internet. Plain + // Storage.Bucket blocks stay private. Cardinal-rule schema-driven: + // the iceType-specific check is replaced by a role lookup in the + // shared classifier table. const iceType = String(data.iceType || ''); - const isStaticSite = iceType === 'Compute.StaticSite'; + const isPublicWebsite = hasBlockRole(iceType, 'publicWebsiteSource'); return { location: region.toUpperCase().split('-').slice(0, 1).join('') || 'US', storage_class: data.storageClass || 'STANDARD', versioning: data.versioning ?? false, - public_access: isStaticSite || data.public_access === true, - website_hosting: isStaticSite || data.website_hosting === true, + public_access: isPublicWebsite || data.public_access === true, + website_hosting: isPublicWebsite || data.website_hosting === true, index_page: (data.index_page as string) || 'index.html', not_found_page: (data.not_found_page as string) || '404.html', labels: {}, diff --git a/packages/core/src/deploy/internal-ingress-overrides.ts b/packages/core/src/deploy/internal-ingress-overrides.ts new file mode 100644 index 00000000..13a9e69b --- /dev/null +++ b/packages/core/src/deploy/internal-ingress-overrides.ts @@ -0,0 +1,43 @@ +/** + * Per-provider-resource overrides applied when a service backend is + * nested inside a network-isolation container (see + * `hasNetworkIsolatingAncestor`). Each entry knows how to mutate the + * extractor's property dict so the deployed resource serves traffic + * internally instead of from the public internet. + * + * Cardinal-rule schema-driven: the translator iterates this table + * generically. Adding a new provider's internal-mode override means + * registering an entry; the translator stays unchanged. Replaces the + * inline `if (gcp_type === 'gcp.run.service') ... else if (gcp_type + * === 'aws.ecs.service') ...` branches that mixed provider-specific + * logic into a provider-agnostic file. + */ + +export type InternalIngressOverride = (properties: Record) => void; + +export const INTERNAL_INGRESS_OVERRIDES: Record = { + // GCP Cloud Run — only reachable via VPC or internal LB. + 'gcp.run.service': (p) => { + p.allow_unauthenticated = false; + p.ingress = 'internal-and-cloud-load-balancing'; + }, + // AWS ECS — no public ALB; rely on nested ingress block. + 'aws.ecs.service': (p) => { + p.assign_public_ip = false; + p.internal = true; + }, + // Azure Container App — disable external ingress. + 'azure.containerapp.containerApp': (p) => { + p.ingress_external = false; + }, +}; + +/** + * Apply the registered override (if any) for `resourceType` in place. + * No-op when no entry exists — the resource doesn't have an internal + * variant on this provider, or the override hasn't been declared yet. + */ +export function applyInternalIngressOverride(resourceType: string, properties: Record): void { + const override = INTERNAL_INGRESS_OVERRIDES[resourceType]; + if (override) override(properties); +} diff --git a/packages/core/src/deploy/passes/__tests__/pass-1-46-socket-port-targeting.test.ts b/packages/core/src/deploy/passes/__tests__/pass-1-46-socket-port-targeting.test.ts new file mode 100644 index 00000000..e43bf640 --- /dev/null +++ b/packages/core/src/deploy/passes/__tests__/pass-1-46-socket-port-targeting.test.ts @@ -0,0 +1,161 @@ +/** + * Tests for `passes/pass-1-46-socket-port-targeting.ts`. + * + * Pass 1.46 reads `edge.data.targetSocket` / `sourceSocket` and, when + * the id matches the `port--(in|out)` shape, writes the port onto + * the compute node's `target_port` (and `port` when not explicitly + * set). This is what lets a typed wiring on a multi-port Container + * actually drive what the deployer routes to. + */ + +import { describe, it, expect } from 'vitest'; +import { create_mutable_graph } from '../../../graph/mutable-graph'; +import { propagate_socket_port_targets } from '../pass-1-46-socket-port-targeting'; +import type { CardEdgeInput, CardNodeInput } from '../../card-translator'; + +function setup_graph( + computeName: string, + initialProps: Record = {}, +): { graph: ReturnType; nodeName: string } { + const graph = create_mutable_graph('test-project'); + const result = graph.add_node({ + type: 'gcp.run.service', + name: computeName, + properties: { region: 'us-central1', ...initialProps }, + }); + if (!result.success || !result.node) { + throw new Error(`fixture setup failed: ${result.errors?.join(', ')}`); + } + return { graph, nodeName: result.node.name }; +} + +describe('propagate_socket_port_targets', () => { + it('writes target_port when targetSocket is `port--in`', () => { + const { graph, nodeName } = setup_graph('backend-1'); + const nodes: CardNodeInput[] = [ + { id: 'cd', type: 'block', data: { iceType: 'Network.CustomDomain' } }, + { id: 'backend', type: 'block', data: { iceType: 'Compute.Container' } }, + ]; + const edges: CardEdgeInput[] = [ + { + id: 'e1', + source: 'cd', + target: 'backend', + data: { sourceSocket: 'domain-out-r1', targetSocket: 'port-8080-in' }, + }, + ]; + const idMap = new Map([['backend', nodeName]]); + propagate_socket_port_targets(edges, nodes, idMap, graph); + const props = graph.get_node_by_name(nodeName)!.properties as Record; + expect(props.target_port).toBe(8080); + expect(props.port).toBe(8080); + }); + + it('writes the port when sourceSocket is `port--out` (backend exposes its listener)', () => { + const { graph, nodeName } = setup_graph('backend-2'); + const nodes: CardNodeInput[] = [ + { id: 'backend', type: 'block', data: { iceType: 'Compute.Container' } }, + { id: 'gw', type: 'block', data: { iceType: 'Network.Gateway' } }, + ]; + const edges: CardEdgeInput[] = [ + { + id: 'e2', + source: 'backend', + target: 'gw', + data: { sourceSocket: 'port-3000-out', targetSocket: 'upstream-in' }, + }, + ]; + const idMap = new Map([['backend', nodeName]]); + propagate_socket_port_targets(edges, nodes, idMap, graph); + const props = graph.get_node_by_name(nodeName)!.properties as Record; + expect(props.target_port).toBe(3000); + expect(props.port).toBe(3000); + }); + + it("doesn't overwrite a user-set port (but still writes target_port)", () => { + const { graph, nodeName } = setup_graph('backend-3', { port: 9999 }); + const nodes: CardNodeInput[] = [ + { id: 'cd', type: 'block', data: { iceType: 'Network.CustomDomain' } }, + { id: 'backend', type: 'block', data: { iceType: 'Compute.Container' } }, + ]; + const edges: CardEdgeInput[] = [ + { + id: 'e3', + source: 'cd', + target: 'backend', + data: { targetSocket: 'port-8080-in' }, + }, + ]; + const idMap = new Map([['backend', nodeName]]); + propagate_socket_port_targets(edges, nodes, idMap, graph); + const props = graph.get_node_by_name(nodeName)!.properties as Record; + expect(props.port).toBe(9999); // user value preserved + expect(props.target_port).toBe(8080); // routing target still captured + }); + + it('ignores edges without typed sockets', () => { + const { graph, nodeName } = setup_graph('backend-4'); + const nodes: CardNodeInput[] = [ + { id: 'cd', type: 'block', data: { iceType: 'Network.CustomDomain' } }, + { id: 'backend', type: 'block', data: { iceType: 'Compute.Container' } }, + ]; + const edges: CardEdgeInput[] = [ + { id: 'e4', source: 'cd', target: 'backend', data: { relationship: 'connects_to' } }, + ]; + const idMap = new Map([['backend', nodeName]]); + propagate_socket_port_targets(edges, nodes, idMap, graph); + const props = graph.get_node_by_name(nodeName)!.properties as Record; + expect(props.target_port).toBeUndefined(); + }); + + it('ignores non-port socket ids (`domain-in`, `repository-in`, etc.)', () => { + const { graph, nodeName } = setup_graph('backend-5'); + const nodes: CardNodeInput[] = [ + { id: 'cd', type: 'block', data: { iceType: 'Network.CustomDomain' } }, + { id: 'backend', type: 'block', data: { iceType: 'Compute.Container' } }, + ]; + const edges: CardEdgeInput[] = [ + { + id: 'e5', + source: 'cd', + target: 'backend', + data: { sourceSocket: 'domain-out-r1', targetSocket: 'domain-in' }, + }, + ]; + const idMap = new Map([['backend', nodeName]]); + propagate_socket_port_targets(edges, nodes, idMap, graph); + const props = graph.get_node_by_name(nodeName)!.properties as Record; + expect(props.target_port).toBeUndefined(); + }); + + it('ignores malformed port socket ids (`port-abc-in`, `port--in`, ...)', () => { + const { graph, nodeName } = setup_graph('backend-6'); + const nodes: CardNodeInput[] = [ + { id: 'cd', type: 'block', data: {} }, + { id: 'backend', type: 'block', data: {} }, + ]; + const edges: CardEdgeInput[] = [ + { id: 'e', source: 'cd', target: 'backend', data: { targetSocket: 'port-abc-in' } }, + { id: 'e2', source: 'cd', target: 'backend', data: { targetSocket: 'port--in' } }, + { id: 'e3', source: 'cd', target: 'backend', data: { targetSocket: 'port-0-in' } }, + ]; + const idMap = new Map([['backend', nodeName]]); + propagate_socket_port_targets(edges, nodes, idMap, graph); + const props = graph.get_node_by_name(nodeName)!.properties as Record; + expect(props.target_port).toBeUndefined(); + }); + + it('skips silently when the target node was not deployed', () => { + const { graph, nodeName } = setup_graph('backend-7'); + const nodes: CardNodeInput[] = [ + { id: 'cd', type: 'block', data: {} }, + { id: 'backend', type: 'block', data: {} }, + ]; + const edges: CardEdgeInput[] = [ + // edge.target is "ghost-node" which has no entry in idMap (was filtered out) + { id: 'e', source: 'cd', target: 'ghost-node', data: { targetSocket: 'port-8080-in' } }, + ]; + const idMap = new Map([['backend', nodeName]]); // ghost-node intentionally missing + expect(() => propagate_socket_port_targets(edges, nodes, idMap, graph)).not.toThrow(); + }); +}); diff --git a/packages/core/src/deploy/passes/__tests__/pass-1-5-endpoint-wiring.test.ts b/packages/core/src/deploy/passes/__tests__/pass-1-5-endpoint-wiring.test.ts index f61e5dd3..f428cec0 100644 --- a/packages/core/src/deploy/passes/__tests__/pass-1-5-endpoint-wiring.test.ts +++ b/packages/core/src/deploy/passes/__tests__/pass-1-5-endpoint-wiring.test.ts @@ -555,7 +555,9 @@ describe('wire_public_endpoints — RISK #7 atomic forwarding-rule removal', () it('all-static-site backends → graph.remove_node + deployables.splice + delta-- atomic', () => { const { graph, card_id_to_name, deployables, endpointNodeKey, endpointNodeName } = setup_fixture({ endpoint: { cardId: 'ep-card', resourceName: 'fr-1' }, - computes: [{ cardId: 'site-card', resourceName: 'firebase-site-1' }], + // Mark the static-site graph node with the Firebase Hosting + // resource type so the new self-serving-resource check matches. + computes: [{ cardId: 'site-card', resourceName: 'firebase-site-1', type: 'gcp.firebase.hosting' }], }); const nodes: CardNodeInput[] = [ { @@ -594,7 +596,10 @@ describe('wire_public_endpoints — RISK #7 atomic forwarding-rule removal', () const { graph, card_id_to_name, deployables, endpointNodeKey, endpointNodeName } = setup_fixture({ endpoint: { cardId: 'ep-card', resourceName: 'fr-1' }, computes: [ - { cardId: 'site-card', resourceName: 'firebase-site-1' }, + // The static-site graph node must carry the Firebase resource + // type so the new self-serving-resource check (resolved type, + // not iceType) recognises it. + { cardId: 'site-card', resourceName: 'firebase-site-1', type: 'gcp.firebase.hosting' }, { cardId: 'svc-card', resourceName: 'cloud-run-1' }, ], }); @@ -639,7 +644,7 @@ describe('wire_public_endpoints — RISK #7 atomic forwarding-rule removal', () // double-decrement the delta. const { graph, card_id_to_name, deployables } = setup_fixture({ endpoint: { cardId: 'ep-card', resourceName: 'fr-1' }, - computes: [{ cardId: 'site-card', resourceName: 'firebase-site-1' }], + computes: [{ cardId: 'site-card', resourceName: 'firebase-site-1', type: 'gcp.firebase.hosting' }], }); const nodes: CardNodeInput[] = [ { id: 'ep-card', type: 'block', data: { iceType: 'Network.PublicEndpoint', domain: 'x.io' } }, diff --git a/packages/core/src/deploy/passes/deploy-expansion.ts b/packages/core/src/deploy/passes/deploy-expansion.ts new file mode 100644 index 00000000..2bd75ad6 --- /dev/null +++ b/packages/core/src/deploy/passes/deploy-expansion.ts @@ -0,0 +1,175 @@ +/** + * Generic 1→N block expansion at translate time. + * + * Reads `deployExpansion` from the canonical block schema (a + * `HighLevelResource`) and emits one graph node per entry in + * `properties[partitionBy]`. The translator delegates here whenever a + * resource declares expansion semantics — there is NO iceType-specific + * logic in this file or the caller. + * + * Provider agnostic by construction: the per-resource properties shape + * came from the provider's extractor; we forward it verbatim to each + * emitted node and only touch the entry-derived name + per-entry label. + * Adding a new provider for the same canonical block means adding an + * extractor + handler for the provider's resource type — nothing here + * changes. + * + * Dedupes within the block AND across blocks by resolved resource name + * (`graph.has_node`) — two rows pointing at the same upstream entry + * share one cloud resource. + */ + +import { sanitize_label_value, sanitize_name } from '../utils/name-utils'; +import type { MutableGraph } from '../../graph/mutable-graph'; +import type { DeployExpansion } from '../../resources/high-level-resources'; +import type { DeployableNodeInfo, SkippedNode } from '../card-translator'; + +export interface ExpandDeployableArgs { + /** Schema-declared expansion metadata. */ + expansion: DeployExpansion; + /** Canvas node id (passed through onto each deployable for traceability). */ + nodeId: string; + /** Human-readable label for the source block. */ + blockLabel: string; + /** Canonical iceType (stored on each deployable). */ + iceType: string; + /** Provider-resolved resource type for the cloud handler. */ + resourceType: string; + /** Extractor output — `properties[expansion.partitionBy]` is the partition source. */ + properties: Record; + /** Standard `ice-*` labels every emitted resource carries. */ + baseLabels: Record; + /** Mutable graph to add nodes to. */ + graph: MutableGraph; + /** Receives one entry per emitted resource. */ + deployables: DeployableNodeInfo[]; + /** Receives a single skip entry when the block has zero usable rows. */ + skipped: SkippedNode[]; + /** Receives free-form warnings (empty partition, add-node failures). */ + warnings: string[]; + /** Provider id, used only for the empty-partition warning message. */ + provider: string; +} + +export interface ExpandDeployableResult { + /** Number of cloud resources added to the graph. */ + added: number; +} + +/** + * Coerce a raw partition entry into a uniform record so the rest of the + * function can read fields by name regardless of how the user typed them. + * Lifts plain strings into a single-field record under `nameFrom.field` + * (covers the legacy `string[]` shape that pre-dated the typed `{key,ref}` + * editor — projects don't lose data on first edit). + */ +function normalizeEntry(raw: unknown, expansion: DeployExpansion): Record | null { + if (typeof raw === 'string') { + const v = raw.trim(); + if (!v) return null; + return { [expansion.nameFrom.field]: v }; + } + if (raw && typeof raw === 'object') { + const o = raw as Record; + const out: Record = {}; + for (const [k, v] of Object.entries(o)) { + if (typeof v === 'string') out[k] = v.trim(); + } + return out; + } + return null; +} + +/** Resolve the cloud resource name for one entry, with fallback. */ +function resolveEntryName(entry: Record, expansion: DeployExpansion): string { + const primary = entry[expansion.nameFrom.field]; + if (primary) return primary; + if (expansion.nameFrom.fallback) { + const fb = entry[expansion.nameFrom.fallback]; + if (fb) return fb; + } + return ''; +} + +export function expand_deployable_per_entry(args: ExpandDeployableArgs): ExpandDeployableResult { + const { + expansion, + nodeId, + blockLabel, + iceType, + resourceType, + properties, + baseLabels, + graph, + deployables, + skipped, + warnings, + provider, + } = args; + + const rawPartition = Array.isArray(properties[expansion.partitionBy]) + ? (properties[expansion.partitionBy] as unknown[]) + : []; + const entries = rawPartition + .map((row) => normalizeEntry(row, expansion)) + .filter((e): e is Record => e !== null && Boolean(resolveEntryName(e, expansion))); + + if (entries.length === 0) { + warnings.push( + `"${blockLabel}" (${iceType}) has no ${expansion.partitionBy} configured. Nothing will be created in ${provider}.`, + ); + skipped.push({ + nodeId, + label: blockLabel, + reason: `${iceType} has no ${expansion.partitionBy} configured`, + }); + return { added: 0 }; + } + + // Strip the partition key from the per-resource properties — every + // other field came from the provider's extractor and is already + // shaped for that provider's handler. + const { [expansion.partitionBy]: _strip, ...sharedProps } = properties; + + let added = 0; + const seen = new Set(); + for (const entry of entries) { + const rawName = resolveEntryName(entry, expansion); + const resourceName = sanitize_name(rawName); + if (!resourceName || seen.has(resourceName)) continue; + seen.add(resourceName); + if (graph.has_node(resourceName)) continue; + + const perEntryLabels: Record = { ...baseLabels }; + if (expansion.tagPerEntry) { + const tagValue = entry[expansion.tagPerEntry.fromField]; + if (tagValue) perEntryLabels[expansion.tagPerEntry.labelKey] = sanitize_label_value(tagValue); + } + + const addResult = graph.add_node({ + type: resourceType, + name: resourceName, + properties: { ...sharedProps, labels: perEntryLabels }, + labels: perEntryLabels, + }); + + if (!addResult.success) { + warnings.push( + `Failed to add ${resourceType} "${resourceName}" for block "${blockLabel}": ${addResult.errors?.join(', ')}`, + ); + continue; + } + + const labelSuffix = expansion.labelFrom ? entry[expansion.labelFrom] : undefined; + deployables.push({ + node_id: nodeId, + label: labelSuffix ? `${blockLabel} · ${labelSuffix}` : blockLabel, + ice_type: iceType, + resource_type: resourceType, + resource_name: resourceName, + }); + added++; + } + + return { added }; +} diff --git a/packages/core/src/deploy/passes/pass-1-45-domain-propagation.ts b/packages/core/src/deploy/passes/pass-1-45-domain-propagation.ts index a3004bc4..428ac07f 100644 --- a/packages/core/src/deploy/passes/pass-1-45-domain-propagation.ts +++ b/packages/core/src/deploy/passes/pass-1-45-domain-propagation.ts @@ -8,6 +8,7 @@ * contract. */ +import { getBlockDeployClassifiers } from '../block-deploy-classifiers'; import type { MutableGraph } from '../../graph/mutable-graph'; import type { CardEdgeInput, CardNodeInput } from '../card-translator'; @@ -50,12 +51,18 @@ export function propagate_custom_domain_hosts( if (!src || !dst) continue; const srcIce = (src.data?.iceType as string) || ''; const dstIce = (dst.data?.iceType as string) || ''; + // Schema-driven: a "domain propagator" is any block whose iceType + // is flagged with `isDomainPropagator` in BLOCK_DEPLOY_CLASSIFIERS. + // Adding a new domain-source block adds a table entry; this pass + // stays unchanged. + const srcIsDomain = !!getBlockDeployClassifiers(srcIce).isDomainPropagator; + const dstIsDomain = !!getBlockDeployClassifiers(dstIce).isDomainPropagator; let domainNode: typeof src; let targetNode: typeof src; - if (srcIce === 'Network.CustomDomain') { + if (srcIsDomain) { domainNode = src; targetNode = dst; - } else if (dstIce === 'Network.CustomDomain') { + } else if (dstIsDomain) { domainNode = dst; targetNode = src; } else { diff --git a/packages/core/src/deploy/passes/pass-1-46-socket-port-targeting.ts b/packages/core/src/deploy/passes/pass-1-46-socket-port-targeting.ts new file mode 100644 index 00000000..76d350eb --- /dev/null +++ b/packages/core/src/deploy/passes/pass-1-46-socket-port-targeting.ts @@ -0,0 +1,95 @@ +/** + * Pass 1.46 — Socket-driven target-port routing. + * + * When an edge's `targetSocket` (or `sourceSocket`) id encodes a + * specific listener port (e.g. `port-8080-in`, `port-8443-out`), the + * deployer needs to know which port to wire the LB / DNS at. Without + * this pass the deployer falls back to `data.port` (a single scalar), + * which is what every multi-port wiring would collapse to. + * + * Naming convention (must match the port-schema authoring in + * `@ice/types/ports/schemas/compute.ts`): + * - `port--out` — HTTP/TCP listener N exposed by the service + * - `port--in` — explicit "route traffic to listener N" target + * + * For a wire `CustomDomain.domain-out-` → `Backend.port-8080-in`, + * this pass writes `target_port = 8080` onto the Backend graph node. + * Pass 1.45 (domain propagation) already wrote `domain` — together they + * give the LB enough to bind subdomain → backend port. + * + * Mutates `graph` node properties in place. Lives next to + * `pass-1-45-domain-propagation` because the two are commonly read + * together by provider deployers. + */ + +import type { MutableGraph } from '../../graph/mutable-graph'; +import type { CardEdgeInput, CardNodeInput } from '../card-translator'; + +/** Pulls `` out of `port--(in|out)` ids. Returns `null` if the id isn't a port id. */ +function extract_port_from_socket_id(socketId: string | undefined): number | null { + if (!socketId) return null; + const match = socketId.match(/^port-(\d+)-(?:in|out)$/); + if (!match) return null; + const n = Number(match[1]); + return Number.isFinite(n) && n > 0 ? n : null; +} + +export function propagate_socket_port_targets( + edges: CardEdgeInput[], + nodes: CardNodeInput[], + card_id_to_name: Map, + graph: MutableGraph, +): void { + for (const edge of edges) { + const data = edge.data ?? {}; + const sourceSocket = (data as { sourceSocket?: string }).sourceSocket; + const targetSocket = (data as { targetSocket?: string }).targetSocket; + + // Either end may encode the port. The TARGET-side socket is the + // common case (e.g. CustomDomain.domain-out-r1 → Backend.port-8080-in): + // the "8080" is on the target. But a Backend's `port-8080-out` → + // Gateway.upstream-in also has the port on the SOURCE side, so we + // handle both — the port goes to whichever end is the compute + // node being routed to. + const targetPort = extract_port_from_socket_id(targetSocket); + const sourcePort = extract_port_from_socket_id(sourceSocket); + + if (targetPort !== null) { + // Wire ends ON a port socket — that node's compute listener is + // the LB target. + writePortToNode(edge.target, targetPort, nodes, card_id_to_name, graph); + } + if (sourcePort !== null) { + // Wire emanates FROM a port socket — that source's listener is + // what the downstream block (gateway / LB / domain) routes to. + writePortToNode(edge.source, sourcePort, nodes, card_id_to_name, graph); + } + } +} + +function writePortToNode( + cardNodeId: string, + port: number, + nodes: CardNodeInput[], + card_id_to_name: Map, + graph: MutableGraph, +): void { + const node = nodes.find((n) => n.id === cardNodeId); + if (!node) return; + const graphName = card_id_to_name.get(node.id); + if (!graphName) return; + const graphNode = graph.get_node_by_name(graphName); + if (!graphNode) return; + const props = graphNode.properties as Record; + // Don't clobber an explicit user-set value. The socket-encoded port + // is a hint from the wiring; if the user manually set `port` in the + // properties panel, that wins (consistent with Pass 1.4's "explicit + // override always wins" rule). + if (props.port == null || props.port === 8080) { + props.port = port; + } + // Always record the LB target port — separate from the container + // listener port — so providers that distinguish (e.g. Container App + // ingress targetPort vs application port) can read both. + props.target_port = port; +} diff --git a/packages/core/src/deploy/passes/pass-1-5-endpoint-wiring.ts b/packages/core/src/deploy/passes/pass-1-5-endpoint-wiring.ts index b66ce3f7..ccf0247d 100644 --- a/packages/core/src/deploy/passes/pass-1-5-endpoint-wiring.ts +++ b/packages/core/src/deploy/passes/pass-1-5-endpoint-wiring.ts @@ -14,6 +14,9 @@ * the counter, only the graph + deployables array. */ +import { hasBlockRole } from '@ice/constants'; +import { isPublicIngressNode } from '../edge-classifier'; +import { isSelfServingPublicResource } from '../self-serving-resources'; import { sanitize_name, sanitize_label_value } from '../utils/name-utils'; import type { MutableGraph } from '../../graph/mutable-graph'; import type { CardEdgeInput, CardNodeInput, DeployableNodeInfo } from '../card-translator'; @@ -95,32 +98,22 @@ export function wire_public_endpoints(args: { // Map every PublicEndpoint node to its connected backends. const endpointToBackends = new Map(); - // Match both PublicEndpoint AND CustomDomain-nested-inside-PrivateNetwork - // as endpoint blocks. Both compile to gcp.compute.globalForwardingRule. - // - // - PublicEndpoint: standalone public LB for VPC-internal services. - // - CustomDomain nested inside PrivateNetwork: the nested CD acts as - // the PrivateNetwork's public gateway, compiling to the same LB - // chain but targeting sibling services inside the parent VPC. - // Standalone CustomDomain (no parent) stays DNS-only and is NOT an - // endpoint — it's handled in Pass 1.6 instead. - const isEndpointIceType = (t: string, node?: { parentId?: string | null }) => { - if (t === 'Network.PublicEndpoint') return true; - if (t === 'Network.CustomDomain' && node?.parentId) { - const parent = nodes.find((n) => n.id === node.parentId); - return parent?.data?.iceType === 'Network.PrivateNetwork'; - } - return false; - }; + // Public-ingress detection is schema-driven via + // `BLOCK_DEPLOY_CLASSIFIERS.publicIngressMode`: + // - 'always' (Network.PublicEndpoint): a standalone public LB. + // - 'when-nested-in-isolated-network' (Network.CustomDomain inside + // Network.PrivateNetwork): the nested CD acts as the network's + // public gateway, compiling to the same LB chain. Standalone CD + // stays DNS-only and is handled in Pass 1.6 instead. + // No iceType strings appear here — adding a new ingress block adds a + // table entry in `block-deploy-classifiers.ts`. for (const edge of edges) { const src = nodes.find((n) => n.id === edge.source); const dst = nodes.find((n) => n.id === edge.target); if (!src || !dst) continue; - const srcIce = (src.data?.iceType as string) || ''; - const dstIce = (dst.data?.iceType as string) || ''; - const srcIsEndpoint = isEndpointIceType(srcIce, src); - const dstIsEndpoint = isEndpointIceType(dstIce, dst); + const srcIsEndpoint = isPublicIngressNode(src, nodes); + const dstIsEndpoint = isPublicIngressNode(dst, nodes); if (!srcIsEndpoint && !dstIsEndpoint) continue; const endpointNode = srcIsEndpoint ? src : dst; @@ -191,45 +184,39 @@ export function wire_public_endpoints(args: { }> = []; const defaultBackends: BackendEntry[] = []; - // Compute types that compile to Cloud Run services — each of these - // gets wrapped in a Serverless NEG + backend service by the LB - // handler at deploy time. Static sites use backendBuckets instead. - const SERVICE_BACKEND_ICE_TYPES = new Set([ - 'Compute.Container', - 'Compute.BackendAPI', - 'Compute.SSRSite', - 'Compute.Worker', - 'Compute.ServerlessFunction', - ]); + // Whether a backend's iceType compiles to a Cloud Run service + // wrapped in a Serverless NEG is a schema-declared fact — see the + // `serviceBackend` role in `@ice/constants/block-classifiers.ts`. + // The previous in-file duplicate of this set (mirroring + // SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS in edge-classifier) is + // gone; both consumers now read the same role. for (const be of backends) { - // Static sites on GCP now compile to Firebase Hosting (which - // gives a public HTTPS URL out of the box, with its own CDN + - // managed cert + optional custom domain). The Public Endpoint - // load-balancer chain is REDUNDANT for Firebase Hosting — it - // serves traffic itself, no backend bucket / URL map / forwarding - // rule needed. We skip the LB wiring here and let the Firebase - // Hosting handler register the custom domain on its own. + // Self-serving public resources (Firebase Hosting today; future: + // AWS Amplify, Azure Static Web Apps) bring their own CDN + cert + // + public URL, so the Public Endpoint LB chain is REDUNDANT in + // front of them. Skip the LB wiring; propagate the upstream + // domain onto the node so the resource's own handler can + // register the custom domain. // - // The static site node still gets the user's custom domain - // propagated so the Firebase Hosting handler picks it up. - if (be.targetIceType === 'Compute.StaticSite') { - // Propagate the PublicEndpoint's domain onto the static site - // node so the Firebase Hosting handler can register it as a - // custom domain. Subdomains become per-site subdomains; blank - // becomes the root domain. - const targetGraphNode = graph.get_node_by_name(be.targetResourceName); - if (targetGraphNode && rootDomain) { + // Schema-driven: the set of self-serving resource types lives in + // `self-serving-resources.ts`. The check below reads the target + // graph node's resolved provider resource_type — no iceType + // strings appear here. + const targetGraphNode = graph.get_node_by_name(be.targetResourceName); + if (targetGraphNode && isSelfServingPublicResource(targetGraphNode.type)) { + if (rootDomain) { const fullHost = be.subdomain ? `${be.subdomain}.${rootDomain}` : rootDomain; (targetGraphNode.properties as any).domain = fullHost; } - // Mark the static-site → forwarding-rule mapping so the post-deploy - // overlay still knows the static site is wired to a public endpoint - // (used for the canvas pill propagation). The forwarding rule itself - // will be created EMPTY and skipped at deploy time when no other - // backend uses it. + // Mark the self-serving target → forwarding-rule mapping so + // the post-deploy overlay still knows it was wired to a + // public endpoint (used for the canvas pill propagation). The + // forwarding rule itself will be created EMPTY and skipped at + // deploy time when no other backend uses it. staticSiteToForwardingRule.set(be.targetNodeId, forwardingResourceName); - // Skip adding a host rule — Firebase Hosting serves directly. + // Skip adding a host rule — the self-serving resource handles + // routing itself. continue; } @@ -238,7 +225,7 @@ export function wire_public_endpoints(args: { // needs the runtime region, which lives on the handler context // but not in the translator. We just record the names here and // pass them through `host_rules` as metadata. - if (SERVICE_BACKEND_ICE_TYPES.has(be.targetIceType)) { + if (hasBlockRole(be.targetIceType, 'serviceBackend')) { const backendServiceName = sanitize_name(`${be.targetResourceName}-backend`); be.sourceServiceName = be.targetResourceName; be.backendServiceName = backendServiceName; diff --git a/packages/core/src/deploy/providers/__tests__/_aws-test-harness.ts b/packages/core/src/deploy/providers/__tests__/_aws-test-harness.ts new file mode 100644 index 00000000..e8881460 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/_aws-test-harness.ts @@ -0,0 +1,131 @@ +/** + * Shared test harness for AWS handler tests. + * + * Every handler in `providers/aws/handlers/` loads its SDK via the + * `Function('m', 'return import(m)')` indirection that Vitest's + * module registry doesn't see. This harness installs a global + * Function stub that routes the indirection through a controllable + * registry of fake SDK modules, plus a generic factory for building + * those fakes with arbitrary command-class shapes. + * + * See `aws-deployer.test.ts` (the original consumer) for the + * inspiration; the harness below is the deduplicated form so each + * per-handler test file stays small. + */ + +import { vi } from 'vitest'; + +// ============================================================================= +// Function-constructor stub +// ============================================================================= + +export interface AwsFakeImportRegistry { + [module_name: string]: unknown; +} + +const original_function = globalThis.Function; + +export function install_dynamic_import_stub(registry: AwsFakeImportRegistry): void { + const stub = function (...args: unknown[]) { + if (args.length === 2 && args[0] === 'm' && typeof args[1] === 'string' && args[1].includes('return import')) { + return (module_name: string) => { + const mod = registry[module_name]; + if (mod === undefined) return Promise.reject(new Error(`Mocked module not registered: ${module_name}`)); + return Promise.resolve(mod); + }; + } + return (original_function as unknown as (...a: unknown[]) => unknown).apply(original_function, args); + }; + (globalThis as { Function: unknown }).Function = stub; +} + +export function restore_dynamic_import_stub(): void { + (globalThis as { Function: unknown }).Function = original_function; +} + +// ============================================================================= +// Generic AWS SDK mock factory +// ============================================================================= + +export interface SdkMockOptions { + /** Name of the SDK's client constructor (`'S3Client'`, `'RDSClient'`, …). */ + client_class_name: string; + /** Command class names — each gets a real class so `new X(input)` works. */ + command_class_names: string[]; + /** + * Default behaviour for `client.send(cmd)`. Defaults to returning + * `{}`. Override per command via `sendImpl`. + */ + sendImpl?: (cmd: { __cmd: string; input: any }) => unknown | Promise; +} + +export interface SdkMock { + send: ReturnType; + destroy: ReturnType; + sendCalls: Array<{ __cmd: string; input: any }>; + /** Indexable bag of constructors so callers can pull commands out by name. */ + module: Record; +} + +/** + * Build a fake AWS SDK module with a constructor-based client + a + * matching set of command classes. Use this for handlers that need + * just a few SDK commands without writing a bespoke factory. + */ +export function makeSdkMock(opts: SdkMockOptions): SdkMock { + const sendCalls: Array<{ __cmd: string; input: any }> = []; + const send = vi.fn(async (cmd: any) => { + sendCalls.push(cmd); + if (opts.sendImpl) return opts.sendImpl(cmd); + return {}; + }); + const destroy = vi.fn(); + + // Build the client constructor (named exactly `opts.client_class_name`). + // The handler indexes the module by this name. + const Client = class { + region: string; + send: any; + destroy: any; + constructor(args: any) { + this.region = args?.region; + this.send = send; + this.destroy = destroy; + } + }; + Object.defineProperty(Client, 'name', { value: opts.client_class_name }); + + const module: Record = { [opts.client_class_name]: Client }; + for (const cmdName of opts.command_class_names) { + // Tests assert via `sendCalls[i].__cmd === 'CreateX'` — strip the + // 'Command' suffix so the label matches the operation name AWS + // documents (`CreateSecret`, not `CreateSecretCommand`). + const cmdLabel = cmdName.endsWith('Command') ? cmdName.slice(0, -'Command'.length) : cmdName; + const Cmd = class { + input: any; + __cmd = cmdLabel; + constructor(input: any) { + this.input = input; + } + }; + Object.defineProperty(Cmd, 'name', { value: cmdName }); + module[cmdName] = Cmd; + } + + return { send, destroy, sendCalls, module }; +} + +// ============================================================================= +// STS mock — shared across every handler that touches account-id resolution +// ============================================================================= + +export const FAKE_ACCOUNT_ID = '000000000000'; + +export function makeStsMock(account?: string): SdkMock { + const mock = makeSdkMock({ + client_class_name: 'STSClient', + command_class_names: ['GetCallerIdentityCommand'], + sendImpl: () => ({ Account: account ?? FAKE_ACCOUNT_ID }), + }); + return mock; +} diff --git a/packages/core/src/deploy/providers/__tests__/aws-api-gateway.test.ts b/packages/core/src/deploy/providers/__tests__/aws-api-gateway.test.ts new file mode 100644 index 00000000..7d168987 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-api-gateway.test.ts @@ -0,0 +1,30 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const api = makeSdkMock({ + client_class_name: 'APIGatewayClient', + command_class_names: ['CreateRestApiCommand', 'CreateDeploymentCommand', 'DeleteRestApiCommand'], + sendImpl: (cmd) => (cmd.__cmd === 'CreateRestApi' ? { id: 'abc123' } : {}), + }); + install_dynamic_import_stub({ '@aws-sdk/client-api-gateway': api.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, api }; +} + +describe('aws.apigateway.restApi handler', () => { + it('creates REST API + default-stage deployment', async () => { + const { d, api } = await setup(); + const out = await d.create('aws.apigateway.restApi', 'gw', { endpoint_type: 'REGIONAL', stage_name: 'prod' }, {}); + expect(out.success).toBe(true); + expect(out.provider_id).toContain('/restapis/abc123'); + const cmds = api.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['CreateRestApi', 'CreateDeployment']); + expect(api.sendCalls[1].input).toEqual({ restApiId: 'abc123', stageName: 'prod' }); + }); +}); diff --git a/packages/core/src/deploy/providers/__tests__/aws-bedrock.test.ts b/packages/core/src/deploy/providers/__tests__/aws-bedrock.test.ts new file mode 100644 index 00000000..d8a027f0 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-bedrock.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +describe('aws.bedrock.endpoint handler', () => { + it('is a no-op on create when model_units=0 (on-demand mode)', async () => { + const bedrock = makeSdkMock({ + client_class_name: 'BedrockClient', + command_class_names: ['CreateProvisionedModelThroughputCommand', 'DeleteProvisionedModelThroughputCommand'], + }); + install_dynamic_import_stub({ '@aws-sdk/client-bedrock': bedrock.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + + const out = await d.create( + 'aws.bedrock.endpoint', + 'llm', + { model_id: 'anthropic.claude-3-haiku-20240307-v1:0', model_units: 0 }, + {}, + ); + expect(out.success).toBe(true); + expect(out.provider_id).toContain('model/anthropic.claude-3-haiku-20240307-v1:0'); + expect(bedrock.sendCalls).toHaveLength(0); + }); + + it('creates provisioned throughput when model_units>0', async () => { + const bedrock = makeSdkMock({ + client_class_name: 'BedrockClient', + command_class_names: ['CreateProvisionedModelThroughputCommand', 'DeleteProvisionedModelThroughputCommand'], + sendImpl: (cmd) => + cmd.__cmd === 'CreateProvisionedModelThroughput' + ? { provisionedModelArn: `arn:aws:bedrock:us-east-1:111:provisioned-model/${cmd.input.provisionedModelName}` } + : {}, + }); + install_dynamic_import_stub({ '@aws-sdk/client-bedrock': bedrock.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + + const out = await d.create( + 'aws.bedrock.endpoint', + 'llm', + { model_id: 'anthropic.claude-3-haiku-20240307-v1:0', model_units: 2, commitment_duration: 'OneMonth' }, + {}, + ); + expect(out.success).toBe(true); + expect(bedrock.sendCalls[0].__cmd).toBe('CreateProvisionedModelThroughput'); + expect(bedrock.sendCalls[0].input.modelUnits).toBe(2); + }); +}); diff --git a/packages/core/src/deploy/providers/__tests__/aws-cloudfront.test.ts b/packages/core/src/deploy/providers/__tests__/aws-cloudfront.test.ts new file mode 100644 index 00000000..f72b98aa --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-cloudfront.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup(opts: { withAcm?: boolean } = {}) { + const cf = makeSdkMock({ + client_class_name: 'CloudFrontClient', + command_class_names: ['CreateDistributionCommand', 'DeleteDistributionCommand'], + sendImpl: (cmd) => + cmd.__cmd === 'CreateDistribution' ? { Distribution: { ARN: 'arn:aws:cloudfront::111:distribution/E123' } } : {}, + }); + const acm = makeSdkMock({ + client_class_name: 'ACMClient', + command_class_names: ['RequestCertificateCommand'], + sendImpl: () => ({ CertificateArn: 'arn:aws:acm:us-east-1:111:certificate/abc' }), + }); + const registry: Record = { '@aws-sdk/client-cloudfront': cf.module }; + if (opts.withAcm) registry['@aws-sdk/client-acm'] = acm.module; + install_dynamic_import_stub(registry); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, cf, acm }; +} + +describe('aws.cloudfront.distribution handler', () => { + it('creates a distribution with the default CF cert when ACM is absent', async () => { + const { d, cf } = await setup(); + const out = await d.create('aws.cloudfront.distribution', 'cdn', { domain: 'example.com' }, {}); + expect(out.success).toBe(true); + const cfg = cf.sendCalls[0].input.DistributionConfig; + expect(cfg.ViewerCertificate.CloudFrontDefaultCertificate).toBe(true); + }); + + it('requests an ACM cert in us-east-1 when auto_provision_cert and domain set', async () => { + const { d, cf } = await setup({ withAcm: true }); + const out = await d.create( + 'aws.cloudfront.distribution', + 'cdn', + { domain: 'example.com', enableHttps: true, auto_provision_cert: true }, + {}, + ); + expect(out.success).toBe(true); + const cfg = cf.sendCalls[0].input.DistributionConfig; + expect(cfg.ViewerCertificate.ACMCertificateArn).toBe('arn:aws:acm:us-east-1:111:certificate/abc'); + expect(cfg.ViewerCertificate.SSLSupportMethod).toBe('sni-only'); + }); +}); diff --git a/packages/core/src/deploy/providers/__tests__/aws-cognito.test.ts b/packages/core/src/deploy/providers/__tests__/aws-cognito.test.ts new file mode 100644 index 00000000..de2f8968 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-cognito.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const cog = makeSdkMock({ + client_class_name: 'CognitoIdentityProviderClient', + command_class_names: ['CreateUserPoolCommand', 'DeleteUserPoolCommand'], + sendImpl: (cmd) => + cmd.__cmd === 'CreateUserPool' + ? { UserPool: { Arn: `arn:aws:cognito-idp:us-east-1:111:userpool/us-east-1_${cmd.input.PoolName}` } } + : {}, + }); + install_dynamic_import_stub({ '@aws-sdk/client-cognito-identity-provider': cog.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, cog }; +} + +describe('aws.cognito.userPool handler', () => { + it('creates a user pool with the extractor password policy', async () => { + const { d, cog } = await setup(); + const out = await d.create( + 'aws.cognito.userPool', + 'main', + { + auto_verified_attributes: ['email'], + mfa_configuration: 'ON', + password_policy: { minimum_length: 12, require_symbols: true }, + }, + {}, + ); + expect(out.success).toBe(true); + expect(out.provider_id).toContain('userpool/us-east-1_main'); + expect(cog.sendCalls[0].input.MfaConfiguration).toBe('ON'); + expect(cog.sendCalls[0].input.Policies.PasswordPolicy.MinimumLength).toBe(12); + expect(cog.sendCalls[0].input.Policies.PasswordPolicy.RequireSymbols).toBe(true); + }); +}); diff --git a/packages/core/src/deploy/providers/__tests__/aws-deployer.test.ts b/packages/core/src/deploy/providers/__tests__/aws-deployer.test.ts index b778f182..99977964 100644 --- a/packages/core/src/deploy/providers/__tests__/aws-deployer.test.ts +++ b/packages/core/src/deploy/providers/__tests__/aws-deployer.test.ts @@ -35,8 +35,16 @@ interface FakeImportRegistry { '@aws-sdk/client-ec2'?: unknown; '@aws-sdk/client-s3'?: unknown; '@aws-sdk/client-lambda'?: unknown; + '@aws-sdk/client-sts'?: unknown; + '@aws-sdk/client-cloudwatch-logs'?: unknown; } +// Fake AWS account id every test uses. The post-#7 S3 handler suffixes +// every bucket with `-{accountId}` via STS GetCallerIdentity, so this +// value shows up in every assertion that checks the resulting ARN. +const FAKE_ACCOUNT_ID = '000000000000'; +const SUFFIX = `-${FAKE_ACCOUNT_ID}`; + const original_function = globalThis.Function; function install_dynamic_import_stub(registry: FakeImportRegistry): void { @@ -172,6 +180,27 @@ function makeS3Module(opts: { sendImpl?: (cmd: any) => any | Promise } = {} this.input = input; } } + class PutPublicAccessBlockCommand { + input: any; + __cmd = 'PutPublicAccessBlock'; + constructor(input: any) { + this.input = input; + } + } + class PutBucketPolicyCommand { + input: any; + __cmd = 'PutBucketPolicy'; + constructor(input: any) { + this.input = input; + } + } + class PutBucketWebsiteCommand { + input: any; + __cmd = 'PutBucketWebsite'; + constructor(input: any) { + this.input = input; + } + } return { S3Client, CreateBucketCommand, @@ -179,12 +208,86 @@ function makeS3Module(opts: { sendImpl?: (cmd: any) => any | Promise } = {} DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand, + PutPublicAccessBlockCommand, + PutBucketPolicyCommand, + PutBucketWebsiteCommand, + send, + destroy, + sendCalls, + }; +} + +function makeCloudWatchLogsModule() { + const sendCalls: any[] = []; + const send = vi.fn(async (cmd: any) => { + sendCalls.push(cmd); + return {}; + }); + const destroy = vi.fn(); + class CloudWatchLogsClient { + region: string; + send: any; + destroy: any; + constructor(args: any) { + this.region = args.region; + this.send = send; + this.destroy = destroy; + } + } + class CreateLogGroupCommand { + input: any; + __cmd = 'CreateLogGroup'; + constructor(input: any) { + this.input = input; + } + } + class PutRetentionPolicyCommand { + input: any; + __cmd = 'PutRetentionPolicy'; + constructor(input: any) { + this.input = input; + } + } + class DeleteLogGroupCommand { + input: any; + __cmd = 'DeleteLogGroup'; + constructor(input: any) { + this.input = input; + } + } + return { + CloudWatchLogsClient, + CreateLogGroupCommand, + PutRetentionPolicyCommand, + DeleteLogGroupCommand, send, destroy, sendCalls, }; } +function makeStsModule(opts: { account?: string } = {}) { + const send = vi.fn(async () => ({ Account: opts.account ?? FAKE_ACCOUNT_ID })); + const destroy = vi.fn(); + class STSClient { + region: string; + send: any; + destroy: any; + constructor(args: any) { + this.region = args.region; + this.send = send; + this.destroy = destroy; + } + } + class GetCallerIdentityCommand { + input: any; + constructor(input: any) { + this.input = input; + } + } + return { STSClient, GetCallerIdentityCommand, send, destroy }; +} + function makeLambdaModule(opts: { sendImpl?: (cmd: any) => any | Promise } = {}) { const sendCalls: any[] = []; const send = vi.fn(async (cmd: any) => { @@ -247,15 +350,21 @@ function makeFullRegistry() { const ec2 = makeEc2Module(); const s3 = makeS3Module(); const lambda = makeLambdaModule(); + const sts = makeStsModule(); + const cwl = makeCloudWatchLogsModule(); return { registry: { '@aws-sdk/client-ec2': ec2, '@aws-sdk/client-s3': s3, '@aws-sdk/client-lambda': lambda, + '@aws-sdk/client-sts': sts, + '@aws-sdk/client-cloudwatch-logs': cwl, } satisfies FakeImportRegistry, ec2, s3, lambda, + sts, + cwl, }; } @@ -360,13 +469,15 @@ describe('initialize', () => { it('initializes only the S3 client when EC2 and Lambda are missing', async () => { const s3 = makeS3Module(); - install_dynamic_import_stub({ '@aws-sdk/client-s3': s3 }); + const sts = makeStsModule(); + // STS is needed for the account-id suffix that the S3 handler appends. + install_dynamic_import_stub({ '@aws-sdk/client-s3': s3, '@aws-sdk/client-sts': sts }); const d = new AWSDeployer(); await d.initialize({ provider: 'aws' }); const out = await d.create('aws.s3.bucket', 'b1', {}, {}); expect(out.success).toBe(true); - expect(out.provider_id).toBe('arn:aws:s3:::b1'); + expect(out.provider_id).toBe('arn:aws:s3:::b1' + SUFFIX); }); it('initializes only the Lambda client when EC2 and S3 are missing', async () => { @@ -377,7 +488,15 @@ describe('initialize', () => { const d = new AWSDeployer(); await d.initialize({ provider: 'aws' }); - const out = await d.create('aws.lambda.function', 'f1', {}, {}); + // The hardened Lambda handler now requires a role + code source up + // front (commit #9). Supply both so the create-time validation + // passes and the SDK fake's FunctionArn response wins. + const out = await d.create( + 'aws.lambda.function', + 'f1', + { role: 'arn:aws:iam::1:role/r', s3_bucket: 'pkg', s3_key: 'app.zip' }, + {}, + ); expect(out.success).toBe(true); expect(out.provider_id).toBe('arn:aws:lambda:us-east-1:1:function:f1'); }); @@ -595,7 +714,7 @@ describe('create', () => { const out = await d.create('aws.s3.bucket', 'my-bucket', {}, {}); expect(out.success).toBe(true); - expect(out.provider_id).toBe('arn:aws:s3:::my-bucket'); + expect(out.provider_id).toBe('arn:aws:s3:::my-bucket' + SUFFIX); }); it('omits CreateBucketConfiguration on S3 create when region is us-east-1', async () => { @@ -649,6 +768,44 @@ describe('create', () => { expect(out.error).toMatch(/S3 SDK not available\. Install @aws-sdk\/client-s3/); }); + it('suffixes the S3 bucket name with the AWS account id', async () => { + const { d, s3 } = await deployerWithFullSdk(); + await d.create('aws.s3.bucket', 'my-bucket', {}, {}); + const createCmd = s3.sendCalls[0]; + expect(createCmd.__cmd).toBe('CreateBucket'); + expect(createCmd.input.Bucket).toBe('my-bucket' + SUFFIX); + }); + + it('attaches public-read bucket policy + website config when public_access + website_hosting', async () => { + const { d, s3 } = await deployerWithFullSdk(); + const out = await d.create( + 'aws.s3.bucket', + 'static-site', + { public_access: true, website_hosting: true, index_page: 'home.html', not_found_page: 'oops.html' }, + {}, + ); + expect(out.success).toBe(true); + // CreateBucket → PutPublicAccessBlock → PutBucketPolicy → PutBucketWebsite + const cmds = s3.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['CreateBucket', 'PutPublicAccessBlock', 'PutBucketPolicy', 'PutBucketWebsite']); + // Public-read policy points at the suffixed bucket name. + const policy = JSON.parse(s3.sendCalls[2].input.Policy); + expect(policy.Statement[0].Resource).toBe(`arn:aws:s3:::static-site${SUFFIX}/*`); + // Website config picks up the index/404 overrides from properties. + expect(s3.sendCalls[3].input.WebsiteConfiguration).toEqual({ + IndexDocument: { Suffix: 'home.html' }, + ErrorDocument: { Key: 'oops.html' }, + }); + }); + + it('does NOT attach public-read policy on a plain (non-website) bucket', async () => { + const { d, s3 } = await deployerWithFullSdk(); + await d.create('aws.s3.bucket', 'private-bucket', { public_access: false, website_hosting: false }, {}); + const cmds = s3.sendCalls.map((c: any) => c.__cmd); + // CreateBucket only — no PublicAccessBlock / Policy / Website calls. + expect(cmds).toEqual(['CreateBucket']); + }); + it('creates a Lambda function and returns the FunctionArn', async () => { const ctx = makeFullRegistry(); install_dynamic_import_stub(ctx.registry); @@ -657,7 +814,12 @@ describe('create', () => { ctx.lambda.send.mockResolvedValueOnce({ FunctionArn: 'arn:aws:lambda:us-east-1:1:function:f1' }); - const out = await d.create('aws.lambda.function', 'f1', { role: 'arn:aws:iam::1:role/r' }, {}); + const out = await d.create( + 'aws.lambda.function', + 'f1', + { role: 'arn:aws:iam::1:role/r', s3_bucket: 'pkg', s3_key: 'app.zip' }, + {}, + ); expect(out.success).toBe(true); expect(out.provider_id).toBe('arn:aws:lambda:us-east-1:1:function:f1'); @@ -667,7 +829,7 @@ describe('create', () => { const { d, lambda } = await deployerWithFullSdk(); lambda.send.mockResolvedValueOnce({ FunctionArn: 'arn' }); - await d.create('aws.lambda.function', 'f1', { role: 'r' }, {}); + await d.create('aws.lambda.function', 'f1', { role: 'r', s3_bucket: 'pkg', s3_key: 'x.zip' }, {}); const cmd = lambda.send.mock.calls[0][0]; expect(cmd.input.Runtime).toBe('nodejs18.x'); @@ -726,7 +888,7 @@ describe('create', () => { const { d, lambda } = await deployerWithFullSdk(); lambda.send.mockResolvedValueOnce({ FunctionArn: 'arn' }); - await d.create('aws.lambda.function', 'f1', { role: 'r' }, {}); + await d.create('aws.lambda.function', 'f1', { role: 'r', s3_bucket: 'pkg', s3_key: 'x.zip' }, {}); const cmd = lambda.send.mock.calls[0][0]; expect(cmd.input.Environment).toBeUndefined(); @@ -736,12 +898,26 @@ describe('create', () => { const { d, lambda } = await deployerWithFullSdk(); lambda.send.mockResolvedValueOnce({ FunctionArn: 'arn' }); - await d.create('aws.lambda.function', 'f1', { role: 'r' }, {}); + await d.create('aws.lambda.function', 'f1', { role: 'r', s3_bucket: 'pkg', s3_key: 'x.zip' }, {}); const cmd = lambda.send.mock.calls[0][0]; expect(cmd.input.Code.ZipFile).toBeUndefined(); }); + it('fails fast with a clear error when properties.role is missing on Lambda create', async () => { + const { d } = await deployerWithFullSdk(); + const out = await d.create('aws.lambda.function', 'f1', { s3_bucket: 'pkg', s3_key: 'x.zip' }, {}); + expect(out.success).toBe(false); + expect(out.error).toMatch(/IAM execution role/); + }); + + it('fails fast with a clear error when no code source is supplied on Lambda create', async () => { + const { d } = await deployerWithFullSdk(); + const out = await d.create('aws.lambda.function', 'f1', { role: 'r' }, {}); + expect(out.success).toBe(false); + expect(out.error).toMatch(/code source is missing/); + }); + it('returns success:false with "Lambda SDK not available" when Lambda client is missing', async () => { install_dynamic_import_stub({}); const d = new AWSDeployer(); @@ -1134,3 +1310,38 @@ describe('delete', () => { expect(out.error).toBe('[object Object]'); }); }); + +// ============================================================================= +// CloudWatch Logs handler — commit #10 +// ============================================================================= + +describe('aws.cloudwatch.logGroup handler', () => { + it('creates a log group with retention when retention_in_days is set', async () => { + const { d, cwl } = await deployerWithFullSdk(); + const out = await d.create( + 'aws.cloudwatch.logGroup', + 'my-app-logs', + { retention_in_days: 30, tags: { Env: 'prod' } }, + {}, + ); + expect(out.success).toBe(true); + expect(out.provider_id).toBe('arn:aws:logs:us-east-1:*:log-group:my-app-logs'); + const cmds = cwl.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['CreateLogGroup', 'PutRetentionPolicy']); + expect(cwl.sendCalls[0].input).toMatchObject({ logGroupName: 'my-app-logs', tags: { Env: 'prod' } }); + expect(cwl.sendCalls[1].input).toEqual({ logGroupName: 'my-app-logs', retentionInDays: 30 }); + }); + + it('skips PutRetentionPolicy when retention is not set', async () => { + const { d, cwl } = await deployerWithFullSdk(); + await d.create('aws.cloudwatch.logGroup', 'lg', {}, {}); + expect(cwl.sendCalls.map((c: any) => c.__cmd)).toEqual(['CreateLogGroup']); + }); + + it('deletes the log group on delete', async () => { + const { d, cwl } = await deployerWithFullSdk(); + const out = await d.delete('aws.cloudwatch.logGroup', 'lg', 'arn:aws:logs:us-east-1:*:log-group:lg', {}); + expect(out.success).toBe(true); + expect(cwl.sendCalls.map((c: any) => c.__cmd)).toEqual(['DeleteLogGroup']); + }); +}); diff --git a/packages/core/src/deploy/providers/__tests__/aws-docdb.test.ts b/packages/core/src/deploy/providers/__tests__/aws-docdb.test.ts new file mode 100644 index 00000000..88bc1588 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-docdb.test.ts @@ -0,0 +1,43 @@ +/** + * Tests for the aws.docdb.cluster handler. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const docdb = makeSdkMock({ + client_class_name: 'DocDBClient', + command_class_names: ['CreateDBClusterCommand', 'CreateDBInstanceCommand', 'DeleteDBClusterCommand'], + }); + install_dynamic_import_stub({ '@aws-sdk/client-docdb': docdb.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, docdb }; +} + +describe('aws.docdb.cluster handler', () => { + it('refuses to create without master_user_password', async () => { + const { d } = await setup(); + const out = await d.create('aws.docdb.cluster', 'db', { master_username: 'admin', master_user_password: '' }, {}); + expect(out.success).toBe(false); + expect(out.error).toMatch(/master_user_password is empty/); + }); + + it('creates cluster + N instances per instance_count', async () => { + const { d, docdb } = await setup(); + const out = await d.create( + 'aws.docdb.cluster', + 'db', + { master_username: 'admin', master_user_password: 'p', instance_count: 3 }, + {}, + ); + expect(out.success).toBe(true); + const cmds = docdb.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['CreateDBCluster', 'CreateDBInstance', 'CreateDBInstance', 'CreateDBInstance']); + }); +}); diff --git a/packages/core/src/deploy/providers/__tests__/aws-dynamodb.test.ts b/packages/core/src/deploy/providers/__tests__/aws-dynamodb.test.ts new file mode 100644 index 00000000..7bb7f16a --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-dynamodb.test.ts @@ -0,0 +1,91 @@ +/** + * Tests for the aws.dynamodb.table handler. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const dynamo = makeSdkMock({ + client_class_name: 'DynamoDBClient', + command_class_names: [ + 'CreateTableCommand', + 'UpdateTableCommand', + 'DeleteTableCommand', + 'UpdateContinuousBackupsCommand', + ], + }); + install_dynamic_import_stub({ '@aws-sdk/client-dynamodb': dynamo.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, dynamo }; +} + +describe('aws.dynamodb.table handler', () => { + it('creates a PAY_PER_REQUEST table with a single hash key by default', async () => { + const { d, dynamo } = await setup(); + const out = await d.create( + 'aws.dynamodb.table', + 'orders', + { billing_mode: 'PAY_PER_REQUEST', partition_key: 'id', partition_key_type: 'S' }, + {}, + ); + expect(out.success).toBe(true); + const cmd = dynamo.sendCalls[0]; + expect(cmd.__cmd).toBe('CreateTable'); + expect(cmd.input.BillingMode).toBe('PAY_PER_REQUEST'); + expect(cmd.input.KeySchema).toEqual([{ AttributeName: 'id', KeyType: 'HASH' }]); + expect(cmd.input.ProvisionedThroughput).toBeUndefined(); + }); + + it('adds a RANGE entry to KeySchema when sort_key is set', async () => { + const { d, dynamo } = await setup(); + await d.create( + 'aws.dynamodb.table', + 'events', + { partition_key: 'pk', partition_key_type: 'S', sort_key: 'ts', sort_key_type: 'N' }, + {}, + ); + const cmd = dynamo.sendCalls[0]; + expect(cmd.input.KeySchema).toEqual([ + { AttributeName: 'pk', KeyType: 'HASH' }, + { AttributeName: 'ts', KeyType: 'RANGE' }, + ]); + expect(cmd.input.AttributeDefinitions).toEqual([ + { AttributeName: 'pk', AttributeType: 'S' }, + { AttributeName: 'ts', AttributeType: 'N' }, + ]); + }); + + it('emits ProvisionedThroughput when billing_mode=PROVISIONED', async () => { + const { d, dynamo } = await setup(); + await d.create( + 'aws.dynamodb.table', + 't', + { billing_mode: 'PROVISIONED', partition_key: 'id', read_capacity: 25, write_capacity: 50 }, + {}, + ); + expect(dynamo.sendCalls[0].input.ProvisionedThroughput).toEqual({ + ReadCapacityUnits: 25, + WriteCapacityUnits: 50, + }); + }); + + it('issues UpdateContinuousBackups when point_in_time_recovery=true', async () => { + const { d, dynamo } = await setup(); + await d.create('aws.dynamodb.table', 't', { partition_key: 'id', point_in_time_recovery: true }, {}); + const cmds = dynamo.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['CreateTable', 'UpdateContinuousBackups']); + }); + + it('deletes the table on delete', async () => { + const { d, dynamo } = await setup(); + await d.delete('aws.dynamodb.table', 't', 'arn', {}); + expect(dynamo.sendCalls[0].__cmd).toBe('DeleteTable'); + expect(dynamo.sendCalls[0].input.TableName).toBe('t'); + }); +}); diff --git a/packages/core/src/deploy/providers/__tests__/aws-ecs.test.ts b/packages/core/src/deploy/providers/__tests__/aws-ecs.test.ts new file mode 100644 index 00000000..29ba5af4 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-ecs.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup(opts: { clusterActive?: boolean } = { clusterActive: true }) { + const ecs = makeSdkMock({ + client_class_name: 'ECSClient', + command_class_names: [ + 'DescribeClustersCommand', + 'CreateClusterCommand', + 'RegisterTaskDefinitionCommand', + 'CreateServiceCommand', + 'UpdateServiceCommand', + 'DeleteServiceCommand', + ], + sendImpl: (cmd) => { + if (cmd.__cmd === 'DescribeClusters') { + return opts.clusterActive + ? { clusters: [{ clusterName: 'ice-default-cluster', status: 'ACTIVE' }] } + : { clusters: [] }; + } + if (cmd.__cmd === 'RegisterTaskDefinition') { + return { + taskDefinition: { taskDefinitionArn: `arn:aws:ecs:us-east-1:111:task-definition/${cmd.input.family}:1` }, + }; + } + if (cmd.__cmd === 'CreateService') { + return { + service: { serviceArn: `arn:aws:ecs:us-east-1:111:service/ice-default-cluster/${cmd.input.serviceName}` }, + }; + } + return {}; + }, + }); + const iam = makeSdkMock({ + client_class_name: 'IAMClient', + command_class_names: ['GetRoleCommand', 'CreateRoleCommand', 'AttachRolePolicyCommand'], + sendImpl: (cmd) => (cmd.__cmd === 'GetRole' ? { Role: { Arn: 'arn:aws:iam::111:role/ecsTaskExecutionRole' } } : {}), + }); + install_dynamic_import_stub({ '@aws-sdk/client-ecs': ecs.module, '@aws-sdk/client-iam': iam.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, ecs, iam }; +} + +describe('aws.ecs.service handler', () => { + it('uses the default cluster when it already exists', async () => { + const { d, ecs } = await setup({ clusterActive: true }); + const out = await d.create( + 'aws.ecs.service', + 'api', + { image: 'app:v1', port: 8080, cpu: '256', memory: '512', desired_count: 2 }, + {}, + ); + expect(out.success).toBe(true); + const cmds = ecs.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['DescribeClusters', 'RegisterTaskDefinition', 'CreateService']); + expect(ecs.sendCalls[1].input.containerDefinitions[0].image).toBe('app:v1'); + expect(ecs.sendCalls[2].input.desiredCount).toBe(2); + }); + + it('creates the default cluster on first deploy when absent', async () => { + const { d, ecs } = await setup({ clusterActive: false }); + const out = await d.create( + 'aws.ecs.service', + 'api', + { image: 'app:v1', port: 8080, cpu: '256', memory: '512' }, + {}, + ); + expect(out.success).toBe(true); + const cmds = ecs.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['DescribeClusters', 'CreateCluster', 'RegisterTaskDefinition', 'CreateService']); + }); + + it('delete scales to zero then deletes the service', async () => { + const { d, ecs } = await setup(); + await d.delete('aws.ecs.service', 'api', 'arn:aws:ecs:us-east-1:111:service/ice-default-cluster/api', {}); + const cmds = ecs.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['UpdateService', 'DeleteService']); + expect(ecs.sendCalls[0].input.desiredCount).toBe(0); + expect(ecs.sendCalls[1].input.force).toBe(true); + }); +}); diff --git a/packages/core/src/deploy/providers/__tests__/aws-elasticache.test.ts b/packages/core/src/deploy/providers/__tests__/aws-elasticache.test.ts new file mode 100644 index 00000000..c72c2750 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-elasticache.test.ts @@ -0,0 +1,55 @@ +/** + * Tests for the aws.elasticache.cluster handler. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const ec = makeSdkMock({ + client_class_name: 'ElastiCacheClient', + command_class_names: [ + 'CreateCacheClusterCommand', + 'CreateReplicationGroupCommand', + 'DeleteCacheClusterCommand', + 'DeleteReplicationGroupCommand', + ], + }); + install_dynamic_import_stub({ '@aws-sdk/client-elasticache': ec.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, ec }; +} + +describe('aws.elasticache.cluster handler', () => { + it('creates a single-node CacheCluster when num_cache_nodes=1', async () => { + const { d, ec } = await setup(); + const out = await d.create( + 'aws.elasticache.cluster', + 'cache', + { cache_node_type: 'cache.t3.micro', num_cache_nodes: 1 }, + {}, + ); + expect(out.success).toBe(true); + expect(ec.sendCalls[0].__cmd).toBe('CreateCacheCluster'); + expect(ec.sendCalls[0].input.NumCacheNodes).toBe(1); + }); + + it('creates a ReplicationGroup when num_cache_nodes>1', async () => { + const { d, ec } = await setup(); + const out = await d.create( + 'aws.elasticache.cluster', + 'cache', + { cache_node_type: 'cache.m5.xlarge', num_cache_nodes: 2 }, + {}, + ); + expect(out.success).toBe(true); + expect(ec.sendCalls[0].__cmd).toBe('CreateReplicationGroup'); + expect(ec.sendCalls[0].input.NumCacheClusters).toBe(2); + expect(ec.sendCalls[0].input.AutomaticFailoverEnabled).toBe(true); + }); +}); diff --git a/packages/core/src/deploy/providers/__tests__/aws-elbv2.test.ts b/packages/core/src/deploy/providers/__tests__/aws-elbv2.test.ts new file mode 100644 index 00000000..c943aa65 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-elbv2.test.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const elb = makeSdkMock({ + client_class_name: 'ElasticLoadBalancingV2Client', + command_class_names: ['CreateLoadBalancerCommand', 'CreateTargetGroupCommand', 'DeleteLoadBalancerCommand'], + sendImpl: (cmd) => + cmd.__cmd === 'CreateLoadBalancer' + ? { LoadBalancers: [{ LoadBalancerArn: 'arn:aws:elasticloadbalancing:us-east-1:111:loadbalancer/app/lb/abc' }] } + : {}, + }); + install_dynamic_import_stub({ '@aws-sdk/client-elastic-load-balancing-v2': elb.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, elb }; +} + +describe('aws.elbv2.loadBalancer handler', () => { + it('creates LB + skeleton TG and returns the LB ARN', async () => { + const { d, elb } = await setup(); + const out = await d.create( + 'aws.elbv2.loadBalancer', + 'lb', + { scheme: 'internet-facing', type: 'application', target_group_port: 8080, target_group_protocol: 'HTTP' }, + {}, + ); + expect(out.success).toBe(true); + expect(out.provider_id).toBe('arn:aws:elasticloadbalancing:us-east-1:111:loadbalancer/app/lb/abc'); + const cmds = elb.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['CreateLoadBalancer', 'CreateTargetGroup']); + expect(elb.sendCalls[1].input.Port).toBe(8080); + }); +}); diff --git a/packages/core/src/deploy/providers/__tests__/aws-events-rule.test.ts b/packages/core/src/deploy/providers/__tests__/aws-events-rule.test.ts new file mode 100644 index 00000000..d9147349 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-events-rule.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const ev = makeSdkMock({ + client_class_name: 'EventBridgeClient', + command_class_names: ['PutRuleCommand', 'PutTargetsCommand', 'RemoveTargetsCommand', 'DeleteRuleCommand'], + sendImpl: (cmd) => (cmd.__cmd === 'PutRule' ? { RuleArn: 'arn:aws:events:us-east-1:111:rule/nightly' } : {}), + }); + install_dynamic_import_stub({ '@aws-sdk/client-eventbridge': ev.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, ev }; +} + +describe('aws.events.rule handler', () => { + it('creates a rule and emits PutTargets when target_arn is set', async () => { + const { d, ev } = await setup(); + const out = await d.create( + 'aws.events.rule', + 'nightly', + { + schedule_expression: 'cron(0 0 * * ? *)', + state: 'ENABLED', + target_arn: 'arn:aws:lambda:us-east-1:111:function:cleanup', + }, + {}, + ); + expect(out.success).toBe(true); + expect(out.provider_id).toBe('arn:aws:events:us-east-1:111:rule/nightly'); + const cmds = ev.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['PutRule', 'PutTargets']); + expect(ev.sendCalls[1].input.Targets[0].Arn).toContain('lambda'); + }); + + it('skips PutTargets when target_arn is absent', async () => { + const { d, ev } = await setup(); + await d.create('aws.events.rule', 'r', { schedule_expression: 'cron(0 0 * * ? *)', state: 'ENABLED' }, {}); + const cmds = ev.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['PutRule']); + }); +}); diff --git a/packages/core/src/deploy/providers/__tests__/aws-opensearch.test.ts b/packages/core/src/deploy/providers/__tests__/aws-opensearch.test.ts new file mode 100644 index 00000000..e0e1e1f4 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-opensearch.test.ts @@ -0,0 +1,28 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +describe('aws.opensearch.domain handler', () => { + it('creates the domain and returns the ARN', async () => { + const os = makeSdkMock({ + client_class_name: 'OpenSearchClient', + command_class_names: ['CreateDomainCommand', 'DeleteDomainCommand'], + }); + install_dynamic_import_stub({ '@aws-sdk/client-opensearch': os.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + + const out = await d.create( + 'aws.opensearch.domain', + 'search', + { engine_version: 'OpenSearch_2.13', instance_type: 't3.small.search', instance_count: 1 }, + {}, + ); + expect(out.success).toBe(true); + expect(out.provider_id).toContain('domain/search'); + expect(os.sendCalls[0].input.ClusterConfig.InstanceCount).toBe(1); + }); +}); diff --git a/packages/core/src/deploy/providers/__tests__/aws-rds.test.ts b/packages/core/src/deploy/providers/__tests__/aws-rds.test.ts new file mode 100644 index 00000000..d3e05985 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-rds.test.ts @@ -0,0 +1,79 @@ +/** + * Tests for the aws.rds.dbInstance handler. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup(opts: { available?: boolean; failed?: boolean } = { available: true }) { + const rds = makeSdkMock({ + client_class_name: 'RDSClient', + command_class_names: [ + 'CreateDBInstanceCommand', + 'DescribeDBInstancesCommand', + 'ModifyDBInstanceCommand', + 'DeleteDBInstanceCommand', + ], + sendImpl: (cmd) => { + if (cmd.__cmd === 'DescribeDBInstances') { + const status = opts.failed ? 'failed' : opts.available ? 'available' : 'creating'; + return { DBInstances: [{ DBInstanceStatus: status }] }; + } + return {}; + }, + }); + install_dynamic_import_stub({ '@aws-sdk/client-rds': rds.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, rds }; +} + +describe('aws.rds.dbInstance handler', () => { + it('refuses to create when master_user_password is empty', async () => { + const { d } = await setup(); + const out = await d.create( + 'aws.rds.dbInstance', + 'db', + { engine: 'postgres', engine_version: '16', master_username: 'postgres', master_user_password: '' }, + {}, + ); + expect(out.success).toBe(false); + expect(out.error).toMatch(/master_user_password is empty/); + }); + + it('creates the instance + polls until status=available', async () => { + const { d, rds } = await setup({ available: true }); + const out = await d.create( + 'aws.rds.dbInstance', + 'db', + { + engine: 'postgres', + engine_version: '16', + master_username: 'postgres', + master_user_password: 'secret', + db_instance_class: 'db.t3.micro', + allocated_storage: 20, + }, + {}, + ); + expect(out.success).toBe(true); + const cmds = rds.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['CreateDBInstance', 'DescribeDBInstances']); + }); + + it('errors out when DescribeDBInstances returns status=failed', async () => { + const { d } = await setup({ failed: true }); + const out = await d.create( + 'aws.rds.dbInstance', + 'db', + { engine: 'postgres', master_username: 'a', master_user_password: 'p' }, + {}, + ); + expect(out.success).toBe(false); + expect(out.error).toMatch(/failed state/); + }); +}); diff --git a/packages/core/src/deploy/providers/__tests__/aws-redshift.test.ts b/packages/core/src/deploy/providers/__tests__/aws-redshift.test.ts new file mode 100644 index 00000000..3bd055fb --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-redshift.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const rs = makeSdkMock({ + client_class_name: 'RedshiftClient', + command_class_names: ['CreateClusterCommand', 'DeleteClusterCommand'], + }); + install_dynamic_import_stub({ '@aws-sdk/client-redshift': rs.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, rs }; +} + +describe('aws.redshift.cluster handler', () => { + it('refuses to create when master_user_password is empty', async () => { + const { d } = await setup(); + const out = await d.create('aws.redshift.cluster', 'dw', { master_user_password: '' }, {}); + expect(out.success).toBe(false); + expect(out.error).toMatch(/master_user_password is empty/); + }); + + it('creates the cluster on the happy path', async () => { + const { d, rs } = await setup(); + const out = await d.create( + 'aws.redshift.cluster', + 'dw', + { + node_type: 'dc2.large', + cluster_type: 'single-node', + db_name: 'analytics', + master_username: 'admin', + master_user_password: 'secret', + port: 5439, + }, + {}, + ); + expect(out.success).toBe(true); + expect(rs.sendCalls[0].__cmd).toBe('CreateCluster'); + expect(rs.sendCalls[0].input.ClusterIdentifier).toBe('dw'); + }); +}); diff --git a/packages/core/src/deploy/providers/__tests__/aws-sagemaker.test.ts b/packages/core/src/deploy/providers/__tests__/aws-sagemaker.test.ts new file mode 100644 index 00000000..eee1c570 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-sagemaker.test.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +describe('aws.sagemaker.endpoint handler', () => { + it('refuses to create when model_name is empty', async () => { + const sm = makeSdkMock({ + client_class_name: 'SageMakerClient', + command_class_names: ['CreateEndpointConfigCommand', 'CreateEndpointCommand', 'DeleteEndpointCommand'], + }); + install_dynamic_import_stub({ '@aws-sdk/client-sagemaker': sm.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + const out = await d.create('aws.sagemaker.endpoint', 'ep', { model_name: '' }, {}); + expect(out.success).toBe(false); + expect(out.error).toMatch(/model_name/); + }); + + it('creates EndpointConfig then Endpoint when model_name is set', async () => { + const sm = makeSdkMock({ + client_class_name: 'SageMakerClient', + command_class_names: ['CreateEndpointConfigCommand', 'CreateEndpointCommand', 'DeleteEndpointCommand'], + sendImpl: (cmd) => + cmd.__cmd === 'CreateEndpoint' + ? { EndpointArn: `arn:aws:sagemaker:us-east-1:111:endpoint/${cmd.input.EndpointName}` } + : {}, + }); + install_dynamic_import_stub({ '@aws-sdk/client-sagemaker': sm.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + + const out = await d.create( + 'aws.sagemaker.endpoint', + 'ep', + { model_name: 'my-model', instance_type: 'ml.t2.medium', initial_instance_count: 1, initial_variant_weight: 1 }, + {}, + ); + expect(out.success).toBe(true); + const cmds = sm.sendCalls.map((c: any) => c.__cmd); + expect(cmds).toEqual(['CreateEndpointConfig', 'CreateEndpoint']); + }); +}); diff --git a/packages/core/src/deploy/providers/__tests__/aws-secrets-manager.test.ts b/packages/core/src/deploy/providers/__tests__/aws-secrets-manager.test.ts new file mode 100644 index 00000000..7656511f --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-secrets-manager.test.ts @@ -0,0 +1,78 @@ +/** + * Tests for the aws.secretsmanager.secret handler. + * + * Uses the shared `_aws-test-harness` so we don't duplicate the + * Function-constructor stub setup per file. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const sm = makeSdkMock({ + client_class_name: 'SecretsManagerClient', + command_class_names: ['CreateSecretCommand', 'UpdateSecretCommand', 'DeleteSecretCommand'], + sendImpl: (cmd) => { + if (cmd.__cmd === 'CreateSecret') { + return { ARN: `arn:aws:secretsmanager:us-east-1:111:secret:${cmd.input.Name}-AbCdEf` }; + } + return {}; + }, + }); + install_dynamic_import_stub({ '@aws-sdk/client-secrets-manager': sm.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, sm }; +} + +describe('aws.secretsmanager.secret handler', () => { + it('creates a secret and returns the ARN from the SDK response', async () => { + const { d, sm } = await setup(); + const out = await d.create('aws.secretsmanager.secret', 'prod-stripe-key', { description: 'stripe' }, {}); + expect(out.success).toBe(true); + expect(out.provider_id).toBe('arn:aws:secretsmanager:us-east-1:111:secret:prod-stripe-key-AbCdEf'); + expect(sm.sendCalls[0].__cmd).toBe('CreateSecret'); + expect(sm.sendCalls[0].input.Name).toBe('prod-stripe-key'); + expect(sm.sendCalls[0].input.Description).toBe('stripe'); + }); + + it('updates description + KmsKeyId via UpdateSecret', async () => { + const { d, sm } = await setup(); + const out = await d.update( + 'aws.secretsmanager.secret', + 'k', + 'arn:aws:secretsmanager:us-east-1:111:secret:k', + { description: 'rotated', kms_key_id: 'alias/aws/secretsmanager' }, + {}, + {}, + ); + expect(out.success).toBe(true); + expect(sm.sendCalls[0].__cmd).toBe('UpdateSecret'); + expect(sm.sendCalls[0].input).toMatchObject({ + SecretId: 'arn:aws:secretsmanager:us-east-1:111:secret:k', + Description: 'rotated', + KmsKeyId: 'alias/aws/secretsmanager', + }); + }); + + it('delete passes ForceDeleteWithoutRecovery=true', async () => { + const { d, sm } = await setup(); + const out = await d.delete('aws.secretsmanager.secret', 'k', 'arn:aws:secretsmanager:us-east-1:111:secret:k', {}); + expect(out.success).toBe(true); + expect(sm.sendCalls[0].__cmd).toBe('DeleteSecret'); + expect(sm.sendCalls[0].input.ForceDeleteWithoutRecovery).toBe(true); + }); + + it('returns SDK-not-installed error when @aws-sdk/client-secrets-manager is absent', async () => { + install_dynamic_import_stub({}); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + const out = await d.create('aws.secretsmanager.secret', 'k', {}, {}); + expect(out.success).toBe(false); + expect(out.error).toMatch(/Secrets Manager SDK not available/); + }); +}); diff --git a/packages/core/src/deploy/providers/__tests__/aws-shared.test.ts b/packages/core/src/deploy/providers/__tests__/aws-shared.test.ts new file mode 100644 index 00000000..5b74d628 --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-shared.test.ts @@ -0,0 +1,211 @@ +/** + * Tests for the AWS shared infra helpers — account-id resolver (STS) + * and ensureManagedRole (IAM). + * + * Reuses the same Function-constructor stub the AWS deployer test + * suite uses so the dynamic SDK imports resolve to controllable fakes. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { create_account_id_resolver } from '../aws/account'; +import { ensureEcsTaskExecutionRole, ensureManagedRole } from '../aws/iam-roles'; + +// ─── Function-constructor stub (mirrors aws-deployer.test.ts) ─────── + +interface FakeImportRegistry { + '@aws-sdk/client-sts'?: unknown; + '@aws-sdk/client-iam'?: unknown; +} + +const original_function = globalThis.Function; + +function install_dynamic_import_stub(registry: FakeImportRegistry): void { + const stub = function (...args: unknown[]) { + if (args.length === 2 && args[0] === 'm' && typeof args[1] === 'string' && args[1].includes('return import')) { + return (module_name: string) => { + const mod = (registry as Record)[module_name]; + if (mod === undefined) return Promise.reject(new Error(`Mocked module not registered: ${module_name}`)); + return Promise.resolve(mod); + }; + } + return (original_function as unknown as (...a: unknown[]) => unknown).apply(original_function, args); + }; + (globalThis as { Function: unknown }).Function = stub; +} + +function restore_dynamic_import_stub(): void { + (globalThis as { Function: unknown }).Function = original_function; +} + +// ─── Fake STS / IAM SDK shapes ───────────────────────────────────── + +function makeStsModule(opts: { account?: string | null; throwOn?: 'send' } = {}) { + const sendCalls: any[] = []; + const send = vi.fn(async (cmd: any) => { + sendCalls.push(cmd); + if (opts.throwOn === 'send') throw new Error('STS exploded'); + // `null` opts.account → response with no Account field. + if (opts.account === null) return {}; + return { Account: opts.account ?? '123456789012' }; + }); + const destroy = vi.fn(); + class STSClient { + region: string; + send: any; + destroy: any; + constructor(args: any) { + this.region = args.region; + this.send = send; + this.destroy = destroy; + } + } + class GetCallerIdentityCommand { + input: any; + constructor(input: any) { + this.input = input; + } + } + return { STSClient, GetCallerIdentityCommand, send, destroy, sendCalls }; +} + +function makeIamModule(opts: { getRoleArn?: string | null; createRoleArn?: string } = {}) { + const sendCalls: any[] = []; + const send = vi.fn(async (cmd: any) => { + sendCalls.push(cmd); + const name = cmd.__cmd; + if (name === 'GetRole') { + if (opts.getRoleArn === null) { + const err: any = new Error('NoSuchEntity'); + err.name = 'NoSuchEntityException'; + throw err; + } + return { Role: { Arn: opts.getRoleArn ?? 'arn:aws:iam::1:role/existing' } }; + } + if (name === 'CreateRole') return { Role: { Arn: opts.createRoleArn ?? 'arn:aws:iam::1:role/created' } }; + if (name === 'AttachRolePolicy') return {}; + return {}; + }); + const destroy = vi.fn(); + class IAMClient { + region: string; + send: any; + destroy: any; + constructor(args: any) { + this.region = args.region; + this.send = send; + this.destroy = destroy; + } + } + class GetRoleCommand { + input: any; + __cmd = 'GetRole'; + constructor(input: any) { + this.input = input; + } + } + class CreateRoleCommand { + input: any; + __cmd = 'CreateRole'; + constructor(input: any) { + this.input = input; + } + } + class AttachRolePolicyCommand { + input: any; + __cmd = 'AttachRolePolicy'; + constructor(input: any) { + this.input = input; + } + } + return { IAMClient, GetRoleCommand, CreateRoleCommand, AttachRolePolicyCommand, send, destroy, sendCalls }; +} + +beforeEach(() => { + install_dynamic_import_stub({}); +}); +afterEach(() => { + restore_dynamic_import_stub(); +}); + +// ─── account-id resolver ──────────────────────────────────────────── + +describe('create_account_id_resolver', () => { + it('returns the STS Account field on first call', async () => { + const sts = makeStsModule({ account: '111122223333' }); + install_dynamic_import_stub({ '@aws-sdk/client-sts': sts }); + const resolve = create_account_id_resolver('us-east-1'); + expect(await resolve()).toBe('111122223333'); + }); + + it('memoises — second call returns the cached value without re-hitting STS', async () => { + const sts = makeStsModule({ account: '999999999999' }); + install_dynamic_import_stub({ '@aws-sdk/client-sts': sts }); + const resolve = create_account_id_resolver('us-east-1'); + expect(await resolve()).toBe('999999999999'); + expect(await resolve()).toBe('999999999999'); + expect(sts.send).toHaveBeenCalledTimes(1); + }); + + it('coalesces concurrent first calls into one STS request', async () => { + const sts = makeStsModule({ account: '555' }); + install_dynamic_import_stub({ '@aws-sdk/client-sts': sts }); + const resolve = create_account_id_resolver('us-east-1'); + const [a, b, c] = await Promise.all([resolve(), resolve(), resolve()]); + expect(a).toBe('555'); + expect(b).toBe('555'); + expect(c).toBe('555'); + expect(sts.send).toHaveBeenCalledTimes(1); + }); + + it('throws a clear "install the SDK" message when STS is missing', async () => { + install_dynamic_import_stub({}); + const resolve = create_account_id_resolver('us-east-1'); + await expect(resolve()).rejects.toThrow(/install @aws-sdk\/client-sts/); + }); + + it('throws when STS GetCallerIdentity returns no Account field', async () => { + const sts = makeStsModule({ account: null }); + install_dynamic_import_stub({ '@aws-sdk/client-sts': sts }); + const resolve = create_account_id_resolver('us-east-1'); + await expect(resolve()).rejects.toThrow(/no Account field/); + }); +}); + +// ─── ensureManagedRole ────────────────────────────────────────────── + +describe('ensureManagedRole', () => { + const TRUST = JSON.stringify({ V: 1 }); + const ARN_POLICY = 'arn:aws:iam::aws:policy/Foo'; + + it('returns the existing role ARN on the happy path (no CreateRole call)', async () => { + const iam = makeIamModule({ getRoleArn: 'arn:aws:iam::1:role/existing' }); + install_dynamic_import_stub({ '@aws-sdk/client-iam': iam }); + const arn = await ensureManagedRole('us-east-1', 'my-role', TRUST, ARN_POLICY); + expect(arn).toBe('arn:aws:iam::1:role/existing'); + // GetRole only, no CreateRole / AttachRolePolicy + expect(iam.sendCalls.map((c: any) => c.__cmd)).toEqual(['GetRole']); + }); + + it('creates the role + attaches the managed policy on NoSuchEntity', async () => { + const iam = makeIamModule({ getRoleArn: null, createRoleArn: 'arn:aws:iam::1:role/created' }); + install_dynamic_import_stub({ '@aws-sdk/client-iam': iam }); + const arn = await ensureManagedRole('us-east-1', 'new-role', TRUST, ARN_POLICY); + expect(arn).toBe('arn:aws:iam::1:role/created'); + expect(iam.sendCalls.map((c: any) => c.__cmd)).toEqual(['GetRole', 'CreateRole', 'AttachRolePolicy']); + }); + + it('throws when IAM SDK is not installed', async () => { + install_dynamic_import_stub({}); + await expect(ensureManagedRole('us-east-1', 'r', TRUST, ARN_POLICY)).rejects.toThrow(/@aws-sdk\/client-iam/); + }); +}); + +describe('ensureEcsTaskExecutionRole', () => { + it('delegates to ensureManagedRole with the standard ECS trust + managed policy', async () => { + const iam = makeIamModule({ getRoleArn: 'arn:aws:iam::1:role/ecsTaskExecutionRole' }); + install_dynamic_import_stub({ '@aws-sdk/client-iam': iam }); + const arn = await ensureEcsTaskExecutionRole('us-east-1'); + expect(arn).toBe('arn:aws:iam::1:role/ecsTaskExecutionRole'); + expect((iam.sendCalls[0] as any).input).toEqual({ RoleName: 'ecsTaskExecutionRole' }); + }); +}); diff --git a/packages/core/src/deploy/providers/__tests__/aws-sns.test.ts b/packages/core/src/deploy/providers/__tests__/aws-sns.test.ts new file mode 100644 index 00000000..270b1bff --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-sns.test.ts @@ -0,0 +1,50 @@ +/** + * Tests for the aws.sns.topic handler. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const sns = makeSdkMock({ + client_class_name: 'SNSClient', + command_class_names: ['CreateTopicCommand', 'SetTopicAttributesCommand', 'DeleteTopicCommand'], + sendImpl: (cmd) => { + if (cmd.__cmd === 'CreateTopic') return { TopicArn: `arn:aws:sns:us-east-1:111:${cmd.input.Name}` }; + return {}; + }, + }); + install_dynamic_import_stub({ '@aws-sdk/client-sns': sns.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, sns }; +} + +describe('aws.sns.topic handler', () => { + it('creates a standard topic and returns the TopicArn', async () => { + const { d, sns } = await setup(); + const out = await d.create('aws.sns.topic', 'alerts', {}, {}); + expect(out.success).toBe(true); + expect(out.provider_id).toBe('arn:aws:sns:us-east-1:111:alerts'); + expect(sns.sendCalls[0].input.Name).toBe('alerts'); + }); + + it('appends .fifo + sets FifoTopic attribute when fifo=true', async () => { + const { d, sns } = await setup(); + await d.create('aws.sns.topic', 'jobs', { fifo: true }, {}); + const cmd = sns.sendCalls[0]; + expect(cmd.input.Name).toBe('jobs.fifo'); + expect(cmd.input.Attributes.FifoTopic).toBe('true'); + }); + + it('deletes via DeleteTopic with the TopicArn provider_id', async () => { + const { d, sns } = await setup(); + await d.delete('aws.sns.topic', 'alerts', 'arn:aws:sns:us-east-1:111:alerts', {}); + expect(sns.sendCalls[0].__cmd).toBe('DeleteTopic'); + expect(sns.sendCalls[0].input.TopicArn).toBe('arn:aws:sns:us-east-1:111:alerts'); + }); +}); diff --git a/packages/core/src/deploy/providers/__tests__/aws-sqs.test.ts b/packages/core/src/deploy/providers/__tests__/aws-sqs.test.ts new file mode 100644 index 00000000..4aa9863e --- /dev/null +++ b/packages/core/src/deploy/providers/__tests__/aws-sqs.test.ts @@ -0,0 +1,67 @@ +/** + * Tests for the aws.sqs.queue handler. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { AWSDeployer } from '../aws-deployer'; +import { install_dynamic_import_stub, makeSdkMock, restore_dynamic_import_stub } from './_aws-test-harness'; + +beforeEach(() => install_dynamic_import_stub({})); +afterEach(() => restore_dynamic_import_stub()); + +async function setup() { + const sqs = makeSdkMock({ + client_class_name: 'SQSClient', + command_class_names: ['CreateQueueCommand', 'SetQueueAttributesCommand', 'DeleteQueueCommand'], + sendImpl: (cmd) => { + if (cmd.__cmd === 'CreateQueue') { + return { QueueUrl: `https://sqs.us-east-1.amazonaws.com/111/${cmd.input.QueueName}` }; + } + return {}; + }, + }); + install_dynamic_import_stub({ '@aws-sdk/client-sqs': sqs.module }); + const d = new AWSDeployer(); + await d.initialize({ provider: 'aws' }); + return { d, sqs }; +} + +describe('aws.sqs.queue handler', () => { + it('creates a standard queue and returns the QueueUrl as provider_id', async () => { + const { d, sqs } = await setup(); + const out = await d.create( + 'aws.sqs.queue', + 'orders', + { message_retention_seconds: 345600, visibility_timeout_seconds: 30, delay_seconds: 0, fifo: false }, + {}, + ); + expect(out.success).toBe(true); + expect(out.provider_id).toBe('https://sqs.us-east-1.amazonaws.com/111/orders'); + const cmd = sqs.sendCalls[0]; + expect(cmd.input.QueueName).toBe('orders'); + expect(cmd.input.Attributes).toMatchObject({ + MessageRetentionPeriod: '345600', + VisibilityTimeout: '30', + DelaySeconds: '0', + }); + expect(cmd.input.Attributes.FifoQueue).toBeUndefined(); + }); + + it('appends .fifo suffix + FifoQueue attribute when fifo=true', async () => { + const { d, sqs } = await setup(); + const out = await d.create('aws.sqs.queue', 'jobs', { fifo: true, content_based_deduplication: true }, {}); + expect(out.success).toBe(true); + const cmd = sqs.sendCalls[0]; + expect(cmd.input.QueueName).toBe('jobs.fifo'); + expect(cmd.input.Attributes.FifoQueue).toBe('true'); + expect(cmd.input.Attributes.ContentBasedDeduplication).toBe('true'); + }); + + it('deletes via DeleteQueue using the provider_id URL', async () => { + const { d, sqs } = await setup(); + const out = await d.delete('aws.sqs.queue', 'orders', 'https://sqs.us-east-1.amazonaws.com/111/orders', {}); + expect(out.success).toBe(true); + expect(sqs.sendCalls[0].__cmd).toBe('DeleteQueue'); + expect(sqs.sendCalls[0].input.QueueUrl).toBe('https://sqs.us-east-1.amazonaws.com/111/orders'); + }); +}); diff --git a/packages/core/src/deploy/providers/aws-deployer.ts b/packages/core/src/deploy/providers/aws-deployer.ts index 169c4d3d..281b5a5e 100644 --- a/packages/core/src/deploy/providers/aws-deployer.ts +++ b/packages/core/src/deploy/providers/aws-deployer.ts @@ -1,496 +1,10 @@ /** - * AWS Deployer + * AWS Deployer — back-compat shim. * - * Deploys resources to Amazon Web Services using direct API calls. + * Modular implementation moved to `./aws/`. Kept here so the existing + * import paths in `providers/index.ts` and the test suite continue to + * resolve unchanged. */ -import type { DeployOptions, ResourceDeployResult, ProviderDeployer } from '../types'; - -/** - * AWS resource deployer. - */ -export class AWSDeployer implements ProviderDeployer { - provider = 'aws'; - - private region: string = 'us-east-1'; - private ec2_client: any = null; - private s3_client: any = null; - private lambda_client: any = null; - - async initialize(options: DeployOptions): Promise { - this.region = options.regions?.[0] || 'us-east-1'; - - try { - // Dynamic import of AWS SDK v3 - const client_ec2_module = '@aws-sdk/client-ec2'; - const client_s3_module = '@aws-sdk/client-s3'; - const client_lambda_module = '@aws-sdk/client-lambda'; - - try { - const ec2 = await Function('m', 'return import(m)')(client_ec2_module); - this.ec2_client = new ec2.EC2Client({ region: this.region }); - } catch { - // EC2 client not available - } - - try { - const s3 = await Function('m', 'return import(m)')(client_s3_module); - this.s3_client = new s3.S3Client({ region: this.region }); - } catch { - // S3 client not available - } - - try { - const lambda = await Function('m', 'return import(m)')(client_lambda_module); - this.lambda_client = new lambda.LambdaClient({ region: this.region }); - } catch { - // Lambda client not available - } - } catch (error) { - throw new Error(`Failed to initialize AWS SDK: ${error instanceof Error ? error.message : String(error)}`, { - cause: error, - }); - } - } - - async cleanup(): Promise { - // Destroy clients - if (this.ec2_client) this.ec2_client.destroy(); - if (this.s3_client) this.s3_client.destroy(); - if (this.lambda_client) this.lambda_client.destroy(); - } - - async create( - type: string, - name: string, - properties: Record, - _options: Record, - ): Promise { - const start = Date.now(); - - try { - let provider_id: string | undefined; - - if (type.startsWith('aws.ec2.instance')) { - provider_id = await this.create_ec2_instance(name, properties); - } else if (type.startsWith('aws.s3.bucket')) { - provider_id = await this.create_s3_bucket(name, properties); - } else if (type.startsWith('aws.lambda.function')) { - provider_id = await this.create_lambda_function(name, properties); - } else { - return { - resource_id: name, - name, - type, - action: 'create', - success: false, - error: `Unsupported resource type for creation: ${type}`, - duration_ms: Date.now() - start, - }; - } - - return { - resource_id: name, - name, - type, - action: 'create', - success: true, - provider_id, - duration_ms: Date.now() - start, - }; - } catch (error) { - return { - resource_id: name, - name, - type, - action: 'create', - success: false, - error: error instanceof Error ? error.message : String(error), - duration_ms: Date.now() - start, - }; - } - } - - async update( - type: string, - name: string, - provider_id: string, - properties: Record, - current_properties: Record, - _options: Record, - ): Promise { - const start = Date.now(); - - try { - if (type.startsWith('aws.ec2.instance')) { - await this.update_ec2_instance(name, provider_id, properties, current_properties); - } else if (type.startsWith('aws.s3.bucket')) { - await this.update_s3_bucket(name, provider_id, properties); - } else if (type.startsWith('aws.lambda.function')) { - await this.update_lambda_function(name, provider_id, properties); - } else { - return { - resource_id: name, - name, - type, - action: 'update', - success: false, - error: `Unsupported resource type for update: ${type}`, - duration_ms: Date.now() - start, - }; - } - - return { - resource_id: name, - name, - type, - action: 'update', - success: true, - provider_id, - duration_ms: Date.now() - start, - }; - } catch (error) { - return { - resource_id: name, - name, - type, - action: 'update', - success: false, - error: error instanceof Error ? error.message : String(error), - duration_ms: Date.now() - start, - }; - } - } - - async delete( - type: string, - name: string, - provider_id: string, - _options: Record, - ): Promise { - const start = Date.now(); - - try { - if (type.startsWith('aws.ec2.instance')) { - await this.delete_ec2_instance(name, provider_id); - } else if (type.startsWith('aws.s3.bucket')) { - await this.delete_s3_bucket(name, provider_id); - } else if (type.startsWith('aws.lambda.function')) { - await this.delete_lambda_function(name, provider_id); - } else { - return { - resource_id: name, - name, - type, - action: 'delete', - success: false, - error: `Unsupported resource type for deletion: ${type}`, - duration_ms: Date.now() - start, - }; - } - - return { - resource_id: name, - name, - type, - action: 'delete', - success: true, - duration_ms: Date.now() - start, - }; - } catch (error) { - return { - resource_id: name, - name, - type, - action: 'delete', - success: false, - error: error instanceof Error ? error.message : String(error), - duration_ms: Date.now() - start, - }; - } - } - - // ============================================================================ - // EC2 - // ============================================================================ - - private async create_ec2_instance(name: string, properties: Record): Promise { - if (!this.ec2_client) { - throw new Error('EC2 SDK not available. Install @aws-sdk/client-ec2'); - } - - const ec2_module = '@aws-sdk/client-ec2'; - const ec2 = await Function('m', 'return import(m)')(ec2_module); - - const image_id = (properties.image_id as string) || 'ami-0c55b159cbfafe1f0'; - const instance_type = (properties.instance_type as string) || 't2.micro'; - - const command = new ec2.RunInstancesCommand({ - ImageId: image_id, - InstanceType: instance_type, - MinCount: 1, - MaxCount: 1, - TagSpecifications: [ - { - ResourceType: 'instance', - Tags: [ - { Key: 'Name', Value: name }, - ...Object.entries(properties.tags || {}).map(([Key, Value]) => ({ - Key, - Value: Value as string, - })), - ], - }, - ], - SubnetId: properties.subnet_id as string, - SecurityGroupIds: properties.security_group_ids as string[], - }); - - const result = await this.ec2_client.send(command); - const instance_id = result.Instances?.[0]?.InstanceId; - - if (!instance_id) { - throw new Error('Failed to get instance ID from RunInstances response'); - } - - return `arn:aws:ec2:${this.region}:*:instance/${instance_id}`; - } - - private async update_ec2_instance( - name: string, - provider_id: string, - properties: Record, - _current_properties: Record, - ): Promise { - if (!this.ec2_client) { - throw new Error('EC2 SDK not available'); - } - - const ec2_module = '@aws-sdk/client-ec2'; - const ec2 = await Function('m', 'return import(m)')(ec2_module); - - // Extract instance ID from ARN - const instance_id = provider_id.split('/').pop(); - - // Update tags - if (properties.tags) { - const command = new ec2.CreateTagsCommand({ - Resources: [instance_id], - Tags: Object.entries(properties.tags as Record).map(([Key, Value]) => ({ - Key, - Value, - })), - }); - await this.ec2_client.send(command); - } - } - - private async delete_ec2_instance(name: string, provider_id: string): Promise { - if (!this.ec2_client) { - throw new Error('EC2 SDK not available'); - } - - const ec2_module = '@aws-sdk/client-ec2'; - const ec2 = await Function('m', 'return import(m)')(ec2_module); - - const instance_id = provider_id.split('/').pop(); - - const command = new ec2.TerminateInstancesCommand({ - InstanceIds: [instance_id], - }); - - await this.ec2_client.send(command); - } - - // ============================================================================ - // S3 - // ============================================================================ - - private async create_s3_bucket(name: string, properties: Record): Promise { - if (!this.s3_client) { - throw new Error('S3 SDK not available. Install @aws-sdk/client-s3'); - } - - const s3_module = '@aws-sdk/client-s3'; - const s3 = await Function('m', 'return import(m)')(s3_module); - - const command = new s3.CreateBucketCommand({ - Bucket: name, - CreateBucketConfiguration: - this.region !== 'us-east-1' - ? { - LocationConstraint: this.region, - } - : undefined, - }); - - await this.s3_client.send(command); - - // Apply tags if provided - if (properties.tags) { - const tag_command = new s3.PutBucketTaggingCommand({ - Bucket: name, - Tagging: { - TagSet: Object.entries(properties.tags as Record).map(([Key, Value]) => ({ - Key, - Value, - })), - }, - }); - await this.s3_client.send(tag_command); - } - - return `arn:aws:s3:::${name}`; - } - - private async update_s3_bucket( - name: string, - provider_id: string, - properties: Record, - ): Promise { - if (!this.s3_client) { - throw new Error('S3 SDK not available'); - } - - const s3_module = '@aws-sdk/client-s3'; - const s3 = await Function('m', 'return import(m)')(s3_module); - - // Update tags - if (properties.tags) { - const command = new s3.PutBucketTaggingCommand({ - Bucket: name, - Tagging: { - TagSet: Object.entries(properties.tags as Record).map(([Key, Value]) => ({ - Key, - Value, - })), - }, - }); - await this.s3_client.send(command); - } - } - - private async delete_s3_bucket(name: string, _provider_id: string): Promise { - if (!this.s3_client) { - throw new Error('S3 SDK not available'); - } - - const s3_module = '@aws-sdk/client-s3'; - const s3 = await Function('m', 'return import(m)')(s3_module); - - // Delete all objects first - const list_command = new s3.ListObjectsV2Command({ Bucket: name }); - const objects = await this.s3_client.send(list_command); - - if (objects.Contents && objects.Contents.length > 0) { - const delete_command = new s3.DeleteObjectsCommand({ - Bucket: name, - Delete: { - Objects: objects.Contents.map((obj: any) => ({ Key: obj.Key })), - }, - }); - await this.s3_client.send(delete_command); - } - - // Delete the bucket - const command = new s3.DeleteBucketCommand({ Bucket: name }); - await this.s3_client.send(command); - } - - // ============================================================================ - // Lambda - // ============================================================================ - - private async create_lambda_function(name: string, properties: Record): Promise { - if (!this.lambda_client) { - throw new Error('Lambda SDK not available. Install @aws-sdk/client-lambda'); - } - - const lambda_module = '@aws-sdk/client-lambda'; - const lambda = await Function('m', 'return import(m)')(lambda_module); - - const command = new lambda.CreateFunctionCommand({ - FunctionName: name, - Runtime: (properties.runtime as string) || 'nodejs18.x', - Role: properties.role as string, - Handler: (properties.handler as string) || 'index.handler', - Code: { - S3Bucket: properties.s3_bucket as string, - S3Key: properties.s3_key as string, - ZipFile: properties.zip_file ? Buffer.from(properties.zip_file as string, 'base64') : undefined, - }, - Description: properties.description as string, - Timeout: (properties.timeout as number) || 30, - MemorySize: (properties.memory_size as number) || 128, - Environment: properties.environment - ? { - Variables: properties.environment as Record, - } - : undefined, - Tags: properties.tags as Record, - }); - - const result = await this.lambda_client.send(command); - - return result.FunctionArn; - } - - private async update_lambda_function( - name: string, - provider_id: string, - properties: Record, - ): Promise { - if (!this.lambda_client) { - throw new Error('Lambda SDK not available'); - } - - const lambda_module = '@aws-sdk/client-lambda'; - const lambda = await Function('m', 'return import(m)')(lambda_module); - - // Update configuration - const config_command = new lambda.UpdateFunctionConfigurationCommand({ - FunctionName: name, - Description: properties.description as string, - Timeout: properties.timeout as number, - MemorySize: properties.memory_size as number, - Environment: properties.environment - ? { - Variables: properties.environment as Record, - } - : undefined, - }); - await this.lambda_client.send(config_command); - - // Update code if provided - if (properties.s3_bucket && properties.s3_key) { - const code_command = new lambda.UpdateFunctionCodeCommand({ - FunctionName: name, - S3Bucket: properties.s3_bucket as string, - S3Key: properties.s3_key as string, - }); - await this.lambda_client.send(code_command); - } - } - - private async delete_lambda_function(name: string, _provider_id: string): Promise { - if (!this.lambda_client) { - throw new Error('Lambda SDK not available'); - } - - const lambda_module = '@aws-sdk/client-lambda'; - const lambda = await Function('m', 'return import(m)')(lambda_module); - - const command = new lambda.DeleteFunctionCommand({ - FunctionName: name, - }); - - await this.lambda_client.send(command); - } -} - -/** - * Create an AWS deployer instance. - */ -export function create_aws_deployer(): AWSDeployer { - return new AWSDeployer(); -} +export { AWSDeployer, create_aws_deployer } from './aws'; +export type { AWSHandlerContext, AWSResourceHandler } from './aws'; diff --git a/packages/core/src/deploy/providers/aws/README.md b/packages/core/src/deploy/providers/aws/README.md new file mode 100644 index 00000000..4c872834 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/README.md @@ -0,0 +1,163 @@ +# AWS Deployer — Operator Notes + +This file documents the AWS-specific quirks the deployer handles +silently, the assumptions it bakes in, and the deferred work future +commits should pick up. Read this before changing any handler or +adding a new AWS resource type. + +## Rollout state + +AWS is feature-flagged at the category level in +`packages/constants/src/feature-flags.ts` (`PROVIDER_FLAGS.aws`). The +top-level `enabled` flag is **on**; categories are flipped selectively +based on the deploy path's actual readiness. + +| Category | State | Notes | +| ---------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Storage | ✅ on | S3 handler + account-id suffix | +| Messaging | ✅ on | SQS, SNS, EventBridge — FIFO suffix handled | +| Cache | ✅ on | ElastiCache | +| Monitoring | ✅ on | CloudWatch Logs | +| Security | ✅ on | Secrets Manager (Cognito stays create-only) | +| Source | ✅ on | Provider-agnostic | +| Config | ✅ on | Provider-agnostic | +| Compute | ⛔ off | ECS needs canvas-driven `Network.VPC` / `Network.Subnet` / `Network.SecurityGroup` blocks before it's safe to expose. Lambda alone is solid but the category gate is all-or-nothing today. | +| Frontend | ⛔ off | `Compute.StaticSite` requires the S3 + CloudFront + us-east-1 ACM cert dance and operator-side DNS validation — not yet automated. | +| Scheduler | ⛔ off | `Compute.CronJob` → EventBridge schedule expression wiring not finished. | +| Network | ⛔ off | ELBv2 needs VPC blocks; CloudFront is create-only and depends on the cert-validation flow. | +| Database | ⛔ off | DynamoDB-only deploys would be fine; RDS / DocDB / Redshift work for first-deploy but have no update path and RDS takes 5–10 min. Unblock by either a sub-category gate or by shipping update handlers. | +| AI | ⛔ off | Bedrock on-demand is a no-op resource (low value); SageMaker has only mocked-SDK coverage. | +| Analytics | ⛔ off | Redshift + OpenSearch are create-only. | + +Flip an `off` entry to `on` in `PROVIDER_FLAGS.aws.categories` once +its unblocker lands. The integrity test in +`packages/constants/src/__tests__/index.test.ts` keeps the map +exhaustive — adding a new `CategoryId` will require a deliberate +on/off decision here. + +## Architecture + +Mirrors the GCP layout (`../gcp/`): + +- `aws-deployer.ts` — thin dispatcher. Iterates `HANDLER_REGISTRY` + generically; cardinal rule preserved (no hardcoded iceType branches). +- `types.ts` — `AWSHandlerContext` + `AWSResourceHandler`. +- `sdk-loader.ts` — lazy `@aws-sdk/client-*` loading with graceful + fallthrough when a package isn't installed. +- `account.ts` — memoised STS GetCallerIdentity (used by S3 + future + handlers that need the AWS account id). +- `iam-roles.ts` — `ensureManagedRole` helper for idempotent IAM + bootstrap; `ensureEcsTaskExecutionRole` is the only consumer today. +- `handlers/.ts` — one file per AWS service. Register an + entry in `HANDLER_REGISTRY` to wire a new handler in. + +## Quirks shipped today + +### S3 bucket names get an account-id suffix + +S3 bucket names are globally unique across all AWS accounts. The +handler reads the account id from STS and appends `-{accountId}` to +the translator's resource name (`ice-myapp-bucket` → +`ice-myapp-bucket-111122223333`). The provider_id ARN carries the +post-suffix name so update + delete round-trip cleanly. + +### CloudFront ACM certs must live in us-east-1 + +CloudFront refuses ACM certs from any region but `us-east-1`. The +CloudFront handler spins up a one-shot ACM client pinned to +`us-east-1` for `RequestCertificate`, regardless of the deploy +region. Operator validates the cert (DNS records) outside ICE. + +### ECS auto-provisions a default cluster + task role + +`Compute.Container` on AWS works without operators touching ECS +infrastructure. On first deploy the handler: + +1. Calls `ensureEcsTaskExecutionRole(region)` — creates + `ecsTaskExecutionRole` with `AmazonECSTaskExecutionRolePolicy` + attached, idempotent (GetRole-first, CreateRole-on-NoSuchEntity). +2. Calls `ensureDefaultCluster(client, ecs, ctx)` — creates + `ice-default-cluster` if no `ACTIVE` cluster with that name exists. + +Then `RegisterTaskDefinition` + `CreateService` run against the +default cluster. Subnets + security groups still come from +properties (operator-supplied today; canvas VPC blocks for AWS land +in a follow-up). + +### RDS / DocDB / Redshift refuse to ship without a password + +The extractor for each of these defaults `master_user_password` to +`''` and the handler refuses to call CreateDB\* when the field is +empty. Operators wire a `Security.Secret` block or set the property +explicitly. This is intentional: AWS APIs accept an empty password +and create an unusable instance with no warning. + +### RDS provisioning is polled + +RDS instances take 5–10 minutes to provision. The RDS handler runs a +20-minute `DescribeDBInstances` poll loop after `CreateDBInstance`, +reports progress via `ctx.on_step`, and honours `ctx.abort_signal` +so a user-cancel actually stops the wait. + +### Lambda auto-build from Source.Repository + +When a `Compute.ServerlessFunction` has a connected `Source.Repository` +AND no explicit S3 ref: + +1. `git clone --depth 1 --branch ` the repo to a tmpdir. +2. `npm install --omit=dev` (skipped if no `package.json`). +3. `zip -qr function.zip .`. +4. Upload to `ice-bootstrap-{accountId}-{region}` (CreateBucket if + absent). +5. Stamp `s3_bucket` + `s3_key` onto `properties` and continue with + the normal CreateFunction path. + +Local-only — assumes `git` + `npm` + `zip` are on the deploy host. +AWS CodeBuild integration is deferred; the failure message is +explicit so operators know to install the local tools. + +### Bedrock on-demand is a no-op resource + +`AI.LLMGateway` defaults to Bedrock on-demand (no provisioned +throughput). The handler short-circuits create with a synthetic ARN +and no SDK call. Operators set `model_units > 0` to actually +provision throughput. + +### Secrets Manager values are never written + +Parallel to the GCP Secret Manager contract: ICE creates the +`Secret` resource only. Values are populated by operators via the +AWS console / CLI / IaC. The schema-declared deploy-expansion pass +emits one Secret per binding row. + +### SQS + SNS FIFO `.fifo` suffix + +AWS requires FIFO queues + topics to end in `.fifo`. The handlers +append it when the extractor sets `fifo: true`. + +## SDK packages + +All `@aws-sdk/client-*` packages are loaded via `load_aws_sdk` +through the `Function('m', 'return import(m)')` indirection so a +missing package fails gracefully with a friendly "install …" +message instead of a bundler error. Mark every SDK package as an +optional peer dependency so installs stay small for users on other +providers. + +## Test harness + +`__tests__/_aws-test-harness.ts` exports a Function-constructor +stub + a generic `makeSdkMock` factory. Per-handler tests +(`aws-.test.ts`) use them to mock SDK clients without +adding to the giant `aws-deployer.test.ts` file. New handlers +follow the same shape. + +## Future work + +- VPC-aware canvas for AWS (Network.VPC / Network.Subnet blocks + drive ECS service `subnets` + `securityGroups`). +- AWS CodeBuild path for Lambda auto-build (no local `git`/`npm`/ + `zip` requirement). +- Update paths for CloudFront / Cognito / DocDB / Redshift / EC2 EBS + (current handlers are create-only / no-op on update). +- LocalStack integration tests for end-to-end SDK contract checks. diff --git a/packages/core/src/deploy/providers/aws/account.ts b/packages/core/src/deploy/providers/aws/account.ts new file mode 100644 index 00000000..a48c4ee8 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/account.ts @@ -0,0 +1,65 @@ +/** + * AWS account-id resolution via STS GetCallerIdentity. + * + * Two handlers need the caller's AWS account id: + * - S3 (commit #8) — appends `-{accountId}` to bucket names so + * globally-unique names don't collide across AWS accounts. + * - ECS (commit #23) — references the ecsTaskExecutionRole ARN, + * which embeds the account id. + * + * The deployer never knows the account id at process start (the + * caller authenticates via env vars or `~/.aws/credentials`, both + * of which are read by the SDK at first call). STS GetCallerIdentity + * is the one-call resolution path; result is cached on the context + * for the rest of the deploy. + */ + +import { load_aws_sdk } from './sdk-loader'; + +/** + * Account-id resolver shape attached to AWSHandlerContext when the + * deployer initialises STS. Calling the function the first time + * fetches + caches; subsequent calls return the cached value. + */ +export type AccountIdResolver = () => Promise; + +/** + * Build a memoised resolver. The returned function makes at most one + * STS call per process lifetime. Throws when the STS SDK isn't + * installed OR the call fails (no point falling back to a fake id — + * S3 bucket names would silently collide). + */ +export function create_account_id_resolver(region: string): AccountIdResolver { + let cached: string | undefined; + let in_flight: Promise | undefined; + return async () => { + if (cached) return cached; + if (in_flight) return in_flight; + in_flight = (async () => { + const sts = await load_aws_sdk('@aws-sdk/client-sts'); + if (!sts) { + throw new Error( + 'AWS STS SDK not available — install @aws-sdk/client-sts to enable account-id-suffixed bucket names', + ); + } + const client = new sts.STSClient({ region }); + try { + const result = await client.send(new sts.GetCallerIdentityCommand({})); + if (!result?.Account) { + throw new Error('STS GetCallerIdentity returned no Account field'); + } + cached = String(result.Account); + return cached; + } finally { + if (typeof (client as { destroy?: () => void }).destroy === 'function') { + (client as { destroy: () => void }).destroy(); + } + } + })(); + try { + return await in_flight; + } finally { + in_flight = undefined; + } + }; +} diff --git a/packages/core/src/deploy/providers/aws/aws-deployer.ts b/packages/core/src/deploy/providers/aws/aws-deployer.ts new file mode 100644 index 00000000..24df0252 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/aws-deployer.ts @@ -0,0 +1,188 @@ +/** + * AWS Deployer — Modular Dispatcher + * + * Routes create/update/delete calls to per-service handler modules. + * Replaces the monolithic aws-deployer.ts with the same dispatch + * shape the GCP deployer uses. Adding a new AWS service = + * register an entry in HANDLER_REGISTRY + add a file under + * `handlers/.ts`. + * + * Cardinal-rule schema-driven: HANDLER_REGISTRY is the single + * declarative fact for "which handler runs for which resource type". + * The dispatcher iterates it generically — no `if (type === 'aws.X')` + * branches. + */ + +import { create_account_id_resolver } from './account'; +import { api_gateway_handler } from './handlers/api-gateway'; +import { bedrock_handler } from './handlers/bedrock'; +import { cloudfront_handler } from './handlers/cloudfront'; +import { cloudwatch_logs_handler } from './handlers/cloudwatch-logs'; +import { cognito_handler } from './handlers/cognito'; +import { docdb_handler } from './handlers/docdb'; +import { dynamodb_handler } from './handlers/dynamodb'; +import { ec2_handler } from './handlers/ec2'; +import { ecs_handler } from './handlers/ecs'; +import { elasticache_handler } from './handlers/elasticache'; +import { elbv2_handler } from './handlers/elbv2'; +import { events_rule_handler } from './handlers/events-rule'; +import { lambda_handler } from './handlers/lambda'; +import { opensearch_handler } from './handlers/opensearch'; +import { rds_handler } from './handlers/rds'; +import { redshift_handler } from './handlers/redshift'; +import { s3_handler } from './handlers/s3'; +import { sagemaker_handler } from './handlers/sagemaker'; +import { secrets_manager_handler } from './handlers/secrets-manager'; +import { sns_handler } from './handlers/sns'; +import { sqs_handler } from './handlers/sqs'; +import { destroy_aws_clients, initialize_aws_clients } from './sdk-loader'; +import type { AWSHandlerContext, AWSResourceHandler } from './types'; +import type { DeployOptions, ResourceDeployResult, ProviderDeployer } from '../../types'; + +// ============================================================================= +// Handler registry — maps type prefixes to handlers +// ============================================================================= +// +// Ordering matters when prefixes overlap (longer / more-specific +// prefixes go first). At present every AWS resource type is unique +// at the `aws..` granularity so order is not yet +// meaningful — kept consistent with the GCP shape for symmetry. + +const HANDLER_REGISTRY: Array<{ prefix: string; handler: AWSResourceHandler }> = [ + { prefix: 'aws.ec2.instance', handler: ec2_handler }, + { prefix: 'aws.s3.bucket', handler: s3_handler }, + { prefix: 'aws.lambda.function', handler: lambda_handler }, + { prefix: 'aws.cloudwatch.logGroup', handler: cloudwatch_logs_handler }, + { prefix: 'aws.secretsmanager.secret', handler: secrets_manager_handler }, + { prefix: 'aws.sqs.queue', handler: sqs_handler }, + { prefix: 'aws.sns.topic', handler: sns_handler }, + { prefix: 'aws.dynamodb.table', handler: dynamodb_handler }, + { prefix: 'aws.elasticache.cluster', handler: elasticache_handler }, + { prefix: 'aws.rds.dbInstance', handler: rds_handler }, + { prefix: 'aws.docdb.cluster', handler: docdb_handler }, + { prefix: 'aws.cognito.userPool', handler: cognito_handler }, + { prefix: 'aws.cloudfront.distribution', handler: cloudfront_handler }, + { prefix: 'aws.elbv2.loadBalancer', handler: elbv2_handler }, + { prefix: 'aws.apigateway.restApi', handler: api_gateway_handler }, + { prefix: 'aws.events.rule', handler: events_rule_handler }, + { prefix: 'aws.ecs.service', handler: ecs_handler }, + { prefix: 'aws.opensearch.domain', handler: opensearch_handler }, + { prefix: 'aws.bedrock.endpoint', handler: bedrock_handler }, + { prefix: 'aws.sagemaker.endpoint', handler: sagemaker_handler }, + { prefix: 'aws.redshift.cluster', handler: redshift_handler }, +]; + +function resolve_handler(type: string): AWSResourceHandler | undefined { + for (const entry of HANDLER_REGISTRY) { + if (type.startsWith(entry.prefix)) return entry.handler; + } + return undefined; +} + +function unsupported( + name: string, + type: string, + action: 'create' | 'update' | 'delete', + start: number, +): ResourceDeployResult { + // Preserve the original wording verbatim — the test suite pins these + // exact strings and consumer dashboards may key off them. + const phrase = action === 'create' ? 'creation' : action === 'delete' ? 'deletion' : 'update'; + return { + resource_id: name, + name, + type, + action, + success: false, + error: `Unsupported resource type for ${phrase}: ${type}`, + duration_ms: Date.now() - start, + }; +} + +/** + * AWS resource deployer. + * + * Holds an AWSHandlerContext that's reused for every create/update/ + * delete call within a single `initialize`/`cleanup` cycle. Per- + * handler logic lives in `./handlers/.ts`. + */ +export class AWSDeployer implements ProviderDeployer { + provider = 'aws'; + + private ctx: AWSHandlerContext = { + region: 'us-east-1', + clients: new Map(), + // Stub resolver until initialize() replaces it. Throws if a + // handler tries to use account id before the deployer's + // initialise() ran (shouldn't happen in practice but fails + // loudly if it ever does). + ensure_account_id: async () => { + throw new Error('AWSDeployer.ensure_account_id called before initialize()'); + }, + }; + + async initialize(options: DeployOptions): Promise { + const region = options.regions?.[0] || 'us-east-1'; + try { + const clients = await initialize_aws_clients(region); + this.ctx = { + region, + clients, + ensure_account_id: create_account_id_resolver(region), + }; + } catch (error) { + throw new Error(`Failed to initialize AWS SDK: ${error instanceof Error ? error.message : String(error)}`, { + cause: error, + }); + } + } + + async cleanup(): Promise { + destroy_aws_clients(this.ctx.clients); + } + + async create( + type: string, + name: string, + properties: Record, + _options: Record, + ): Promise { + const start = Date.now(); + const handler = resolve_handler(type); + if (!handler) return unsupported(name, type, 'create', start); + return handler.create(name, properties, this.ctx); + } + + async update( + type: string, + name: string, + provider_id: string, + properties: Record, + current_properties: Record, + _options: Record, + ): Promise { + const start = Date.now(); + const handler = resolve_handler(type); + if (!handler) return unsupported(name, type, 'update', start); + return handler.update(name, provider_id, properties, current_properties, this.ctx); + } + + async delete( + type: string, + name: string, + provider_id: string, + _options: Record, + ): Promise { + const start = Date.now(); + const handler = resolve_handler(type); + if (!handler) return unsupported(name, type, 'delete', start); + return handler.delete(name, provider_id, this.ctx); + } +} + +/** + * Create an AWS deployer instance. + */ +export function create_aws_deployer(): AWSDeployer { + return new AWSDeployer(); +} diff --git a/packages/core/src/deploy/providers/aws/handlers/_result.ts b/packages/core/src/deploy/providers/aws/handlers/_result.ts new file mode 100644 index 00000000..4dac4b99 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/_result.ts @@ -0,0 +1,59 @@ +/** + * Shared result/fail builders for AWS resource handlers. + * + * Every handler returns the same `ResourceDeployResult` shape; these + * helpers keep the per-handler files focused on the SDK calls instead + * of boilerplate object construction. + */ + +import type { ResourceDeployResult } from '../../../types'; + +export type DeployAction = 'create' | 'update' | 'delete'; + +export function ok( + name: string, + type: string, + action: DeployAction, + start: number, + overrides: Partial = {}, +): ResourceDeployResult { + return { + resource_id: name, + name, + type, + action, + success: true, + duration_ms: Date.now() - start, + ...overrides, + }; +} + +export function err( + name: string, + type: string, + action: DeployAction, + start: number, + message: string, +): ResourceDeployResult { + return { + resource_id: name, + name, + type, + action, + success: false, + error: message, + duration_ms: Date.now() - start, + }; +} + +/** Bundle a SDK-not-installed error with the friendly install hint. */ +export function sdkMissing( + name: string, + type: string, + action: DeployAction, + start: number, + service_display: string, + package_name: string, +): ResourceDeployResult { + return err(name, type, action, start, `${service_display} SDK not available. Install ${package_name}`); +} diff --git a/packages/core/src/deploy/providers/aws/handlers/api-gateway.ts b/packages/core/src/deploy/providers/aws/handlers/api-gateway.ts new file mode 100644 index 00000000..f14bd2df --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/api-gateway.ts @@ -0,0 +1,74 @@ +/** + * API Gateway Handler + * + * Handles: aws.apigateway.restApi + * + * CreateRestApi → CreateDeployment (default stage). Routes / + * integrations are wired by the consuming compute handler (Lambda + * etc.) via outgoing edges; the baseline here just stands up an + * empty REST API + deployable stage. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.apigateway.restApi'; +const SDK = '@aws-sdk/client-api-gateway'; + +export const api_gateway_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('apigateway') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'API Gateway', SDK); + + try { + const api = await load_aws_sdk(SDK); + if (!api) return sdkMissing(name, TYPE, 'create', start, 'API Gateway', SDK); + + const created = await client.send( + new api.CreateRestApiCommand({ + name, + description: properties.description as string, + endpointConfiguration: { types: [(properties.endpoint_type as string) || 'REGIONAL'] }, + apiKeySource: properties.api_key_required ? 'HEADER' : undefined, + binaryMediaTypes: properties.binary_media_types as string[], + }), + ); + const restApiId = created?.id; + if (!restApiId) return err(name, TYPE, 'create', start, 'CreateRestApi returned no id'); + + await client.send( + new api.CreateDeploymentCommand({ restApiId, stageName: (properties.stage_name as string) || 'prod' }), + ); + + return ok(name, TYPE, 'create', start, { + provider_id: `arn:aws:apigateway:${ctx.region}::/restapis/${restApiId}`, + }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, _ctx) { + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('apigateway') as any; + if (!client) return err(name, TYPE, 'delete', start, 'API Gateway SDK not available'); + + try { + const api = await load_aws_sdk(SDK); + if (!api) return err(name, TYPE, 'delete', start, 'API Gateway SDK not available'); + + // Recover restApiId from the ARN. + const restApiId = provider_id.split('/').pop(); + await client.send(new api.DeleteRestApiCommand({ restApiId })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/bedrock.ts b/packages/core/src/deploy/providers/aws/handlers/bedrock.ts new file mode 100644 index 00000000..a24df7b4 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/bedrock.ts @@ -0,0 +1,80 @@ +/** + * Bedrock Handler + * + * Handles: aws.bedrock.endpoint + * + * Bedrock on-demand foundation-model access is account-level + * (nothing to provision). Provisioned throughput IS a real resource + * — the handler only emits a CreateProvisionedModelThroughput when + * `model_units > 0`. Otherwise create is a deliberate no-op so the + * deploy succeeds without an orphan resource. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.bedrock.endpoint'; +const SDK = '@aws-sdk/client-bedrock'; + +export const bedrock_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const modelUnits = (properties.model_units as number) ?? 0; + + // On-demand mode — no resource to create. Surface a clear log + // message so operators don't think the create silently failed. + if (modelUnits <= 0) { + ctx.on_log?.(`Bedrock on-demand mode for ${name}: no provisioned throughput resource created.`); + return ok(name, TYPE, 'create', start, { + provider_id: `arn:aws:bedrock:${ctx.region}:*:model/${properties.model_id}`, + }); + } + + const client = ctx.clients.get('bedrock') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'Bedrock', SDK); + + try { + const bedrock = await load_aws_sdk(SDK); + if (!bedrock) return sdkMissing(name, TYPE, 'create', start, 'Bedrock', SDK); + + const created = await client.send( + new bedrock.CreateProvisionedModelThroughputCommand({ + provisionedModelName: name, + modelId: properties.model_id as string, + modelUnits, + commitmentDuration: properties.commitment_duration as string, + }), + ); + + return ok(name, TYPE, 'create', start, { + provider_id: created?.provisionedModelArn || `arn:aws:bedrock:${ctx.region}:*:provisioned-model/${name}`, + }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, _ctx) { + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, provider_id, ctx) { + const start = Date.now(); + // On-demand model: nothing to delete (the create returned a synthetic ARN). + if (!provider_id.includes('provisioned-model')) return ok(name, TYPE, 'delete', start); + + const client = ctx.clients.get('bedrock') as any; + if (!client) return err(name, TYPE, 'delete', start, 'Bedrock SDK not available'); + + try { + const bedrock = await load_aws_sdk(SDK); + if (!bedrock) return err(name, TYPE, 'delete', start, 'Bedrock SDK not available'); + + await client.send(new bedrock.DeleteProvisionedModelThroughputCommand({ provisionedModelId: provider_id })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/cloudfront.ts b/packages/core/src/deploy/providers/aws/handlers/cloudfront.ts new file mode 100644 index 00000000..c8b18ef4 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/cloudfront.ts @@ -0,0 +1,135 @@ +/** + * CloudFront Handler + * + * Handles: aws.cloudfront.distribution + * + * Creates a distribution. The CloudFront API requires ACM certs to + * live in us-east-1 regardless of the deploy region, so when the + * extractor flags `auto_provision_cert` we request the cert via a + * dedicated us-east-1 ACM client (cert request only — DNS validation + * is operator-side; the cert ARN can be wired back later). + * + * The CloudFront origins + cache-behaviour graph is large; this + * baseline emits a minimal-viable distribution. Subsequent commits + * can extend per-origin config without touching the dispatch. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.cloudfront.distribution'; +const SDK = '@aws-sdk/client-cloudfront'; +const ACM_SDK = '@aws-sdk/client-acm'; + +/** + * Request an ACM cert in us-east-1 (the only region CloudFront + * accepts). Returns the cert ARN. Caller wires it into the + * distribution's ViewerCertificate. + */ +async function request_acm_cert_in_us_east_1(domain: string): Promise { + const acm = await load_aws_sdk(ACM_SDK); + if (!acm) return undefined; + const client = new acm.ACMClient({ region: 'us-east-1' }); + try { + const result = await client.send( + new acm.RequestCertificateCommand({ DomainName: domain, ValidationMethod: 'DNS' }), + ); + return result?.CertificateArn; + } finally { + if (typeof (client as { destroy?: () => void }).destroy === 'function') { + (client as { destroy: () => void }).destroy(); + } + } +} + +export const cloudfront_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('cloudfront') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'CloudFront', SDK); + + try { + const cf = await load_aws_sdk(SDK); + if (!cf) return sdkMissing(name, TYPE, 'create', start, 'CloudFront', SDK); + + const domain = (properties.domain as string) || ''; + let certArn: string | undefined; + if (properties.enableHttps !== false && properties.auto_provision_cert !== false && domain) { + certArn = await request_acm_cert_in_us_east_1(domain); + } + + // Minimal distribution — operators wire backing origins later + // via the post-deploy GUI or by re-running with origin props set. + // The handler creates an "Origin Placeholder" S3-style origin + // so the distribution is valid; subsequent edits replace it. + const config = { + CallerReference: `ice-${name}-${Date.now()}`, + Comment: `ICE-managed ${name}`, + Enabled: true, + PriceClass: (properties.price_class as string) || 'PriceClass_100', + Origins: { + Quantity: 1, + Items: [ + { + Id: 'placeholder', + DomainName: domain || 'origin.example.com', + CustomOriginConfig: { + HTTPPort: 80, + HTTPSPort: 443, + OriginProtocolPolicy: 'https-only', + }, + }, + ], + }, + DefaultCacheBehavior: { + TargetOriginId: 'placeholder', + ViewerProtocolPolicy: properties.redirect_http_to_https !== false ? 'redirect-to-https' : 'allow-all', + CachePolicyId: '658327ea-f89d-4fab-a63d-7e88639e58f6', // CachingOptimized managed policy + }, + Aliases: domain ? { Quantity: 1, Items: [domain] } : { Quantity: 0 }, + ViewerCertificate: certArn + ? { ACMCertificateArn: certArn, SSLSupportMethod: 'sni-only', MinimumProtocolVersion: 'TLSv1.2_2021' } + : { CloudFrontDefaultCertificate: true }, + }; + + const created = await client.send(new cf.CreateDistributionCommand({ DistributionConfig: config })); + const arn = created?.Distribution?.ARN ?? `arn:aws:cloudfront::*:distribution/${name}`; + return ok(name, TYPE, 'create', start, { provider_id: arn }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, _ctx) { + // CloudFront updates require fetching the current config + ETag, + // mutating, then UpdateDistribution. Deferred until the canvas + // exposes the live origin / behaviour edits. + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('cloudfront') as any; + if (!client) return err(name, TYPE, 'delete', start, 'CloudFront SDK not available'); + + try { + const cf = await load_aws_sdk(SDK); + if (!cf) return err(name, TYPE, 'delete', start, 'CloudFront SDK not available'); + + // CloudFront delete is a two-step: disable first, then delete. + // Skipped here — operator-side via the AWS console until a full + // disable+poll+delete cycle lands. + const id = provider_id.split('/').pop(); + try { + await client.send(new cf.DeleteDistributionCommand({ Id: id })); + } catch { + // Distributions must be disabled before deletion; tolerated + // until the disable-then-delete chain is wired. + } + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/cloudwatch-logs.ts b/packages/core/src/deploy/providers/aws/handlers/cloudwatch-logs.ts new file mode 100644 index 00000000..f954339d --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/cloudwatch-logs.ts @@ -0,0 +1,81 @@ +/** + * CloudWatch Logs Handler + * + * Handles: aws.cloudwatch.logGroup + * + * CreateLogGroup → PutRetentionPolicy (optional) → AssociateKmsKey + * (optional) on create. Retention update on update. DeleteLogGroup + * on delete. Tags are passed at creation via the `tags` parameter. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.cloudwatch.logGroup'; +const SDK = '@aws-sdk/client-cloudwatch-logs'; + +export const cloudwatch_logs_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('cloudwatch-logs') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'CloudWatch Logs', SDK); + + try { + const cwl = await load_aws_sdk(SDK); + if (!cwl) return sdkMissing(name, TYPE, 'create', start, 'CloudWatch Logs', SDK); + + await client.send( + new cwl.CreateLogGroupCommand({ + logGroupName: name, + tags: properties.tags as Record, + kmsKeyId: (properties.kms_key_id as string) || undefined, + }), + ); + + const retention = properties.retention_in_days as number | undefined; + if (typeof retention === 'number' && retention > 0) { + await client.send(new cwl.PutRetentionPolicyCommand({ logGroupName: name, retentionInDays: retention })); + } + + return ok(name, TYPE, 'create', start, { provider_id: `arn:aws:logs:${ctx.region}:*:log-group:${name}` }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('cloudwatch-logs') as any; + if (!client) return err(name, TYPE, 'update', start, 'CloudWatch Logs SDK not available'); + + try { + const cwl = await load_aws_sdk(SDK); + if (!cwl) return err(name, TYPE, 'update', start, 'CloudWatch Logs SDK not available'); + + const retention = properties.retention_in_days as number | undefined; + if (typeof retention === 'number' && retention > 0) { + await client.send(new cwl.PutRetentionPolicyCommand({ logGroupName: name, retentionInDays: retention })); + } + return ok(name, TYPE, 'update', start, { provider_id }); + } catch (error) { + return err(name, TYPE, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('cloudwatch-logs') as any; + if (!client) return err(name, TYPE, 'delete', start, 'CloudWatch Logs SDK not available'); + + try { + const cwl = await load_aws_sdk(SDK); + if (!cwl) return err(name, TYPE, 'delete', start, 'CloudWatch Logs SDK not available'); + + await client.send(new cwl.DeleteLogGroupCommand({ logGroupName: name })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/cognito.ts b/packages/core/src/deploy/providers/aws/handlers/cognito.ts new file mode 100644 index 00000000..b3aac8e8 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/cognito.ts @@ -0,0 +1,75 @@ +/** + * Cognito Handler + * + * Handles: aws.cognito.userPool + * + * Creates a Cognito User Pool with the password policy + MFA config + * + auto-verified attributes laid down by the extractor. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.cognito.userPool'; +const SDK = '@aws-sdk/client-cognito-identity-provider'; + +export const cognito_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('cognito') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'Cognito Identity Provider', SDK); + + try { + const cognito = await load_aws_sdk(SDK); + if (!cognito) return sdkMissing(name, TYPE, 'create', start, 'Cognito Identity Provider', SDK); + + const pp = (properties.password_policy as Record) || {}; + const created = await client.send( + new cognito.CreateUserPoolCommand({ + PoolName: name, + AutoVerifiedAttributes: properties.auto_verified_attributes as string[], + MfaConfiguration: (properties.mfa_configuration as string) || 'OFF', + Policies: { + PasswordPolicy: { + MinimumLength: (pp.minimum_length as number) || 8, + RequireUppercase: pp.require_uppercase !== false, + RequireLowercase: pp.require_lowercase !== false, + RequireNumbers: pp.require_numbers !== false, + RequireSymbols: pp.require_symbols === true, + }, + }, + UserPoolTags: properties.tags as Record, + }), + ); + const arn = created?.UserPool?.Arn ?? `arn:aws:cognito-idp:${ctx.region}:*:userpool/${name}`; + return ok(name, TYPE, 'create', start, { provider_id: arn }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, _ctx) { + // Cognito attribute changes are mostly destructive — defer to a + // future commit. No-op the update path until then. + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('cognito') as any; + if (!client) return err(name, TYPE, 'delete', start, 'Cognito SDK not available'); + + try { + const cognito = await load_aws_sdk(SDK); + if (!cognito) return err(name, TYPE, 'delete', start, 'Cognito SDK not available'); + + // Cognito needs the UserPoolId (last segment of the ARN). + const userPoolId = provider_id.split('/').pop(); + await client.send(new cognito.DeleteUserPoolCommand({ UserPoolId: userPoolId })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/docdb.ts b/packages/core/src/deploy/providers/aws/handlers/docdb.ts new file mode 100644 index 00000000..9bdb05fb --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/docdb.ts @@ -0,0 +1,88 @@ +/** + * DocumentDB Handler + * + * Handles: aws.docdb.cluster + * + * CreateDBCluster + N × CreateDBInstance (one per instance_count). + * Like RDS, refuses to ship with an empty master password. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.docdb.cluster'; +const SDK = '@aws-sdk/client-docdb'; + +export const docdb_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('docdb') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'DocumentDB', SDK); + + if (!properties.master_user_password) { + return err(name, TYPE, 'create', start, 'DocumentDB create refused: master_user_password is empty.'); + } + + try { + const docdb = await load_aws_sdk(SDK); + if (!docdb) return sdkMissing(name, TYPE, 'create', start, 'DocumentDB', SDK); + + const clusterId = (properties.db_cluster_identifier as string) || name; + const instanceCount = (properties.instance_count as number) ?? 1; + + await client.send( + new docdb.CreateDBClusterCommand({ + DBClusterIdentifier: clusterId, + Engine: 'docdb', + EngineVersion: properties.engine_version as string, + MasterUsername: properties.master_username as string, + MasterUserPassword: properties.master_user_password as string, + BackupRetentionPeriod: (properties.backup_retention_period as number) ?? 7, + StorageEncrypted: properties.storage_encrypted !== false, + Port: (properties.port as number) || 27017, + }), + ); + + for (let i = 0; i < instanceCount; i++) { + await client.send( + new docdb.CreateDBInstanceCommand({ + DBInstanceIdentifier: `${clusterId}-${i + 1}`, + DBClusterIdentifier: clusterId, + DBInstanceClass: (properties.db_instance_class as string) || 'db.t3.medium', + Engine: 'docdb', + }), + ); + } + + return ok(name, TYPE, 'create', start, { provider_id: `arn:aws:docdb:${ctx.region}:*:cluster:${clusterId}` }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, _ctx) { + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('docdb') as any; + if (!client) return err(name, TYPE, 'delete', start, 'DocumentDB SDK not available'); + + try { + const docdb = await load_aws_sdk(SDK); + if (!docdb) return err(name, TYPE, 'delete', start, 'DocumentDB SDK not available'); + + await client.send( + new docdb.DeleteDBClusterCommand({ + DBClusterIdentifier: name, + SkipFinalSnapshot: true, + }), + ); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/dynamodb.ts b/packages/core/src/deploy/providers/aws/handlers/dynamodb.ts new file mode 100644 index 00000000..7d6c7d2c --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/dynamodb.ts @@ -0,0 +1,135 @@ +/** + * DynamoDB Handler + * + * Handles: aws.dynamodb.table + * + * CreateTable with the extractor-shaped partition/sort key spec + + * billing mode. PROVISIONED billing emits ProvisionedThroughput; + * PAY_PER_REQUEST omits it. Point-in-time recovery is set via a + * follow-up UpdateContinuousBackups call when enabled. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.dynamodb.table'; +const SDK = '@aws-sdk/client-dynamodb'; + +function build_key_schema(properties: Record): { + KeySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }>; + AttributeDefinitions: Array<{ AttributeName: string; AttributeType: string }>; +} { + const pk = String(properties.partition_key); + const pkType = String(properties.partition_key_type || 'S'); + const sk = properties.sort_key ? String(properties.sort_key) : undefined; + const skType = String(properties.sort_key_type || 'S'); + + const KeySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [ + { AttributeName: pk, KeyType: 'HASH' }, + ]; + const AttributeDefinitions: Array<{ AttributeName: string; AttributeType: string }> = [ + { AttributeName: pk, AttributeType: pkType }, + ]; + if (sk) { + KeySchema.push({ AttributeName: sk, KeyType: 'RANGE' }); + AttributeDefinitions.push({ AttributeName: sk, AttributeType: skType }); + } + return { KeySchema, AttributeDefinitions }; +} + +export const dynamodb_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('dynamodb') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'DynamoDB', SDK); + + try { + const dynamo = await load_aws_sdk(SDK); + if (!dynamo) return sdkMissing(name, TYPE, 'create', start, 'DynamoDB', SDK); + + const { KeySchema, AttributeDefinitions } = build_key_schema(properties); + const billing = (properties.billing_mode as string) || 'PAY_PER_REQUEST'; + + await client.send( + new dynamo.CreateTableCommand({ + TableName: name, + KeySchema, + AttributeDefinitions, + BillingMode: billing, + ...(billing === 'PROVISIONED' && { + ProvisionedThroughput: { + ReadCapacityUnits: (properties.read_capacity as number) || 5, + WriteCapacityUnits: (properties.write_capacity as number) || 5, + }, + }), + Tags: properties.tags + ? Object.entries(properties.tags as Record).map(([Key, Value]) => ({ Key, Value })) + : undefined, + }), + ); + + if (properties.point_in_time_recovery === true) { + await client.send( + new dynamo.UpdateContinuousBackupsCommand({ + TableName: name, + PointInTimeRecoverySpecification: { PointInTimeRecoveryEnabled: true }, + }), + ); + } + + return ok(name, TYPE, 'create', start, { provider_id: `arn:aws:dynamodb:${ctx.region}:*:table/${name}` }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('dynamodb') as any; + if (!client) return err(name, TYPE, 'update', start, 'DynamoDB SDK not available'); + + try { + const dynamo = await load_aws_sdk(SDK); + if (!dynamo) return err(name, TYPE, 'update', start, 'DynamoDB SDK not available'); + + // Only billing mode + provisioned capacity are safely updatable + // mid-flight (key schema is locked at create). PITR can be + // toggled via UpdateContinuousBackups. + const billing = properties.billing_mode as string | undefined; + if (billing) { + await client.send( + new dynamo.UpdateTableCommand({ + TableName: name, + BillingMode: billing, + ...(billing === 'PROVISIONED' && { + ProvisionedThroughput: { + ReadCapacityUnits: (properties.read_capacity as number) || 5, + WriteCapacityUnits: (properties.write_capacity as number) || 5, + }, + }), + }), + ); + } + return ok(name, TYPE, 'update', start, { provider_id }); + } catch (error) { + return err(name, TYPE, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('dynamodb') as any; + if (!client) return err(name, TYPE, 'delete', start, 'DynamoDB SDK not available'); + + try { + const dynamo = await load_aws_sdk(SDK); + if (!dynamo) return err(name, TYPE, 'delete', start, 'DynamoDB SDK not available'); + + await client.send(new dynamo.DeleteTableCommand({ TableName: name })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/ec2.ts b/packages/core/src/deploy/providers/aws/handlers/ec2.ts new file mode 100644 index 00000000..2915f38f --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/ec2.ts @@ -0,0 +1,142 @@ +/** + * EC2 Handler + * + * Handles: aws.ec2.instance + * + * Migrated from the monolithic aws-deployer.ts. Behaviour-equivalent: + * RunInstancesCommand on create, CreateTagsCommand on update, and + * TerminateInstancesCommand on delete. The instance id is encoded + * into the provider_id as `arn:aws:ec2:{region}:*:instance/{id}` so + * update + delete can recover it from the ARN. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import type { ResourceDeployResult } from '../../../types'; +import type { AWSResourceHandler } from '../types'; + +function result( + name: string, + type: string, + action: 'create' | 'update' | 'delete', + start: number, + overrides: Partial = {}, +): ResourceDeployResult { + return { + resource_id: name, + name, + type, + action, + success: true, + duration_ms: Date.now() - start, + ...overrides, + }; +} + +function fail( + name: string, + type: string, + action: 'create' | 'update' | 'delete', + start: number, + error: string, +): ResourceDeployResult { + return { + resource_id: name, + name, + type, + action, + success: false, + error, + duration_ms: Date.now() - start, + }; +} + +const TYPE = 'aws.ec2.instance'; + +export const ec2_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('ec2') as any; + if (!client) return fail(name, TYPE, 'create', start, 'EC2 SDK not available. Install @aws-sdk/client-ec2'); + + try { + const ec2 = await load_aws_sdk('@aws-sdk/client-ec2'); + if (!ec2) return fail(name, TYPE, 'create', start, 'EC2 SDK not available. Install @aws-sdk/client-ec2'); + + const image_id = (properties.image_id as string) || 'ami-0c55b159cbfafe1f0'; + const instance_type = (properties.instance_type as string) || 't2.micro'; + + const command = new ec2.RunInstancesCommand({ + ImageId: image_id, + InstanceType: instance_type, + MinCount: 1, + MaxCount: 1, + TagSpecifications: [ + { + ResourceType: 'instance', + Tags: [ + { Key: 'Name', Value: name }, + ...Object.entries((properties.tags as Record) || {}).map(([Key, Value]) => ({ + Key, + Value: Value as string, + })), + ], + }, + ], + SubnetId: properties.subnet_id as string, + SecurityGroupIds: properties.security_group_ids as string[], + }); + + const sendResult = await client.send(command); + const instance_id = sendResult.Instances?.[0]?.InstanceId; + if (!instance_id) + return fail(name, TYPE, 'create', start, 'Failed to get instance ID from RunInstances response'); + + return result(name, TYPE, 'create', start, { + provider_id: `arn:aws:ec2:${ctx.region}:*:instance/${instance_id}`, + }); + } catch (error) { + return fail(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('ec2') as any; + if (!client) return fail(name, TYPE, 'update', start, 'EC2 SDK not available'); + + try { + const ec2 = await load_aws_sdk('@aws-sdk/client-ec2'); + if (!ec2) return fail(name, TYPE, 'update', start, 'EC2 SDK not available'); + + const instance_id = provider_id.split('/').pop(); + if (properties.tags) { + const command = new ec2.CreateTagsCommand({ + Resources: [instance_id], + Tags: Object.entries(properties.tags as Record).map(([Key, Value]) => ({ Key, Value })), + }); + await client.send(command); + } + return result(name, TYPE, 'update', start, { provider_id }); + } catch (error) { + return fail(name, TYPE, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('ec2') as any; + if (!client) return fail(name, TYPE, 'delete', start, 'EC2 SDK not available'); + + try { + const ec2 = await load_aws_sdk('@aws-sdk/client-ec2'); + if (!ec2) return fail(name, TYPE, 'delete', start, 'EC2 SDK not available'); + + const instance_id = provider_id.split('/').pop(); + const command = new ec2.TerminateInstancesCommand({ InstanceIds: [instance_id] }); + await client.send(command); + return result(name, TYPE, 'delete', start); + } catch (error) { + return fail(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/ecs.ts b/packages/core/src/deploy/providers/aws/handlers/ecs.ts new file mode 100644 index 00000000..81d243fc --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/ecs.ts @@ -0,0 +1,151 @@ +/** + * ECS Handler + * + * Handles: aws.ecs.service + * + * Auto-bootstraps the operator's environment so Compute.Container + * "just works" out of the box, mirroring the GCP Cloud Run UX: + * + * 1. ensureEcsTaskExecutionRole() — idempotently creates + * `ecsTaskExecutionRole` with the standard managed policy. + * 2. ensureDefaultCluster() — creates `ice-default-cluster` if it + * doesn't exist. + * 3. RegisterTaskDefinition with the user's image/cpu/memory. + * 4. CreateService backed by the new task definition. + * + * Steps 1 and 2 fail closed if the IAM/ECS SDK isn't installed — + * the user sees a clear "install the SDK" message rather than a + * cryptic AWS error. + */ + +import { ensureEcsTaskExecutionRole } from '../iam-roles'; +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSHandlerContext, AWSResourceHandler } from '../types'; + +const TYPE = 'aws.ecs.service'; +const SDK = '@aws-sdk/client-ecs'; +const DEFAULT_CLUSTER = 'ice-default-cluster'; + +async function ensureDefaultCluster(client: any, ecs: any, ctx: AWSHandlerContext): Promise { + const desc = await client.send(new ecs.DescribeClustersCommand({ clusters: [DEFAULT_CLUSTER] })); + const existing = desc?.clusters?.find((c: any) => c.clusterName === DEFAULT_CLUSTER && c.status === 'ACTIVE'); + if (existing) return; + ctx.on_log?.(`Creating default ECS cluster ${DEFAULT_CLUSTER}`); + await client.send(new ecs.CreateClusterCommand({ clusterName: DEFAULT_CLUSTER })); +} + +export const ecs_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('ecs') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'ECS', SDK); + + try { + const ecs = await load_aws_sdk(SDK); + if (!ecs) return sdkMissing(name, TYPE, 'create', start, 'ECS', SDK); + + ctx.on_step?.(name, { label: 'Ensuring ECS task execution role', index: 0, total: 4 }); + const executionRoleArn = await ensureEcsTaskExecutionRole(ctx.region); + + ctx.on_step?.(name, { label: 'Ensuring default ECS cluster', index: 1, total: 4 }); + await ensureDefaultCluster(client, ecs, ctx); + + ctx.on_step?.(name, { label: 'Registering task definition', index: 2, total: 4 }); + const taskDef = await client.send( + new ecs.RegisterTaskDefinitionCommand({ + family: name, + executionRoleArn, + networkMode: 'awsvpc', + requiresCompatibilities: ['FARGATE'], + cpu: String(properties.cpu ?? '256'), + memory: String(properties.memory ?? '512'), + containerDefinitions: [ + { + name, + image: properties.image as string, + portMappings: [{ containerPort: properties.port as number }], + environment: Object.entries((properties.env_vars as Record) || {}).map(([k, v]) => ({ + name: k, + value: v, + })), + }, + ], + }), + ); + const taskDefArn = taskDef?.taskDefinition?.taskDefinitionArn; + + ctx.on_step?.(name, { label: 'Creating ECS service', index: 3, total: 4 }); + const service = await client.send( + new ecs.CreateServiceCommand({ + serviceName: name, + cluster: DEFAULT_CLUSTER, + taskDefinition: taskDefArn, + desiredCount: (properties.desired_count as number) ?? 1, + launchType: 'FARGATE', + networkConfiguration: { + awsvpcConfiguration: { + assignPublicIp: properties.assign_public_ip === false ? 'DISABLED' : 'ENABLED', + // Subnets + security groups need to come from a wired + // VPC block (or AWS account default-VPC) — handled in a + // follow-up commit when canvas VPCs land for AWS. + subnets: (properties.subnets as string[]) || [], + securityGroups: (properties.security_groups as string[]) || [], + }, + }, + }), + ); + + return ok(name, TYPE, 'create', start, { + provider_id: service?.service?.serviceArn || `arn:aws:ecs:${ctx.region}:*:service/${DEFAULT_CLUSTER}/${name}`, + }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('ecs') as any; + if (!client) return err(name, TYPE, 'update', start, 'ECS SDK not available'); + + try { + const ecs = await load_aws_sdk(SDK); + if (!ecs) return err(name, TYPE, 'update', start, 'ECS SDK not available'); + + await client.send( + new ecs.UpdateServiceCommand({ + service: name, + cluster: DEFAULT_CLUSTER, + desiredCount: properties.desired_count as number, + }), + ); + return ok(name, TYPE, 'update', start, { provider_id }); + } catch (error) { + return err(name, TYPE, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('ecs') as any; + if (!client) return err(name, TYPE, 'delete', start, 'ECS SDK not available'); + + try { + const ecs = await load_aws_sdk(SDK); + if (!ecs) return err(name, TYPE, 'delete', start, 'ECS SDK not available'); + + // Scale to zero before delete; AWS rejects DeleteService on + // services with desiredCount > 0. + try { + await client.send(new ecs.UpdateServiceCommand({ service: name, cluster: DEFAULT_CLUSTER, desiredCount: 0 })); + } catch { + /* may not exist; fall through to delete */ + } + await client.send(new ecs.DeleteServiceCommand({ service: name, cluster: DEFAULT_CLUSTER, force: true })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/elasticache.ts b/packages/core/src/deploy/providers/aws/handlers/elasticache.ts new file mode 100644 index 00000000..81965966 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/elasticache.ts @@ -0,0 +1,91 @@ +/** + * ElastiCache Handler + * + * Handles: aws.elasticache.cluster + * + * Create the cache cluster — Redis only today (Memcached is a stale + * engine; ICE doesn't expose it on the canvas). For multi-node setups + * the handler creates a replication group instead so HA mode actually + * has standby nodes. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.elasticache.cluster'; +const SDK = '@aws-sdk/client-elasticache'; + +export const elasticache_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('elasticache') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'ElastiCache', SDK); + + try { + const ec = await load_aws_sdk(SDK); + if (!ec) return sdkMissing(name, TYPE, 'create', start, 'ElastiCache', SDK); + + const isReplicated = (properties.num_cache_nodes as number) > 1; + if (isReplicated) { + await client.send( + new ec.CreateReplicationGroupCommand({ + ReplicationGroupId: name, + ReplicationGroupDescription: `ICE-managed ${name}`, + Engine: 'redis', + EngineVersion: (properties.engine_version as string) || '7.0', + CacheNodeType: (properties.cache_node_type as string) || 'cache.t3.micro', + NumCacheClusters: properties.num_cache_nodes as number, + AutomaticFailoverEnabled: true, + Port: (properties.port as number) || 6379, + CacheParameterGroupName: properties.parameter_group_name as string, + }), + ); + } else { + await client.send( + new ec.CreateCacheClusterCommand({ + CacheClusterId: name, + Engine: 'redis', + EngineVersion: (properties.engine_version as string) || '7.0', + CacheNodeType: (properties.cache_node_type as string) || 'cache.t3.micro', + NumCacheNodes: 1, + Port: (properties.port as number) || 6379, + CacheParameterGroupName: properties.parameter_group_name as string, + }), + ); + } + + return ok(name, TYPE, 'create', start, { provider_id: `arn:aws:elasticache:${ctx.region}:*:cluster:${name}` }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, ctx) { + // ElastiCache supports limited live updates (engine_version only, + // and only forward). Skip the no-op path entirely until the + // canvas exposes the relevant fields. + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('elasticache') as any; + if (!client) return err(name, TYPE, 'delete', start, 'ElastiCache SDK not available'); + + try { + const ec = await load_aws_sdk(SDK); + if (!ec) return err(name, TYPE, 'delete', start, 'ElastiCache SDK not available'); + + // Best-effort: try cluster first, fall back to replication group. + try { + await client.send(new ec.DeleteCacheClusterCommand({ CacheClusterId: name })); + } catch { + await client.send(new ec.DeleteReplicationGroupCommand({ ReplicationGroupId: name })); + } + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/elbv2.ts b/packages/core/src/deploy/providers/aws/handlers/elbv2.ts new file mode 100644 index 00000000..99c46f5f --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/elbv2.ts @@ -0,0 +1,74 @@ +/** + * ELBv2 Handler + * + * Handles: aws.elbv2.loadBalancer + * + * CreateLoadBalancer + CreateTargetGroup (skeleton target — operators + * register backend services via outgoing edges + a follow-up + * RegisterTargets call from the consuming compute handler). + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.elbv2.loadBalancer'; +const SDK = '@aws-sdk/client-elastic-load-balancing-v2'; + +export const elbv2_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('elbv2') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'ELBv2', SDK); + + try { + const elb = await load_aws_sdk(SDK); + if (!elb) return sdkMissing(name, TYPE, 'create', start, 'ELBv2', SDK); + + const lb = await client.send( + new elb.CreateLoadBalancerCommand({ + Name: name, + Scheme: properties.scheme as string, + Type: properties.type as string, + IpAddressType: properties.ip_address_type as string, + }), + ); + const arn = + lb?.LoadBalancers?.[0]?.LoadBalancerArn ?? + `arn:aws:elasticloadbalancing:${ctx.region}:*:loadbalancer/app/${name}`; + + // Skeleton target group so the LB is wired even before backends connect. + await client.send( + new elb.CreateTargetGroupCommand({ + Name: `${name}-tg`, + Port: (properties.target_group_port as number) || 80, + Protocol: (properties.target_group_protocol as string) || 'HTTP', + }), + ); + + return ok(name, TYPE, 'create', start, { provider_id: arn }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, _ctx) { + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('elbv2') as any; + if (!client) return err(name, TYPE, 'delete', start, 'ELBv2 SDK not available'); + + try { + const elb = await load_aws_sdk(SDK); + if (!elb) return err(name, TYPE, 'delete', start, 'ELBv2 SDK not available'); + + await client.send(new elb.DeleteLoadBalancerCommand({ LoadBalancerArn: provider_id })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/events-rule.ts b/packages/core/src/deploy/providers/aws/handlers/events-rule.ts new file mode 100644 index 00000000..577306a8 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/events-rule.ts @@ -0,0 +1,81 @@ +/** + * EventBridge Rule Handler + * + * Handles: aws.events.rule + * + * PutRule (schedule + state) → optional PutTargets when target_arn + * is set. CronJob on the canvas wires this rule to a Lambda + * (target_type='lambda') today; future ECS/StepFunctions targets + * just add a new target_type branch. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.events.rule'; +const SDK = '@aws-sdk/client-eventbridge'; + +export const events_rule_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('eventbridge') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'EventBridge', SDK); + + try { + const ev = await load_aws_sdk(SDK); + if (!ev) return sdkMissing(name, TYPE, 'create', start, 'EventBridge', SDK); + + const put = await client.send( + new ev.PutRuleCommand({ + Name: name, + ScheduleExpression: properties.schedule_expression as string, + Description: properties.description as string, + State: (properties.state as string) || 'ENABLED', + }), + ); + + if (properties.target_arn) { + await client.send( + new ev.PutTargetsCommand({ + Rule: name, + Targets: [{ Id: '1', Arn: properties.target_arn as string }], + }), + ); + } + + return ok(name, TYPE, 'create', start, { + provider_id: put?.RuleArn || `arn:aws:events:${ctx.region}:*:rule/${name}`, + }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + // PutRule is upsert — call it again to update. + return this.create(name, properties, ctx).then((r) => ({ ...r, action: 'update', provider_id })); + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('eventbridge') as any; + if (!client) return err(name, TYPE, 'delete', start, 'EventBridge SDK not available'); + + try { + const ev = await load_aws_sdk(SDK); + if (!ev) return err(name, TYPE, 'delete', start, 'EventBridge SDK not available'); + + // Targets must be detached before the rule can be deleted. + try { + await client.send(new ev.RemoveTargetsCommand({ Rule: name, Ids: ['1'] })); + } catch { + /* no targets attached — ignore */ + } + await client.send(new ev.DeleteRuleCommand({ Name: name })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/lambda-builder.ts b/packages/core/src/deploy/providers/aws/handlers/lambda-builder.ts new file mode 100644 index 00000000..d54c86f4 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/lambda-builder.ts @@ -0,0 +1,118 @@ +/** + * Lambda code-builder — auto-build path for Compute.ServerlessFunction + * blocks that have a connected Source.Repository on the canvas. + * + * Flow: + * 1. git clone --depth 1 --branch + * 2. npm install --omit=dev --silent (skip if no package.json) + * 3. zip -r function.zip . + * 4. PutObject to `ice-bootstrap-{accountId}-{region}` (CreateBucket + * first if absent). + * 5. Return { s3Bucket, s3Key } so the Lambda handler can pass them + * straight to CreateFunction. + * + * Local-only — assumes `git`, `npm`, and `zip` are available on the + * deploy host. Failures bubble up so the Lambda handler can fall + * through to a clear error. AWS CodeBuild integration is deferred + * to a future commit. + */ + +import { execSync } from 'child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { load_aws_sdk } from '../sdk-loader'; +import type { AWSHandlerContext } from '../types'; + +export interface BuildArgs { + /** Lambda function name — used as the S3 key prefix. */ + function_name: string; + /** Repository URL — passed straight to git clone (HTTPS or git@…). */ + repository: string; + /** Git branch / ref to check out. Defaults to 'main'. */ + branch: string; + ctx: AWSHandlerContext; +} + +export interface BuildResult { + s3Bucket: string; + s3Key: string; +} + +const BOOTSTRAP_BUCKET_PREFIX = 'ice-bootstrap'; + +function shell(command: string, cwd?: string): void { + execSync(command, { cwd, stdio: ['ignore', 'ignore', 'pipe'] }); +} + +async function ensure_bootstrap_bucket(bucket: string, region: string, ctx: AWSHandlerContext): Promise { + const client = ctx.clients.get('s3') as any; + if (!client) throw new Error('S3 SDK not available — required for Lambda auto-build'); + const s3 = await load_aws_sdk('@aws-sdk/client-s3'); + if (!s3) throw new Error('S3 SDK not available — required for Lambda auto-build'); + + try { + await client.send(new s3.HeadBucketCommand({ Bucket: bucket })); + return; + } catch { + // Doesn't exist — create it. + } + await client.send( + new s3.CreateBucketCommand({ + Bucket: bucket, + CreateBucketConfiguration: region !== 'us-east-1' ? { LocationConstraint: region } : undefined, + }), + ); +} + +async function upload_zip(bucket: string, key: string, body: Buffer, ctx: AWSHandlerContext): Promise { + const client = ctx.clients.get('s3') as any; + const s3 = await load_aws_sdk('@aws-sdk/client-s3'); + if (!client || !s3) throw new Error('S3 SDK not available'); + await client.send(new s3.PutObjectCommand({ Bucket: bucket, Key: key, Body: body })); +} + +/** + * Run the full auto-build flow. Returns the S3 ref the Lambda handler + * should pass to CreateFunction. Throws on any sub-step failure with + * a message that names the step (clone / install / zip / upload). + */ +export async function build_and_upload_lambda(args: BuildArgs): Promise { + const accountId = await args.ctx.ensure_account_id(); + const bucket = `${BOOTSTRAP_BUCKET_PREFIX}-${accountId}-${args.ctx.region}`; + const tmpdir_path = mkdtempSync(join(tmpdir(), 'ice-lambda-build-')); + const zipPath = join(tmpdir_path, 'function.zip'); + + try { + args.ctx.on_log?.(`Cloning ${args.repository}@${args.branch}`); + shell(`git clone --depth 1 --branch ${args.branch} ${args.repository} ${tmpdir_path}/src`); + + // Best-effort npm install — skip if no package.json present. + try { + readFileSync(join(tmpdir_path, 'src', 'package.json')); + args.ctx.on_log?.('Running npm install --omit=dev'); + shell('npm install --omit=dev --silent', join(tmpdir_path, 'src')); + } catch { + args.ctx.on_log?.('No package.json — skipping npm install'); + } + + args.ctx.on_log?.('Zipping build output'); + shell(`zip -qr ${zipPath} .`, join(tmpdir_path, 'src')); + + args.ctx.on_log?.(`Ensuring bootstrap bucket ${bucket}`); + await ensure_bootstrap_bucket(bucket, args.ctx.region, args.ctx); + + const key = `lambda/${args.function_name}/${Date.now()}.zip`; + args.ctx.on_log?.(`Uploading to s3://${bucket}/${key}`); + const body = readFileSync(zipPath); + await upload_zip(bucket, key, body, args.ctx); + + return { s3Bucket: bucket, s3Key: key }; + } finally { + try { + rmSync(tmpdir_path, { recursive: true, force: true }); + } catch { + /* best-effort cleanup */ + } + } +} diff --git a/packages/core/src/deploy/providers/aws/handlers/lambda.ts b/packages/core/src/deploy/providers/aws/handlers/lambda.ts new file mode 100644 index 00000000..a306fa56 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/lambda.ts @@ -0,0 +1,189 @@ +/** + * Lambda Handler + * + * Handles: aws.lambda.function + * + * Migrated from the monolithic aws-deployer.ts. Baseline accepts the + * S3-ref code source today (`properties.s3_bucket` + `properties.s3_key` + * or a base64 `properties.zip_file`). Auto-build from a connected + * Source.Repository is wired in commit #28 (Phase 3). + */ + +import { build_and_upload_lambda } from './lambda-builder'; +import { load_aws_sdk } from '../sdk-loader'; +import type { ResourceDeployResult } from '../../../types'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.lambda.function'; + +function result( + name: string, + action: 'create' | 'update' | 'delete', + start: number, + overrides: Partial = {}, +): ResourceDeployResult { + return { + resource_id: name, + name, + type: TYPE, + action, + success: true, + duration_ms: Date.now() - start, + ...overrides, + }; +} + +function fail( + name: string, + action: 'create' | 'update' | 'delete', + start: number, + error: string, +): ResourceDeployResult { + return { + resource_id: name, + name, + type: TYPE, + action, + success: false, + error, + duration_ms: Date.now() - start, + }; +} + +export const lambda_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('lambda') as any; + if (!client) return fail(name, 'create', start, 'Lambda SDK not available. Install @aws-sdk/client-lambda'); + + // Fail fast on missing required fields — the SDK error for these + // is cryptic ("Could not find resource ...") and burns user time. + const role = (properties.role as string) || ''; + if (!role) { + return fail( + name, + 'create', + start, + 'Lambda function requires an IAM execution role ARN (properties.role). Wire one in or use the auto-role helper.', + ); + } + + // Auto-build path — when no explicit code source is set but the + // extractor passed through a `repository` (set by the canvas's + // wire_source_repositories pass when Source.Repository is wired + // to this block), clone + zip + upload to the bootstrap bucket + // and stamp the resulting S3 ref onto `properties` so the rest + // of the handler proceeds as if the operator had supplied it. + const hasS3Ref = !!(properties.s3_bucket && properties.s3_key); + const hasZipFile = !!properties.zip_file; + const hasRepo = !!properties.repository; + if (!hasS3Ref && !hasZipFile && hasRepo) { + try { + const built = await build_and_upload_lambda({ + function_name: name, + repository: properties.repository as string, + branch: (properties.branch as string) || 'main', + ctx, + }); + properties.s3_bucket = built.s3Bucket; + properties.s3_key = built.s3Key; + } catch (error) { + return fail( + name, + 'create', + start, + `Lambda auto-build failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + if (!properties.s3_bucket && !hasZipFile) { + return fail( + name, + 'create', + start, + 'Lambda function code source is missing. Provide properties.code.{s3Bucket,s3Key}, zip_file, or wire a Source.Repository to enable auto-build.', + ); + } + + try { + const lambda = await load_aws_sdk('@aws-sdk/client-lambda'); + if (!lambda) return fail(name, 'create', start, 'Lambda SDK not available. Install @aws-sdk/client-lambda'); + + const command = new lambda.CreateFunctionCommand({ + FunctionName: name, + Runtime: (properties.runtime as string) || 'nodejs18.x', + Role: role, + Handler: (properties.handler as string) || 'index.handler', + Code: { + S3Bucket: properties.s3_bucket as string, + S3Key: properties.s3_key as string, + ZipFile: properties.zip_file ? Buffer.from(properties.zip_file as string, 'base64') : undefined, + }, + Description: properties.description as string, + Timeout: (properties.timeout as number) || 30, + MemorySize: (properties.memory_size as number) || 128, + Environment: properties.environment + ? { Variables: properties.environment as Record } + : undefined, + Tags: properties.tags as Record, + }); + + const sendResult = await client.send(command); + return result(name, 'create', start, { provider_id: sendResult.FunctionArn }); + } catch (error) { + return fail(name, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('lambda') as any; + if (!client) return fail(name, 'update', start, 'Lambda SDK not available'); + + try { + const lambda = await load_aws_sdk('@aws-sdk/client-lambda'); + if (!lambda) return fail(name, 'update', start, 'Lambda SDK not available'); + + const config_command = new lambda.UpdateFunctionConfigurationCommand({ + FunctionName: name, + Description: properties.description as string, + Timeout: properties.timeout as number, + MemorySize: properties.memory_size as number, + Environment: properties.environment + ? { Variables: properties.environment as Record } + : undefined, + }); + await client.send(config_command); + + if (properties.s3_bucket && properties.s3_key) { + const code_command = new lambda.UpdateFunctionCodeCommand({ + FunctionName: name, + S3Bucket: properties.s3_bucket as string, + S3Key: properties.s3_key as string, + }); + await client.send(code_command); + } + return result(name, 'update', start, { provider_id }); + } catch (error) { + return fail(name, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('lambda') as any; + if (!client) return fail(name, 'delete', start, 'Lambda SDK not available'); + + try { + const lambda = await load_aws_sdk('@aws-sdk/client-lambda'); + if (!lambda) return fail(name, 'delete', start, 'Lambda SDK not available'); + + const command = new lambda.DeleteFunctionCommand({ FunctionName: name }); + await client.send(command); + return result(name, 'delete', start); + } catch (error) { + return fail(name, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/opensearch.ts b/packages/core/src/deploy/providers/aws/handlers/opensearch.ts new file mode 100644 index 00000000..2a6c8d05 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/opensearch.ts @@ -0,0 +1,73 @@ +/** + * OpenSearch Handler + * + * Handles: aws.opensearch.domain + * + * CreateDomain (single-call setup; updates + deletes are simple + * pass-throughs). OpenSearch domain creation takes 10-15 minutes + * — polling deferred until the canvas shows long-running state. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.opensearch.domain'; +const SDK = '@aws-sdk/client-opensearch'; + +export const opensearch_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('opensearch') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'OpenSearch', SDK); + + try { + const os = await load_aws_sdk(SDK); + if (!os) return sdkMissing(name, TYPE, 'create', start, 'OpenSearch', SDK); + + await client.send( + new os.CreateDomainCommand({ + DomainName: name, + EngineVersion: properties.engine_version as string, + ClusterConfig: { + InstanceType: properties.instance_type as string, + InstanceCount: properties.instance_count as number, + DedicatedMasterEnabled: properties.dedicated_master_enabled as boolean, + DedicatedMasterType: properties.dedicated_master_type as string, + DedicatedMasterCount: properties.dedicated_master_count as number, + }, + EBSOptions: { + EBSEnabled: properties.ebs_enabled as boolean, + VolumeType: properties.ebs_volume_type as string, + VolumeSize: properties.ebs_volume_size_gb as number, + }, + EncryptionAtRestOptions: { Enabled: properties.encryption_at_rest as boolean }, + NodeToNodeEncryptionOptions: { Enabled: properties.node_to_node_encryption as boolean }, + }), + ); + return ok(name, TYPE, 'create', start, { provider_id: `arn:aws:es:${ctx.region}:*:domain/${name}` }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, _ctx) { + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('opensearch') as any; + if (!client) return err(name, TYPE, 'delete', start, 'OpenSearch SDK not available'); + + try { + const os = await load_aws_sdk(SDK); + if (!os) return err(name, TYPE, 'delete', start, 'OpenSearch SDK not available'); + + await client.send(new os.DeleteDomainCommand({ DomainName: name })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/rds.ts b/packages/core/src/deploy/providers/aws/handlers/rds.ts new file mode 100644 index 00000000..edd0c81d --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/rds.ts @@ -0,0 +1,132 @@ +/** + * RDS Handler + * + * Handles: aws.rds.dbInstance + * + * CreateDBInstance + optional status polling. RDS provisioning takes + * 5–10 minutes; the handler optionally polls DescribeDBInstances + * until the instance status reads "available". Polling is bounded by + * a 20-minute cap and respects ctx.abort_signal (cancel-safe). + * + * Honours the extractor's no-default-password invariant — refuses to + * call CreateDBInstance when master_user_password is empty. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSHandlerContext, AWSResourceHandler } from '../types'; + +const TYPE = 'aws.rds.dbInstance'; +const SDK = '@aws-sdk/client-rds'; +const POLL_INTERVAL_MS = 30_000; +const POLL_TIMEOUT_MS = 20 * 60 * 1000; + +async function wait_until_available(client: any, rds: any, identifier: string, ctx: AWSHandlerContext): Promise { + const deadline = Date.now() + POLL_TIMEOUT_MS; + let step = 0; + while (Date.now() < deadline) { + if (ctx.abort_signal?.aborted) throw new Error('RDS provisioning cancelled'); + const describe = await client.send(new rds.DescribeDBInstancesCommand({ DBInstanceIdentifier: identifier })); + const status = describe?.DBInstances?.[0]?.DBInstanceStatus; + ctx.on_step?.(identifier, { label: `RDS status: ${status}`, index: step++, total: 0 }); + if (status === 'available') return; + if (status === 'failed') throw new Error(`RDS instance ${identifier} entered failed state`); + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + } + throw new Error(`Timed out waiting for RDS instance ${identifier} to become available`); +} + +export const rds_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('rds') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'RDS', SDK); + + if (!properties.master_user_password) { + return err( + name, + TYPE, + 'create', + start, + 'RDS create refused: master_user_password is empty. Wire a Security.Secret block or set the field explicitly.', + ); + } + + try { + const rds = await load_aws_sdk(SDK); + if (!rds) return sdkMissing(name, TYPE, 'create', start, 'RDS', SDK); + + await client.send( + new rds.CreateDBInstanceCommand({ + DBInstanceIdentifier: name, + DBInstanceClass: (properties.db_instance_class as string) || 'db.t3.micro', + Engine: properties.engine as string, + EngineVersion: properties.engine_version as string, + AllocatedStorage: (properties.allocated_storage as number) || 20, + StorageType: (properties.storage_type as string) || 'gp3', + MasterUsername: properties.master_username as string, + MasterUserPassword: properties.master_user_password as string, + Port: properties.port as number, + PubliclyAccessible: !!properties.publicly_accessible, + MultiAZ: !!properties.multi_az, + BackupRetentionPeriod: (properties.backup_retention_period as number) ?? 7, + }), + ); + + // Poll until the instance is available. Set ctx.abort_signal to + // cancel mid-flight; the loop also logs status via on_step so + // the UI can show progress. + await wait_until_available(client, rds, name, ctx); + + return ok(name, TYPE, 'create', start, { provider_id: `arn:aws:rds:${ctx.region}:*:db:${name}` }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('rds') as any; + if (!client) return err(name, TYPE, 'update', start, 'RDS SDK not available'); + + try { + const rds = await load_aws_sdk(SDK); + if (!rds) return err(name, TYPE, 'update', start, 'RDS SDK not available'); + + await client.send( + new rds.ModifyDBInstanceCommand({ + DBInstanceIdentifier: name, + DBInstanceClass: properties.db_instance_class as string, + AllocatedStorage: properties.allocated_storage as number, + BackupRetentionPeriod: properties.backup_retention_period as number, + ApplyImmediately: true, + }), + ); + return ok(name, TYPE, 'update', start, { provider_id }); + } catch (error) { + return err(name, TYPE, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('rds') as any; + if (!client) return err(name, TYPE, 'delete', start, 'RDS SDK not available'); + + try { + const rds = await load_aws_sdk(SDK); + if (!rds) return err(name, TYPE, 'delete', start, 'RDS SDK not available'); + + await client.send( + new rds.DeleteDBInstanceCommand({ + DBInstanceIdentifier: name, + SkipFinalSnapshot: true, + DeleteAutomatedBackups: true, + }), + ); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/redshift.ts b/packages/core/src/deploy/providers/aws/handlers/redshift.ts new file mode 100644 index 00000000..dfe94216 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/redshift.ts @@ -0,0 +1,71 @@ +/** + * Redshift Handler + * + * Handles: aws.redshift.cluster + * + * Standard CreateCluster with the password-required invariant + * shared by RDS + DocDB. Multi-node vs single-node is set by + * cluster_type + number_of_nodes from the extractor. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.redshift.cluster'; +const SDK = '@aws-sdk/client-redshift'; + +export const redshift_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('redshift') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'Redshift', SDK); + + if (!properties.master_user_password) { + return err(name, TYPE, 'create', start, 'Redshift create refused: master_user_password is empty.'); + } + + try { + const rs = await load_aws_sdk(SDK); + if (!rs) return sdkMissing(name, TYPE, 'create', start, 'Redshift', SDK); + + await client.send( + new rs.CreateClusterCommand({ + ClusterIdentifier: name, + NodeType: properties.node_type as string, + ClusterType: properties.cluster_type as string, + NumberOfNodes: properties.number_of_nodes as number, + DBName: properties.db_name as string, + MasterUsername: properties.master_username as string, + MasterUserPassword: properties.master_user_password as string, + PubliclyAccessible: !!properties.publicly_accessible, + Encrypted: properties.encrypted !== false, + Port: properties.port as number, + }), + ); + return ok(name, TYPE, 'create', start, { provider_id: `arn:aws:redshift:${ctx.region}:*:cluster:${name}` }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, _ctx) { + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('redshift') as any; + if (!client) return err(name, TYPE, 'delete', start, 'Redshift SDK not available'); + + try { + const rs = await load_aws_sdk(SDK); + if (!rs) return err(name, TYPE, 'delete', start, 'Redshift SDK not available'); + + await client.send(new rs.DeleteClusterCommand({ ClusterIdentifier: name, SkipFinalClusterSnapshot: true })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/s3.ts b/packages/core/src/deploy/providers/aws/handlers/s3.ts new file mode 100644 index 00000000..75ba87db --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/s3.ts @@ -0,0 +1,226 @@ +/** + * S3 Handler + * + * Handles: aws.s3.bucket + * + * Two enhancements over the Phase 0 baseline: + * + * 1. **Account-id suffix.** S3 bucket names are globally unique + * across all AWS accounts. The handler appends `-{accountId}` + * to the translator's resource name before calling the SDK so + * `ice-myapp-bucket` becomes `ice-myapp-bucket-111122223333`, + * eliminating the collision class. The provider_id ARN carries + * the actual S3 bucket name (post-suffix) so update + delete + * round-trip cleanly. + * + * 2. **publicWebsite bucket policy.** When the extractor flags the + * bucket as `public_access` + `website_hosting` (today only + * Compute.StaticSite triggers this via the publicWebsiteSource + * role), the handler attaches a public-read bucket policy AND + * sets the static-website configuration with the index/404 + * pages the extractor supplied. Plain Storage.Bucket stays + * private. + * + * Delete is symmetric — uses the stored provider_id ARN to recover + * the suffixed name. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import type { ResourceDeployResult } from '../../../types'; +import type { AWSHandlerContext, AWSResourceHandler } from '../types'; + +const TYPE = 'aws.s3.bucket'; + +function result( + name: string, + action: 'create' | 'update' | 'delete', + start: number, + overrides: Partial = {}, +): ResourceDeployResult { + return { + resource_id: name, + name, + type: TYPE, + action, + success: true, + duration_ms: Date.now() - start, + ...overrides, + }; +} + +function fail( + name: string, + action: 'create' | 'update' | 'delete', + start: number, + error: string, +): ResourceDeployResult { + return { + resource_id: name, + name, + type: TYPE, + action, + success: false, + error, + duration_ms: Date.now() - start, + }; +} + +/** + * Build the actual S3 bucket name. Appends `-{accountId}` so deploys + * in different AWS accounts don't fight over a globally-unique name. + * Suffix-already-present is preserved (idempotent). + */ +async function resolve_bucket_name(translator_name: string, ctx: AWSHandlerContext): Promise { + const accountId = await ctx.ensure_account_id(); + if (translator_name.endsWith(`-${accountId}`)) return translator_name; + return `${translator_name}-${accountId}`; +} + +/** Parse the S3 bucket name back out of `arn:aws:s3:::`. */ +function bucket_name_from_arn(arn: string): string { + const idx = arn.lastIndexOf(':'); + return idx === -1 ? arn : arn.slice(idx + 1); +} + +function public_read_policy(bucket_name: string): string { + return JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Sid: 'PublicReadGetObject', + Effect: 'Allow', + Principal: '*', + Action: 's3:GetObject', + Resource: `arn:aws:s3:::${bucket_name}/*`, + }, + ], + }); +} + +export const s3_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('s3') as any; + if (!client) return fail(name, 'create', start, 'S3 SDK not available. Install @aws-sdk/client-s3'); + + try { + const s3 = await load_aws_sdk('@aws-sdk/client-s3'); + if (!s3) return fail(name, 'create', start, 'S3 SDK not available. Install @aws-sdk/client-s3'); + + const bucket = await resolve_bucket_name(name, ctx); + const isPublicWebsite = properties.public_access === true && properties.website_hosting === true; + + // 1. Create the bucket. us-east-1 must NOT pass LocationConstraint + // (AWS treats it as "default" and rejects the explicit value). + await client.send( + new s3.CreateBucketCommand({ + Bucket: bucket, + CreateBucketConfiguration: ctx.region !== 'us-east-1' ? { LocationConstraint: ctx.region } : undefined, + }), + ); + + // 2. Tags pass-through (when the translator/extractor populates them). + if (properties.tags && Object.keys(properties.tags as Record).length > 0) { + await client.send( + new s3.PutBucketTaggingCommand({ + Bucket: bucket, + Tagging: { + TagSet: Object.entries(properties.tags as Record).map(([Key, Value]) => ({ Key, Value })), + }, + }), + ); + } + + // 3. Public-website branch — must drop the default block-public-acls + // policy before attaching the public-read bucket policy. + if (isPublicWebsite) { + // 3a. Loosen account-default public-access block on the bucket. + await client.send( + new s3.PutPublicAccessBlockCommand({ + Bucket: bucket, + PublicAccessBlockConfiguration: { + BlockPublicAcls: false, + IgnorePublicAcls: false, + BlockPublicPolicy: false, + RestrictPublicBuckets: false, + }, + }), + ); + // 3b. Attach the read-only bucket policy. + await client.send(new s3.PutBucketPolicyCommand({ Bucket: bucket, Policy: public_read_policy(bucket) })); + // 3c. Enable static website hosting with index/404 pages. + await client.send( + new s3.PutBucketWebsiteCommand({ + Bucket: bucket, + WebsiteConfiguration: { + IndexDocument: { Suffix: (properties.index_page as string) || 'index.html' }, + ErrorDocument: { Key: (properties.not_found_page as string) || '404.html' }, + }, + }), + ); + } + + return result(name, 'create', start, { provider_id: `arn:aws:s3:::${bucket}` }); + } catch (error) { + return fail(name, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('s3') as any; + if (!client) return fail(name, 'update', start, 'S3 SDK not available'); + + try { + const s3 = await load_aws_sdk('@aws-sdk/client-s3'); + if (!s3) return fail(name, 'update', start, 'S3 SDK not available'); + + // Recover the actual bucket name from the provider_id ARN. + const bucket = bucket_name_from_arn(provider_id); + + if (properties.tags && Object.keys(properties.tags as Record).length > 0) { + await client.send( + new s3.PutBucketTaggingCommand({ + Bucket: bucket, + Tagging: { + TagSet: Object.entries(properties.tags as Record).map(([Key, Value]) => ({ Key, Value })), + }, + }), + ); + } + return result(name, 'update', start, { provider_id }); + } catch (error) { + return fail(name, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('s3') as any; + if (!client) return fail(name, 'delete', start, 'S3 SDK not available'); + + try { + const s3 = await load_aws_sdk('@aws-sdk/client-s3'); + if (!s3) return fail(name, 'delete', start, 'S3 SDK not available'); + + // Recover bucket name from the ARN; fall back to resolving the + // suffix again if the caller passed the translator name. + const bucket = provider_id ? bucket_name_from_arn(provider_id) : await resolve_bucket_name(name, ctx); + + // DeleteBucket fails on non-empty buckets — empty first. + const list = await client.send(new s3.ListObjectsV2Command({ Bucket: bucket })); + if (list.Contents && list.Contents.length > 0) { + await client.send( + new s3.DeleteObjectsCommand({ + Bucket: bucket, + Delete: { Objects: list.Contents.map((obj: any) => ({ Key: obj.Key })) }, + }), + ); + } + await client.send(new s3.DeleteBucketCommand({ Bucket: bucket })); + return result(name, 'delete', start); + } catch (error) { + return fail(name, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/sagemaker.ts b/packages/core/src/deploy/providers/aws/handlers/sagemaker.ts new file mode 100644 index 00000000..69f95782 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/sagemaker.ts @@ -0,0 +1,85 @@ +/** + * SageMaker Handler + * + * Handles: aws.sagemaker.endpoint + * + * CreateEndpointConfig → CreateEndpoint. The model itself (training + + * registration) is operator-side — the handler refuses to create an + * endpoint when `model_name` is empty. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.sagemaker.endpoint'; +const SDK = '@aws-sdk/client-sagemaker'; + +export const sagemaker_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('sagemaker') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'SageMaker', SDK); + + if (!properties.model_name) { + return err( + name, + TYPE, + 'create', + start, + 'SageMaker endpoint requires properties.model_name (register the model first).', + ); + } + + try { + const sm = await load_aws_sdk(SDK); + if (!sm) return sdkMissing(name, TYPE, 'create', start, 'SageMaker', SDK); + + const configName = `${name}-config`; + await client.send( + new sm.CreateEndpointConfigCommand({ + EndpointConfigName: configName, + ProductionVariants: [ + { + VariantName: 'default', + ModelName: properties.model_name as string, + InstanceType: properties.instance_type as string, + InitialInstanceCount: properties.initial_instance_count as number, + InitialVariantWeight: properties.initial_variant_weight as number, + }, + ], + }), + ); + + const created = await client.send( + new sm.CreateEndpointCommand({ EndpointName: name, EndpointConfigName: configName }), + ); + + return ok(name, TYPE, 'create', start, { + provider_id: created?.EndpointArn || `arn:aws:sagemaker:${ctx.region}:*:endpoint/${name}`, + }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, _properties, _current, _ctx) { + return ok(name, TYPE, 'update', Date.now(), { provider_id }); + }, + + async delete(name, _provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('sagemaker') as any; + if (!client) return err(name, TYPE, 'delete', start, 'SageMaker SDK not available'); + + try { + const sm = await load_aws_sdk(SDK); + if (!sm) return err(name, TYPE, 'delete', start, 'SageMaker SDK not available'); + + await client.send(new sm.DeleteEndpointCommand({ EndpointName: name })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/secrets-manager.ts b/packages/core/src/deploy/providers/aws/handlers/secrets-manager.ts new file mode 100644 index 00000000..31e2c6ce --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/secrets-manager.ts @@ -0,0 +1,92 @@ +/** + * Secrets Manager Handler + * + * Handles: aws.secretsmanager.secret + * + * Mirrors the GCP Secret Manager handler's contract: the schema- + * declared deploy-expansion pass emits one of these per binding row, + * so this handler just creates / updates / deletes ONE Secret. Values + * are NOT written — operators populate `SecretString`/`SecretBinary` + * via the AWS console / CLI, same security tradeoff as GCP. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.secretsmanager.secret'; +const SDK = '@aws-sdk/client-secrets-manager'; + +export const secrets_manager_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('secrets-manager') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'Secrets Manager', SDK); + + try { + const sm = await load_aws_sdk(SDK); + if (!sm) return sdkMissing(name, TYPE, 'create', start, 'Secrets Manager', SDK); + + const created = await client.send( + new sm.CreateSecretCommand({ + Name: name, + Description: (properties.description as string) || 'Auto-created by ICE', + KmsKeyId: (properties.kms_key_id as string) || undefined, + Tags: properties.tags + ? Object.entries(properties.tags as Record).map(([Key, Value]) => ({ Key, Value })) + : undefined, + }), + ); + const arn = created?.ARN || `arn:aws:secretsmanager:${ctx.region}:*:secret:${name}`; + return ok(name, TYPE, 'create', start, { provider_id: arn }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('secrets-manager') as any; + if (!client) return err(name, TYPE, 'update', start, 'Secrets Manager SDK not available'); + + try { + const sm = await load_aws_sdk(SDK); + if (!sm) return err(name, TYPE, 'update', start, 'Secrets Manager SDK not available'); + + // Description + KMS key are the only fields safe to update from + // the canvas — rotation is operator-managed; tags are best-effort. + await client.send( + new sm.UpdateSecretCommand({ + SecretId: provider_id, + Description: (properties.description as string) || undefined, + KmsKeyId: (properties.kms_key_id as string) || undefined, + }), + ); + return ok(name, TYPE, 'update', start, { provider_id }); + } catch (error) { + return err(name, TYPE, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('secrets-manager') as any; + if (!client) return err(name, TYPE, 'delete', start, 'Secrets Manager SDK not available'); + + try { + const sm = await load_aws_sdk(SDK); + if (!sm) return err(name, TYPE, 'delete', start, 'Secrets Manager SDK not available'); + + // ForceDeleteWithoutRecovery=true skips the default 30-day + // recovery window — appropriate when an ICE deploy is the + // source of truth and the operator explicitly removed the + // binding from the canvas. + await client.send( + new sm.DeleteSecretCommand({ SecretId: provider_id || name, ForceDeleteWithoutRecovery: true }), + ); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/sns.ts b/packages/core/src/deploy/providers/aws/handlers/sns.ts new file mode 100644 index 00000000..ca4a7f42 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/sns.ts @@ -0,0 +1,96 @@ +/** + * SNS Handler + * + * Handles: aws.sns.topic + * + * CreateTopic returns the topic ARN as provider_id. FIFO topics need + * the .fifo suffix in the name (AWS enforces); the handler appends + * it when the extractor sets fifo:true. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.sns.topic'; +const SDK = '@aws-sdk/client-sns'; + +function resolve_name(translator_name: string, properties: Record): string { + if (properties.fifo === true && !translator_name.endsWith('.fifo')) return `${translator_name}.fifo`; + return translator_name; +} + +function build_topic_attributes(properties: Record): Record { + const attrs: Record = {}; + if (properties.fifo === true) attrs.FifoTopic = 'true'; + if (properties.display_name) attrs.DisplayName = String(properties.display_name); + if (properties.kms_master_key_id) attrs.KmsMasterKeyId = String(properties.kms_master_key_id); + return attrs; +} + +export const sns_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('sns') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'SNS', SDK); + + try { + const sns = await load_aws_sdk(SDK); + if (!sns) return sdkMissing(name, TYPE, 'create', start, 'SNS', SDK); + + const topicName = resolve_name(name, properties); + const created = await client.send( + new sns.CreateTopicCommand({ + Name: topicName, + Attributes: build_topic_attributes(properties), + Tags: properties.tags + ? Object.entries(properties.tags as Record).map(([Key, Value]) => ({ Key, Value })) + : undefined, + }), + ); + const arn = created?.TopicArn ?? `arn:aws:sns:${ctx.region}:*:${topicName}`; + return ok(name, TYPE, 'create', start, { provider_id: arn }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('sns') as any; + if (!client) return err(name, TYPE, 'update', start, 'SNS SDK not available'); + + try { + const sns = await load_aws_sdk(SDK); + if (!sns) return err(name, TYPE, 'update', start, 'SNS SDK not available'); + + // SNS topic-level attributes are set one at a time via + // SetTopicAttributes — issue one call per non-empty attribute. + const attrs = build_topic_attributes(properties); + // FifoTopic can't change after creation; skip it on update. + delete attrs.FifoTopic; + for (const [AttributeName, AttributeValue] of Object.entries(attrs)) { + await client.send(new sns.SetTopicAttributesCommand({ TopicArn: provider_id, AttributeName, AttributeValue })); + } + return ok(name, TYPE, 'update', start, { provider_id }); + } catch (error) { + return err(name, TYPE, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('sns') as any; + if (!client) return err(name, TYPE, 'delete', start, 'SNS SDK not available'); + + try { + const sns = await load_aws_sdk(SDK); + if (!sns) return err(name, TYPE, 'delete', start, 'SNS SDK not available'); + + await client.send(new sns.DeleteTopicCommand({ TopicArn: provider_id })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/handlers/sqs.ts b/packages/core/src/deploy/providers/aws/handlers/sqs.ts new file mode 100644 index 00000000..dc79cc1a --- /dev/null +++ b/packages/core/src/deploy/providers/aws/handlers/sqs.ts @@ -0,0 +1,97 @@ +/** + * SQS Handler + * + * Handles: aws.sqs.queue + * + * CreateQueue → SetQueueAttributes (if needed) → returns the QueueUrl + * as provider_id. FIFO queues require the `.fifo` suffix in the name + * (AWS enforces this); the handler appends it when the extractor + * marks fifo:true. + */ + +import { load_aws_sdk } from '../sdk-loader'; +import { err, ok, sdkMissing } from './_result'; +import type { AWSResourceHandler } from '../types'; + +const TYPE = 'aws.sqs.queue'; +const SDK = '@aws-sdk/client-sqs'; + +function build_queue_attributes(properties: Record): Record { + const attrs: Record = {}; + if (typeof properties.message_retention_seconds === 'number') + attrs.MessageRetentionPeriod = String(properties.message_retention_seconds); + if (typeof properties.visibility_timeout_seconds === 'number') + attrs.VisibilityTimeout = String(properties.visibility_timeout_seconds); + if (typeof properties.delay_seconds === 'number') attrs.DelaySeconds = String(properties.delay_seconds); + if (properties.fifo === true) { + attrs.FifoQueue = 'true'; + if (properties.content_based_deduplication === true) attrs.ContentBasedDeduplication = 'true'; + } + return attrs; +} + +function resolve_name(translator_name: string, properties: Record): string { + if (properties.fifo === true && !translator_name.endsWith('.fifo')) return `${translator_name}.fifo`; + return translator_name; +} + +export const sqs_handler: AWSResourceHandler = { + async create(name, properties, ctx) { + const start = Date.now(); + const client = ctx.clients.get('sqs') as any; + if (!client) return sdkMissing(name, TYPE, 'create', start, 'SQS', SDK); + + try { + const sqs = await load_aws_sdk(SDK); + if (!sqs) return sdkMissing(name, TYPE, 'create', start, 'SQS', SDK); + + const queueName = resolve_name(name, properties); + const created = await client.send( + new sqs.CreateQueueCommand({ + QueueName: queueName, + Attributes: build_queue_attributes(properties), + tags: properties.tags as Record, + }), + ); + const url = created?.QueueUrl ?? `https://sqs.${ctx.region}.amazonaws.com/*/${queueName}`; + return ok(name, TYPE, 'create', start, { provider_id: url }); + } catch (error) { + return err(name, TYPE, 'create', start, error instanceof Error ? error.message : String(error)); + } + }, + + async update(name, provider_id, properties, _current, ctx) { + const start = Date.now(); + const client = ctx.clients.get('sqs') as any; + if (!client) return err(name, TYPE, 'update', start, 'SQS SDK not available'); + + try { + const sqs = await load_aws_sdk(SDK); + if (!sqs) return err(name, TYPE, 'update', start, 'SQS SDK not available'); + + const attrs = build_queue_attributes(properties); + if (Object.keys(attrs).length > 0) { + await client.send(new sqs.SetQueueAttributesCommand({ QueueUrl: provider_id, Attributes: attrs })); + } + return ok(name, TYPE, 'update', start, { provider_id }); + } catch (error) { + return err(name, TYPE, 'update', start, error instanceof Error ? error.message : String(error)); + } + }, + + async delete(name, provider_id, ctx) { + const start = Date.now(); + const client = ctx.clients.get('sqs') as any; + if (!client) return err(name, TYPE, 'delete', start, 'SQS SDK not available'); + + try { + const sqs = await load_aws_sdk(SDK); + if (!sqs) return err(name, TYPE, 'delete', start, 'SQS SDK not available'); + + await client.send(new sqs.DeleteQueueCommand({ QueueUrl: provider_id })); + return ok(name, TYPE, 'delete', start); + } catch (error) { + return err(name, TYPE, 'delete', start, error instanceof Error ? error.message : String(error)); + } + }, +}; diff --git a/packages/core/src/deploy/providers/aws/iam-roles.ts b/packages/core/src/deploy/providers/aws/iam-roles.ts new file mode 100644 index 00000000..1644cc45 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/iam-roles.ts @@ -0,0 +1,99 @@ +/** + * IAM role bootstrap helper used by the ECS handler (commit #23) to + * ensure the default ecsTaskExecutionRole exists before service + * creation. Idempotent — checks for the role first and only creates + * it on miss. + * + * AWS's recommended default trust + managed policy attachment: + * - Trust: ecs-tasks.amazonaws.com + * - Policy: AmazonECSTaskExecutionRolePolicy (managed) + * + * Any future ICE-managed default role (Lambda execution role, etc.) + * goes through the same ensureManagedRole pattern below. + */ + +import { load_aws_sdk } from './sdk-loader'; + +const DEFAULT_ECS_TASK_ROLE = 'ecsTaskExecutionRole'; +const DEFAULT_ECS_TASK_TRUST_POLICY = JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { Service: 'ecs-tasks.amazonaws.com' }, + Action: 'sts:AssumeRole', + }, + ], +}); +const DEFAULT_ECS_TASK_MANAGED_POLICY_ARN = 'arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy'; + +/** + * Ensure an IAM role exists with the given trust policy + a managed + * policy attached. Returns the role ARN. Idempotent: getRole-first, + * createRole on NoSuchEntity. AttachRolePolicy is best-effort — + * already-attached policies return success. + */ +export async function ensureManagedRole( + region: string, + role_name: string, + trust_policy_json: string, + managed_policy_arn: string, +): Promise { + const iam = await load_aws_sdk('@aws-sdk/client-iam'); + if (!iam) throw new Error('AWS IAM SDK not available — install @aws-sdk/client-iam'); + + const client = new iam.IAMClient({ region }); + try { + // 1. Try fetching the role — happy path returns its ARN. + try { + const got = await client.send(new iam.GetRoleCommand({ RoleName: role_name })); + if (got?.Role?.Arn) return got.Role.Arn; + } catch (error) { + const err = error as { name?: string; Code?: string }; + const code = err.name || err.Code || ''; + if (code !== 'NoSuchEntityException' && code !== 'NoSuchEntity') throw error; + // Falls through to create. + } + + // 2. Create the role. + const created = await client.send( + new iam.CreateRoleCommand({ + RoleName: role_name, + AssumeRolePolicyDocument: trust_policy_json, + Description: 'Auto-created by ICE', + Path: '/', + }), + ); + const arn = created?.Role?.Arn; + if (!arn) throw new Error(`CreateRole returned no ARN for ${role_name}`); + + // 3. Attach the managed policy. AlreadyAttached returns success; + // any other error is fatal (the role exists but isn't usable). + try { + await client.send(new iam.AttachRolePolicyCommand({ RoleName: role_name, PolicyArn: managed_policy_arn })); + } catch (error) { + const err = error as { name?: string; Code?: string }; + const code = err.name || err.Code || ''; + if (code !== 'EntityAlreadyExistsException') throw error; + } + + return arn; + } finally { + if (typeof (client as { destroy?: () => void }).destroy === 'function') { + (client as { destroy: () => void }).destroy(); + } + } +} + +/** + * Convenience for the most common case — the ECS task execution role. + * Returns the ARN every Fargate task definition needs in `executionRoleArn`. + */ +export async function ensureEcsTaskExecutionRole(region: string): Promise { + return ensureManagedRole( + region, + DEFAULT_ECS_TASK_ROLE, + DEFAULT_ECS_TASK_TRUST_POLICY, + DEFAULT_ECS_TASK_MANAGED_POLICY_ARN, + ); +} diff --git a/packages/core/src/deploy/providers/aws/index.ts b/packages/core/src/deploy/providers/aws/index.ts new file mode 100644 index 00000000..49d62eac --- /dev/null +++ b/packages/core/src/deploy/providers/aws/index.ts @@ -0,0 +1,11 @@ +/** + * AWS Deployer Module + * + * Re-exports the modular AWS deployer and types. + */ + +export { AWSDeployer, create_aws_deployer } from './aws-deployer'; +export type { AWSHandlerContext, AWSResourceHandler } from './types'; +export { load_aws_sdk, initialize_aws_clients, destroy_aws_clients } from './sdk-loader'; +export { create_account_id_resolver, type AccountIdResolver } from './account'; +export { ensureManagedRole, ensureEcsTaskExecutionRole } from './iam-roles'; diff --git a/packages/core/src/deploy/providers/aws/sdk-loader.ts b/packages/core/src/deploy/providers/aws/sdk-loader.ts new file mode 100644 index 00000000..70fc0282 --- /dev/null +++ b/packages/core/src/deploy/providers/aws/sdk-loader.ts @@ -0,0 +1,117 @@ +/** + * AWS SDK Lazy Loader + * + * Centralised lazy loading of `@aws-sdk/client-*` packages. Uses the + * `Function('m', 'return import(m)')` indirection so bundlers don't + * try to resolve the optional SDK packages at build time — packages + * absent from the install footprint fall through to `null` and the + * caller emits a friendly "SDK not installed" message. + * + * Parallel to `../gcp/sdk-loader.ts`. New SDK packages get an entry + * in `initialize_aws_clients` keyed by AWS service short-name; that + * key is what handlers ask for via `ctx.clients.get('')`. + */ + +/** + * Dynamically import an AWS SDK package. Returns null when the + * package isn't installed (the cross-cloud test harness intercepts + * this same pattern via a Function-constructor stub). + */ +export async function load_aws_sdk(module_name: string): Promise { + try { + return await Function('m', 'return import(m)')(module_name); + } catch { + return null; + } +} + +/** + * Initialise every AWS SDK client that's installed. + * + * Per-service short-name → constructor in this table is the schema + * the rest of the deployer reads. Handlers index the resulting Map + * by short-name (`ctx.clients.get('s3')`). Missing SDK packages are + * silently skipped — handlers detect absence and return a clean + * "install the package" message. + */ +export async function initialize_aws_clients(region: string): Promise> { + const clients = new Map(); + + const ec2 = await load_aws_sdk('@aws-sdk/client-ec2'); + if (ec2) clients.set('ec2', new ec2.EC2Client({ region })); + + const s3 = await load_aws_sdk('@aws-sdk/client-s3'); + if (s3) clients.set('s3', new s3.S3Client({ region })); + + const lambda = await load_aws_sdk('@aws-sdk/client-lambda'); + if (lambda) clients.set('lambda', new lambda.LambdaClient({ region })); + + const cwl = await load_aws_sdk('@aws-sdk/client-cloudwatch-logs'); + if (cwl) clients.set('cloudwatch-logs', new cwl.CloudWatchLogsClient({ region })); + + const sm = await load_aws_sdk('@aws-sdk/client-secrets-manager'); + if (sm) clients.set('secrets-manager', new sm.SecretsManagerClient({ region })); + + const sqs = await load_aws_sdk('@aws-sdk/client-sqs'); + if (sqs) clients.set('sqs', new sqs.SQSClient({ region })); + + const sns = await load_aws_sdk('@aws-sdk/client-sns'); + if (sns) clients.set('sns', new sns.SNSClient({ region })); + + const dynamo = await load_aws_sdk('@aws-sdk/client-dynamodb'); + if (dynamo) clients.set('dynamodb', new dynamo.DynamoDBClient({ region })); + + const ec = await load_aws_sdk('@aws-sdk/client-elasticache'); + if (ec) clients.set('elasticache', new ec.ElastiCacheClient({ region })); + + const rds = await load_aws_sdk('@aws-sdk/client-rds'); + if (rds) clients.set('rds', new rds.RDSClient({ region })); + + const docdb = await load_aws_sdk('@aws-sdk/client-docdb'); + if (docdb) clients.set('docdb', new docdb.DocDBClient({ region })); + + const cognito = await load_aws_sdk('@aws-sdk/client-cognito-identity-provider'); + if (cognito) clients.set('cognito', new cognito.CognitoIdentityProviderClient({ region })); + + const cf = await load_aws_sdk('@aws-sdk/client-cloudfront'); + if (cf) clients.set('cloudfront', new cf.CloudFrontClient({ region })); + + const elb = await load_aws_sdk('@aws-sdk/client-elastic-load-balancing-v2'); + if (elb) clients.set('elbv2', new elb.ElasticLoadBalancingV2Client({ region })); + + const api = await load_aws_sdk('@aws-sdk/client-api-gateway'); + if (api) clients.set('apigateway', new api.APIGatewayClient({ region })); + + const ev = await load_aws_sdk('@aws-sdk/client-eventbridge'); + if (ev) clients.set('eventbridge', new ev.EventBridgeClient({ region })); + + const ecs = await load_aws_sdk('@aws-sdk/client-ecs'); + if (ecs) clients.set('ecs', new ecs.ECSClient({ region })); + + const os = await load_aws_sdk('@aws-sdk/client-opensearch'); + if (os) clients.set('opensearch', new os.OpenSearchClient({ region })); + + const bedrock = await load_aws_sdk('@aws-sdk/client-bedrock'); + if (bedrock) clients.set('bedrock', new bedrock.BedrockClient({ region })); + + const sagemaker = await load_aws_sdk('@aws-sdk/client-sagemaker'); + if (sagemaker) clients.set('sagemaker', new sagemaker.SageMakerClient({ region })); + + const redshift = await load_aws_sdk('@aws-sdk/client-redshift'); + if (redshift) clients.set('redshift', new redshift.RedshiftClient({ region })); + + return clients; +} + +/** + * Tear down every client in `clients` that exposes a `.destroy()` + * method. Idempotent; safe to call when some clients never received + * SDK loading. + */ +export function destroy_aws_clients(clients: Map): void { + for (const client of clients.values()) { + if (client && typeof (client as { destroy?: () => void }).destroy === 'function') { + (client as { destroy: () => void }).destroy(); + } + } +} diff --git a/packages/core/src/deploy/providers/aws/types.ts b/packages/core/src/deploy/providers/aws/types.ts new file mode 100644 index 00000000..eb1b47eb --- /dev/null +++ b/packages/core/src/deploy/providers/aws/types.ts @@ -0,0 +1,74 @@ +/** + * AWS Deployer Types + * + * Shared interfaces for all AWS resource handlers. Parallel to the + * GCP equivalents in `../gcp/types.ts`. Adopting the same shape lets + * the AWS deployer benefit from the same per-handler patterns: + * + * - lazy SDK client pool (clients fetched once, reused per deploy) + * - sub-step progress reporting for long-running creates + * - user-cancel via AbortSignal + * - on_log callback for handlers that stream provider-side output + */ + +import type { AccountIdResolver } from './account'; +import type { ResourceDeployResult } from '../../types'; + +/** + * Context passed to every AWS resource handler. + */ +export interface AWSHandlerContext { + /** Default AWS region (e.g. `us-east-1`). Single-region deploys today. */ + region: string; + /** + * Lazy-loaded SDK clients keyed by AWS service short-name (`ec2`, + * `s3`, `lambda`, `rds`, …). Handlers `ctx.clients.get('s3')` to + * read theirs. Returns `undefined` when the SDK package isn't + * installed — handlers must guard with a friendly error. + */ + clients: Map; + /** + * Memoised AWS account id (via STS GetCallerIdentity). Fetched on + * first call and cached for the deploy's lifetime. Used by S3 to + * suffix bucket names + by ECS to build ecsTaskExecutionRole ARNs. + * Throws when the STS SDK isn't installed. + */ + ensure_account_id: AccountIdResolver; + /** Optional log callback for progress messages. */ + on_log?: (message: string) => void; + /** + * Optional sub-step progress reporter. Handlers that chain multiple + * long-running AWS operations (RDS provisioning, CloudFront, etc.) + * call this between sub-operations so the UI can show fractional + * progress instead of a 0 → 100% jump. + */ + on_step?: (resource: string, step: { label: string; index: number; total: number }) => void; + /** + * User-cancel signal from the per-card deploy lock. Handlers with + * long polls (RDS create polling, CloudFront distribution propagation, + * etc.) honour this so a cancel actually stops the remote work. + */ + abort_signal?: AbortSignal; +} + +/** + * Interface every AWS resource handler implements. Mirrors + * `GCPResourceHandler` so a future shared dispatch surface can treat + * both providers uniformly. + */ +export interface AWSResourceHandler { + /** Create a new resource. Returns the deploy result with `provider_id`. */ + create(name: string, properties: Record, ctx: AWSHandlerContext): Promise; + + /** Update an existing resource. */ + update( + name: string, + provider_id: string, + properties: Record, + current_properties: Record, + ctx: AWSHandlerContext, + ): Promise; + + /** Delete a resource. */ + delete(name: string, provider_id: string, ctx: AWSHandlerContext): Promise; +} diff --git a/packages/core/src/deploy/providers/gcp/handlers/secret-manager.ts b/packages/core/src/deploy/providers/gcp/handlers/secret-manager.ts index 24a2aa33..34b337b3 100644 --- a/packages/core/src/deploy/providers/gcp/handlers/secret-manager.ts +++ b/packages/core/src/deploy/providers/gcp/handlers/secret-manager.ts @@ -2,6 +2,18 @@ * Secret Manager Handler * * Handles: gcp.secretmanager.secret + * + * Translator expansion: one Secret Store block emits one of these per + * binding row (see `card-translator.ts` Security.Secret expansion). The + * resource `name` is the upstream ref (`ref || key` from the binding), + * so service `secretRefs` entries — wired by the canvas propagation + * rules — resolve against the same id GCP knows. + * + * Values are NOT written. This handler creates the parent `Secret` + * resource only; `SecretVersion`s must be populated by the operator in + * the GCP console / IaC. That keeps actual secret values out of the + * ICE project file (and out of the canvas), which is the security + * tradeoff we want. */ import { SERVICE_NAMES, sdk_not_available, sdk_not_available_short } from '../messages'; diff --git a/packages/core/src/deploy/self-serving-resources.ts b/packages/core/src/deploy/self-serving-resources.ts new file mode 100644 index 00000000..3696a08c --- /dev/null +++ b/packages/core/src/deploy/self-serving-resources.ts @@ -0,0 +1,28 @@ +/** + * Provider resource types that serve public traffic on their own and + * therefore need NO load-balancer chain in front of them. The + * endpoint-wiring pass propagates the upstream PublicEndpoint's domain + * onto these nodes but skips backend bucket / backend service + * synthesis entirely; if every backend behind a PublicEndpoint turns + * out to be self-serving, the forwarding rule itself is removed (no + * point provisioning an empty LB). + * + * Cardinal-rule schema-driven: keyed by resolved provider resource + * type (the same key shape used by `INTERNAL_INGRESS_OVERRIDES` and + * the extractor / handler tables). Adding a new self-serving resource + * on any provider — AWS Amplify, Azure Static Web Apps, Vercel-style + * managed front-ends — adds one entry; the pass stays unchanged. + */ + +export const SELF_SERVING_PUBLIC_RESOURCES: ReadonlySet = new Set([ + // GCP — Firebase Hosting gives a public HTTPS URL out of the box + // with its own CDN + managed cert + optional custom domain. + 'gcp.firebase.hosting', + // Future entries: + // 'aws.amplify.app' + // 'azure.staticwebapps.staticSite' +]); + +export function isSelfServingPublicResource(resourceType: string): boolean { + return SELF_SERVING_PUBLIC_RESOURCES.has(resourceType); +} diff --git a/packages/core/src/deploy/type-maps.ts b/packages/core/src/deploy/type-maps.ts index d0da2f5f..8341fb72 100644 --- a/packages/core/src/deploy/type-maps.ts +++ b/packages/core/src/deploy/type-maps.ts @@ -47,6 +47,12 @@ export const GCP_TYPE_MAP: Record = { // are set, and the URL map host rules are populated from each // outgoing edge's `subdomain` field. 'Network.PublicEndpoint': 'gcp.compute.globalForwardingRule', + // `Network.CustomDomain` resolves to the same forwarding-rule chain + // as PublicEndpoint when nested inside a PrivateNetwork (it acts as + // that network's public gateway). Standalone CD is skipped before + // this lookup by `isStandaloneMetadataOnly` — only the nested case + // ever needs the mapping. + 'Network.CustomDomain': 'gcp.compute.globalForwardingRule', // `Network.PrivateNetwork` is the user-facing "private network" block: // one group on the canvas that wraps the services we want isolated. // Compiles to an auto-mode VPC (`autoCreateSubnetworks: true`) so the @@ -94,6 +100,9 @@ export const AWS_TYPE_MAP: Record = { 'Storage.ObjectStorage': 'aws.s3.bucket', 'Network.Gateway': 'aws.apigateway.restApi', 'Network.PublicEndpoint': 'aws.cloudfront.distribution', + // Nested CustomDomain mirrors PublicEndpoint per the same rationale + // as the GCP map above. + 'Network.CustomDomain': 'aws.cloudfront.distribution', 'Network.LoadBalancer': 'aws.elbv2.loadBalancer', 'Messaging.Queue': 'aws.sqs.queue', 'Messaging.Topic': 'aws.sns.topic', @@ -128,6 +137,9 @@ export const AZURE_TYPE_MAP: Record = { 'Storage.ObjectStorage': 'azure.storage.storageAccount', 'Network.Gateway': 'azure.apimanagement.service', 'Network.PublicEndpoint': 'azure.cdn.profile', + // Nested CustomDomain mirrors PublicEndpoint per the same rationale + // as the GCP map above. + 'Network.CustomDomain': 'azure.cdn.profile', 'Network.LoadBalancer': 'azure.network.loadBalancer', 'Messaging.Queue': 'azure.servicebus.queue', 'Messaging.Topic': 'azure.servicebus.topic', diff --git a/packages/core/src/resources/high-level-resources.ts b/packages/core/src/resources/high-level-resources.ts index 817780cf..6b8406d2 100644 --- a/packages/core/src/resources/high-level-resources.ts +++ b/packages/core/src/resources/high-level-resources.ts @@ -26,6 +26,7 @@ // ─── Type re-exports ──────────────────────────────────────────────────────── export type { + DeployExpansion, HighLevelCategory, HighLevelProperty, HighLevelResource, @@ -43,5 +44,6 @@ export { getBehaviorColor, getBehaviorLabel, getGCPCloudAssetTypes, + getHighLevelResourceByIceType, getHighLevelResourcesForPalette, } from './high-level-resources/helpers'; diff --git a/packages/core/src/resources/high-level-resources/categories/compute.ts b/packages/core/src/resources/high-level-resources/categories/compute.ts index 2067fa05..7124bb47 100644 --- a/packages/core/src/resources/high-level-resources/categories/compute.ts +++ b/packages/core/src/resources/high-level-resources/categories/compute.ts @@ -336,6 +336,16 @@ export const compute: HighLevelCategory = { description: 'Scale up when the metric exceeds this percentage', default: 70, }, + { + name: 'exposed_ports', + label: 'Exposed ports', + type: 'port_list', + required: false, + tier: 'detailed', + description: + 'HTTP/TCP listeners this API exposes. Each port becomes a typed output socket on the canvas — wire a custom domain to one and leave the others private.', + addLabel: 'Add port', + }, ], }, { @@ -966,6 +976,16 @@ export const compute: HighLevelCategory = { description: 'Scale up when the metric exceeds this percentage', default: 70, }, + { + name: 'exposed_ports', + label: 'Exposed ports', + type: 'port_list', + required: false, + tier: 'detailed', + description: + 'HTTP/TCP listeners this container exposes. Each port becomes a typed output socket on the canvas — wire a custom domain to one and leave the others private.', + addLabel: 'Add port', + }, ], }, { diff --git a/packages/core/src/resources/high-level-resources/categories/security.ts b/packages/core/src/resources/high-level-resources/categories/security.ts index 2589ce62..44a3002f 100644 --- a/packages/core/src/resources/high-level-resources/categories/security.ts +++ b/packages/core/src/resources/high-level-resources/categories/security.ts @@ -25,6 +25,16 @@ export const security: HighLevelCategory = { resources: [ { id: 'secret-store', + iceType: 'Security.Secret', + // Declarative deploy-time expansion: one cloud secret per binding, + // not one stub per block. Provider-agnostic — extractors/handlers + // own the per-provider resource shape. See `DeployExpansion`. + deployExpansion: { + partitionBy: 'bindings', + nameFrom: { field: 'ref', fallback: 'key' }, + labelFrom: 'key', + tagPerEntry: { labelKey: 'ice-secret-key', fromField: 'key' }, + }, name: 'Secret Store', description: 'Securely store API keys and credentials', icon: 'Key', @@ -57,31 +67,28 @@ export const security: HighLevelCategory = { properties: [ { name: 'name', - label: 'Name', + label: 'Store name', type: 'string', required: true, tier: 'essential', - description: 'A friendly name for this secret', - placeholder: 'My Secret', + description: 'A friendly name for this secret store', + placeholder: 'My Secrets', }, { name: 'secrets', - label: 'Secret values', - type: 'list', + label: 'Secret bindings', + type: 'secret_bindings', required: false, tier: 'essential', - description: 'The secret key-value pairs to store', - placeholder: 'e.g. STRIPE_API_KEY', - addLabel: 'Add a secret', - }, - { - name: 'auto_rotate', - label: 'Auto-rotate?', - type: 'boolean', - required: false, - tier: 'detailed', - description: 'Automatically change this secret on a schedule for better security', - default: false, + // The block does NOT store secret values — values live in the + // upstream secret manager (Secrets Manager / Secret Manager / + // Key Vault). Each row binds an env-var name (`key`, e.g. + // `STRIPE_API_KEY`) to a secret entry there (`ref`, e.g. + // `prod-stripe-key`). Wiring this block to a service injects + // those env vars at runtime. + description: + 'Bind env var names to entries in your cloud secret manager. Values are managed there, not here.', + addLabel: 'Add a binding', }, ], }, diff --git a/packages/core/src/resources/high-level-resources/helpers.ts b/packages/core/src/resources/high-level-resources/helpers.ts index 78b0fa47..e298119a 100644 --- a/packages/core/src/resources/high-level-resources/helpers.ts +++ b/packages/core/src/resources/high-level-resources/helpers.ts @@ -47,6 +47,33 @@ export function getAllHighLevelResources(): HighLevelResource[] { return HIGH_LEVEL_CATEGORIES.flatMap((cat) => cat.resources); } +// Lazy iceType → resource index. Built once on first lookup and cached +// thereafter — `HIGH_LEVEL_CATEGORIES` is a static module-level constant +// so the map is safe to cache for the lifetime of the process. +let HIGH_LEVEL_BY_ICE_TYPE: Map | null = null; + +function buildIceTypeIndex(): Map { + const map = new Map(); + for (const resource of getAllHighLevelResources()) { + if (resource.iceType) map.set(resource.iceType, resource); + } + return map; +} + +/** + * Look up the canonical `HighLevelResource` by iceType. + * + * The translator (and any other cross-cutting layer) uses this to read + * schema-declared deploy semantics like `deployExpansion` WITHOUT + * hardcoding iceType-specific branches. Resources that don't set + * `iceType` on the schema return `undefined` here. + */ +export function getHighLevelResourceByIceType(iceType: string): HighLevelResource | undefined { + if (!iceType) return undefined; + if (!HIGH_LEVEL_BY_ICE_TYPE) HIGH_LEVEL_BY_ICE_TYPE = buildIceTypeIndex(); + return HIGH_LEVEL_BY_ICE_TYPE.get(iceType); +} + /** * Get resources formatted for the palette */ diff --git a/packages/core/src/resources/high-level-resources/types.ts b/packages/core/src/resources/high-level-resources/types.ts index c210a95d..04eb3ca5 100644 --- a/packages/core/src/resources/high-level-resources/types.ts +++ b/packages/core/src/resources/high-level-resources/types.ts @@ -27,6 +27,15 @@ export interface HighLevelResource { description: string; icon: string; category: string; + /** + * Canonical ICE type (e.g. `Security.Secret`). Optional so resources + * without a canvas block (raw catalog-only entries) stay valid, but + * REQUIRED for anything the deploy translator needs to look up by + * iceType — including any resource that declares `deployExpansion`. + * Source of truth for the iceType↔resource mapping; blueprints in + * `@ice/blocks` reference this transitively via `resourceId`. + */ + iceType?: string; // Node behavior type behavior: NodeBehavior; // Which providers support this resource @@ -37,6 +46,44 @@ export interface HighLevelResource { keywords: string[]; // Common properties users care about properties: HighLevelProperty[]; + /** + * Declarative deploy-time cardinality. When set, the card translator + * emits ONE provider resource per entry in `properties[]` + * (which the extractor pulled from `node.data`) instead of the default + * one-resource-per-block. Provider-shaped fields stay untouched — + * extractor output is forwarded verbatim to each emitted resource — + * so this metadata is provider-agnostic and lives on the canonical + * schema, not in the translator or a provider file. + * + * Cardinal rule: cross-cutting layers (translator, dispatcher) MUST + * read this from the schema. NEVER hardcode `if (iceType === 'X')` + * branches. + */ + deployExpansion?: DeployExpansion; +} + +/** + * Declarative 1→N expansion at deploy time. The translator partitions + * `properties[partitionBy]` (the extractor's output array) and emits one + * cloud resource per entry, with the resource name derived from the + * entry's `nameFrom.field` (falling back to `nameFrom.fallback`). + * + * Optional bookkeeping: + * - `labelFrom`: which entry field is appended to the deployable's + * human label (`" · "`) so the plan UI reads + * well. + * - `tagPerEntry`: copies one entry field into a cloud label on each + * emitted resource (e.g. `ice-secret-key: STRIPE_API_KEY`) so the + * resource → binding mapping survives in the cloud console. + * + * Dedupes within a block AND across blocks by resolved name — two rows + * pointing at the same upstream entry share one cloud resource. + */ +export interface DeployExpansion { + partitionBy: string; + nameFrom: { field: string; fallback?: string }; + labelFrom?: string; + tagPerEntry?: { labelKey: string; fromField: string }; } /** @@ -68,8 +115,22 @@ export interface HighLevelProperty { * - `list`: generic string list with add/remove * - `queue_list`: bespoke queue renderer — each item shows as a queue pill * with a distinct icon, FIFO badge, and queue-semantic affordances + * - `port_list`: list of HTTP/TCP listeners on a service. Each entry + * becomes a typed `http-endpoint` OUT port on the canvas, so a + * user can wire an EC2-style block's port 8080 to a custom domain + * while leaving port 443 free. */ - type: 'string' | 'number' | 'boolean' | 'select' | 'list' | 'queue_list' | 'task_list'; + type: + | 'string' + | 'number' + | 'boolean' + | 'select' + | 'list' + | 'queue_list' + | 'task_list' + | 'port_list' + /** Two-input rows binding an env-var name to an upstream secret ref. */ + | 'secret_bindings'; required: boolean; description: string; options?: string[]; diff --git a/packages/core/src/resources/index.ts b/packages/core/src/resources/index.ts index 30a60dd8..43b5070e 100644 --- a/packages/core/src/resources/index.ts +++ b/packages/core/src/resources/index.ts @@ -29,10 +29,12 @@ export { export { HIGH_LEVEL_CATEGORIES, getAllHighLevelResources, + getHighLevelResourceByIceType, getHighLevelResourcesForPalette, filterResourcesByProvider, getBehaviorLabel, getBehaviorColor, + type DeployExpansion, type HighLevelResource, type HighLevelProperty, type HighLevelCategory, diff --git a/packages/types/src/connection-rules.ts b/packages/types/src/connection-rules.ts index 500f0d05..20686db5 100644 --- a/packages/types/src/connection-rules.ts +++ b/packages/types/src/connection-rules.ts @@ -45,6 +45,7 @@ import { isVectorDb, isLLM, isRepo, + isReroute, isEnvConfig, isDomain, isCustomDomain, @@ -88,6 +89,7 @@ export { isVectorDb, isLLM, isRepo, + isReroute, isEnvConfig, isDomain, isCustomDomain, diff --git a/packages/types/src/connection-rules/predicates.ts b/packages/types/src/connection-rules/predicates.ts index bd0cc68e..60dab292 100644 --- a/packages/types/src/connection-rules/predicates.ts +++ b/packages/types/src/connection-rules/predicates.ts @@ -6,88 +6,84 @@ * logical group (database, cache, queue, ...). The CONNECTION_RULES * array composes these predicates into source/target classifiers. * - * Extracted from `connection-rules.ts` in rf-conn-2. The regex bodies - * are copied byte-identical from the original — adding or removing a - * single alternation here has shipped behavioral consequences in - * every caller (canConnect, validateConnection, AI prompt). Touch - * with care. + * Cardinal-rule schema-driven: every predicate body is now a one-line + * lookup against `hasBlockRole` (defined in `@ice/constants/block- + * classifiers.ts`). The shared role tables there are the single + * source of truth for "what role does this iceType play?". Adding or + * removing a single alternation only requires editing the role table. + * + * `@ice/core/compute/propagation-rules.ts` reads the same tables, so + * the two packages can no longer drift apart. */ -import { NETWORK_CONTAINER_TYPES } from '@ice/constants'; +import { hasBlockRole, NETWORK_CONTAINER_TYPES } from '@ice/constants'; export function isDatabase(t: string): boolean { - return ( - t.startsWith('Database.') || - /PostgreSQL|MySQL|MongoDB|DynamoDB|Firestore|CosmosDB|AutonomousDB|Tablestore|ManagedDB/i.test(t) - ); + return hasBlockRole(t, 'database'); } export function isCache(t: string): boolean { - return /Redis|Cache|Memcache/i.test(t); + return hasBlockRole(t, 'cache'); } export function isQueue(t: string): boolean { - return t.startsWith('Messaging.') || /Queue|SQS|SNS|PubSub|ServiceBus|RabbitMQ|Kafka|Event/i.test(t); + return hasBlockRole(t, 'queue'); } export function isStorage(t: string): boolean { - return t.startsWith('Storage.') || /Bucket|S3|GCS|Blob|ObjectStorage|Spaces/i.test(t); + return hasBlockRole(t, 'storage'); } export function isBackend(t: string): boolean { - return ( - /Backend|Container|Worker|Function|CronJob|Scheduled|AppPlatform|OCIFunctions/i.test(t) || - t.startsWith('Compute.') || - t.startsWith('Compute.') - ); + return hasBlockRole(t, 'backend'); } export function isFrontend(t: string): boolean { - return /StaticSite|SSRSite|Frontend/i.test(t); + return hasBlockRole(t, 'frontend'); } export function isGateway(t: string): boolean { - return /Gateway|LoadBalancer|Internet|WAF/i.test(t) || t === 'Network.Gateway'; + return hasBlockRole(t, 'gateway'); } export function isAuth(t: string): boolean { - return /Auth|Identity|IAM/i.test(t) || t === 'Security.Identity'; + return hasBlockRole(t, 'auth'); } export function isSecrets(t: string): boolean { - return /Secret|Vault|Certificate/i.test(t) || t === 'Security.Secret'; + return hasBlockRole(t, 'secrets'); } export function isMonitoring(t: string): boolean { - return /Log|Monitor|Observability|Terminal/i.test(t) || t.startsWith('Monitoring.') || t.startsWith('Log.'); + return hasBlockRole(t, 'monitoring'); } export function isSearch(t: string): boolean { - return /Search|Elasticsearch/i.test(t) || t === 'Analytics.Search'; + return hasBlockRole(t, 'search'); } export function isDataWarehouse(t: string): boolean { - return /Warehouse|BigQuery|Redshift|Synapse/i.test(t) || t === 'Analytics.DataWarehouse'; + return hasBlockRole(t, 'dataWarehouse'); } export function isVectorDb(t: string): boolean { - return /VectorDB|Vector/i.test(t) || t === 'AI.VectorDB'; + return hasBlockRole(t, 'vectorDb'); } export function isLLM(t: string): boolean { - return /LLM|ModelServing/i.test(t) || t === 'AI.LLMGateway' || t === 'AI.ModelServing'; + return hasBlockRole(t, 'llm'); } export function isRepo(t: string): boolean { - return t === 'Source.Repository'; + return hasBlockRole(t, 'repo'); } export function isEnvConfig(t: string): boolean { - return t === 'Config.Environment'; + return hasBlockRole(t, 'envConfig'); } export function isDomain(t: string): boolean { - return t === 'Network.PublicEndpoint' || t === 'Network.CustomDomain' || /Domain|DNS/i.test(t); + return hasBlockRole(t, 'domain'); } /** @@ -104,7 +100,7 @@ export function isDomain(t: string): boolean { * because the compiler will synthesize the LB. */ export function isCustomDomain(t: string): boolean { - return t === 'Network.CustomDomain'; + return hasBlockRole(t, 'customDomain'); } /** @@ -114,7 +110,17 @@ export function isCustomDomain(t: string): boolean { * ingress. */ export function isPrivateNetwork(t: string): boolean { - return t === 'Network.PrivateNetwork'; + return hasBlockRole(t, 'privateNetwork'); +} + +/** + * `Util.Reroute` is a pass-through routing dot — not a container, not + * an infrastructure resource. It exists purely to let users bend wires + * cleanly. Edges to/from a Reroute inherit the category of the other + * end via the passthrough rule in rules-data. + */ +export function isReroute(t: string): boolean { + return hasBlockRole(t, 'reroute'); } export function isContainer(iceType: string, nodeType?: string): boolean { diff --git a/packages/types/src/connection-rules/rules-data.ts b/packages/types/src/connection-rules/rules-data.ts index 20074ae9..0a9a1dd0 100644 --- a/packages/types/src/connection-rules/rules-data.ts +++ b/packages/types/src/connection-rules/rules-data.ts @@ -31,6 +31,7 @@ import { isMonitoring, isQueue, isRepo, + isReroute, isRoutable, isSearch, isSecrets, @@ -343,6 +344,29 @@ export const CONNECTION_RULES: ConnectionRule[] = [ reverse: true, }, + // ── REROUTE ──────────────────────────────────────────────────────────── + // Pass-through dot. Accepts from / emits to any non-container block. + // The visual category of the wire is inherited from whichever end is + // NOT the reroute — see `reroute-node/passthrough.ts` for the color + // derivation. Without these two entries `canConnect` would reject the + // edge as no-rule. + { + label: 'Anything → Reroute', + source: (t) => !isContainer(t) && !isReroute(t), + target: isReroute, + category: 'traffic', + trafficType: 'request', + lineStyle: 'solid', + }, + { + label: 'Reroute → Anything', + source: isReroute, + target: (t) => !isContainer(t), + category: 'traffic', + trafficType: 'request', + lineStyle: 'solid', + }, + // ── DNS ──────────────────────────────────────────────────────────────── { label: 'Domain → Routable', source: isDomain, target: isRoutable, category: 'dns', lineStyle: 'solid' }, // Reverse: user drags service→domain, we flip @@ -416,5 +440,44 @@ The arrow shows "who initiates." Auto-flip ensures: - Repo is always SOURCE (repo → service) - EnvVars/Secrets is always TARGET (service → config) - Domain is always SOURCE (domain → service) -- Monitoring is always TARGET (service → logs)`; +- Monitoring is always TARGET (service → logs) + +### Port roles (typed sockets) +Every block in the catalog exposes named "ports" anchored to its real +properties — e.g. a Frontend has a 'repository-in' port (wires from a +GitHub repo), a 'domain-in' port (wires from a Custom Domain), and a +'web-out' port (its HTTPS endpoint). When you create an edge, you SHOULD +include explicit \`sourceSocket\` and \`targetSocket\` ids on edge.data +so the canvas snaps the wire to the right dots. + +Common port ids you should target: +- Frontends / Backends (Compute.StaticSite, Compute.SSRSite, + Compute.Container, Compute.BackendAPI, Compute.ServerlessFunction, + Compute.Worker, Compute.CronJob): + - in: \`repository-in\`, \`env-in\`, \`secret-in\`, \`domain-in\`, + \`db-in\`, \`cache-in\`, \`storage-in\`, \`search-in\`, + \`vector-in\`, \`llm-in\`, \`queue-in\` (subscribe) + - out: \`web-out\` (HTTP/HTTPS), \`queue-out\` (publish), \`logs-out\` +- Source.Repository: out \`repository-out\` +- Network.CustomDomain: out \`domain-out\` +- Network.Gateway: in \`upstream-in\`, \`domain-in\`; out \`public-out\` +- Database.PostgreSQL / .MySQL / .MongoDB: out \`db-out\` +- Database.Redis: out \`cache-out\` +- Storage.Bucket: out \`storage-out\` +- Messaging.Queue / .EventStream: in \`queue-in\` (from publishers), out \`queue-out\` (to subscribers) +- Messaging.Email: in \`queue-in\` +- Security.Secret: out \`secret-out\` +- Config.Environment: out \`env-out\` +- Monitoring.Log: in \`logs-in\` +- AI.VectorDB: out \`vector-out\` +- AI.LLMGateway: out \`llm-out\` + +If you omit the port ids the canvas will infer them at render time from +the category, but the edge will appear "loose" until the user touches +it — always emit explicit ids when you know them. + +For multi-port services (Compute.Container, Compute.BackendAPI), the +user may have added \`exposed_ports: [{port, protocol, label}]\` — each +entry becomes a \`port--out\` socket. Pick the one matching the +listener you intend (e.g. \`port-8080-out\` for HTTP :8080).`; } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index bd6b9136..fe5d355a 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -8,3 +8,5 @@ export * from './events'; export * from './ai'; export * from './connection-rules'; export * from './propagation-rules'; +export * from './sockets'; +export * from './ports'; diff --git a/packages/types/src/ports/__tests__/derive.test.ts b/packages/types/src/ports/__tests__/derive.test.ts new file mode 100644 index 00000000..b5b4c78f --- /dev/null +++ b/packages/types/src/ports/__tests__/derive.test.ts @@ -0,0 +1,248 @@ +/** + * Port derivation tests — pinning the user's explicit examples: + * GitHub → Frontend matches by `repository` role, Frontend → Domain + * matches by `domain` role, multi-port blocks emit one port per + * exposed_ports entry, containers expose nothing. + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { getPortsForNode, hasPort, findPort, _resetPortCache, type NodeForPorts } from '../derive'; +import { canPortsConnect } from '../match'; +import type { PortDef } from '../types'; + +beforeEach(() => { + _resetPortCache(); +}); + +function node(iceType: string, extra: Record = {}, type?: string): NodeForPorts { + return { id: 't', data: { iceType, ...extra }, type }; +} + +function ids(ports: PortDef[]): string[] { + return ports.map((p) => p.id); +} + +describe('user example chain — GitHub → Frontend → Domain', () => { + it('Source.Repository exposes a single repository-out', () => { + const ports = getPortsForNode(node('Source.Repository')); + expect(ids(ports)).toEqual(['repository-out']); + expect(ports[0].direction).toBe('out'); + expect(ports[0].role).toBe('repository'); + }); + + it('Compute.StaticSite has matching repository-in (+ env, secret, domain inputs + web/logs outputs)', () => { + const ports = getPortsForNode(node('Compute.StaticSite')); + const portIds = ids(ports); + expect(portIds).toContain('repository-in'); + expect(portIds).toContain('env-in'); + expect(portIds).toContain('secret-in'); + expect(portIds).toContain('domain-in'); + expect(portIds).toContain('web-out'); + expect(portIds).toContain('logs-out'); + }); + + it("Repo's repository-out connects to Frontend's repository-in (role match)", () => { + const repoOut = findPort(node('Source.Repository'), 'repository-out')!; + const frontendIn = findPort(node('Compute.StaticSite'), 'repository-in')!; + expect(canPortsConnect(repoOut, frontendIn)).toBe(true); + }); + + it("Repo's repository-out does NOT connect to Frontend's domain-in (role mismatch)", () => { + const repoOut = findPort(node('Source.Repository'), 'repository-out')!; + const frontendDomainIn = findPort(node('Compute.StaticSite'), 'domain-in')!; + expect(canPortsConnect(repoOut, frontendDomainIn)).toBe(false); + }); + + it("Network.CustomDomain's domain-out connects to Frontend's domain-in", () => { + const domainOut = findPort(node('Network.CustomDomain'), 'domain-out')!; + const frontendDomainIn = findPort(node('Compute.StaticSite'), 'domain-in')!; + expect(canPortsConnect(domainOut, frontendDomainIn)).toBe(true); + }); +}); + +describe('backend wiring — Postgres / Redis / Queue', () => { + it('Database.PostgreSQL.db-out connects to Compute.Container.db-in', () => { + const dbOut = findPort(node('Database.PostgreSQL'), 'db-out')!; + const backendDbIn = findPort(node('Compute.Container'), 'db-in')!; + expect(canPortsConnect(dbOut, backendDbIn)).toBe(true); + }); + + it('Database.Redis exposes a cache-out (not db-out — Redis is a cache)', () => { + const ports = ids(getPortsForNode(node('Database.Redis'))); + expect(ports).toContain('cache-out'); + expect(ports).not.toContain('db-out'); + }); + + it('Backend publishes to Queue: backend.queue-out → queue.queue-in', () => { + const backendQueueOut = findPort(node('Compute.Container'), 'queue-out')!; + const queueIn = findPort(node('Messaging.Queue'), 'queue-in')!; + expect(canPortsConnect(backendQueueOut, queueIn)).toBe(true); + }); + + it('Queue → Backend subscribers: queue.queue-out → backend.queue-in', () => { + const queueOut = findPort(node('Messaging.Queue'), 'queue-out')!; + const backendQueueIn = findPort(node('Compute.Container'), 'queue-in')!; + expect(canPortsConnect(queueOut, backendQueueIn)).toBe(true); + }); +}); + +describe('containers and non-deployables', () => { + it.each(['Network.VPC', 'Network.Subnet', 'Group.Frontend', 'Group.Custom'])('%s emits no ports', (iceType) => { + expect(getPortsForNode(node(iceType))).toEqual([]); + }); + + it('Network.PrivateNetwork has empty base ports (container)', () => { + expect(getPortsForNode(node('Network.PrivateNetwork'))).toEqual([]); + }); + + it('Util.Reroute has any-role in + any-role out so wires pass through', () => { + const ports = getPortsForNode(node('Util.Reroute')); + expect(ports.map((p) => p.role)).toEqual(['any', 'any']); + const passIn = ports[0]; + const dbOut = findPort(node('Database.PostgreSQL'), 'db-out')!; + expect(canPortsConnect(dbOut, passIn)).toBe(true); + }); +}); + +describe('property-anchored IN ports', () => { + it('Frontend domain-in writes to property=custom_domain', () => { + const p = findPort(node('Compute.StaticSite'), 'domain-in')!; + expect(p.property).toBe('custom_domain'); + }); + + it('Frontend repository-in writes to property=repository', () => { + const p = findPort(node('Compute.StaticSite'), 'repository-in')!; + expect(p.property).toBe('repository'); + }); +}); + +describe('peerStyle coloring', () => { + it("Frontend's domain-in carries peerStyle='Network' (reads as Custom Domain)", () => { + expect(findPort(node('Compute.StaticSite'), 'domain-in')?.peerStyle).toBe('Network'); + }); + + it("Frontend's repository-in carries peerStyle='Source'", () => { + expect(findPort(node('Compute.StaticSite'), 'repository-in')?.peerStyle).toBe('Source'); + }); +}); + +describe('hasPort / findPort', () => { + it('hasPort returns true for an existing port', () => { + expect(hasPort(node('Compute.StaticSite'), 'domain-in')).toBe(true); + expect(hasPort(node('Compute.StaticSite'), 'nonexistent')).toBe(false); + }); +}); + +describe('multi-route (Network.CustomDomain routes)', () => { + it('exposes the fallback domain-out when no routes are configured', () => { + const ports = getPortsForNode(node('Network.CustomDomain')); + const ids2 = ids(ports); + expect(ids2).toContain('domain-out'); + expect(ids2.filter((id) => id.startsWith('domain-out-'))).toEqual([]); + }); + + it('emits one socket per route and hides the fallback when routes are set', () => { + const ports = getPortsForNode( + node('Network.CustomDomain', { + routes: [ + { id: 'r1', subdomain: 'api' }, + { id: 'r2', subdomain: 'admin' }, + ], + }), + ); + const ids2 = ids(ports); + expect(ids2).not.toContain('domain-out'); + expect(ids2).toContain('domain-out-r1'); + expect(ids2).toContain('domain-out-r2'); + }); + + it('labels each route socket with the subdomain text', () => { + const ports = getPortsForNode(node('Network.CustomDomain', { routes: [{ id: 'r1', subdomain: 'api' }] })); + const apiPort = ports.find((p) => p.id === 'domain-out-r1'); + expect(apiPort?.label).toBe('api'); + }); + + it('each route socket carries the same peer-kind constraint as the fallback', () => { + const ports = getPortsForNode(node('Network.CustomDomain', { routes: [{ id: 'r1', subdomain: 'api' }] })); + const apiPort = ports.find((p) => p.id === 'domain-out-r1'); + expect(apiPort?.peerKind).toBe('service'); + }); +}); + +describe('multi-port (Compute.Container exposed_ports)', () => { + it('default Container exposes one web-out (HTTPS :8080) when no exposed_ports set', () => { + const ports = getPortsForNode(node('Compute.Container')); + const ids2 = ids(ports); + expect(ids2.filter((id) => id.endsWith('-out'))).toContain('web-out'); + }); + + it('emits one port per exposed_ports entry (JSON form)', () => { + const ports = getPortsForNode( + node('Compute.Container', { + exposed_ports: [ + JSON.stringify({ port: 8080, protocol: 'http', label: 'api' }), + JSON.stringify({ port: 8443, protocol: 'https' }), + JSON.stringify({ port: 22, protocol: 'tcp', label: 'ssh' }), + ], + }), + ); + const dynamicIds = ports.filter((p) => p.removable).map((p) => p.id); + expect(dynamicIds).toEqual(['port-8080-out', 'port-8443-out', 'port-22-out']); + }); + + it('hides the default web-out once the user declares any exposed_ports', () => { + const ports = getPortsForNode( + node('Compute.Container', { + exposed_ports: [JSON.stringify({ port: 8080, protocol: 'http' })], + }), + ); + expect(ports.some((p) => p.id === 'web-out')).toBe(false); + expect(ports.some((p) => p.id === 'port-8080-out')).toBe(true); + }); + + it('parses compact text form "https:443:api"', () => { + const ports = getPortsForNode(node('Compute.Container', { exposed_ports: ['https:443:api'] })); + const userPort = ports.find((p) => p.removable); + expect(userPort?.port).toBe(443); + expect(userPort?.protocol).toBe('https'); + expect(userPort?.label).toContain('443'); + expect(userPort?.label).toContain('api'); + }); + + it('skips malformed entries silently rather than throwing', () => { + const ports = getPortsForNode( + node('Compute.Container', { exposed_ports: ['nonsense', '', JSON.stringify({ port: 9000 })] }), + ); + const dynamic = ports.filter((p) => p.removable); + expect(dynamic).toHaveLength(1); + expect(dynamic[0].port).toBe(9000); + }); + + it('Compute.BackendAPI mirrors Container behavior — exposed_ports honored', () => { + const ports = getPortsForNode( + node('Compute.BackendAPI', { exposed_ports: [JSON.stringify({ port: 3000, protocol: 'http' })] }), + ); + expect(ports.some((p) => p.id === 'port-3000-out')).toBe(true); + expect(ports.some((p) => p.id === 'web-out')).toBe(false); + }); + + it('Compute.Worker has NO exposed_ports schema (single-port category)', () => { + const ports = getPortsForNode(node('Compute.Worker', { exposed_ports: ['https:443'] })); + // Worker schema ignores exposed_ports — port_list only ships on Container + BackendAPI. + expect(ports.some((p) => p.id === 'port-443-out')).toBe(false); + }); +}); + +describe('memoization', () => { + it('repeated calls with same data return the same array', () => { + const a = getPortsForNode(node('Compute.StaticSite')); + const b = getPortsForNode(node('Compute.StaticSite')); + expect(a).toBe(b); + }); + + it('different iceType invalidates cache', () => { + const a = getPortsForNode(node('Compute.StaticSite')); + const b = getPortsForNode(node('Compute.Container')); + expect(a).not.toBe(b); + }); +}); diff --git a/packages/types/src/ports/__tests__/infer.test.ts b/packages/types/src/ports/__tests__/infer.test.ts new file mode 100644 index 00000000..25a7837f --- /dev/null +++ b/packages/types/src/ports/__tests__/infer.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { inferEdgePorts } from '../infer'; +import { getPortsForNode } from '../derive'; + +function node(iceType: string, extra: Record = {}) { + return { id: 't', data: { iceType, ...extra } }; +} + +describe('inferEdgePorts — render-time fallback for legacy edges', () => { + it('Repo → Frontend infers repository-out / repository-in', () => { + const src = getPortsForNode(node('Source.Repository')); + const tgt = getPortsForNode(node('Compute.StaticSite')); + const { sourcePort, targetPort } = inferEdgePorts(src, tgt, 'pipeline'); + expect(sourcePort?.id).toBe('repository-out'); + expect(targetPort?.id).toBe('repository-in'); + }); + + it('CustomDomain → Frontend infers domain-out / domain-in', () => { + const src = getPortsForNode(node('Network.CustomDomain')); + const tgt = getPortsForNode(node('Compute.StaticSite')); + const { sourcePort, targetPort } = inferEdgePorts(src, tgt, 'dns'); + expect(sourcePort?.id).toBe('domain-out'); + expect(targetPort?.id).toBe('domain-in'); + }); + + it('Postgres → Backend infers db-out / db-in', () => { + const src = getPortsForNode(node('Database.PostgreSQL')); + const tgt = getPortsForNode(node('Compute.Container')); + const { sourcePort, targetPort } = inferEdgePorts(src, tgt, 'traffic'); + expect(sourcePort?.id).toBe('db-out'); + expect(targetPort?.id).toBe('db-in'); + }); + + it('Service → Monitoring infers logs-out / logs-in', () => { + const src = getPortsForNode(node('Compute.Container')); + const tgt = getPortsForNode(node('Monitoring.Log')); + const { sourcePort, targetPort } = inferEdgePorts(src, tgt, 'traffic'); + expect(sourcePort?.id).toBe('logs-out'); + expect(targetPort?.id).toBe('logs-in'); + }); + + it('returns undefined when no pair is compatible', () => { + // Custom Domain has only domain-out, Postgres has only db-out — no IN port matches. + const src = getPortsForNode(node('Network.CustomDomain')); + const tgt = getPortsForNode(node('Database.PostgreSQL')); + const { sourcePort, targetPort } = inferEdgePorts(src, tgt, 'dns'); + expect(sourcePort).toBeUndefined(); + expect(targetPort).toBeUndefined(); + }); + + it('ignores reroute (`any`) when a concrete role match exists', () => { + // If a Reroute is in the source list, its 'any' OUT shouldn't outscore + // a real role match. + const rerouteOut = getPortsForNode(node('Util.Reroute')).find((p) => p.direction === 'out')!; + const repoOut = getPortsForNode(node('Source.Repository'))[0]; + const tgt = getPortsForNode(node('Compute.StaticSite')); + const { sourcePort } = inferEdgePorts([rerouteOut, repoOut], tgt, 'pipeline'); + expect(sourcePort?.id).toBe('repository-out'); + }); +}); diff --git a/packages/types/src/ports/__tests__/match.test.ts b/packages/types/src/ports/__tests__/match.test.ts new file mode 100644 index 00000000..734e6245 --- /dev/null +++ b/packages/types/src/ports/__tests__/match.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest'; +import { canPortsConnect, rolesCompatible, findMatchingPorts, chooseBestTargetPort } from '../match'; +import type { PortDef } from '../types'; + +function port(over: Partial): PortDef { + return { + id: 'p', + direction: 'out', + role: 'database', + label: '', + side: 'right', + shape: 'circle', + ...over, + }; +} + +describe('rolesCompatible', () => { + it('matches identical roles', () => { + expect(rolesCompatible('domain', 'domain')).toBe(true); + expect(rolesCompatible('database', 'database')).toBe(true); + }); + + it('rejects different roles', () => { + expect(rolesCompatible('domain', 'repository')).toBe(false); + expect(rolesCompatible('database', 'cache')).toBe(false); + }); + + it("'any' is the reroute passthrough — matches everything", () => { + expect(rolesCompatible('any', 'database')).toBe(true); + expect(rolesCompatible('domain', 'any')).toBe(true); + expect(rolesCompatible('any', 'any')).toBe(true); + }); +}); + +describe('canPortsConnect', () => { + it('two out ports never connect', () => { + const a = port({ direction: 'out', role: 'database' }); + const b = port({ direction: 'out', role: 'database' }); + expect(canPortsConnect(a, b)).toBe(false); + }); + + it('two in ports never connect', () => { + const a = port({ direction: 'in', role: 'database' }); + const b = port({ direction: 'in', role: 'database' }); + expect(canPortsConnect(a, b)).toBe(false); + }); + + it('out + matching in → true', () => { + const out = port({ direction: 'out', role: 'database' }); + const inn = port({ direction: 'in', role: 'database' }); + expect(canPortsConnect(out, inn)).toBe(true); + }); + + it('out + mismatched in → false', () => { + const out = port({ direction: 'out', role: 'database' }); + const inn = port({ direction: 'in', role: 'cache' }); + expect(canPortsConnect(out, inn)).toBe(false); + }); + + it('any-role port connects to any other direction', () => { + const a = port({ direction: 'out', role: 'any' }); + const b = port({ direction: 'in', role: 'database' }); + expect(canPortsConnect(a, b)).toBe(true); + }); +}); + +describe('canPortsConnect — peer-kind cross-check (queue + similar)', () => { + it("Backend.queue-out (publish, peerKind='queue') does NOT connect to another Backend.queue-in (subscribe, peerKind='queue')", () => { + // Both ports declare peerKind='queue'; both blocks are kind='service'. + // The pre-existing role-identity match would pass — peer-kind is what + // blocks the wrong wiring. + const backendPublishOut = port({ + id: 'queue-out', + direction: 'out', + role: 'queue', + peerKind: 'queue', + }); + const backendSubscribeIn = port({ + id: 'queue-in', + direction: 'in', + role: 'queue', + peerKind: 'queue', + }); + expect(canPortsConnect(backendPublishOut, backendSubscribeIn, 'service', 'service')).toBe(false); + }); + + it("Backend.queue-out (peerKind='queue') connects to a real Queue.queue-in (peerKind='service')", () => { + const backendPublishOut = port({ + id: 'queue-out', + direction: 'out', + role: 'queue', + peerKind: 'queue', + }); + const queueIn = port({ + id: 'queue-in', + direction: 'in', + role: 'queue', + peerKind: 'service', + }); + expect(canPortsConnect(backendPublishOut, queueIn, 'service', 'queue')).toBe(true); + }); + + it("Queue.queue-out (peerKind='service') connects to a Backend.queue-in (peerKind='queue')", () => { + const queueOut = port({ + id: 'queue-out', + direction: 'out', + role: 'queue', + peerKind: 'service', + }); + const backendSubscribeIn = port({ + id: 'queue-in', + direction: 'in', + role: 'queue', + peerKind: 'queue', + }); + expect(canPortsConnect(queueOut, backendSubscribeIn, 'queue', 'service')).toBe(true); + }); + + it('peer-kind is permissive when kinds are not provided (backward compat)', () => { + const a = port({ direction: 'out', role: 'queue', peerKind: 'queue' }); + const b = port({ direction: 'in', role: 'queue', peerKind: 'queue' }); + // Callers without iceType context — the model degrades to role-only. + expect(canPortsConnect(a, b)).toBe(true); + }); + + it('reroute kind is universally acceptable on either side', () => { + const out = port({ direction: 'out', role: 'any', peerKind: 'any' }); + const in_ = port({ direction: 'in', role: 'database', peerKind: 'database' }); + expect(canPortsConnect(out, in_, 'reroute', 'database')).toBe(true); + }); + + it('blocks the partner when our peer-kind disagrees with their block kind', () => { + const a = port({ direction: 'out', role: 'repository', peerKind: 'service' }); + const b = port({ direction: 'in', role: 'repository', peerKind: 'repository' }); + expect(canPortsConnect(a, b, 'repository', 'queue')).toBe(false); + }); +}); + +describe('findMatchingPorts', () => { + it('returns all candidates matching the source role + opposite direction', () => { + const src = port({ direction: 'out', role: 'database' }); + const candidates = [ + port({ direction: 'in', role: 'database', id: 'db-in' }), + port({ direction: 'in', role: 'cache', id: 'cache-in' }), + port({ direction: 'out', role: 'database', id: 'wrong-direction' }), + port({ direction: 'in', role: 'any', id: 'any-in' }), + ]; + const ids = findMatchingPorts(src, candidates).map((p) => p.id); + expect(ids).toEqual(['db-in', 'any-in']); + }); +}); + +describe('chooseBestTargetPort', () => { + it('prefers exact-role IN over any-role IN', () => { + const src = port({ direction: 'out', role: 'database' }); + const candidates = [ + port({ direction: 'in', role: 'any', id: 'any-in' }), + port({ direction: 'in', role: 'database', id: 'db-in' }), + ]; + expect(chooseBestTargetPort(src, candidates)?.id).toBe('db-in'); + }); + + it('falls back to any-role when no exact match exists', () => { + const src = port({ direction: 'out', role: 'database' }); + const candidates = [port({ direction: 'in', role: 'any', id: 'any-in' })]; + expect(chooseBestTargetPort(src, candidates)?.id).toBe('any-in'); + }); + + it('returns undefined when no compatible IN port exists', () => { + const src = port({ direction: 'out', role: 'database' }); + const candidates = [port({ direction: 'in', role: 'cache', id: 'cache-in' })]; + expect(chooseBestTargetPort(src, candidates)).toBeUndefined(); + }); + + it('reverses when source is an IN port (drag started from an input)', () => { + const src = port({ direction: 'in', role: 'database' }); + const candidates = [ + port({ direction: 'out', role: 'cache', id: 'cache-out' }), + port({ direction: 'out', role: 'database', id: 'db-out' }), + ]; + expect(chooseBestTargetPort(src, candidates)?.id).toBe('db-out'); + }); +}); diff --git a/packages/types/src/ports/derive.ts b/packages/types/src/ports/derive.ts new file mode 100644 index 00000000..5fc7216b --- /dev/null +++ b/packages/types/src/ports/derive.ts @@ -0,0 +1,87 @@ +/** + * Port derivation — produces the ordered port list for a node. + * + * Replaces `getSocketsForNode` from the prior abstract-socket model. + * The new model is fully schema-driven: each iceType has a hand-authored + * `PortSchema` (in `./schemas/`) that declares ports anchored to the + * block's typed properties. Property changes still reshape sockets at + * render time — `dynamic(data)` lets a multi-port block emit one port + * per item in `node.data.exposed_ports`, and `hide` lets a schema drop + * a base port when a property predicate fires. + */ + +import { getPortSchema } from './schemas'; +import { isContainer } from '../connection-rules/predicates'; +import type { PortDef } from './types'; +import type { NodeForConnectionCheck } from '../connection-rules/types'; + +export interface NodeForPorts extends NodeForConnectionCheck { + data?: Record; +} + +const cache = new Map(); + +/** Test helper — clears the memo cache. */ +export function _resetPortCache(): void { + cache.clear(); +} + +function cacheKey(iceType: string, data: Record): string { + const schema = getPortSchema(iceType); + if (!schema) return iceType; + const keys = new Set(); + for (const h of schema.hide ?? []) h.keys.forEach((k) => keys.add(k)); + // `dynamic` reads `node.data` opaquely — if a schema declares one, key + // on a structural hash of the data object to be safe. (Practically + // only `exposed_ports` triggers this, so the JSON is small.) + const parts: string[] = [iceType]; + for (const k of Array.from(keys).sort()) { + parts.push(`${k}=${JSON.stringify(data[k] ?? null)}`); + } + if (schema.dynamic) parts.push(`dyn=${JSON.stringify(data.exposed_ports ?? null)}`); + return parts.join('|'); +} + +export function getPortsForNode(node: NodeForPorts): PortDef[] { + const data = node.data ?? {}; + const iceType = typeof data.iceType === 'string' ? data.iceType : ''; + if (!iceType) return []; + // Containers (VPC, Subnet, Group.*) never expose ports; children attach via parentId. + if (isContainer(iceType, node.type)) return []; + + const schema = getPortSchema(iceType); + if (!schema) return []; + + const key = cacheKey(iceType, data); + const cached = cache.get(key); + if (cached) return cached; + + let ports: PortDef[] = [...schema.base]; + if (schema.hide) { + for (const hide of schema.hide) { + if (hide.when(data)) { + const ids = new Set(hide.portIds); + ports = ports.filter((p) => !ids.has(p.id)); + } + } + } + if (schema.dynamic) { + ports = ports.concat(schema.dynamic(data)); + } + // Dedupe by id (later wins). + const byId = new Map(); + for (const p of ports) byId.set(p.id, p); + const result = Array.from(byId.values()); + cache.set(key, result); + return result; +} + +/** Lookup helper for the render layer. */ +export function findPort(node: NodeForPorts, portId: string): PortDef | undefined { + return getPortsForNode(node).find((p) => p.id === portId); +} + +/** Used by `svg-connection-path.tsx` to detect dangling edges (socket removed). */ +export function hasPort(node: NodeForPorts, portId: string): boolean { + return getPortsForNode(node).some((p) => p.id === portId); +} diff --git a/packages/types/src/ports/index.ts b/packages/types/src/ports/index.ts new file mode 100644 index 00000000..b4557c1d --- /dev/null +++ b/packages/types/src/ports/index.ts @@ -0,0 +1,21 @@ +/** + * Ports — typed connection points on blocks. + * + * Public surface: types (`PortDef`, `PortRole`, `PortSchema`, …), + * matching primitives (`canPortsConnect`, `rolesCompatible`, + * `chooseBestTargetPort`), and the derivation entrypoint + * (`getPortsForNode`, `hasPort`, `findPort`). + * + * Replaces (and supersedes) the earlier `@ice/types/sockets` API, + * which derived sockets from CONNECTION_RULES categories. The old + * exports remain available as a thin shim that delegates here so + * existing imports keep resolving during the migration window. + */ + +export * from './types'; +export { getBlockKind } from './types'; +export { canPortsConnect, rolesCompatible, findMatchingPorts, chooseBestTargetPort } from './match'; +export { getPortsForNode, hasPort, findPort, _resetPortCache, type NodeForPorts } from './derive'; +export { PORT_SCHEMAS, getPortSchema } from './schemas'; +export { inferEdgePorts, type InferredEdgePorts } from './infer'; +export { getPortAnchorPoint, type Bounds, type Point } from './position'; diff --git a/packages/types/src/ports/infer.ts b/packages/types/src/ports/infer.ts new file mode 100644 index 00000000..6f7e3083 --- /dev/null +++ b/packages/types/src/ports/infer.ts @@ -0,0 +1,69 @@ +/** + * Render-time port inference for legacy edges. + * + * Edges created before the port-driven socket model — or AI-generated + * edges that didn't specify ports — have no `sourceSocket` / + * `targetSocket` on `edge.data`. They should still render with the + * right magnetic anchors, so the canvas tells the user a deterministic + * story: a Repo → Frontend edge attaches to the `repository-in` socket + * on the Frontend, even if the edge data is silent. + * + * Inference scores all (sourceOut × targetIn) port pairs and picks the + * best match. The result is NOT written back to the edge — purely + * visual. The user can right-click an edge → "Reconnect typed" if they + * want to lock in explicit ports. + */ + +import { canPortsConnect } from './match'; +import { ROLE_CATEGORY } from './types'; +import type { PortDef } from './types'; +import type { ConnectionCategory } from '@ice/constants'; + +export interface InferredEdgePorts { + sourcePort?: PortDef; + targetPort?: PortDef; +} + +/** + * Score how well a candidate (src, tgt) port pair matches an edge. + * + * Roughly: + * +100 — roles match exactly (not `any`) + * +30 — role's connection category matches the edge's category + * +10 — both are not `any` + * -50 — either is `any` (passthrough wins only when nothing else does) + * + * Higher is better. Pairs that can't connect at all return 0. + */ +function score(src: PortDef, tgt: PortDef, category: ConnectionCategory | null): number { + if (!canPortsConnect(src, tgt)) return 0; + let s = 0; + if (src.role === tgt.role && src.role !== 'any') s += 100; + if (src.role === 'any' || tgt.role === 'any') s -= 50; + else s += 10; + if (category && ROLE_CATEGORY[src.role] === category) s += 30; + return s; +} + +/** + * Pick the best (source OUT, target IN) port pair given the two nodes' + * port lists and the edge's known category. Returns whichever side + * could be resolved — if no pair scores above 0, the result has both + * sides undefined and the renderer falls back to anonymous routing. + */ +export function inferEdgePorts( + sourcePorts: PortDef[], + targetPorts: PortDef[], + category: ConnectionCategory | null, +): InferredEdgePorts { + let best: { src?: PortDef; tgt?: PortDef; score: number } = { score: 0 }; + for (const src of sourcePorts) { + if (src.direction !== 'out') continue; + for (const tgt of targetPorts) { + if (tgt.direction !== 'in') continue; + const s = score(src, tgt, category); + if (s > best.score) best = { src, tgt, score: s }; + } + } + return { sourcePort: best.src, targetPort: best.tgt }; +} diff --git a/packages/types/src/ports/match.ts b/packages/types/src/ports/match.ts new file mode 100644 index 00000000..1e41ce41 --- /dev/null +++ b/packages/types/src/ports/match.ts @@ -0,0 +1,94 @@ +/** + * Port matching — does this OUT port accept that IN port (or vice versa)? + * + * Identity by default: same role + opposite direction. The `any` role + * is the reroute escape hatch — it accepts everything and is accepted + * by everything, so wires can flow through a Reroute node without + * the role check rejecting them. + * + * No cross-role aliases beyond `any`. Keep the model boring so users + * can predict it: a `repository` out connects to a `repository` in, + * never to a `database` in. + */ + +import type { PeerKind, PortDef, PortRole } from './types'; + +/** + * Returns true if a wire can be drawn between these two ports. + * + * Three gates, in order: + * 1. Opposite directions (out↔in). + * 2. Roles compatible (identity match, or either side is `any`). + * 3. Optional peer-kind cross-check — if `a.peerKind` is set and the + * caller passes the partner's block kind, the partner must be of + * that kind. This is what stops two Backends from accidentally + * wiring up via `queue-out` ↔ `queue-in` (both endpoints are + * `service` kind; both ports declare `peerKind: 'queue'`). + * + * Callers without iceType context (e.g. drag start before a target + * exists) can omit the kind args — the peer-kind gate then short- + * circuits to true and the model degrades to role-only matching, + * matching the prior contract. + */ +export function canPortsConnect(a: PortDef, b: PortDef, aPeerKind?: PeerKind, bPeerKind?: PeerKind): boolean { + if (a.direction === b.direction) return false; + if (!rolesCompatible(a.role, b.role)) return false; + if (!peerKindAccepts(a.peerKind, bPeerKind)) return false; + if (!peerKindAccepts(b.peerKind, aPeerKind)) return false; + return true; +} + +/** + * Checks one side of the peer-kind constraint: a port declaring + * `expected` must see a partner of that kind. `'any'` on either side + * is the wildcard. Reroute nodes are universally compatible too. + */ +function peerKindAccepts(expected: PeerKind | undefined, actual: PeerKind | undefined): boolean { + if (!expected || expected === 'any') return true; + if (!actual) return true; // caller didn't provide context — degrade to permissive + if (actual === 'any' || actual === 'reroute' || expected === 'reroute') return true; + return expected === actual; +} + +/** Compatibility check on roles alone — used when only role info is known. */ +export function rolesCompatible(a: PortRole, b: PortRole): boolean { + if (a === 'any' || b === 'any') return true; + return a === b; +} + +/** + * Given a source port and a target node's ports, return all target + * ports that the source could connect to. Used by the drag-target + * highlight to decide if a node is a valid drop target at all. + * + * Pass `sourceKind` + `targetKind` (the iceType's `getBlockKind` + * result) to enforce the peer-kind cross-check; omit for permissive + * role-only matching. + */ +export function findMatchingPorts( + source: PortDef, + candidates: PortDef[], + sourceKind?: PeerKind, + targetKind?: PeerKind, +): PortDef[] { + return candidates.filter((c) => canPortsConnect(source, c, sourceKind, targetKind)); +} + +/** + * When the drop target is the *node* rather than a specific port (the + * user dropped on the block body), pick the best target port: the + * first IN port matching the source's role + peer-kind, preferring + * exact-role over `any`-role matches. + */ +export function chooseBestTargetPort( + source: PortDef, + candidates: PortDef[], + sourceKind?: PeerKind, + targetKind?: PeerKind, +): PortDef | undefined { + const matching = candidates.filter((c) => canPortsConnect(source, c, sourceKind, targetKind)); + if (matching.length === 0) return undefined; + // Prefer an exact-role match over a wildcard (`any`) one — keeps + // reroute passthroughs from outranking a real semantic match. + return matching.find((c) => c.role === source.role) ?? matching[0]; +} diff --git a/packages/types/src/ports/position.ts b/packages/types/src/ports/position.ts new file mode 100644 index 00000000..315313c4 --- /dev/null +++ b/packages/types/src/ports/position.ts @@ -0,0 +1,50 @@ +/** + * Port position math — shared by the canvas renderer and the connection- + * drawing hook so both agree where a port dot sits in canvas space. + * + * Ports are distributed evenly along their declared side (left / right / + * top / bottom). Distributing within a side means side-N is the N-th of + * all ports on that same side, in declaration order. This matches the + * `TypedSockets` SVG layout and keeps the snap-target math honest. + */ + +import type { PortDef } from './types'; + +export interface Bounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface Point { + x: number; + y: number; +} + +/** + * Returns the (x, y) canvas-space coordinate of `port` on a node with + * the given `bounds`, accounting for sibling ports on the same side. + * + * `allPorts` is the full port list from `getPortsForNode(node)`. The + * helper finds the port's side group and computes its slot index. + */ +export function getPortAnchorPoint(bounds: Bounds, port: PortDef, allPorts: PortDef[]): Point { + const sideGroup = allPorts.filter((p) => p.side === port.side); + const idx = sideGroup.findIndex((p) => p.id === port.id); + const count = sideGroup.length; + const safeIdx = idx >= 0 ? idx : 0; + const r = (safeIdx + 1) / (count + 1); + const { x, y, width: w, height: h } = bounds; + switch (port.side) { + case 'top': + return { x: x + w * r, y }; + case 'right': + return { x: x + w, y: y + h * r }; + case 'bottom': + return { x: x + w * r, y: y + h }; + case 'left': + default: + return { x, y: y + h * r }; + } +} diff --git a/packages/types/src/ports/schemas/ai.ts b/packages/types/src/ports/schemas/ai.ts new file mode 100644 index 00000000..41d50942 --- /dev/null +++ b/packages/types/src/ports/schemas/ai.ts @@ -0,0 +1,101 @@ +import type { PortSchema } from '../types'; + +export const aiVectorDbSchema: PortSchema = { + iceType: 'AI.VectorDB', + base: [ + { + id: 'vector-out', + direction: 'out', + role: 'vector', + label: 'Vector DB', + side: 'right', + shape: 'circle', + peerStyle: 'AI', + }, + ], +}; + +export const aiLlmGatewaySchema: PortSchema = { + iceType: 'AI.LLMGateway', + base: [ + { + id: 'llm-out', + direction: 'out', + role: 'llm', + label: 'LLM gateway', + side: 'right', + shape: 'circle', + peerStyle: 'AI', + }, + ], +}; + +/** + * AI.PrivateAIService is itself a backend (it can be deployed and + * fronted by a domain) so it exposes the standard service ports plus + * the LLM out. + */ +export const aiPrivateAiServiceSchema: PortSchema = { + iceType: 'AI.PrivateAIService', + base: [ + { + id: 'repository-in', + direction: 'in', + role: 'repository', + label: 'Source code', + property: 'repository', + side: 'left', + shape: 'diamond', + peerStyle: 'Source', + }, + { + id: 'env-in', + direction: 'in', + role: 'env', + label: 'Environment variables', + property: 'env_vars', + side: 'left', + shape: 'ring', + peerStyle: 'Config', + }, + { + id: 'secret-in', + direction: 'in', + role: 'secret', + label: 'Secrets', + property: 'secrets', + side: 'left', + shape: 'ring', + peerStyle: 'Security', + }, + { + id: 'vector-in', + direction: 'in', + role: 'vector', + label: 'Vector DB', + side: 'left', + shape: 'circle', + peerStyle: 'AI', + }, + { + id: 'llm-out', + direction: 'out', + role: 'llm', + label: 'LLM gateway', + side: 'right', + shape: 'circle', + peerStyle: 'AI', + }, + { + id: 'web-out', + direction: 'out', + role: 'http-endpoint', + label: 'Web (HTTPS)', + port: 443, + protocol: 'https', + side: 'right', + shape: 'circle', + peerStyle: 'Network', + }, + ], +}; diff --git a/packages/types/src/ports/schemas/compute.ts b/packages/types/src/ports/schemas/compute.ts new file mode 100644 index 00000000..e21bfa53 --- /dev/null +++ b/packages/types/src/ports/schemas/compute.ts @@ -0,0 +1,323 @@ +import type { PortDef, PortSchema } from '../types'; + +/** + * Inputs every deployable service (frontend or backend) shares — source + * code from a repository, environment variables from a config block, + * secret references from a secret store, and an optional custom domain. + */ +function serviceCommonInputs(includeDomain = true): PortDef[] { + const base: PortDef[] = [ + { + id: 'repository-in', + direction: 'in', + role: 'repository', + label: 'Source code', + property: 'repository', + side: 'left', + shape: 'diamond', + peerStyle: 'Source', + }, + { + id: 'env-in', + direction: 'in', + role: 'env', + label: 'Environment variables', + property: 'env_vars', + side: 'left', + shape: 'ring', + peerStyle: 'Config', + }, + { + id: 'secret-in', + direction: 'in', + role: 'secret', + label: 'Secrets', + property: 'secrets', + side: 'left', + shape: 'ring', + peerStyle: 'Security', + }, + ]; + if (includeDomain) { + base.push({ + id: 'domain-in', + direction: 'in', + role: 'domain', + label: 'Custom domain', + property: 'custom_domain', + side: 'left', + shape: 'square', + peerStyle: 'Network', + }); + } + return base; +} + +/** + * Data-store inputs that a backend can consume. Each one corresponds to + * a stable env var the deploy compiler injects (DATABASE_URL, + * REDIS_URL, …) via the existing `Backend → DataStore` propagation + * rule. + */ +function backendDataInputs(): PortDef[] { + return [ + { + id: 'db-in', + direction: 'in', + role: 'database', + label: 'Database', + side: 'left', + shape: 'circle', + peerStyle: 'Database', + }, + { + id: 'cache-in', + direction: 'in', + role: 'cache', + label: 'Cache', + side: 'left', + shape: 'circle', + peerStyle: 'Database', + }, + { + id: 'queue-out', + direction: 'out', + role: 'queue', + label: 'Publish to queue', + side: 'right', + shape: 'circle', + peerStyle: 'Messaging', + // Publish targets a Queue (or Stream/Email) — NEVER another Service. + peerKind: 'queue', + }, + { + id: 'queue-in', + direction: 'in', + role: 'queue', + label: 'Subscribe to queue', + side: 'left', + shape: 'circle', + peerStyle: 'Messaging', + // Subscribe receives from a Queue/Stream — NEVER from another Service. + peerKind: 'queue', + }, + { + id: 'storage-in', + direction: 'in', + role: 'storage', + label: 'Object storage', + side: 'left', + shape: 'circle', + peerStyle: 'Storage', + }, + { + id: 'search-in', + direction: 'in', + role: 'search', + label: 'Search', + side: 'left', + shape: 'circle', + peerStyle: 'Analytics', + }, + { + id: 'vector-in', + direction: 'in', + role: 'vector', + label: 'Vector DB', + side: 'left', + shape: 'circle', + peerStyle: 'AI', + }, + { + id: 'llm-in', + direction: 'in', + role: 'llm', + label: 'LLM', + side: 'left', + shape: 'circle', + peerStyle: 'AI', + }, + ]; +} + +const httpsOut: PortDef = { + id: 'web-out', + direction: 'out', + role: 'http-endpoint', + label: 'Web (HTTPS)', + port: 443, + protocol: 'https', + side: 'right', + shape: 'circle', + peerStyle: 'Network', +}; + +const logsOut: PortDef = { + id: 'logs-out', + direction: 'out', + role: 'monitoring', + label: 'Logs', + side: 'right', + shape: 'circle', + peerStyle: 'Monitoring', +}; + +/** Compute.StaticSite — frontend that consumes repo/env/domain, exposes HTTPS. */ +export const computeStaticSiteSchema: PortSchema = { + iceType: 'Compute.StaticSite', + base: [...serviceCommonInputs(true), httpsOut, logsOut], +}; + +/** Compute.SSRSite — same wiring as static site (SSR is an implementation detail). */ +export const computeSsrSiteSchema: PortSchema = { + iceType: 'Compute.SSRSite', + base: [...serviceCommonInputs(true), httpsOut, logsOut], +}; + +/** + * Compute.Container — the multi-port-capable backend. + * + * Base ports cover the standard service wiring (repo, env, secret, + * domain, all data-store inputs, logs out). The default `web-out` is + * dropped once the user adds explicit `exposed_ports` — only the + * user-declared listeners show, so the canvas never lies about what + * the service exposes. + */ +export const computeContainerSchema: PortSchema = { + iceType: 'Compute.Container', + base: [ + ...serviceCommonInputs(true), + ...backendDataInputs(), + { + id: 'web-out', + direction: 'out', + role: 'http-endpoint', + label: 'HTTPS :8080', + port: 8080, + protocol: 'https', + side: 'right', + shape: 'circle', + peerStyle: 'Network', + }, + logsOut, + ], + hide: [ + { + keys: ['exposed_ports'], + when: (data) => Array.isArray(data.exposed_ports) && data.exposed_ports.length > 0, + portIds: ['web-out'], + }, + ], + dynamic: makeExposedPortsDynamic, +}; + +/** + * Compute.BackendAPI — same wiring story as Container. Most blueprints + * land here when the user picks "Backend API" from the palette. + */ +export const computeBackendApiSchema: PortSchema = { + iceType: 'Compute.BackendAPI', + base: [ + ...serviceCommonInputs(true), + ...backendDataInputs(), + { + id: 'web-out', + direction: 'out', + role: 'http-endpoint', + label: 'HTTPS :443', + port: 443, + protocol: 'https', + side: 'right', + shape: 'circle', + peerStyle: 'Network', + }, + logsOut, + ], + hide: [ + { + keys: ['exposed_ports'], + when: (data) => Array.isArray(data.exposed_ports) && data.exposed_ports.length > 0, + portIds: ['web-out'], + }, + ], + dynamic: makeExposedPortsDynamic, +}; + +/** + * Parses the user's `exposed_ports` list (JSON strings or compact text + * forms — see `port-spec.ts` in the UI package) into typed `http-endpoint` + * OUT ports. Pure parser; doesn't import the UI's port-spec helper to + * keep `@ice/types` UI-agnostic. + */ +function makeExposedPortsDynamic(data: Record): PortDef[] { + const raw = data.exposed_ports; + if (!Array.isArray(raw)) return []; + return raw.map((entry, idx) => parseExposedPort(entry, idx)).filter((p): p is PortDef => p !== null); +} + +function parseExposedPort(raw: unknown, idx: number): PortDef | null { + let port = 0; + let protocol: 'http' | 'https' | 'tcp' = 'http'; + let userLabel = ''; + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw) as { port?: unknown; protocol?: unknown; label?: unknown }; + if (parsed && typeof parsed.port === 'number') { + port = parsed.port; + if (parsed.protocol === 'https' || parsed.protocol === 'tcp') protocol = parsed.protocol; + if (typeof parsed.label === 'string') userLabel = parsed.label; + } + } catch { + const parts = raw.split(':'); + if (parts.length >= 2 && (parts[0] === 'http' || parts[0] === 'https' || parts[0] === 'tcp')) { + protocol = parts[0]; + port = Number(parts[1]); + if (parts[2]) userLabel = parts[2]; + } else { + const n = Number(raw); + if (Number.isFinite(n)) port = n; + } + } + } else if (raw && typeof raw === 'object') { + const obj = raw as { port?: unknown; protocol?: unknown; label?: unknown }; + if (typeof obj.port === 'number') port = obj.port; + if (obj.protocol === 'https' || obj.protocol === 'tcp') protocol = obj.protocol; + if (typeof obj.label === 'string') userLabel = obj.label; + } + if (!Number.isFinite(port) || port <= 0) return null; + const protoLabel = protocol.toUpperCase(); + const label = userLabel ? `${protoLabel} :${port} (${userLabel})` : `${protoLabel} :${port}`; + return { + id: `port-${port}-out`, + direction: 'out', + role: 'http-endpoint', + label, + port, + protocol, + side: 'right', + shape: 'circle', + peerStyle: 'Network', + removable: true, + // Stash the index so dedupe-by-id collisions remain stable for repeat + // ports — unlikely in practice but defensive. + ...(idx >= 0 ? {} : {}), + }; +} + +/** Compute.ServerlessFunction — backend without the multi-port story. */ +export const computeServerlessFunctionSchema: PortSchema = { + iceType: 'Compute.ServerlessFunction', + base: [...serviceCommonInputs(true), ...backendDataInputs(), httpsOut, logsOut], +}; + +/** Compute.Worker — long-running background worker. No public endpoint by default. */ +export const computeWorkerSchema: PortSchema = { + iceType: 'Compute.Worker', + base: [...serviceCommonInputs(false), ...backendDataInputs(), logsOut], +}; + +/** Compute.CronJob — scheduled task. Like a worker but ephemeral. */ +export const computeCronJobSchema: PortSchema = { + iceType: 'Compute.CronJob', + base: [...serviceCommonInputs(false), ...backendDataInputs(), logsOut], +}; diff --git a/packages/types/src/ports/schemas/config.ts b/packages/types/src/ports/schemas/config.ts new file mode 100644 index 00000000..f2570657 --- /dev/null +++ b/packages/types/src/ports/schemas/config.ts @@ -0,0 +1,21 @@ +import type { PortSchema } from '../types'; + +/** + * Config.Environment — provides a bundle of env vars to services. Wiring + * it to a service injects the variables via the existing + * `Service → EnvConfig` propagation rule. + */ +export const configEnvironmentSchema: PortSchema = { + iceType: 'Config.Environment', + base: [ + { + id: 'env-out', + direction: 'out', + role: 'env', + label: 'Environment variables', + side: 'right', + shape: 'ring', + peerStyle: 'Config', + }, + ], +}; diff --git a/packages/types/src/ports/schemas/data.ts b/packages/types/src/ports/schemas/data.ts new file mode 100644 index 00000000..9d3db5da --- /dev/null +++ b/packages/types/src/ports/schemas/data.ts @@ -0,0 +1,61 @@ +import type { PortDef, PortSchema } from '../types'; + +function dbBase(label = 'Database', shape: PortDef['shape'] = 'circle'): PortDef[] { + return [ + { + id: 'db-out', + direction: 'out', + role: 'database', + label, + side: 'right', + shape, + peerStyle: 'Database', + }, + ]; +} + +/** Database.PostgreSQL — provides a database connection; gains `replica-out` when replication is enabled. */ +export const databasePostgresSchema: PortSchema = { + iceType: 'Database.PostgreSQL', + base: dbBase('Database (Postgres)'), +}; + +export const databaseMysqlSchema: PortSchema = { + iceType: 'Database.MySQL', + base: dbBase('Database (MySQL)'), +}; + +export const databaseMongoSchema: PortSchema = { + iceType: 'Database.MongoDB', + base: dbBase('Database (Mongo)'), +}; + +export const databaseRedisSchema: PortSchema = { + iceType: 'Database.Redis', + base: [ + { + id: 'cache-out', + direction: 'out', + role: 'cache', + label: 'Cache (Redis)', + side: 'right', + shape: 'circle', + peerStyle: 'Database', + }, + ], +}; + +export const storageBucketSchema: PortSchema = { + iceType: 'Storage.Bucket', + base: [ + { + id: 'storage-out', + direction: 'out', + role: 'storage', + label: 'Object storage', + side: 'right', + shape: 'circle', + peerStyle: 'Storage', + }, + ], +}; diff --git a/packages/types/src/ports/schemas/index.ts b/packages/types/src/ports/schemas/index.ts new file mode 100644 index 00000000..d5303db2 --- /dev/null +++ b/packages/types/src/ports/schemas/index.ts @@ -0,0 +1,75 @@ +/** + * Port schema registry. + * + * Maps iceType → `PortSchema`. The lookup is total for high-level + * concept blocks — every iceType in the palette has an entry. Unknown + * iceTypes get an empty port list (no sockets rendered). + */ + +import { aiLlmGatewaySchema, aiPrivateAiServiceSchema, aiVectorDbSchema } from './ai'; +import { + computeBackendApiSchema, + computeContainerSchema, + computeCronJobSchema, + computeServerlessFunctionSchema, + computeSsrSiteSchema, + computeStaticSiteSchema, + computeWorkerSchema, +} from './compute'; +import { configEnvironmentSchema } from './config'; +import { + databaseMongoSchema, + databaseMysqlSchema, + databasePostgresSchema, + databaseRedisSchema, + storageBucketSchema, +} from './data'; +import { messagingEmailSchema, messagingEventStreamSchema, messagingQueueSchema } from './messaging'; +import { monitoringLogSchema } from './monitoring'; +import { networkCustomDomainSchema, networkGatewaySchema, networkPrivateNetworkSchema } from './network'; +import { securitySecretSchema } from './security'; +import { sourceRepositorySchema } from './source'; +import { utilRerouteSchema } from './util'; +import type { PortSchema } from '../types'; + +const allSchemas: PortSchema[] = [ + // Compute / frontend / backend + computeStaticSiteSchema, + computeSsrSiteSchema, + computeContainerSchema, + computeBackendApiSchema, + computeServerlessFunctionSchema, + computeWorkerSchema, + computeCronJobSchema, + // Data / storage + databasePostgresSchema, + databaseMysqlSchema, + databaseMongoSchema, + databaseRedisSchema, + storageBucketSchema, + // Messaging + messagingQueueSchema, + messagingEventStreamSchema, + messagingEmailSchema, + // Network + networkCustomDomainSchema, + networkGatewaySchema, + networkPrivateNetworkSchema, + // Security / config / monitoring / source + securitySecretSchema, + configEnvironmentSchema, + monitoringLogSchema, + sourceRepositorySchema, + // AI + aiVectorDbSchema, + aiLlmGatewaySchema, + aiPrivateAiServiceSchema, + // Util + utilRerouteSchema, +]; + +export const PORT_SCHEMAS: Record = Object.fromEntries(allSchemas.map((s) => [s.iceType, s])); + +export function getPortSchema(iceType: string): PortSchema | undefined { + return PORT_SCHEMAS[iceType]; +} diff --git a/packages/types/src/ports/schemas/messaging.ts b/packages/types/src/ports/schemas/messaging.ts new file mode 100644 index 00000000..a7c99295 --- /dev/null +++ b/packages/types/src/ports/schemas/messaging.ts @@ -0,0 +1,77 @@ +import type { PortSchema } from '../types'; + +/** + * Messaging.Queue — exposes both `queue-in` (publishers connect here) + * and `queue-out` (subscribers connect here). The direction of the + * port disambiguates publish vs subscribe; the matching backend ports + * are mirrored. + */ +export const messagingQueueSchema: PortSchema = { + iceType: 'Messaging.Queue', + base: [ + { + id: 'queue-in', + direction: 'in', + role: 'queue', + label: 'Publishers', + side: 'left', + shape: 'circle', + peerStyle: 'Messaging', + // Publishers are Services; never another Queue. + peerKind: 'service', + }, + { + id: 'queue-out', + direction: 'out', + role: 'queue', + label: 'Subscribers', + side: 'right', + shape: 'circle', + peerStyle: 'Messaging', + // Subscribers are Services; never another Queue. + peerKind: 'service', + }, + ], +}; + +export const messagingEventStreamSchema: PortSchema = { + iceType: 'Messaging.EventStream', + base: [ + { + id: 'queue-in', + direction: 'in', + role: 'queue', + label: 'Producers', + side: 'left', + shape: 'circle', + peerStyle: 'Messaging', + peerKind: 'service', + }, + { + id: 'queue-out', + direction: 'out', + role: 'queue', + label: 'Consumers', + side: 'right', + shape: 'circle', + peerStyle: 'Messaging', + peerKind: 'service', + }, + ], +}; + +export const messagingEmailSchema: PortSchema = { + iceType: 'Messaging.Email', + base: [ + { + id: 'queue-in', + direction: 'in', + role: 'queue', + label: 'Email senders', + side: 'left', + shape: 'circle', + peerStyle: 'Messaging', + peerKind: 'service', + }, + ], +}; diff --git a/packages/types/src/ports/schemas/monitoring.ts b/packages/types/src/ports/schemas/monitoring.ts new file mode 100644 index 00000000..89b7d7f8 --- /dev/null +++ b/packages/types/src/ports/schemas/monitoring.ts @@ -0,0 +1,19 @@ +import type { PortSchema } from '../types'; + +/** + * Monitoring.Log — receives logs/metrics streams from services. + */ +export const monitoringLogSchema: PortSchema = { + iceType: 'Monitoring.Log', + base: [ + { + id: 'logs-in', + direction: 'in', + role: 'monitoring', + label: 'Logs', + side: 'left', + shape: 'circle', + peerStyle: 'Monitoring', + }, + ], +}; diff --git a/packages/types/src/ports/schemas/network.ts b/packages/types/src/ports/schemas/network.ts new file mode 100644 index 00000000..ac17277a --- /dev/null +++ b/packages/types/src/ports/schemas/network.ts @@ -0,0 +1,105 @@ +import type { PortSchema } from '../types'; + +/** + * Network.CustomDomain — one `domain-out` socket PER ROUTE. + * + * The block stores `data.routes: Array<{ id, subdomain }>`. Each route + * is a distinct subdomain the user has configured (e.g. `api`, + * `admin`, `app`) — and each gets its own typed socket so the user + * can wire each subdomain to a different downstream service. Matches + * the multi-port story of `Compute.Container` with `exposed_ports`. + * + * The base `domain-out` is the fallback for an unconfigured block + * (no routes yet) so the user can still wire the root domain. When + * any route exists, the fallback is hidden — only per-route sockets + * show, identifying which subdomain is being wired. + */ +export const networkCustomDomainSchema: PortSchema = { + iceType: 'Network.CustomDomain', + base: [ + { + id: 'domain-out', + direction: 'out', + role: 'domain', + label: 'Custom domain', + side: 'right', + shape: 'square', + peerStyle: 'Network', + peerKind: 'service', + }, + ], + hide: [ + { + keys: ['routes'], + when: (data) => Array.isArray(data.routes) && (data.routes as Array<{ id: string }>).length > 0, + portIds: ['domain-out'], + }, + ], + dynamic: (data) => { + const routes = (data.routes as Array<{ id: string; subdomain: string }> | undefined) ?? []; + return routes.map((r) => ({ + id: `domain-out-${r.id}`, + direction: 'out' as const, + role: 'domain' as const, + // Label uses the subdomain when set so the tooltip + properties + // panel both read as the same name the user typed. + label: r.subdomain ? r.subdomain : 'Subdomain', + side: 'right' as const, + shape: 'square' as const, + peerStyle: 'Network', + peerKind: 'service' as const, + removable: true, + })); + }, +}; + +/** + * Network.Gateway — exposes an `http-endpoint-out` (the gateway's public + * URL) and consumes an `http-endpoint-in` (the backend it routes to). + * Also accepts a `domain-in` so a custom domain can target the gateway. + */ +export const networkGatewaySchema: PortSchema = { + iceType: 'Network.Gateway', + base: [ + { + id: 'domain-in', + direction: 'in', + role: 'domain', + label: 'Custom domain', + property: 'custom_domain', + side: 'left', + shape: 'square', + peerStyle: 'Network', + }, + { + id: 'upstream-in', + direction: 'in', + role: 'http-endpoint', + label: 'Backend', + side: 'left', + shape: 'circle', + peerStyle: 'Compute', + }, + { + id: 'public-out', + direction: 'out', + role: 'http-endpoint', + label: 'Public URL (HTTPS)', + port: 443, + protocol: 'https', + side: 'right', + shape: 'circle', + peerStyle: 'Network', + }, + ], +}; + +/** + * Network.PrivateNetwork — pure container. No ports; children attach via + * parentId. The schema is here for completeness so the registry's + * iceType lookup is total. + */ +export const networkPrivateNetworkSchema: PortSchema = { + iceType: 'Network.PrivateNetwork', + base: [], +}; diff --git a/packages/types/src/ports/schemas/security.ts b/packages/types/src/ports/schemas/security.ts new file mode 100644 index 00000000..e63e34a5 --- /dev/null +++ b/packages/types/src/ports/schemas/security.ts @@ -0,0 +1,21 @@ +import type { PortSchema } from '../types'; + +/** + * Security.Secret — provides secret references (API keys, passwords, + * certs). Wiring to a service runs the existing `Service → Secret` + * propagation, which writes `secretRefs` onto the service. + */ +export const securitySecretSchema: PortSchema = { + iceType: 'Security.Secret', + base: [ + { + id: 'secret-out', + direction: 'out', + role: 'secret', + label: 'Secret', + side: 'right', + shape: 'ring', + peerStyle: 'Security', + }, + ], +}; diff --git a/packages/types/src/ports/schemas/source.ts b/packages/types/src/ports/schemas/source.ts new file mode 100644 index 00000000..60574bdc --- /dev/null +++ b/packages/types/src/ports/schemas/source.ts @@ -0,0 +1,22 @@ +import type { PortSchema } from '../types'; + +/** + * Source.Repository — provides a single `repository` output. Wiring it to + * a service writes the repo URL into the service's `repository` property + * (PROPAGATION_RULES handles `branch`, `buildCommand`, `outputDirectory` + * propagation as a side-effect). + */ +export const sourceRepositorySchema: PortSchema = { + iceType: 'Source.Repository', + base: [ + { + id: 'repository-out', + direction: 'out', + role: 'repository', + label: 'Source code', + side: 'right', + shape: 'diamond', + peerStyle: 'Source', + }, + ], +}; diff --git a/packages/types/src/ports/schemas/util.ts b/packages/types/src/ports/schemas/util.ts new file mode 100644 index 00000000..901058a1 --- /dev/null +++ b/packages/types/src/ports/schemas/util.ts @@ -0,0 +1,29 @@ +import type { PortSchema } from '../types'; + +/** + * Util.Reroute — pass-through dot. Two `any`-role ports so wires of + * any category can flow through. Rendering is bespoke (see + * reroute-node/index.tsx) but the port schema here keeps the drag + * validation honest. + */ +export const utilRerouteSchema: PortSchema = { + iceType: 'Util.Reroute', + base: [ + { + id: 'in', + direction: 'in', + role: 'any', + label: 'Input', + side: 'left', + shape: 'circle', + }, + { + id: 'out', + direction: 'out', + role: 'any', + label: 'Output', + side: 'right', + shape: 'circle', + }, + ], +}; diff --git a/packages/types/src/ports/types.ts b/packages/types/src/ports/types.ts new file mode 100644 index 00000000..b8bd9bec --- /dev/null +++ b/packages/types/src/ports/types.ts @@ -0,0 +1,235 @@ +/** + * Port model — typed connection points anchored to block properties. + * + * A "port" is what the canvas renders as a socket dot. Unlike the + * earlier `SocketDef` (which was derived from the 4-category + * `CONNECTION_RULES`), a `PortDef` has a specific semantic role + * (domain, repository, database, http-endpoint, …) that matches its + * partner by identity. The whole point is determinism: a user looks at + * a GitHub repo block and a frontend block, sees a matching `repository` + * dot on each, and knows they snap together because the repo provides + * source code and the frontend consumes source code. + * + * Ports are AUTHORED per high-level iceType (see `./schemas/`) from + * the block's typed properties in + * `packages/core/src/resources/high-level-resources/categories/*.ts`. + * They're not generated from `CONNECTION_RULES` — that 4-category model + * stays as a coarse legality gate (used by AI, deploy, propagation). + * + * Color and shape are explicit per port, with the convention that a + * port's `peerStyle` keys into the CATEGORY_STYLE table in the UI + * layer — so a frontend's `domain-in` reads as the same rose color as + * the Custom Domain block it connects to. + */ + +import type { ConnectionCategory } from '@ice/constants'; + +/** + * Semantic role of a port. Identity-matched: an OUT port with role X + * matches an IN port with role X. No aliases — keep the model boring + * so users can predict it. + */ +export type PortRole = + /** DNS routing target. CustomDomain.out ↔ Service.in (`custom_domain` property). */ + | 'domain' + /** Source code repository reference. Repo.out ↔ Service.in (`repository` property). */ + | 'repository' + /** Environment variable bundle. EnvConfig.out ↔ Service.in (`env_vars`). */ + | 'env' + /** Secret reference (single secret or bundle). Secret.out ↔ Service.in (`secrets`). */ + | 'secret' + /** Relational database connection string. Database.out ↔ Backend.in. */ + | 'database' + /** Cache connection (Redis/Memcache). Cache.out ↔ Backend.in. */ + | 'cache' + /** + * Message queue / topic. Queue exposes queue-in (publishers connect) + * and queue-out (subscribers connect); services have the inverse — + * a publisher Service has queue-out, a subscriber has queue-in. + * Direction discriminates the role. + */ + | 'queue' + /** Object storage / bucket. Storage.out ↔ Backend.in. */ + | 'storage' + /** Search index. Search.out ↔ Backend.in. */ + | 'search' + /** Vector DB. VectorDB.out ↔ Backend.in. */ + | 'vector' + /** LLM gateway. LLM.out ↔ Backend.in. */ + | 'llm' + /** + * HTTP / TCP listener on a service. Services expose http-endpoint OUT + * (one per listener); consumers (other services, gateways) have + * http-endpoint IN to receive a URL. + */ + | 'http-endpoint' + /** Logs / metrics stream. Service.out ↔ Monitoring.in. */ + | 'monitoring' + /** Pass-through (reroute node). Accepts and emits any role. */ + | 'any'; + +export type PortDirection = 'in' | 'out'; +export type PortSide = 'left' | 'right' | 'top' | 'bottom'; +export type PortShape = 'circle' | 'ring' | 'diamond' | 'square'; +export type PortProtocol = 'http' | 'https' | 'tcp' | 'udp' | 'ssh'; + +/** + * Semantic "kind" of block this port expects on the other end. + * Identity-role + opposite-direction matching isn't strict enough on its + * own — a Backend's `queue-out` (publish) and another Backend's + * `queue-in` (subscribe) both have role='queue' with opposite + * directions, but they should NOT connect: a Backend doesn't broker + * messages to another Backend; both go through a Queue block. + * `peerKind` enforces that constraint at the port level. + */ +export type PeerKind = + | 'service' // Compute.* + AI.PrivateAIService + | 'queue' // Messaging.Queue / EventStream / Email + | 'database' // Database.PostgreSQL / MySQL / MongoDB + | 'cache' // Database.Redis + | 'storage' // Storage.Bucket + | 'repository' // Source.Repository + | 'domain' // Network.CustomDomain + | 'gateway' // Network.Gateway + | 'env' // Config.Environment + | 'secret' // Security.Secret + | 'monitoring' // Monitoring.Log + | 'vector' // AI.VectorDB + | 'llm' // AI.LLMGateway + | 'reroute' // Util.Reroute — universal passthrough + | 'any'; // wildcard — accepts every kind + +/** + * Maps iceType → PeerKind. Drives the peer-kind cross-check in + * `canPortsConnect`. Unknown iceTypes fall back to `'any'` so partial + * data never blocks a legitimate connection. + */ +export function getBlockKind(iceType: string): PeerKind { + if (iceType.startsWith('Compute.') || iceType === 'AI.PrivateAIService') return 'service'; + if (iceType.startsWith('Messaging.')) return 'queue'; + if (iceType === 'Database.Redis') return 'cache'; + if (iceType.startsWith('Database.')) return 'database'; + if (iceType.startsWith('Storage.')) return 'storage'; + if (iceType === 'Source.Repository') return 'repository'; + if (iceType === 'Network.CustomDomain') return 'domain'; + if (iceType === 'Network.Gateway') return 'gateway'; + if (iceType === 'Config.Environment') return 'env'; + if (iceType === 'Security.Secret') return 'secret'; + if (iceType.startsWith('Monitoring.') || iceType.startsWith('Log.')) return 'monitoring'; + if (iceType === 'AI.VectorDB') return 'vector'; + if (iceType === 'AI.LLMGateway') return 'llm'; + if (iceType === 'Util.Reroute') return 'reroute'; + return 'any'; +} + +export interface PortDef { + /** Unique within the block. e.g. `repository-in`, `db-out`, `http-80-out`. */ + id: string; + direction: PortDirection; + role: PortRole; + /** Tooltip-quality label: "Source code", "HTTPS :443", "Custom domain". */ + label: string; + /** Anchor side for the dot. Wire endpoint may slide via magnetic routing. */ + side: PortSide; + shape: PortShape; + /** + * For an IN port, the `node.data` key this port wires to (e.g. + * `'custom_domain'` for a frontend's domain-in). When set, accepting + * a connection writes the source's anchored value here. + */ + property?: string; + /** TCP/HTTP listener port number when `role === 'http-endpoint'`. */ + port?: number; + protocol?: PortProtocol; + /** True for ports the user added via a multi-port editor — they can remove them too. */ + removable?: boolean; + /** + * CATEGORY_STYLE key for the peer block's category — drives socket + * color. Set to `'Network'` on a frontend's domain-in so the dot + * reads as Custom Domain (rose) instead of the abstract DNS color. + */ + peerStyle?: string; + /** + * Kind of block this port expects on the other end. Critical for + * ports whose role is shared by multiple block kinds (queue: a + * Backend's `queue-out` must only connect to a Queue block, never + * another Backend). When left unset, no peer-kind check fires — + * legacy schemas remain permissive. + */ + peerKind?: PeerKind; +} + +/** A high-level port schema for a single iceType. */ +export interface PortSchema { + iceType: string; + /** Base ports that are always emitted. */ + base: PortDef[]; + /** + * Dynamic ports derived from `node.data` properties — e.g. a list of + * exposed HTTP ports on a Compute.Container. Receives the node data + * and returns extra ports to append to `base`. + */ + dynamic?: (data: Record) => PortDef[]; + /** + * Conditional removal — drop ports from `base` when a property + * predicate is true (e.g. hide `pipeline-in` until a repo is wired). + */ + hide?: Array<{ + keys: readonly string[]; + when: (data: Record) => boolean; + portIds: readonly string[]; + }>; +} + +/** Default anchor side per direction — inputs left, outputs right. */ +export const DEFAULT_PORT_SIDE: Record = { + in: 'left', + out: 'right', +}; + +/** + * Shape per role — chosen for visual distinction at a glance. + * Categories map to category shapes via `CATEGORY_SHAPE` (kept for + * backwards compat with the prior socket model), but specific roles + * can override. + */ +export const ROLE_SHAPE: Record = { + domain: 'square', + repository: 'diamond', + env: 'ring', + secret: 'ring', + database: 'circle', + cache: 'circle', + queue: 'circle', + storage: 'circle', + search: 'circle', + vector: 'circle', + llm: 'circle', + 'http-endpoint': 'circle', + monitoring: 'circle', + any: 'circle', +}; + +/** + * The connection category each role contributes to. Used to keep the + * existing `inferConnectionMeta` + propagation engine firing — when a + * user drags between two ports, the resulting edge still carries the + * right `connectionCategory` so the deploy compiler, AI prompt, and + * propagation rules all keep working unchanged. + */ +export const ROLE_CATEGORY: Record = { + domain: 'dns', + repository: 'pipeline', + env: 'config', + secret: 'config', + database: 'traffic', + cache: 'traffic', + queue: 'traffic', + storage: 'traffic', + search: 'traffic', + vector: 'traffic', + llm: 'traffic', + 'http-endpoint': 'traffic', + monitoring: 'traffic', + any: 'traffic', +}; diff --git a/packages/types/src/sockets/__tests__/derive-sockets.test.ts b/packages/types/src/sockets/__tests__/derive-sockets.test.ts new file mode 100644 index 00000000..c975327a --- /dev/null +++ b/packages/types/src/sockets/__tests__/derive-sockets.test.ts @@ -0,0 +1,194 @@ +/** + * Socket derivation tests. + * + * Covers: + * - Containers (VPC, Subnet, Group.*, PrivateNetwork) emit no sockets. + * - Default derivation walks `CONNECTION_RULES` and produces one IN/OUT + * socket per matching (direction, category) pair, deduped. + * - Schemas can ADD (conditional) and REMOVE (hide) sockets based on + * `node.data` properties — the canonical property-driven case. + * - The memo cache invalidates when a schema-declared key changes. + */ + +import { describe, expect, it, beforeEach } from 'vitest'; +import { getSocketsForNode, hasSocket, findSocket, _resetSocketCache, type NodeForSockets } from '../derive-sockets'; + +beforeEach(() => { + _resetSocketCache(); +}); + +function node(iceType: string, extra: Record = {}, type?: string): NodeForSockets { + return { id: 't', data: { iceType, ...extra }, type }; +} + +describe('containers emit no sockets', () => { + it.each(['Network.VPC', 'Network.Subnet', 'Network.PrivateNetwork', 'Group.Frontend', 'Group.Custom'])( + '%s → []', + (iceType) => { + expect(getSocketsForNode(node(iceType))).toEqual([]); + }, + ); + + it('any node whose `type` is `container` returns no sockets', () => { + expect(getSocketsForNode(node('Database.PostgreSQL', {}, 'container'))).toEqual([]); + }); + + it('missing iceType returns []', () => { + expect(getSocketsForNode({ id: 't', data: {} })).toEqual([]); + }); +}); + +describe('default derivation', () => { + it('Postgres → traffic-in (Backend → Database) and config-out (env-var)', () => { + const sockets = getSocketsForNode(node('Database.PostgreSQL')); + const ids = sockets.map((s) => s.id); + expect(ids).toContain('traffic-in'); + // Postgres is also a source for Database → Backend (reverse), which we skip, + // and Service → Config rules don't classify it as a source — so it has no + // pipeline or dns sockets, but it DOES classify as a config target via + // backend → database injecting env vars on the target side? No — config + // rules are Service → EnvVars only. So Postgres should NOT have a + // config-out by default; that comes from the meta-injection at edge + // creation time. We assert the present-by-default set: + expect(ids).toEqual(expect.arrayContaining(['traffic-in'])); + }); + + it('Backend (Compute.Container) → in + out traffic, pipeline-in, config-out', () => { + // Hide defaults to no repository/no domain, so pipeline-in and dns-in + // are suppressed by the scalable-backend schema. To see the full + // default set, use a non-Compute.Container backend type that has no schema. + const sockets = getSocketsForNode(node('Compute.Worker')); + const ids = new Set(sockets.map((s) => s.id)); + expect(ids).toContain('traffic-in'); + expect(ids).toContain('traffic-out'); + expect(ids).toContain('pipeline-in'); + expect(ids).toContain('config-out'); + }); + + it('dedupes — Backend appears in many rules but we keep one traffic-in', () => { + const sockets = getSocketsForNode(node('Compute.Worker')); + const trafficIn = sockets.filter((s) => s.id === 'traffic-in'); + expect(trafficIn).toHaveLength(1); + }); + + it('default IN sockets anchor left, OUT sockets anchor right', () => { + const sockets = getSocketsForNode(node('Compute.Worker')); + for (const s of sockets) { + if (s.direction === 'in') expect(s.side).toBe('left'); + else expect(s.side).toBe('right'); + } + }); + + it('shape is derived from category', () => { + const sockets = getSocketsForNode(node('Compute.Worker')); + const byId = new Map(sockets.map((s) => [s.id, s])); + expect(byId.get('traffic-in')?.shape).toBe('circle'); + expect(byId.get('pipeline-in')?.shape).toBe('diamond'); + expect(byId.get('config-out')?.shape).toBe('ring'); + }); +}); + +describe('property-driven schemas', () => { + it('Postgres replication=true adds replica-out; replication=false does not', () => { + const withRep = getSocketsForNode(node('Database.PostgreSQL', { replication: true })); + const withoutRep = getSocketsForNode(node('Database.PostgreSQL', { replication: false })); + expect(withRep.some((s) => s.id === 'replica-out')).toBe(true); + expect(withoutRep.some((s) => s.id === 'replica-out')).toBe(false); + }); + + it('Compute.Container hides pipeline-in until a repository is set', () => { + const noRepo = getSocketsForNode(node('Compute.Container')); + const withRepo = getSocketsForNode(node('Compute.Container', { repository: 'org/repo' })); + expect(noRepo.some((s) => s.id === 'pipeline-in')).toBe(false); + expect(withRepo.some((s) => s.id === 'pipeline-in')).toBe(true); + }); + + it('Compute.Container hides dns-in until a domain is configured', () => { + const noDomain = getSocketsForNode(node('Compute.Container', { repository: 'org/repo' })); + const withDomain = getSocketsForNode( + node('Compute.Container', { repository: 'org/repo', domain: 'app.example.com' }), + ); + expect(noDomain.some((s) => s.id === 'dns-in')).toBe(false); + expect(withDomain.some((s) => s.id === 'dns-in')).toBe(true); + }); + + it('Compute.StaticSite hides dns-in until custom_domain is set', () => { + const off = getSocketsForNode(node('Compute.StaticSite')); + const on = getSocketsForNode(node('Compute.StaticSite', { custom_domain: 'shop.example.com' })); + expect(off.some((s) => s.id === 'dns-in')).toBe(false); + expect(on.some((s) => s.id === 'dns-in')).toBe(true); + }); +}); + +describe('peer-style coloring', () => { + it("a frontend's dns-in carries peerStyle='Network' so the dot reads as Custom Domain", () => { + const sockets = getSocketsForNode(node('Compute.StaticSite', { custom_domain: 'shop.example.com' })); + const dnsIn = sockets.find((s) => s.id === 'dns-in'); + expect(dnsIn).toBeDefined(); + expect(dnsIn!.peerStyle).toBe('Network'); + }); + + it("a service's pipeline-in carries peerStyle='Source'", () => { + const sockets = getSocketsForNode(node('Compute.Worker')); + const pipelineIn = sockets.find((s) => s.id === 'pipeline-in'); + expect(pipelineIn).toBeDefined(); + expect(pipelineIn!.peerStyle).toBe('Source'); + }); + + it("a service's config-out carries peerStyle='Config'", () => { + const sockets = getSocketsForNode(node('Compute.Worker')); + const configOut = sockets.find((s) => s.id === 'config-out'); + expect(configOut).toBeDefined(); + expect(configOut!.peerStyle).toBe('Config'); + }); + + it('traffic sockets DO NOT carry a peer style — too many possible peer types', () => { + const sockets = getSocketsForNode(node('Compute.Worker')); + const trafficIn = sockets.find((s) => s.id === 'traffic-in'); + const trafficOut = sockets.find((s) => s.id === 'traffic-out'); + expect(trafficIn?.peerStyle).toBeUndefined(); + expect(trafficOut?.peerStyle).toBeUndefined(); + }); + + it("Postgres's replica-out (schema-authored) carries peerStyle='Compute'", () => { + const sockets = getSocketsForNode(node('Database.PostgreSQL', { replication: true })); + const replicaOut = sockets.find((s) => s.id === 'replica-out'); + expect(replicaOut?.peerStyle).toBe('Compute'); + }); +}); + +describe('memoization', () => { + it('returns equal arrays for repeated calls with the same data', () => { + const a = getSocketsForNode(node('Database.PostgreSQL', { replication: true })); + const b = getSocketsForNode(node('Database.PostgreSQL', { replication: true })); + expect(a).toBe(b); + }); + + it('invalidates when a schema-declared key changes', () => { + const off = getSocketsForNode(node('Database.PostgreSQL', { replication: false })); + const on = getSocketsForNode(node('Database.PostgreSQL', { replication: true })); + expect(off).not.toBe(on); + expect(on.some((s) => s.id === 'replica-out')).toBe(true); + }); + + it('ignores data keys that no schema reads', () => { + // `description` is not declared by any schema → same cache entry. + const a = getSocketsForNode(node('Database.PostgreSQL', { description: 'one' })); + const b = getSocketsForNode(node('Database.PostgreSQL', { description: 'two' })); + expect(a).toBe(b); + }); +}); + +describe('hasSocket / findSocket', () => { + it('hasSocket reflects the current schema state', () => { + const n = node('Database.PostgreSQL', { replication: true }); + expect(hasSocket(n, 'replica-out')).toBe(true); + expect(hasSocket(n, 'nonexistent')).toBe(false); + }); + + it('findSocket returns the SocketDef or undefined', () => { + const n = node('Database.PostgreSQL'); + expect(findSocket(n, 'traffic-in')?.category).toBe('traffic'); + expect(findSocket(n, 'replica-out')).toBeUndefined(); + }); +}); diff --git a/packages/types/src/sockets/derive-sockets.ts b/packages/types/src/sockets/derive-sockets.ts new file mode 100644 index 00000000..1c9d40d6 --- /dev/null +++ b/packages/types/src/sockets/derive-sockets.ts @@ -0,0 +1,206 @@ +/** + * Socket derivation. + * + * `getSocketsForNode(node)` produces the typed socket list for a block, + * combining (1) the default sockets derived from `CONNECTION_RULES` + * with (2) any per-block `SocketSchema` adjustments (additions via + * `base` / `conditional`, removals via `hide`). + * + * Socket geometry tracks block properties — toggling `data.replication` + * on Postgres adds or removes the `replica-out` socket on the next + * render. Edges already attached to a socket that's no longer present + * are not auto-deleted; they enter the "dangling" state for the user + * to clean up explicitly. + * + * Containers (VPC, Subnet, Group, PrivateNetwork) emit zero sockets — + * children attach via `parentId`, not via wires. + */ + +import { getSchema } from './schemas'; +import { CATEGORY_SHAPE, DEFAULT_SIDE, type SocketDef } from './types'; +import { isContainer } from '../connection-rules/predicates'; +import { CONNECTION_RULES } from '../connection-rules/rules-data'; +import type { SocketSchema } from './socket-schema'; +import type { NodeForConnectionCheck } from '../connection-rules/types'; +import type { ConnectionCategory } from '@ice/constants'; + +/** Identifier for a default-derived socket: `-`. */ +function defaultSocketId(category: ConnectionCategory, direction: 'in' | 'out'): string { + return `${category}-${direction}`; +} + +function defaultLabel(category: ConnectionCategory, direction: 'in' | 'out'): string { + const dir = direction === 'in' ? 'input' : 'output'; + return `${category.charAt(0).toUpperCase()}${category.slice(1)} ${dir}`; +} + +/** + * Peer-block-category accent for a default-derived socket, so the dot + * reads as "the thing on the other end" rather than the abstract wire + * category. Per the user's request: a Frontend's dns-in socket should + * be the Custom Domain (Network) color, not the generic DNS color. + * + * Only the unambiguous cases are mapped — TRAFFIC (which can connect + * to many block types depending on direction and source) stays on the + * abstract category color. + */ +function defaultPeerStyle(category: ConnectionCategory, direction: 'in' | 'out'): string | undefined { + // DNS edges are always Custom Domain ↔ Routable. From a Routable's + // perspective the peer is a Domain (Network family). + if (category === 'dns' && direction === 'in') return 'Network'; + // PIPELINE edges are always Repo → Service. From a Service the peer + // is a Source.Repository. + if (category === 'pipeline' && direction === 'in') return 'Source'; + // CONFIG edges are always Service → EnvConfig/Secrets. From a Service + // the peer is a Config block. + if (category === 'config' && direction === 'out') return 'Config'; + return undefined; +} + +/** + * Walk `CONNECTION_RULES` and emit one socket per matching + * (direction, category) pair. Reverse rules are skipped — they're a + * drag-direction convenience, not a separate socket. Deduped by id. + */ +function deriveDefaultSockets(iceType: string): SocketDef[] { + const seen = new Set(); + const out: SocketDef[] = []; + for (const rule of CONNECTION_RULES) { + if (rule.reverse) continue; + if (rule.source(iceType)) { + const id = defaultSocketId(rule.category, 'out'); + if (!seen.has(id)) { + seen.add(id); + const peerStyle = defaultPeerStyle(rule.category, 'out'); + out.push({ + id, + side: DEFAULT_SIDE.out, + category: rule.category, + direction: 'out', + label: defaultLabel(rule.category, 'out'), + shape: CATEGORY_SHAPE[rule.category], + multi: true, + ...(peerStyle && { peerStyle }), + }); + } + } + if (rule.target(iceType)) { + const id = defaultSocketId(rule.category, 'in'); + if (!seen.has(id)) { + seen.add(id); + const peerStyle = defaultPeerStyle(rule.category, 'in'); + out.push({ + id, + side: DEFAULT_SIDE.in, + category: rule.category, + direction: 'in', + label: defaultLabel(rule.category, 'in'), + shape: CATEGORY_SHAPE[rule.category], + multi: rule.category !== 'config', + ...(peerStyle && { peerStyle }), + }); + } + } + } + return out; +} + +function applySchema( + sockets: SocketDef[], + schema: SocketSchema | undefined, + data: Record, +): SocketDef[] { + if (!schema) return sockets; + let result = schema.replaceBase ? [] : [...sockets]; + if (schema.base?.length) result.push(...schema.base); + if (schema.conditional) { + for (const cond of schema.conditional) { + if (cond.when(data)) result.push(...cond.sockets); + } + } + if (schema.hide) { + for (const hide of schema.hide) { + if (hide.when(data)) { + const ids = new Set(hide.socketIds); + result = result.filter((s) => !ids.has(s.id)); + } + } + } + // Final dedupe by id (later wins on conflict). + const byId = new Map(); + for (const s of result) byId.set(s.id, s); + return Array.from(byId.values()); +} + +// ─── Memoization ──────────────────────────────────────────────────────────── + +/** + * Memo cache keyed by (iceType, comma-joined values of the keys read by + * any conditional/hide in the schema). For blocks with no schema, the + * cache key is just the iceType — they have no property-dependent + * branches. + */ +const cache = new Map(); + +function cacheKey(iceType: string, schema: SocketSchema | undefined, data: Record): string { + if (!schema) return iceType; + const keys = new Set(); + for (const c of schema.conditional ?? []) c.keys.forEach((k) => keys.add(k)); + for (const h of schema.hide ?? []) h.keys.forEach((k) => keys.add(k)); + const parts: string[] = [iceType]; + for (const k of Array.from(keys).sort()) { + parts.push(`${k}=${JSON.stringify(data[k] ?? null)}`); + } + return parts.join('|'); +} + +/** Test helper — clears the memo cache. Don't use in production code paths. */ +export function _resetSocketCache(): void { + cache.clear(); +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * The minimal node shape needed for socket derivation: an iceType + * (or container `type`) and the property bag. + */ +export interface NodeForSockets extends NodeForConnectionCheck { + data?: Record; +} + +/** + * Returns the ordered socket list for a node. Empty for containers, + * for nodes without an iceType, and for nodes whose iceType doesn't + * appear as either source or target in any `CONNECTION_RULES` entry. + */ +export function getSocketsForNode(node: NodeForSockets): SocketDef[] { + const data = node.data ?? {}; + const iceType = typeof data.iceType === 'string' ? data.iceType : ''; + if (!iceType) return []; + if (isContainer(iceType, node.type)) return []; + + const schema = getSchema(iceType); + const key = cacheKey(iceType, schema, data); + const cached = cache.get(key); + if (cached) return cached; + + const defaults = deriveDefaultSockets(iceType); + const result = applySchema(defaults, schema, data); + cache.set(key, result); + return result; +} + +/** + * Returns true if `socketId` exists on the node's current socket list. + * Wire-rendering uses this to flag dangling edges whose anchor socket + * has been removed by a property change. + */ +export function hasSocket(node: NodeForSockets, socketId: string): boolean { + return getSocketsForNode(node).some((s) => s.id === socketId); +} + +/** Lookup helper for the render layer. */ +export function findSocket(node: NodeForSockets, socketId: string): SocketDef | undefined { + return getSocketsForNode(node).find((s) => s.id === socketId); +} diff --git a/packages/types/src/sockets/index.ts b/packages/types/src/sockets/index.ts new file mode 100644 index 00000000..15acec94 --- /dev/null +++ b/packages/types/src/sockets/index.ts @@ -0,0 +1,17 @@ +/** + * Sockets — typed connection points on blocks. + * + * Public surface: types (`SocketDef`, `SocketSide`, `SocketDirection`, + * `SocketShape`), schema (`SocketSchema`, `SocketConditional`, + * `SocketHide`), and the derivation entrypoints + * (`getSocketsForNode`, `hasSocket`, `findSocket`). + * + * Schemas themselves are an implementation detail — consumers should + * never import from `./schemas/*` directly; they should ask the + * derivation API for sockets and trust it to consult the schema + * registry. + */ + +export * from './types'; +export type { SocketSchema, SocketConditional, SocketHide } from './socket-schema'; +export { getSocketsForNode, hasSocket, findSocket, _resetSocketCache, type NodeForSockets } from './derive-sockets'; diff --git a/packages/types/src/sockets/schemas/index.ts b/packages/types/src/sockets/schemas/index.ts new file mode 100644 index 00000000..25eb53b7 --- /dev/null +++ b/packages/types/src/sockets/schemas/index.ts @@ -0,0 +1,30 @@ +/** + * Socket schema registry. + * + * Maps iceType → `SocketSchema`. Schemas live here (not next to block + * blueprints in `@ice/blocks`) so `@ice/types` can derive sockets + * without a circular dependency on `@ice/blocks`. + * + * Most blocks need no entry — the default derivation in `derive-sockets.ts` + * already walks `CONNECTION_RULES` and emits one IN/OUT socket per matching + * (direction, category) pair. A schema is only needed when sockets + * depend on the block's *properties* — e.g. Postgres exposes a + * `replica-out` socket only when `data.replication === true`. + */ + +import { postgresSchema } from './postgres'; +import { scalableBackendSchema } from './scalable-backend'; +import { staticSiteSchema } from './static-site'; +import type { SocketSchema } from '../socket-schema'; + +export const SOCKET_SCHEMAS: Record = { + [postgresSchema.iceType]: postgresSchema, + [scalableBackendSchema.iceType]: scalableBackendSchema, + [staticSiteSchema.iceType]: staticSiteSchema, +}; + +export function getSchema(iceType: string): SocketSchema | undefined { + return SOCKET_SCHEMAS[iceType]; +} + +export { postgresSchema, scalableBackendSchema, staticSiteSchema }; diff --git a/packages/types/src/sockets/schemas/postgres.ts b/packages/types/src/sockets/schemas/postgres.ts new file mode 100644 index 00000000..2706e130 --- /dev/null +++ b/packages/types/src/sockets/schemas/postgres.ts @@ -0,0 +1,33 @@ +import type { SocketSchema } from '../socket-schema'; + +/** + * Postgres exposes a read-only replica output ONLY when the user has + * turned on replication in the properties panel. Without it, the socket + * is absent and edges that previously attached to it enter the dangling + * state until cleaned up. + * + * `base` is empty — the default derivation already produces the standard + * traffic-in (Backend → Database) and config-out (env-var injection) + * sockets from `CONNECTION_RULES`. The schema only adds the conditional. + */ +export const postgresSchema: SocketSchema = { + iceType: 'Database.PostgreSQL', + conditional: [ + { + keys: ['replication'], + when: (data) => data.replication === true, + sockets: [ + { + id: 'replica-out', + side: 'right', + category: 'traffic', + direction: 'out', + label: 'Read replica', + shape: 'circle', + // Replica-out peers with backends/services that read it — color by Compute. + peerStyle: 'Compute', + }, + ], + }, + ], +}; diff --git a/packages/types/src/sockets/schemas/scalable-backend.ts b/packages/types/src/sockets/schemas/scalable-backend.ts new file mode 100644 index 00000000..2e505e8c --- /dev/null +++ b/packages/types/src/sockets/schemas/scalable-backend.ts @@ -0,0 +1,27 @@ +import type { SocketSchema } from '../socket-schema'; + +/** + * Scalable backend (`Compute.Container`) gains a `pipeline-in` socket + * only when the user has connected/configured a repository — until then, + * the pipeline socket is noise. Similarly a `dns-in` socket appears only + * when the block is set to receive public traffic via a domain. + * + * `hide` removes the default pipeline-in derived from `Repo → Service` + * when no repository is configured, so the block doesn't dangle an + * unused socket. + */ +export const scalableBackendSchema: SocketSchema = { + iceType: 'Compute.Container', + hide: [ + { + keys: ['repository'], + when: (data) => !data.repository, + socketIds: ['pipeline-in'], + }, + { + keys: ['domain', 'custom_domain'], + when: (data) => !data.domain && !data.custom_domain, + socketIds: ['dns-in'], + }, + ], +}; diff --git a/packages/types/src/sockets/schemas/static-site.ts b/packages/types/src/sockets/schemas/static-site.ts new file mode 100644 index 00000000..8402fa0d --- /dev/null +++ b/packages/types/src/sockets/schemas/static-site.ts @@ -0,0 +1,17 @@ +import type { SocketSchema } from '../socket-schema'; + +/** + * Static site exposes a `dns-in` socket only when the user has opted in + * to a custom domain; otherwise the block defaults to its provider- + * managed URL and the DNS socket is hidden. + */ +export const staticSiteSchema: SocketSchema = { + iceType: 'Compute.StaticSite', + hide: [ + { + keys: ['custom_domain', 'domain'], + when: (data) => !data.custom_domain && !data.domain, + socketIds: ['dns-in'], + }, + ], +}; diff --git a/packages/types/src/sockets/socket-schema.ts b/packages/types/src/sockets/socket-schema.ts new file mode 100644 index 00000000..90f66594 --- /dev/null +++ b/packages/types/src/sockets/socket-schema.ts @@ -0,0 +1,54 @@ +/** + * Socket schema — declarative shape of a block's sockets. + * + * Schemas are property-aware: they declare `base` sockets that are always + * emitted, plus `conditional` and `hide` rules that add or remove sockets + * based on `node.data` predicates. The point is that **socket geometry + * tracks block properties**, the way Blender's Mix / Sample Curve nodes + * grow and shrink their socket lists as the user changes the node's mode. + * + * If a block has no schema entry, the derivation falls back to walking + * `CONNECTION_RULES` and emitting one IN/OUT socket per matching + * (direction, category) pair — see `derive-sockets.ts`. + */ + +import type { SocketDef } from './types'; + +/** + * A conditional gate. The `keys` array is load-bearing: it lists the + * `node.data` keys the `when` predicate reads, which the memoizer uses + * to build a stable cache key without serializing the whole data object. + * + * Adding a new key the predicate reads but forgetting to list it here + * is a correctness bug — the cache will return stale sockets when that + * key flips. Add a unit test that toggles the key and asserts the + * socket list changes. + */ +export interface SocketConditional { + keys: readonly string[]; + when: (data: Record) => boolean; + sockets: SocketDef[]; +} + +/** Same shape as `SocketConditional` but the gate suppresses sockets by id. */ +export interface SocketHide { + keys: readonly string[]; + when: (data: Record) => boolean; + socketIds: readonly string[]; +} + +export interface SocketSchema { + iceType: string; + /** + * If true, ignore the default `CONNECTION_RULES`-driven derivation and + * use only `base` + conditionals. Use sparingly — most blocks should + * augment the defaults, not replace them. + */ + replaceBase?: boolean; + /** Always-emitted sockets. Appended to the default derivation when `replaceBase` is false. */ + base?: SocketDef[]; + /** Sockets emitted only when the gate passes. */ + conditional?: SocketConditional[]; + /** Default-derived or base sockets removed when the gate passes. */ + hide?: SocketHide[]; +} diff --git a/packages/types/src/sockets/types.ts b/packages/types/src/sockets/types.ts new file mode 100644 index 00000000..4d347899 --- /dev/null +++ b/packages/types/src/sockets/types.ts @@ -0,0 +1,78 @@ +/** + * Socket type surface. + * + * A `SocketDef` describes one typed connection point on a block — what + * category of wire it carries (TRAFFIC / PIPELINE / CONFIG / DNS), which + * way wires flow (in / out), and where on the block surface it lives by + * default. The actual wire-attach point is allowed to slide along the + * `side` perimeter at render time ("magnetic" routing), but the visible + * socket dot stays anchored where this type says it does. + * + * Inputs default to the left, outputs to the right — Blender Geometry + * Nodes convention. Schemas may override `side` for blocks where a + * different anchor reads more naturally (e.g. a top-anchored DNS input + * on a frontend block). + */ + +import type { ConnectionCategory } from '@ice/constants'; + +/** Side of the block where this socket's dot is anchored. */ +export type SocketSide = 'left' | 'right' | 'top' | 'bottom'; + +/** Direction of data flow through the socket. */ +export type SocketDirection = 'in' | 'out'; + +/** + * Visual shape of the socket dot. One shape per category so the canvas + * reads at a glance: + * - circle → traffic + * - ring → config + * - diamond → pipeline + * - square → dns + */ +export type SocketShape = 'circle' | 'ring' | 'diamond' | 'square'; + +export interface SocketDef { + /** Stable identifier, persisted on `CardEdge.data.sourceSocket` / `targetSocket`. */ + id: string; + /** Default anchor side. Render layer may slide the actual attach point along this side. */ + side: SocketSide; + /** Wire category — drives color via `CATEGORY_COLORS` + connection-rule match. */ + category: ConnectionCategory; + /** in = receives wires; out = emits wires. */ + direction: SocketDirection; + /** Tooltip / accessibility label. */ + label: string; + /** Visual shape; usually derived from category but overridable. */ + shape: SocketShape; + /** True if this socket accepts more than one edge. Default: false (single). */ + multi?: boolean; + /** + * Override the socket dot's color with the peer block's category + * accent. Set to a CATEGORY_STYLE key like `'Network'`, `'Source'`, + * `'Config'` so a frontend's dns-in reads as "Custom Domain" (rose) + * instead of the abstract DNS color (cyan). Falls back to + * `CATEGORY_COLORS[category]` when unset. + */ + peerStyle?: string; + /** + * Optional peer block iceType for tooltip / discovery — "this socket + * connects to a Custom Domain." Not load-bearing for routing; purely + * for the hover chip and future affordances. + */ + peerIceType?: string; +} + +/** Default shape per category. */ +export const CATEGORY_SHAPE: Record = { + traffic: 'circle', + pipeline: 'diamond', + config: 'ring', + dns: 'square', +}; + +/** Default anchor side per direction. */ +export const DEFAULT_SIDE: Record = { + in: 'left', + out: 'right', +}; diff --git a/packages/ui/src/features/canvas/components/__tests__/connection-preview-overlay.test.tsx b/packages/ui/src/features/canvas/components/__tests__/connection-preview-overlay.test.tsx index 9fb3c949..e2b0bf93 100644 --- a/packages/ui/src/features/canvas/components/__tests__/connection-preview-overlay.test.tsx +++ b/packages/ui/src/features/canvas/components/__tests__/connection-preview-overlay.test.tsx @@ -1,59 +1,29 @@ /** - * rf-canv-14 — `ConnectionPreviewOverlay` subcomponent. + * `ConnectionPreviewOverlay` tests — new socket-to-socket behavior. * - * `ConnectionPreviewOverlay` is a presentational FC that wraps the JSX shell - * for the in-flight connection drag preview (a cubic-bezier `` from - * source port to current cursor, plus two anchor ``s). The bezier - * math (`computeConnectionPreviewPath`) and the color picker - * (`pickPreviewColor`) live in `../utils/connection-preview` and are tested - * exhaustively by `utils/__tests__/connection-preview.test.ts` (rf-canv-8). - * This suite mocks both helpers so the assertions exercise ONLY the new - * component's behavior — the JSX shell + the prop-forwarding contract — and - * don't redundantly retest the rf-canv-8 utils. - * - * Direct-FC tree-walker pattern (cite - * `tree-walker-for-react-fc-tests-must-flatten-nested-children-arrays`): - * invoke the component as a function, then walk the returned React-element - * tree depth-first and assert on type / props / children. + * The overlay now renders the in-flight preview ONLY when the magnet + * has snapped to a compatible target port. Without a snap the preview + * is `null` — the source-socket pulse + per-port halos elsewhere on + * the canvas carry the feedback. This matches the "connections are + * socket ↔ socket only" UX standard. */ import React from 'react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { CanvasNode } from '../types'; - -// ─── Mock the rf-canv-8 utils so we exercise only the JSX shell ────────────── +import { describe, it, expect, beforeEach } from 'vitest'; -const mocks = vi.hoisted(() => ({ - computeConnectionPreviewPath: vi.fn< - (sourcePoint: { x: number; y: number }, currentPoint: { x: number; y: number }) => string - >(() => 'M 0 0 C 0 0, 0 0, 0 0'), - pickPreviewColor: vi.fn< - ( - currentPoint: { x: number; y: number }, - effectiveNodes: CanvasNode[], - sourceId: string, - dragTargets: Map | null | undefined, - ) => string - >(() => '#22d3ee'), -})); -vi.mock('../../utils/connection-preview', () => ({ - computeConnectionPreviewPath: mocks.computeConnectionPreviewPath, - pickPreviewColor: mocks.pickPreviewColor, -})); - -// Import AFTER vi.mock so the mocked module is bound. import { ConnectionPreviewOverlay, type ConnectionPreviewOverlayProps } from '../connection-preview-overlay'; +import { + ConnectionDragProvider, + _resetConnectionDragInfo, + type ConnectionDragInfo, +} from '../nodes/_shared/connection-drag-context'; -// ─── Tree-walker (same shape as rf-canv-10/11/12/13) ───────────────────────── - -type ReactNodeLike = React.ReactNode; +// ─── Tree-walker — same shape as the other rf-canv-* tests ─────────────────── -function* walk(node: ReactNodeLike): Generator { - if (node == null || typeof node === 'boolean' || typeof node === 'string' || typeof node === 'number') { - return; - } +function* walk(node: React.ReactNode): Generator { + if (node == null || typeof node === 'boolean' || typeof node === 'string' || typeof node === 'number') return; if (Array.isArray(node)) { - for (const c of node) yield* walk(c as ReactNodeLike); + for (const c of node) yield* walk(c as React.ReactNode); return; } const el = node as React.ReactElement; @@ -63,18 +33,14 @@ function* walk(node: ReactNodeLike): Generator { yield* walk(children); } -function findByPredicate(tree: React.ReactNode, predicate: (el: React.ReactElement) => boolean): React.ReactElement[] { +function findByType(tree: React.ReactNode, type: unknown): React.ReactElement[] { const out: React.ReactElement[] = []; for (const el of walk(tree)) { - if (el && predicate(el)) out.push(el); + if (el && el.type === type) out.push(el); } return out; } -function findByType(tree: React.ReactNode, type: unknown): React.ReactElement[] { - return findByPredicate(tree, (el) => el.type === type); -} - // ─── Helpers ───────────────────────────────────────────────────────────────── const baseProps = (overrides: Partial = {}): ConnectionPreviewOverlayProps => ({ @@ -88,226 +54,83 @@ const baseProps = (overrides: Partial = {}): Conn ...overrides, }); -const render = (overrides: Partial = {}) => - ConnectionPreviewOverlay(baseProps(overrides)); +/** Renders the overlay as a plain function, optionally seeding the drag context first. */ +function render( + overrides: Partial = {}, + dragInfo: ConnectionDragInfo | null = null, +): React.ReactNode { + // Seed the singleton via the provider's render so getConnectionDragInfo + // sees the value when the overlay calls it. + ConnectionDragProvider({ value: dragInfo, children: null }); + return ConnectionPreviewOverlay(baseProps(overrides)); +} -// Reset the mocks before each test so call-args assertions are clean. beforeEach(() => { - mocks.computeConnectionPreviewPath.mockClear(); - mocks.pickPreviewColor.mockClear(); - mocks.computeConnectionPreviewPath.mockReturnValue('M 0 0 C 0 0, 0 0, 0 0'); - mocks.pickPreviewColor.mockReturnValue('#22d3ee'); + _resetConnectionDragInfo(); }); // ═══════════════════════════════════════════════════════════════════════════ -// Outer wrap (className + pointer-events) +// No snap → no preview // ═══════════════════════════════════════════════════════════════════════════ -describe('ConnectionPreviewOverlay — outer wrap', () => { - it('renders ', () => { - const tree = render(); - const wraps = findByPredicate( - tree, - (el) => el.type === 'g' && (el.props as { className?: string }).className === 'connection-preview', - ); - expect(wraps).toHaveLength(1); - const style = (wraps[0].props as { style?: React.CSSProperties }).style; - expect(style?.pointerEvents).toBe('none'); +describe('ConnectionPreviewOverlay — no snap target', () => { + it('returns null when there is no active drag info (rest state)', () => { + const tree = render({}, null); + expect(tree).toBeNull(); }); -}); -// ═══════════════════════════════════════════════════════════════════════════ -// Util forwarding -// ═══════════════════════════════════════════════════════════════════════════ - -describe('ConnectionPreviewOverlay — forwards args to rf-canv-8 utils', () => { - it('calls computeConnectionPreviewPath with (sourcePoint, currentPoint)', () => { - render({ - drawingConnection: { - sourceId: 'src', - sourcePoint: { x: 11, y: 22 }, - currentPoint: { x: 111, y: 222 }, - }, - }); - expect(mocks.computeConnectionPreviewPath).toHaveBeenCalledTimes(1); - expect(mocks.computeConnectionPreviewPath).toHaveBeenCalledWith({ x: 11, y: 22 }, { x: 111, y: 222 }); - }); - - it('calls pickPreviewColor with (currentPoint, effectiveNodes, sourceId, dragTargets)', () => { - const nodes: CanvasNode[] = [ + it("returns null when a drag is in progress but the cursor isn't on a compatible port", () => { + const tree = render( + {}, { - id: 'n1', - type: 'block', - x: 0, - y: 0, - width: 50, - height: 50, - label: 'n', - data: {}, - }, - ]; - const targets = new Map([['n1', 'valid-target']]); - render({ - drawingConnection: { - sourceId: 'src-id', - sourcePoint: { x: 0, y: 0 }, - currentPoint: { x: 333, y: 444 }, + sourceNodeId: 'src', + sourcePortId: 'env-out', + compatibleByNode: new Map([['tgt', new Set(['env-in'])]]), + snap: null, }, - effectiveNodes: nodes, - connectionDragTargets: targets, - }); - expect(mocks.pickPreviewColor).toHaveBeenCalledTimes(1); - expect(mocks.pickPreviewColor).toHaveBeenCalledWith({ x: 333, y: 444 }, nodes, 'src-id', targets); - }); - - it('threads a null dragTargets through verbatim (no defaulting in the shell)', () => { - render({ connectionDragTargets: null }); - const args = mocks.pickPreviewColor.mock.calls[0]; - expect(args[3]).toBeNull(); + ); + expect(tree).toBeNull(); }); }); // ═══════════════════════════════════════════════════════════════════════════ -// element +// Snap → solid socket-to-socket line // ═══════════════════════════════════════════════════════════════════════════ -describe('ConnectionPreviewOverlay — element', () => { - it('renders one with d = computeConnectionPreviewPath return value', () => { - mocks.computeConnectionPreviewPath.mockReturnValue('M 1 2 C 3 4, 5 6, 7 8'); - const tree = render(); - const paths = findByType(tree, 'path'); - expect(paths).toHaveLength(1); - const props = paths[0].props as { - d: string; - stroke: string; - strokeWidth: number; - fill: string; - strokeDasharray: string; - opacity: number; - }; - expect(props.d).toBe('M 1 2 C 3 4, 5 6, 7 8'); - }); - - it('uses the previewColor returned by pickPreviewColor as the path stroke', () => { - mocks.pickPreviewColor.mockReturnValue('#abcdef'); - const tree = render(); - const path = findByType(tree, 'path')[0]; - expect((path.props as { stroke: string }).stroke).toBe('#abcdef'); +describe('ConnectionPreviewOverlay — snapped to target', () => { + const snappedInfo: ConnectionDragInfo = { + sourceNodeId: 'src', + sourcePortId: 'env-out', + compatibleByNode: new Map([['tgt', new Set(['env-in'])]]), + snap: { nodeId: 'tgt', portId: 'env-in' }, + }; + + it('renders an outer wrapper', () => { + const tree = render({}, snappedInfo); + const wraps = findByType(tree, 'g'); + expect(wraps).toHaveLength(1); + expect((wraps[0].props as { className?: string }).className).toBe('connection-preview'); }); - it('pins the verbatim path props: strokeWidth=2, fill="none", strokeDasharray="8 4", opacity=0.7', () => { - const tree = render(); - const path = findByType(tree, 'path')[0]; - const props = path.props as { - strokeWidth: number; - fill: string; - strokeDasharray: string; - opacity: number; - }; - expect(props.strokeWidth).toBe(2); + it('renders exactly one (no dashes — socket-to-socket is a solid promise)', () => { + const tree = render({}, snappedInfo); + const paths = findByType(tree, 'path'); + expect(paths).toHaveLength(1); + const props = paths[0].props as { fill: string; stroke: string; strokeDasharray?: string }; expect(props.fill).toBe('none'); - expect(props.strokeDasharray).toBe('8 4'); - expect(props.opacity).toBe(0.7); + expect(props.stroke).toBe('#22c55e'); + expect(props.strokeDasharray).toBeUndefined(); }); -}); -// ═══════════════════════════════════════════════════════════════════════════ -// elements (anchors) -// ═══════════════════════════════════════════════════════════════════════════ - -describe('ConnectionPreviewOverlay — anchor elements', () => { - it('renders exactly two elements', () => { - const tree = render(); + it('renders two anchor circles (one at source, one at snapped endpoint)', () => { + const tree = render({}, snappedInfo); const circles = findByType(tree, 'circle'); expect(circles).toHaveLength(2); }); - it('first circle anchors the source: cx/cy = sourcePoint, r=4, opacity=0.9', () => { - const tree = render({ - drawingConnection: { - sourceId: 'src', - sourcePoint: { x: 50, y: 60 }, - currentPoint: { x: 500, y: 600 }, - }, - }); - const circles = findByType(tree, 'circle'); - const props = circles[0].props as { - cx: number; - cy: number; - r: number; - opacity: number; - }; - expect(props.cx).toBe(50); - expect(props.cy).toBe(60); - expect(props.r).toBe(4); - expect(props.opacity).toBe(0.9); - }); - - it('second circle anchors the cursor: cx/cy = currentPoint, r=4, opacity=0.6', () => { - const tree = render({ - drawingConnection: { - sourceId: 'src', - sourcePoint: { x: 50, y: 60 }, - currentPoint: { x: 500, y: 600 }, - }, - }); - const circles = findByType(tree, 'circle'); - const props = circles[1].props as { - cx: number; - cy: number; - r: number; - opacity: number; - }; - expect(props.cx).toBe(500); - expect(props.cy).toBe(600); - expect(props.r).toBe(4); - expect(props.opacity).toBe(0.6); - }); - - it('both circles share the same fill color (the previewColor)', () => { - mocks.pickPreviewColor.mockReturnValue('#deadbe'); - const tree = render(); - const circles = findByType(tree, 'circle'); - expect((circles[0].props as { fill: string }).fill).toBe('#deadbe'); - expect((circles[1].props as { fill: string }).fill).toBe('#deadbe'); - }); - - it('the path stroke and the circle fills all share the same color', () => { - mocks.pickPreviewColor.mockReturnValue('#22c55e'); - const tree = render(); - const path = findByType(tree, 'path')[0]; - const circles = findByType(tree, 'circle'); - expect((path.props as { stroke: string }).stroke).toBe('#22c55e'); - expect((circles[0].props as { fill: string }).fill).toBe('#22c55e'); - expect((circles[1].props as { fill: string }).fill).toBe('#22c55e'); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// Color routing — verifies the JSX shell uses the picker's return value -// (a single source of truth — the picker is mocked, the shell never decides -// the color itself) -// ═══════════════════════════════════════════════════════════════════════════ - -describe('ConnectionPreviewOverlay — color is sourced from pickPreviewColor', () => { - it('renders the cyan default when picker returns the cyan default', () => { - mocks.pickPreviewColor.mockReturnValue('#22d3ee'); - const tree = render(); - const path = findByType(tree, 'path')[0]; - expect((path.props as { stroke: string }).stroke).toBe('#22d3ee'); - }); - - it('renders the green valid-target color when picker returns it', () => { - mocks.pickPreviewColor.mockReturnValue('#22c55e'); - const tree = render(); - const path = findByType(tree, 'path')[0]; - expect((path.props as { stroke: string }).stroke).toBe('#22c55e'); - }); - - it('renders the red invalid-target color when picker returns it', () => { - mocks.pickPreviewColor.mockReturnValue('#ef4444'); - const tree = render(); - const path = findByType(tree, 'path')[0]; - expect((path.props as { stroke: string }).stroke).toBe('#ef4444'); + it('disables pointer-events on the preview so it never blocks the drag', () => { + const tree = render({}, snappedInfo); + const wrap = findByType(tree, 'g')[0]; + expect((wrap.props as { style?: React.CSSProperties }).style?.pointerEvents).toBe('none'); }); }); diff --git a/packages/ui/src/features/canvas/components/__tests__/svg-canvas.test.tsx b/packages/ui/src/features/canvas/components/__tests__/svg-canvas.test.tsx index 8178412f..4baed078 100644 --- a/packages/ui/src/features/canvas/components/__tests__/svg-canvas.test.tsx +++ b/packages/ui/src/features/canvas/components/__tests__/svg-canvas.test.tsx @@ -141,6 +141,7 @@ const components = vi.hoisted(() => ({ ConnectionTooltip: vi.fn(() => null), CanvasDeployBanner: vi.fn(() => null), CanvasContent: vi.fn(() => null), + SocketHoverTooltip: vi.fn(() => null), })); const dispatchSpy = vi.fn(); @@ -178,6 +179,9 @@ vi.mock('../controls-help-modal', () => ({ vi.mock('../connection-tooltip', () => ({ ConnectionTooltip: components.ConnectionTooltip, })); +vi.mock('../nodes/_shared/socket-hover-tooltip', () => ({ + SocketHoverTooltip: components.SocketHoverTooltip, +})); vi.mock('../deploy-banner', () => ({ CanvasDeployBanner: components.CanvasDeployBanner, })); diff --git a/packages/ui/src/features/canvas/components/__tests__/svg-connection-path.test.tsx b/packages/ui/src/features/canvas/components/__tests__/svg-connection-path.test.tsx index 4713910f..3bc7cdfc 100644 --- a/packages/ui/src/features/canvas/components/__tests__/svg-connection-path.test.tsx +++ b/packages/ui/src/features/canvas/components/__tests__/svg-connection-path.test.tsx @@ -352,21 +352,21 @@ describe('SvgConnectionPath — stroke styling', () => { return props.fill === 'none' && props.stroke !== 'transparent' && props.stroke !== undefined; })[0]; - it('renders selected stroke = EDGE_COLORS.selected with strokeWidth=2.5 and opacity=0.7', () => { + it('renders selected stroke = EDGE_COLORS.selected with strokeWidth=2.5 and opacity=1 (fully visible)', () => { const tree = renderEdge({ isSelected: true }); const path = mainPath(tree)!; const props = path.props as { stroke: string; strokeWidth: number; opacity: number }; expect(props.stroke).toBe(EDGE_COLORS.selected); expect(props.strokeWidth).toBe(2.5); - expect(props.opacity).toBe(0.7); + expect(props.opacity).toBe(1); }); - it('renders highlighted stroke (no direction → category color or default)', () => { + it('renders highlighted stroke with opacity 0.95 (near-full visibility)', () => { const tree = renderEdge({ isHighlighted: true }); const path = mainPath(tree)!; const props = path.props as { stroke: string; opacity: number }; expect(props.stroke).toBe(EDGE_COLORS.default); - expect(props.opacity).toBe(0.6); + expect(props.opacity).toBe(0.95); }); it('renders highlighted + direction="outgoing" stroke = EDGE_COLORS.outgoing', () => { @@ -383,12 +383,12 @@ describe('SvgConnectionPath — stroke styling', () => { expect(props.stroke).toBe(EDGE_COLORS.incoming); }); - it('renders default stroke (relationship "default") with low opacity', () => { + it('renders default stroke (relationship "default") at high opacity — connections are fully visible at idle', () => { const tree = renderEdge(); const path = mainPath(tree)!; const props = path.props as { stroke: string; opacity: number }; expect(props.stroke).toBe(EDGE_COLORS.default); - expect(props.opacity).toBe(0.15); + expect(props.opacity).toBe(0.9); }); it('uses the category color from connection.data.color when present', () => { @@ -419,7 +419,7 @@ describe('SvgConnectionPath — stroke styling', () => { expect((path.props as { stroke: string }).stroke).toBe(EDGE_COLORS.depends_on); }); - it('renders pipelineActive stroke = #3b82f6 with opacity 0.6', () => { + it('renders pipelineActive stroke = #3b82f6 with opacity 0.6 (animated overlay sits quieter than the base wire)', () => { const tree = renderEdge({ pipelineActive: true }); const path = mainPath(tree)!; const props = path.props as { stroke: string; opacity: number }; @@ -473,28 +473,28 @@ describe('SvgConnectionPath — stroke styling', () => { expect((path.props as { strokeWidth: number }).strokeWidth).toBe(0.6); }); - it('thin lineStyle reduces opacity floor to 0.12 at full LOD', () => { + it('thin lineStyle drops opacity to 0.6 at full LOD (a notch quieter than primary traffic but still fully readable)', () => { const tree = renderEdge({ connection: makeConn({ data: { lineStyle: 'thin' } }), }); const path = mainPath(tree)!; - expect((path.props as { opacity: number }).opacity).toBe(0.12); + expect((path.props as { opacity: number }).opacity).toBe(0.6); }); - it('LOD 1 reduces strokeWidth to 1.5 * invZoom and opacity to 0.4', () => { + it('LOD 1 reduces strokeWidth to 1.5 * invZoom and opacity to 0.7', () => { const tree = renderEdge({ lod: 1, zoom: 1 }); const path = mainPath(tree)!; const props = path.props as { strokeWidth: number; opacity: number }; expect(props.strokeWidth).toBeCloseTo(1.5); - expect(props.opacity).toBe(0.4); + expect(props.opacity).toBe(0.7); }); - it('LOD 2 reduces strokeWidth to 1.2 * invZoom and opacity to 0.35', () => { + it('LOD 2 reduces strokeWidth to 1.2 * invZoom and opacity to 0.8', () => { const tree = renderEdge({ lod: 2, zoom: 1 }); const path = mainPath(tree)!; const props = path.props as { strokeWidth: number; opacity: number }; expect(props.strokeWidth).toBeCloseTo(1.2); - expect(props.opacity).toBe(0.35); + expect(props.opacity).toBe(0.8); }); it('LOD 1 with zoom 0.5 doubles invZoom-scaled strokeWidth (1.5 * 2 = 3)', () => { diff --git a/packages/ui/src/features/canvas/components/add-menu/__tests__/fuzzy-match.test.ts b/packages/ui/src/features/canvas/components/add-menu/__tests__/fuzzy-match.test.ts new file mode 100644 index 00000000..11faa1bc --- /dev/null +++ b/packages/ui/src/features/canvas/components/add-menu/__tests__/fuzzy-match.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { rank, type RankableItem } from '../fuzzy-match'; + +const items: RankableItem[] = [ + { name: 'PostgreSQL', description: 'Relational database', iceType: 'Database.PostgreSQL', category: 'data' }, + { name: 'Postman Echo', description: 'HTTP echo service', iceType: 'External.PostmanEcho', category: 'external' }, + { name: 'Redis Cache', description: 'In-memory cache', iceType: 'Database.Redis', category: 'data' }, + { name: 'Scalable Backend', description: 'Containerized service', iceType: 'Compute.Container', category: 'backend' }, + { name: 'Static Site', description: 'Frontend hosted on a CDN', iceType: 'Compute.StaticSite', category: 'frontend' }, +]; + +describe('rank', () => { + it('returns items in original order when query is empty', () => { + expect(rank(items, '').map((i) => i.iceType)).toEqual(items.map((i) => i.iceType)); + }); + + it('places word-start matches above mid-word matches', () => { + const result = rank(items, 'post'); + // PostgreSQL and Postman Echo both word-start with "post" — kept; + // Static Site contains "Site" not "post"; Scalable Backend doesn't match. + expect(result.map((r) => r.iceType)).toEqual( + expect.arrayContaining(['Database.PostgreSQL', 'External.PostmanEcho']), + ); + // Word-start hits should appear before "no-match" items. + expect(result[0].name.toLowerCase().startsWith('post')).toBe(true); + }); + + it('matches against the description as well', () => { + const result = rank(items, 'cache'); + expect(result.some((r) => r.iceType === 'Database.Redis')).toBe(true); + }); + + it('filters out non-matching items', () => { + const result = rank(items, 'redis'); + expect(result.map((r) => r.iceType)).toEqual(['Database.Redis']); + }); + + it('is case-insensitive', () => { + expect(rank(items, 'POSTGRES').some((r) => r.iceType === 'Database.PostgreSQL')).toBe(true); + }); + + it('returns stable order on ties (preserves input order)', () => { + const a = rank(items, 'a'); // matches "Scalable Backend" + "Static Site" + maybe more + const b = rank(items, 'a'); + expect(a.map((i) => i.iceType)).toEqual(b.map((i) => i.iceType)); + }); +}); diff --git a/packages/ui/src/features/canvas/components/add-menu/fuzzy-match.ts b/packages/ui/src/features/canvas/components/add-menu/fuzzy-match.ts new file mode 100644 index 00000000..635a8b6e --- /dev/null +++ b/packages/ui/src/features/canvas/components/add-menu/fuzzy-match.ts @@ -0,0 +1,52 @@ +/** + * Minimal fuzzy match — substring + word-start boost. + * + * We don't pull in fuse.js because the catalog is small (~25 concepts) + * and the ranking only needs to feel responsive. Score components: + * - whole-string match → 100 + * - word-start (case-insensitive) → 50 + position bonus + * - substring match → 25 + * - description match → half weight + * + * `rank(items, query)` returns the input filtered + sorted by score + * descending. Empty query yields the input order unchanged. Stable + * (sort preserves relative order on ties — relevant when the catalog + * has an editorial order users have learned). + */ + +export interface RankableItem { + name: string; + description?: string; + iceType: string; + category?: string; +} + +function score(item: RankableItem, query: string): number { + if (!query) return 1; // every item kept, original order + const q = query.toLowerCase(); + const name = item.name.toLowerCase(); + const desc = (item.description ?? '').toLowerCase(); + let s = 0; + + if (name === q) s += 100; + // Word-start: split name on non-word chars, look for any starting with q. + const words = name.split(/[\s\-_./]+/); + for (let i = 0; i < words.length; i++) { + if (words[i].startsWith(q)) { + s += 50 + Math.max(0, 10 - i); + } + } + if (name.includes(q)) s += 25; + if (desc.includes(q)) s += 10; + return s; +} + +export function rank(items: T[], query: string): T[] { + if (!query.trim()) return items.slice(); + const q = query.trim(); + const scored = items + .map((it, idx) => ({ it, s: score(it, q), idx })) + .filter((entry) => entry.s > 0) + .sort((a, b) => b.s - a.s || a.idx - b.idx); + return scored.map((entry) => entry.it); +} diff --git a/packages/ui/src/features/canvas/components/add-menu/spotlight.tsx b/packages/ui/src/features/canvas/components/add-menu/spotlight.tsx new file mode 100644 index 00000000..59381a3e --- /dev/null +++ b/packages/ui/src/features/canvas/components/add-menu/spotlight.tsx @@ -0,0 +1,323 @@ +/** + * Spotlight — Blender-style Shift+A add-block menu. + * + * Centered floating modal with a search input, fuzzy-ranked block list, + * recently-used pinned at the top, and keyboard navigation. Spawning + * goes through the same blueprint path as palette drag-drop so behavior + * stays consistent (same default node data, same containment rules, + * same ghost suggestions). + * + * Closing: Escape, click outside, or successful spawn. + * + * Implementation note — the search input ref is created once via + * `useRef` and focused in a `useEffect` rather than via `autoFocus` so + * it works when the modal re-opens (autoFocus only fires on the + * initial mount). + */ + +import { isIceTypeEnabledForProvider } from '@ice/constants'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { rank, type RankableItem } from './fuzzy-match'; +import { getBlueprint, expandBlueprint } from '../../../../config/blocks'; +import { useTranslation } from '../../../../i18n'; +import { addNodeToCard, expandBlueprintToCard, type CardNode } from '../../../../store/slices/cards-slice'; +import { closeSpotlight, pushSpotlightRecent } from '../../../../store/slices/ui-slice'; +import { getComponents } from '../../../palette/data/components'; +import type { AppDispatch, RootState } from '../../../../store'; +import type { ComponentDef } from '../../../palette/types'; + +type Provider = ComponentDef['providers'][number]; + +interface SpotlightCommand extends RankableItem { + type: 'block'; + origin: ComponentDef; +} + +export const Spotlight: React.FC = () => { + const dispatch = useDispatch(); + const { t } = useTranslation(); + const open = useSelector((s: RootState) => s.ui.spotlight.open); + const canvasPos = useSelector((s: RootState) => ({ + x: s.ui.spotlight.canvasX, + y: s.ui.spotlight.canvasY, + })); + const recent = useSelector((s: RootState) => s.ui.spotlight.recentTypes); + const deployProvider = useSelector((s: RootState) => s.deploy.provider); + + const [query, setQuery] = useState(''); + const [highlightIdx, setHighlightIdx] = useState(0); + const inputRef = useRef(null); + + // Build the searchable command list from the same palette source so + // a block added there is automatically findable here. + const commands = useMemo(() => { + const components = getComponents(t); + return components.map((c) => ({ + type: 'block', + origin: c, + name: c.name, + description: c.description, + iceType: c.type, + category: c.category, + })); + }, [t]); + + // Order: when no query, surface recently-used at the top, then + // everything else in palette order; with a query, fuzzy-rank. + const ranked = useMemo(() => { + if (!query.trim()) { + const recentSet = new Set(recent); + const fromRecent = recent + .map((iceType) => commands.find((c) => c.iceType === iceType)) + .filter((c): c is SpotlightCommand => !!c); + const rest = commands.filter((c) => !recentSet.has(c.iceType)); + return [...fromRecent, ...rest]; + } + return rank(commands, query); + }, [commands, query, recent]); + + // Reset state when the modal opens. + useEffect(() => { + if (open) { + setQuery(''); + setHighlightIdx(0); + // Microtask delay so Radix/portal mounting completes before focus. + const id = setTimeout(() => inputRef.current?.focus(), 0); + return () => clearTimeout(id); + } + return undefined; + }, [open]); + + // Clamp highlight into bounds when ranked list shrinks. + useEffect(() => { + if (highlightIdx >= ranked.length) setHighlightIdx(Math.max(0, ranked.length - 1)); + }, [ranked.length, highlightIdx]); + + const spawn = (cmd: SpotlightCommand): void => { + const blockType = cmd.iceType; + const paletteProvider: Provider | undefined = cmd.origin.providers[0]; + const effectiveProvider = paletteProvider ?? deployProvider; + const gateBlocked = !!effectiveProvider && !isIceTypeEnabledForProvider(blockType, effectiveProvider); + const blueprint = gateBlocked ? undefined : getBlueprint(blockType, effectiveProvider); + + if (blueprint) { + const expanded = expandBlueprint(blueprint, { + position: canvasPos, + provider: effectiveProvider as Provider, + }); + dispatch(expandBlueprintToCard(expanded)); + } else if (blockType === 'Util.Reroute') { + // Reroute is a tiny pass-through dot, not a deployable resource — + // it has no blueprint by design. Spawn a minimal 16×16 node. + const newNode: CardNode = { + id: `reroute-${Date.now()}`, + type: 'resource', + position: { x: canvasPos.x - 8, y: canvasPos.y - 8 }, + width: 16, + height: 16, + data: { + label: '', + iceType: blockType, + behavior: 'singleton', + folded: false, + }, + }; + dispatch(addNodeToCard(newNode)); + } else { + // Fall through to a bare resource node so the user still gets a + // visible placeholder when the blueprint is missing for the active + // provider. Mirrors the palette drop fallback. + const newNode: CardNode = { + id: `node-${Date.now()}`, + type: 'resource', + position: { x: canvasPos.x, y: canvasPos.y }, + width: 200, + height: 120, + data: { + label: cmd.name, + iceType: blockType, + behavior: 'singleton', + folded: false, + provider: deployProvider, + }, + }; + dispatch(addNodeToCard(newNode)); + } + dispatch(pushSpotlightRecent(blockType)); + dispatch(closeSpotlight()); + }; + + const onKey = (e: React.KeyboardEvent): void => { + if (e.key === 'Escape') { + e.preventDefault(); + dispatch(closeSpotlight()); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlightIdx((i) => Math.min(ranked.length - 1, i + 1)); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlightIdx((i) => Math.max(0, i - 1)); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + const cmd = ranked[highlightIdx]; + if (cmd) spawn(cmd); + } + }; + + if (!open) return null; + + return ( +
{ + if (e.target === e.currentTarget) dispatch(closeSpotlight()); + }} + onKeyDown={onKey} + style={{ + position: 'fixed', + inset: 0, + zIndex: 1000, + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'center', + paddingTop: '20vh', + background: 'rgba(0,0,0,0.18)', + backdropFilter: 'blur(2px)', + }} + > +
+ setQuery(e.target.value)} + placeholder="Add block…" + style={{ + padding: '12px 14px', + fontSize: 14, + background: 'transparent', + color: 'var(--ice-text-primary)', + border: 'none', + outline: 'none', + borderBottom: '1px solid var(--ice-border-subtle, var(--ice-border))', + }} + /> +
    + {ranked.length === 0 && ( +
  • + No matches. +
  • + )} + {ranked.map((cmd, i) => ( + spawn(cmd)} + onHover={() => setHighlightIdx(i)} + /> + ))} +
+
+
+ ); +}; + +interface SpotlightRowProps { + cmd: SpotlightCommand; + highlighted: boolean; + onSelect: () => void; + onHover: () => void; +} + +const SpotlightRow: React.FC = ({ cmd, highlighted, onSelect, onHover }) => { + const Icon = cmd.origin.icon; + return ( +
  • + +
    + + {cmd.name} + + {cmd.description && ( + + {cmd.description} + + )} +
    + + {cmd.category} + +
  • + ); +}; diff --git a/packages/ui/src/features/canvas/components/add-menu/use-spotlight-state.ts b/packages/ui/src/features/canvas/components/add-menu/use-spotlight-state.ts new file mode 100644 index 00000000..b77fd0ef --- /dev/null +++ b/packages/ui/src/features/canvas/components/add-menu/use-spotlight-state.ts @@ -0,0 +1,60 @@ +/** + * useSpotlightShortcut + * + * Window-level Shift+A listener that opens the canvas add-menu spotlight + * at the current cursor position (converted to canvas-space). Ignored + * while an input/textarea is focused so users can type freely inside + * the properties panel. + * + * The hook also tracks `lastMouseClient` so when Shift+A fires we know + * where to anchor the spawn. Keeping the listener here instead of in + * the heavier `use-keyboard-handlers.ts` avoids touching that hook's + * `[]`-dep useEffect, which would re-install all keyboard listeners on + * every dep change. + */ + +import { useEffect, useRef } from 'react'; +import { useDispatch } from 'react-redux'; +import { openSpotlight } from '../../../../store/slices/ui-slice'; +import type { AppDispatch } from '../../../../store'; + +interface UseSpotlightShortcutArgs { + screenToCanvas: (clientX: number, clientY: number) => { x: number; y: number }; + /** Disable the shortcut while the canvas is locked or another modal owns the key. */ + enabled?: boolean; +} + +export function useSpotlightShortcut(args: UseSpotlightShortcutArgs): void { + const { screenToCanvas, enabled = true } = args; + const dispatch = useDispatch(); + const lastClient = useRef<{ x: number; y: number }>({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); + const screenToCanvasRef = useRef(screenToCanvas); + screenToCanvasRef.current = screenToCanvas; + + useEffect(() => { + if (!enabled) return undefined; + const onMove = (e: MouseEvent): void => { + lastClient.current = { x: e.clientX, y: e.clientY }; + }; + const onKey = (e: KeyboardEvent): void => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + e.target instanceof HTMLSelectElement || + (e.target instanceof HTMLElement && e.target.isContentEditable) + ) + return; + if (e.key.toLowerCase() === 'a' && e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) { + e.preventDefault(); + const canvasPos = screenToCanvasRef.current(lastClient.current.x, lastClient.current.y); + dispatch(openSpotlight({ canvasX: canvasPos.x, canvasY: canvasPos.y })); + } + }; + window.addEventListener('mousemove', onMove, { passive: true }); + window.addEventListener('keydown', onKey); + return () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('keydown', onKey); + }; + }, [dispatch, enabled]); +} diff --git a/packages/ui/src/features/canvas/components/canvas-renderer/__tests__/canvas-content.test.tsx b/packages/ui/src/features/canvas/components/canvas-renderer/__tests__/canvas-content.test.tsx index f2e07d51..899b3428 100644 --- a/packages/ui/src/features/canvas/components/canvas-renderer/__tests__/canvas-content.test.tsx +++ b/packages/ui/src/features/canvas/components/canvas-renderer/__tests__/canvas-content.test.tsx @@ -97,21 +97,21 @@ describe('CanvasContent', () => { expect(el.props.transform).toBe('translate(5, 7) scale(2)'); }); - it('renders the documented child sequence in order — when no preview is active', () => { + it('renders the documented child sequence in order — when no preview is active (connections ABOVE nodes per user feedback)', () => { const el = renderResult(baseProps); expect(childTypes(el)).toEqual([ mocks.CanvasGrid, mocks.SelectionFrame, - mocks.ConnectionLayer, // background mode mocks.ParentClipDefs, mocks.NodesLayer, + mocks.ConnectionLayer, // background mode — now ABOVE nodes mocks.UserTrafficOverlay, mocks.ConnectionLayer, // highlighted mode mocks.GhostOverlay, ]); }); - it('inserts the connection-drawing preview between nodes-layer and user-traffic when drawingConnection is set', () => { + it('inserts the connection-drawing preview between background-connections and user-traffic when drawingConnection is set', () => { const el = renderResult({ ...baseProps, drawingConnection: { @@ -123,9 +123,9 @@ describe('CanvasContent', () => { expect(childTypes(el)).toEqual([ mocks.CanvasGrid, mocks.SelectionFrame, - mocks.ConnectionLayer, mocks.ParentClipDefs, mocks.NodesLayer, + mocks.ConnectionLayer, mocks.ConnectionPreviewOverlay, mocks.UserTrafficOverlay, mocks.ConnectionLayer, diff --git a/packages/ui/src/features/canvas/components/canvas-renderer/__tests__/node-renderer-registry.test.tsx b/packages/ui/src/features/canvas/components/canvas-renderer/__tests__/node-renderer-registry.test.tsx index c8b64030..bb74ac39 100644 --- a/packages/ui/src/features/canvas/components/canvas-renderer/__tests__/node-renderer-registry.test.tsx +++ b/packages/ui/src/features/canvas/components/canvas-renderer/__tests__/node-renderer-registry.test.tsx @@ -125,7 +125,12 @@ const MockSvgObjectStorageNode = mocks.SvgObjectStorageNode; const MockSvgGithubRepoNode = mocks.SvgGithubRepoNode; // Imports come AFTER the mocks so vitest hoists/wires them correctly. -import { CONCEPT_NODE_RENDERERS, renderCanvasNode, type RenderCtx } from '../node-renderer-registry'; +import { + CONCEPT_NODE_RENDERERS, + SPECIAL_NODE_RENDERERS, + renderCanvasNode, + type RenderCtx, +} from '../node-renderer-registry'; import type { CanvasNode } from '../../types'; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -165,6 +170,64 @@ const makeCtx = (overrides: Partial = {}): RenderCtx => ({ ...overrides, }); +// ─── SPECIAL_NODE_RENDERERS table ───────────────────────────────────────── + +describe('SPECIAL_NODE_RENDERERS', () => { + it('contains exactly the bespoke iceTypes that need a custom renderer', () => { + // Locked-in list. Adding a new bespoke renderer extends this set; + // removing one breaks dispatch. The dispatcher reads this table + // generically so the cardinal rule (no hardcoded iceType branches) + // is preserved by construction. + expect(Object.keys(SPECIAL_NODE_RENDERERS).sort()).toEqual([ + 'Network.CustomDomain', + 'Network.PrivateNetwork', + 'Util.Reroute', + ]); + }); + + it('each entry is a factory that returns an element + innerKey', () => { + for (const [iceType, factory] of Object.entries(SPECIAL_NODE_RENDERERS)) { + const node = makeNode({ data: { iceType } }); + const result = factory(node, makeCtx()); + expect(result.element).toBeTruthy(); + expect(typeof result.innerKey).toBe('string'); + expect(result.innerKey.length).toBeGreaterThan(0); + } + }); + + it('Custom Domain innerKey encodes route count for stable re-mount on add/remove', () => { + const oneRoute = SPECIAL_NODE_RENDERERS['Network.CustomDomain']( + makeNode({ data: { iceType: 'Network.CustomDomain', routes: [{ id: 'r1', subdomain: 'a' }] } }), + makeCtx(), + ); + const twoRoutes = SPECIAL_NODE_RENDERERS['Network.CustomDomain']( + makeNode({ + data: { + iceType: 'Network.CustomDomain', + routes: [ + { id: 'r1', subdomain: 'a' }, + { id: 'r2', subdomain: 'b' }, + ], + }, + }), + makeCtx(), + ); + expect(oneRoute.innerKey).not.toBe(twoRoutes.innerKey); + }); + + it('PrivateNetwork innerKey encodes ingress mode for stable re-mount on toggle', () => { + const open = SPECIAL_NODE_RENDERERS['Network.PrivateNetwork']( + makeNode({ data: { iceType: 'Network.PrivateNetwork', ingress: 'open' } }), + makeCtx(), + ); + const sealed = SPECIAL_NODE_RENDERERS['Network.PrivateNetwork']( + makeNode({ data: { iceType: 'Network.PrivateNetwork', ingress: 'sealed' } }), + makeCtx(), + ); + expect(open.innerKey).not.toBe(sealed.innerKey); + }); +}); + // ─── CONCEPT_NODE_RENDERERS table ───────────────────────────────────────── describe('CONCEPT_NODE_RENDERERS', () => { diff --git a/packages/ui/src/features/canvas/components/canvas-renderer/canvas-content.tsx b/packages/ui/src/features/canvas/components/canvas-renderer/canvas-content.tsx index 8be40f26..0bcaade6 100644 --- a/packages/ui/src/features/canvas/components/canvas-renderer/canvas-content.tsx +++ b/packages/ui/src/features/canvas/components/canvas-renderer/canvas-content.tsx @@ -7,18 +7,19 @@ * * 1. CanvasGrid (background grid) * 2. SelectionFrame - * 3. ConnectionLayer (mode='background') - * 4. ParentClipDefs - * 5. NodesLayer + * 3. ParentClipDefs + * 4. NodesLayer + * 5. ConnectionLayer (mode='background') — ABOVE nodes per user feedback * 6. ConnectionPreviewOverlay (drag-to-connect ghost) * 7. UserTrafficOverlay * 8. ConnectionLayer (mode='highlighted') * 9. GhostOverlay * - * Visual order, prop flow, and dep arrays are preserved verbatim — this - * is purely a JSX wrap-and-extract. The two `` instances - * remain separate (background vs highlighted) because they sandwich the - * NodesLayer in the original SVG draw order. + * The background ConnectionLayer was previously rendered BEFORE the + * NodesLayer (under the original "edges sandwich nodes" model). User + * feedback flagged that as broken — containers and groups overlapped + * the wires, making the data flow unreadable at idle. Both connection + * layers now render after NodesLayer so wires are first-class. * * Earlier rf-canv units (rf-canv-13/14/15/etc.) extracted the leaf * components; this unit extracts only their composition and the @@ -137,12 +138,29 @@ export const CanvasContent: React.FC = ({ {/* VPC/Subnet now render as SvgGroupNode in the nodes layer */} - {/* Connections layer — non-highlighted (behind nodes). - rf-canv-13: extracted to ConnectionLayer in mode='background'. - Inner-vs-outer key shape (`anim-edge-${id}` outer wrap, - `${id}` SvgConnectionPath inner) preserved verbatim per - blueprint risk #4 — SvgConnectionPath's internal hover state - survives reconciliation when the wrap toggles. */} + {/* rf-canv-11: block (shift-drag-shadow filter + + per-container clipPaths) extracted to ParentClipDefs. */} + + + {/* Nodes layer — Groups, Blocks, Resources, or Log terminals. + rf-canv-12: per-node dispatch (iceType + node.type → component + choice) lives in `./node-renderer-registry`. + rf-canv2-7: the wrap-and-key loop lives in `./nodes-layer`; the + wrapper's outer-key priority chain (rf-canv-10) is preserved + verbatim. */} + + + {/* Connections layer — ABOVE the nodes layer so containers and + groups never occlude the wires. Per user feedback: connections + are the architecture's data flow and must be fully visible at + idle. Previously rendered before NodesLayer (mode='background') + which let group tints overlap them. */} = ({ handleContextMenu={handleContextMenu} /> - {/* rf-canv-11: block (shift-drag-shadow filter + - per-container clipPaths) extracted to ParentClipDefs. */} - - - {/* Nodes layer — Groups, Blocks, Resources, or Log terminals. - rf-canv-12: per-node dispatch (iceType + node.type → component - choice) lives in `./node-renderer-registry`. - rf-canv2-7: the wrap-and-key loop lives in `./nodes-layer`; the - wrapper's outer-key priority chain (rf-canv-10) is preserved - verbatim. */} - - {/* Connection drawing preview — extracted to ConnectionPreviewOverlay (rf-canv-14). Bezier math + color picker live in `../utils/connection-preview` (rf-canv-8). */} {drawingConnection && ( diff --git a/packages/ui/src/features/canvas/components/canvas-renderer/node-renderer-registry.tsx b/packages/ui/src/features/canvas/components/canvas-renderer/node-renderer-registry.tsx index 4efa4280..605eff19 100644 --- a/packages/ui/src/features/canvas/components/canvas-renderer/node-renderer-registry.tsx +++ b/packages/ui/src/features/canvas/components/canvas-renderer/node-renderer-registry.tsx @@ -26,21 +26,24 @@ * * - The dispatch order is fixed and order-sensitive: * - * 1. `isLogIceType(iceType)` → `SvgLogNode` - * 2. `iceType === 'Network.CustomDomain'` → `SvgCustomDomainNode` - * 3. `iceType === 'Network.PrivateNetwork'` → `SvgPrivateNetworkNode` - * 4. `isContainerNode(node)` → `SvgGroupNode` - * 5. `node.type === 'block'` → `ConceptRenderer` ?? `SvgCompactNode` - * 6. (default fallthrough — typically `node.type === 'resource'`) → - * `ConceptFallbackRenderer` - * ?? `SvgCompactNode` + * 1. `isLogIceType(iceType)` → `SvgLogNode` + * 2. `SPECIAL_NODE_RENDERERS[iceType]` → bespoke factory (CustomDomain, + * Reroute, PrivateNetwork) + * 3. `isContainerNode(node)` → `SvgGroupNode` + * 4. `node.type === 'block'` → `ConceptRenderer` ?? `SvgCompactNode` + * 5. (default fallthrough — typically `node.type === 'resource'`) → + * `ConceptFallbackRenderer` + * ?? `SvgCompactNode` * - * Re-ordering even subtly changes behaviour. `Network.PrivateNetwork` - * MUST stay above the `isContainerNode` arm because the util classifies - * PrivateNetwork as a container; flipping the order would render every - * PrivateNetwork as a plain `SvgGroupNode` (loss of identity header + - * ingress toggle). Likewise `isLogIceType` matches a few iceTypes that - * might otherwise fall through to the SvgCompactNode branch. + * Step 2 MUST stay above the `isContainerNode` arm — PrivateNetwork + * and Reroute would otherwise render as a plain `SvgGroupNode` (loss + * of identity header / pass-through dot). Likewise `isLogIceType` + * matches a few iceTypes that might otherwise fall through. + * + * `SPECIAL_NODE_RENDERERS` is the schema-declared fact the dispatcher + * iterates over — no `if (iceType === 'X')` branches here. New + * bespoke renderers are added by extending the table (a new entry = + * a new factory), the dispatcher stays unchanged. * * - The `innerKey` per branch is load-bearing for reconciliation when no * wrapper-level branch overrides it (i.e. not lifted, no parent, not @@ -94,6 +97,7 @@ import { SvgPrivateAiServiceNode } from '../nodes/private-ai-service'; import { SvgPrivateNetworkNode } from '../nodes/private-network'; import { SvgPublicTrafficNode } from '../nodes/public-traffic'; import { SvgRedisCacheNode } from '../nodes/redis-cache'; +import { SvgRerouteNode } from '../nodes/reroute-node'; import { SvgScalableBackendNode } from '../nodes/scalable-backend'; import { SvgScheduledTaskNode } from '../nodes/scheduled-task'; import { SvgSecretStoreNode } from '../nodes/secret-store'; @@ -114,6 +118,105 @@ import type { CanvasNode } from '../types'; // when no bespoke renderer is registered. Each entry lives in its own // folder under ../nodes// so customizing one block = editing one file. +// ============================================================================= +// Bespoke renderer registry (cardinal-rule schema-driven dispatch) +// ============================================================================= +// +// Some blocks need a renderer with a unique prop set + a unique React +// reconciliation key derived from custom node-data fields (variable-height +// row stacks, ingress toggles, animated minimal pass-through dots). Each +// factory here owns its own component AND its own innerKey formula, so +// `renderCanvasNode` can dispatch via a generic `Record` lookup without +// `if (iceType === 'X')` branches in cross-cutting code. +// +// Adding a new bespoke renderer: register an entry here. The dispatcher +// stays untouched. Falls through to the per-concept and SvgCompactNode +// tables when no bespoke entry matches the node's iceType. + +export interface BespokeRenderResult { + element: React.ReactNode; + innerKey: string; +} + +export type BespokeRendererFactory = (node: CanvasNode, ctx: RenderCtx) => BespokeRenderResult; + +export const SPECIAL_NODE_RENDERERS: Record = { + // Custom Domain — variable-height stack of per-route rows, each with its + // own right-edge socket. innerKey re-mounts on routes-array length change + // so the renderer re-reads the row layout cleanly. + 'Network.CustomDomain': (node, ctx) => { + const innerKey = `${node.id}-routes${((node.data?.routes as unknown[]) || []).length}`; + return { + innerKey, + element: ( + + ), + }; + }, + // Reroute — minimal 16×16 pass-through dot. MUST be registered BEFORE + // the container check in the dispatcher (it's not a container despite + // looking like one to the classifier) — the bespoke table is consulted + // first so order is preserved by construction. + 'Util.Reroute': (node, ctx) => { + const innerKey = `${node.id}-reroute`; + return { + innerKey, + element: ( + {}} + onRenameCommit={() => {}} + onRenameCancel={ctx.handleRenameCancel} + onUpdateData={ctx.handleUpdateNodeData} + pipelineStatus={ctx.pipelineNodeStatus[node.id]} + onPipelineClick={ctx.handlePipelineClick} + connectedPipelineStatuses={ctx.getConnectedPipelineStatuses(node)} + lod={ctx.lod} + zoom={ctx.zoom} + connectionDragState={ctx.connectionDragTargets?.get(node.id) ?? null} + validationSeverity={ctx.nodeValidationMap.get(node.id)?.severity ?? null} + validationCount={ctx.nodeValidationMap.get(node.id)?.count ?? 0} + /> + ), + }; + }, + // Private Network — container with identity header + ingress toggle. + // innerKey re-mounts on ingress mode change so the header reads the new + // state cleanly. + 'Network.PrivateNetwork': (node, ctx) => { + const innerKey = `${node.id}-pn${(node.data?.ingress as string) || 'open'}`; + return { + innerKey, + element: ( + + ), + }; + }, +}; + export const CONCEPT_NODE_RENDERERS: Record> = { // Frontend 'Compute.StaticSite': SvgStaticSiteNode, @@ -255,49 +358,18 @@ export function renderCanvasNode(node: CanvasNode, ctx: RenderCtx): { element: R }; } - // 2. Custom Domain — owns its own renderer with dynamic per-route rows - // + per-row connection ports. Lives outside the compact-node tree so - // it can have variable height and multiple right-side ports. - if (iceType === 'Network.CustomDomain') { - const innerKey = `${node.id}-routes${((node.data?.routes as unknown[]) || []).length}`; - return { - innerKey, - element: ( - - ), - }; - } - - // 3. Private Network — pure container with a header that shows identity - // (shield icon + title + subtitle) and the Open/Sealed ingress toggle. - // Children nest inside via parentId and render through the standard - // dispatcher loop on top of the Private Network frame. Must come - // BEFORE the generic group dispatch below or it would render as a - // plain SvgGroupNode. - if (iceType === 'Network.PrivateNetwork') { - const innerKey = `${node.id}-pn${(node.data?.ingress as string) || 'open'}`; - return { - innerKey, - element: ( - - ), - }; + // 2. Bespoke renderers — Custom Domain, Reroute, Private Network. Each + // entry in `SPECIAL_NODE_RENDERERS` owns its own component AND its + // own innerKey formula, so this dispatcher is generic: look up by + // iceType, delegate. No hardcoded iceType branches here. + // + // Order is preserved by construction — the bespoke table is consulted + // BEFORE the container check, so Util.Reroute (which the classifier + // would call a container) and Network.PrivateNetwork (a container we + // render with a custom header) hit their bespoke factories first. + const bespokeFactory = SPECIAL_NODE_RENDERERS[iceType]; + if (bespokeFactory) { + return bespokeFactory(node, ctx); } // 4. Groups always render as containers. diff --git a/packages/ui/src/features/canvas/components/connection-preview-overlay.tsx b/packages/ui/src/features/canvas/components/connection-preview-overlay.tsx index fa028068..7aa0f654 100644 --- a/packages/ui/src/features/canvas/components/connection-preview-overlay.tsx +++ b/packages/ui/src/features/canvas/components/connection-preview-overlay.tsx @@ -1,24 +1,27 @@ /** * rf-canv-14 — `ConnectionPreviewOverlay` subcomponent. * - * The in-flight connection drag preview: a temporary cubic-bezier from the - * source port to the current cursor, plus two anchor circles (source + cursor) - * shown while the user is dragging from a node port toward another node. + * The in-flight connection drag preview. Two modes: * - * This component is the JSX shell only. The bezier math - * (`computeConnectionPreviewPath`) and color picker (`pickPreviewColor`) live - * in `../utils/connection-preview.ts` (rf-canv-8) — keep them there. + * 1. **Snapped (socket-to-socket)** — when the orchestrator has magnet- + * locked the cursor onto a compatible target port, render a solid + * bezier from the source socket to the target socket. This is the + * promise: release here and the wire lands here. * - * Both the path and the two anchor circles render with `pointer-events: none` - * (set on the wrapping ``) so the preview never intercepts the cursor — - * the orchestrator's mouse-move handler must keep firing through it. The - * stroke/fill color, dash pattern, opacities, and circle radii are verbatim - * from the original orchestrator IIFE; do NOT tweak them under cover of an - * extraction unit. + * 2. **Searching (no target)** — when the cursor is in free space, no + * preview line is drawn. The pulsing source-socket halo (rendered + * by TypedSockets) plus the per-port green halos on compatible + * targets are the only feedback. This matches the user mental + * model: "connections are socket ↔ socket only." + * + * Both modes use `pointer-events: none` so the preview never intercepts + * the cursor — the orchestrator's mouse-move handler must keep firing + * through it. */ import React from 'react'; -import { computeConnectionPreviewPath, pickPreviewColor } from '../utils/connection-preview'; +import { computeConnectionPreviewPath } from '../utils/connection-preview'; +import { getConnectionDragInfo } from './nodes/_shared/connection-drag-context'; import type { CanvasNode } from './types'; export interface ConnectionPreviewOverlayProps { @@ -31,24 +34,20 @@ export interface ConnectionPreviewOverlayProps { connectionDragTargets: Map | null; } -export const ConnectionPreviewOverlay: React.FC = ({ - drawingConnection, - effectiveNodes, - connectionDragTargets, -}) => { +export const ConnectionPreviewOverlay: React.FC = ({ drawingConnection }) => { const { sourcePoint, currentPoint } = drawingConnection; + const drag = getConnectionDragInfo(); + // Only render a line when the magnet has actually locked on to a + // target socket. Until then, the source-socket pulse + per-port + // halos are the user's feedback — no floating "block to cursor" + // wire to confuse the eye. + if (!drag || !drag.snap) return null; const pathD = computeConnectionPreviewPath(sourcePoint, currentPoint); - const previewColor = pickPreviewColor( - currentPoint, - effectiveNodes, - drawingConnection.sourceId, - connectionDragTargets, - ); return ( - - - + + + ); }; diff --git a/packages/ui/src/features/canvas/components/nodes/_shared/__tests__/typed-sockets.test.tsx b/packages/ui/src/features/canvas/components/nodes/_shared/__tests__/typed-sockets.test.tsx new file mode 100644 index 00000000..66e17ad3 --- /dev/null +++ b/packages/ui/src/features/canvas/components/nodes/_shared/__tests__/typed-sockets.test.tsx @@ -0,0 +1,119 @@ +/** + * TypedSockets behavior tests — uses the same shallow-render trick as + * the rest of the canvas tests (call the component as a function) to + * keep the test simple and dependency-free. + */ + +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { TypedSockets } from '../typed-sockets'; +import type { PortDef } from '@ice/types'; + +function findByType(tree: React.ReactNode, type: string): React.ReactElement[] { + const out: React.ReactElement[] = []; + function walk(n: React.ReactNode): void { + if (!n || typeof n !== 'object') return; + if (Array.isArray(n)) { + n.forEach(walk); + return; + } + const el = n as React.ReactElement; + if (el.type === type) out.push(el); + // Recurse into function components by calling them — needed to see + // SocketDot's rendered /. + if (typeof el.type === 'function') { + const fn = el.type as (p: typeof el.props) => React.ReactNode; + walk(fn(el.props)); + } + const children = (el.props as { children?: React.ReactNode })?.children; + if (children !== undefined) walk(children); + } + walk(tree); + return out; +} + +const SOCKETS: PortDef[] = [ + { id: 'traffic-in', side: 'left', role: 'database', direction: 'in', label: 'Traffic input', shape: 'circle' }, + { + id: 'traffic-out', + side: 'right', + role: 'http-endpoint', + direction: 'out', + label: 'Traffic output', + shape: 'circle', + }, + { id: 'config-in', side: 'left', role: 'env', direction: 'in', label: 'Config input', shape: 'ring' }, + { + id: 'pipeline-in', + side: 'left', + role: 'repository', + direction: 'in', + label: 'Pipeline input', + shape: 'diamond', + }, +]; + +function render(props: Partial> = {}): React.ReactElement { + const full: React.ComponentProps = { + nodeId: 'n1', + x: 100, + y: 100, + width: 200, + height: 100, + sockets: SOCKETS, + lod: 3, + ...props, + }; + // memo() wraps the component; reach into `type.type` to call the raw render. + const Comp = TypedSockets as unknown as { type: (p: typeof full) => React.ReactElement }; + return Comp.type(full); +} + +describe('TypedSockets', () => { + it('renders one socket per SocketDef at LOD 3', () => { + const tree = render(); + const circles = findByType(tree, 'circle'); + const rects = findByType(tree, 'rect'); + expect(circles.length + rects.length).toBeGreaterThanOrEqual(SOCKETS.length); + }); + + it('emits data-socket-id / data-side / data-category attributes', () => { + const tree = render(); + const circles = findByType(tree, 'circle'); + const trafficIn = circles.find((c) => (c.props as Record)['data-socket-id'] === 'traffic-in'); + expect(trafficIn).toBeDefined(); + const props = trafficIn!.props as Record; + expect(props['data-side']).toBe('left'); + expect(props['data-category']).toBe('traffic'); + expect(props['data-direction']).toBe('in'); + expect(props['data-node-id']).toBe('n1'); + expect((props.className as string).includes('connection-port')).toBe(true); + }); + + it('degrades to anonymous L/R dots at LOD < 2', () => { + const tree = render({ lod: 1 }); + const circles = findByType(tree, 'circle'); + // Two fallback dots (left + right) — never four. + expect(circles.length).toBe(2); + }); + + it('renders fallback dots when sockets array is empty', () => { + const tree = render({ sockets: [] }); + const circles = findByType(tree, 'circle'); + expect(circles.length).toBe(2); + }); + + it('honors opacity prop on the group wrapper', () => { + const tree = render({ opacity: 0.42 }); + expect(((tree.props as Record).style as Record).opacity).toBe(0.42); + }); + + it('uses ring shape for config sockets', () => { + const tree = render(); + const circles = findByType(tree, 'circle'); + const configRing = circles.find((c) => (c.props as Record)['data-socket-id'] === 'config-in'); + expect(configRing).toBeDefined(); + // The ring shape renders with fill="var(--ice-bg-raised)" and stroke=color. + expect((configRing!.props as Record).fill).toBe('var(--ice-bg-raised)'); + }); +}); diff --git a/packages/ui/src/features/canvas/components/nodes/_shared/card-shell.tsx b/packages/ui/src/features/canvas/components/nodes/_shared/card-shell.tsx index c82a760f..20d1e92f 100644 --- a/packages/ui/src/features/canvas/components/nodes/_shared/card-shell.tsx +++ b/packages/ui/src/features/canvas/components/nodes/_shared/card-shell.tsx @@ -28,12 +28,14 @@ */ import { CARD_FOOTER_HEIGHT } from '@ice/constants'; +import { getPortsForNode } from '@ice/types'; import React, { useCallback, useState, type ReactNode } from 'react'; +import { getNodeDragState } from './connection-drag-context'; import { ConnectionDragGlow } from './connection-drag-glow'; -import { ConnectionPorts } from './connection-ports'; import { useIsNodeOrphan } from './orphan-context'; import { ProviderPill } from './provider-pill'; import { StatusDot } from './status-dot'; +import { TypedSockets } from './typed-sockets'; import { getBrandIcon } from '../../../../../assets/icons/brand-registry'; import { getServiceName } from '../../../../../assets/icons/service-names'; import { CATEGORY_STYLE, CORNER_RADIUS, STATUS_COLORS } from '../../../../../config/canvas-constants'; @@ -170,9 +172,28 @@ export const CardShell: React.FC = ({ const statusColor = STATUS_COLORS[deployStatus] || STATUS_COLORS.idle; const statusLabel = deployStatus ? deployStatus.charAt(0).toUpperCase() + deployStatus.slice(1) : ''; - const isSource = connectionDragState === 'source'; - const isValidTarget = connectionDragState === 'valid-target'; - const isInvalidTarget = connectionDragState === 'invalid-target'; + const rawIsSource = connectionDragState === 'source'; + const rawIsValidTarget = connectionDragState === 'valid-target'; + const rawIsInvalidTarget = connectionDragState === 'invalid-target'; + // Per-node drag state pulled from the orchestrator-provided singleton. + // Drives the per-port highlight + magnet-snap glow in TypedSockets. + // Pure function call (no hook) so this component stays compatible with + // tests that invoke it as a plain function. + const { + compatiblePortIds, + snappedPortId, + sourcePortId, + isSource: isDragSource, + active: typedDragActive, + } = getNodeDragState(node.id); + // When a typed-port drag is in flight, the per-port glow in + // TypedSockets is the only visual feedback — suppress the whole-block + // green border / glow so the user reads "socket ↔ socket" not "block + // ↔ block." Source / invalid styling still applies for the source + // node itself and for genuinely invalid targets (canConnect failures). + const isSource = rawIsSource; + const isValidTarget = typedDragActive ? false : rawIsValidTarget; + const isInvalidTarget = rawIsInvalidTarget; // Orphan signal — the canvas orchestrator populates the OrphanNodes // context with the set of blocks that have zero edges. We only show // the indicator while no drag is active, so the orphan warning @@ -184,7 +205,22 @@ export const CardShell: React.FC = ({ // hover/selection/source/valid-target, faded out when this block is // an invalid drop target during an active drag. const renderPorts = !customPorts; - const portOpacity = isInvalidTarget ? 0.12 : isHovered || isSelected || isValidTarget || isSource ? 1 : 0.35; + // Dim blocks that have no compatible port during a typed drag so the + // user reads "these are out of play" without a tooltip. + const isNonCompatibleDuringDrag = + typedDragActive && !isDragSource && (compatiblePortIds === null || compatiblePortIds.size === 0); + const portOpacity = isInvalidTarget + ? 0.12 + : isNonCompatibleDuringDrag + ? 0.25 + : isHovered || isSelected || isValidTarget || isSource + ? 1 + : 0.35; + // Derive the typed port list from the node's iceType + property bag. + // `getPortsForNode` is schema-driven (one port per real semantic + // connection — repository / domain / database / …) and memoizes + // internally, so we can call it on every render. + const sockets = getPortsForNode({ id: node.id, type: node.type, data: node.data }); const onEnter = useCallback(() => { setIsHovered(true); @@ -252,7 +288,7 @@ export const CardShell: React.FC = ({ : isHovered ? '0 2px 8px -2px rgba(0,0,0,0.15)' : '0 1px 3px rgba(0,0,0,0.06)', - opacity: isSource ? 0.85 : 1, + opacity: isNonCompatibleDuringDrag ? 0.35 : isSource ? 0.85 : 1, padding: 12, }} data-testid={`cardshell-lod1-${node.id}`} @@ -373,15 +409,20 @@ export const CardShell: React.FC = ({ /> )} {renderPorts && ( - )} @@ -426,7 +467,7 @@ export const CardShell: React.FC = ({ : isHovered ? '0 2px 8px -2px rgba(0,0,0,0.15)' : '0 1px 3px rgba(0,0,0,0.06)', - opacity: isSource ? 0.85 : 1, + opacity: isNonCompatibleDuringDrag ? 0.35 : isSource ? 0.85 : 1, transition: 'box-shadow 150ms ease, border-color 150ms ease', }} > @@ -612,15 +653,16 @@ export const CardShell: React.FC = ({ /> )} {renderPorts && ( - )}
    diff --git a/packages/ui/src/features/canvas/components/nodes/_shared/connection-drag-context.tsx b/packages/ui/src/features/canvas/components/nodes/_shared/connection-drag-context.tsx new file mode 100644 index 00000000..2c9cebd9 --- /dev/null +++ b/packages/ui/src/features/canvas/components/nodes/_shared/connection-drag-context.tsx @@ -0,0 +1,110 @@ +/** + * ConnectionDragContext — propagates per-port drag state from the + * orchestrator (svg-canvas) down to TypedSockets. + * + * While a connection is being drawn, the orchestrator computes which + * specific ports on which nodes can accept the dragged source port. + * CardShell reads that set to brighten matching ports and dim + * everything else — so the user SEES exactly where to drop instead of + * guessing. + * + * Implementation note — the propagation uses a module-level singleton + * rather than React Context. Several canvas tests invoke renderers as + * plain functions (no React render context), so hooks throw there. The + * orchestrator drives a state-bound update via the `` + * component below, which calls `setConnectionDragInfo` synchronously + * on every render. Consumers read the fresh value with + * `getConnectionDragInfo()` — no hooks required. + * + * `null` means no drag in progress; renderers should ignore drag- + * specific visuals. + */ + +import React, { type ReactNode } from 'react'; + +export interface ConnectionDragInfo { + /** Node the drag started from. */ + sourceNodeId: string; + /** Port id the drag started from, when a typed dot was the start point. */ + sourcePortId?: string; + /** + * Per-node, the set of port ids on that node that ACCEPT the dragged + * source. TypedSockets uses this to glow compatible ports and dim + * everything else. + */ + compatibleByNode: Map>; + /** + * The (nodeId, portId) of the port the wire endpoint is currently + * magnet-snapped to, if any. TypedSockets renders this port with the + * "snap" affordance (enlarged ring) so the user knows the drop is + * locked in before they release. + */ + snap: { nodeId: string; portId: string } | null; +} + +// Module-level singleton holding the in-flight drag info. The orchestrator +// updates this synchronously via `` and consumers +// (CardShell) read it with `getConnectionDragInfo()`. State-bound React +// re-renders triggered by the orchestrator's own state changes propagate +// the fresh value down to children — we don't need a Context for that. +let _current: ConnectionDragInfo | null = null; + +/** Returns the active drag info, or null when no drag is in progress. */ +export function getConnectionDragInfo(): ConnectionDragInfo | null { + return _current; +} + +/** + * Test helper — resets the module-level state. Production code paths use + * `ConnectionDragProvider` to drive updates. + */ +export function _resetConnectionDragInfo(): void { + _current = null; +} + +/** + * Per-node lookup helper. Pure function — call it from any renderer to + * get the per-node drag state. Returns `active: false` when no drag is + * in progress. + */ +export function getNodeDragState(nodeId: string): { + active: boolean; + isSource: boolean; + /** When this is the drag-source node, the id of the port the drag started from. */ + sourcePortId: string | null; + compatiblePortIds: Set | null; + snappedPortId: string | null; +} { + const info = _current; + if (!info) + return { + active: false, + isSource: false, + sourcePortId: null, + compatiblePortIds: null, + snappedPortId: null, + }; + const isSource = info.sourceNodeId === nodeId; + return { + active: true, + isSource, + sourcePortId: isSource ? (info.sourcePortId ?? null) : null, + compatiblePortIds: info.compatibleByNode.get(nodeId) ?? null, + snappedPortId: info.snap && info.snap.nodeId === nodeId ? info.snap.portId : null, + }; +} + +/** + * Tiny render-driven syncer. Mounting this with `value={info}` writes + * `info` into the module-level slot during render. The orchestrator + * places this above its CardShell descendants; any prop / state change + * that re-renders the orchestrator re-runs this setter, so children + * see fresh drag state on the very same render pass. + */ +export const ConnectionDragProvider: React.FC<{ value: ConnectionDragInfo | null; children: ReactNode }> = ({ + value, + children, +}) => { + _current = value; + return <>{children}; +}; diff --git a/packages/ui/src/features/canvas/components/nodes/_shared/socket-dot.tsx b/packages/ui/src/features/canvas/components/nodes/_shared/socket-dot.tsx new file mode 100644 index 00000000..7889a0d4 --- /dev/null +++ b/packages/ui/src/features/canvas/components/nodes/_shared/socket-dot.tsx @@ -0,0 +1,228 @@ +/** + * SocketDot — the single source of truth for what a typed port looks + * like on the canvas. + * + * Every block renders sockets through this component: the schema-driven + * `TypedSockets` layer (for blocks that use the standard side-distributed + * port layout) AND any bespoke renderer that needs custom positioning + * (e.g. `Network.CustomDomain` rows). Centralising the visual logic + * here means shape, color, halos, drag-context highlighting, and data + * attributes all stay consistent — fix the dot once and every block gets + * the fix. + * + * The dot's drag-aware `state` controls sizing + halo: + * + * - `idle` — resting state, no drag in progress (or this port + * isn't a candidate target). + * - `compatible` — a drag is in progress from elsewhere and this + * port accepts the source's role. + * - `snapped` — the magnet has locked the wire endpoint onto + * this exact port. + * - `incompatible` — drag in progress but this port doesn't accept + * the source's role. + * - `source-active` — this port IS the source of the current drag. + * + * The dot's shape is driven by `PortDef.shape` so a Repository socket + * (diamond) and a Domain socket (square) read as different things at + * a glance. + */ + +import { CATEGORY_COLORS, type ConnectionCategory } from '@ice/constants'; +import { ROLE_CATEGORY, type PortDef } from '@ice/types'; +import React from 'react'; +import { CATEGORY_STYLE } from '../../../../../config/canvas-constants'; + +export type DotState = 'idle' | 'compatible' | 'snapped' | 'incompatible' | 'source-active'; + +/** Default visible radius (resting state). Other states scale relative to this. */ +export const SHAPE_RADIUS = 6; + +export interface SocketDotProps { + socketId: string; + nodeId: string; + /** Anchor side (left/right/top/bottom). Stored on the DOM attr — used by drag handlers. */ + side: string; + /** Port role (domain/database/repository/queue/…). */ + role: PortDef['role']; + /** Visual shape (circle/ring/diamond/square). */ + shape: PortDef['shape']; + direction: 'in' | 'out'; + /** Human-readable label (used in the hover tooltip + a11y ``). */ + label: string; + /** Peer block category key for color resolution via CATEGORY_STYLE. */ + peerStyle?: string; + /** Canvas-space center of the dot. */ + cx: number; + cy: number; + /** Master opacity (CardShell dims to ~0.35 at idle, full on hover/selection). */ + opacity?: number; + /** Legacy drag-target glow (block-level) — true → green fill + slightly larger radius. */ + isValidTarget?: boolean; + /** Drag-aware per-port state. Defaults to `idle`. */ + state?: DotState; + /** Extra DOM attributes (e.g. `data-route-id` for Custom Domain per-route ports). */ + extraAttrs?: Record<string, string>; +} + +/** + * Pick the dot color: peer block's category accent (so a frontend's + * domain-in reads as Custom Domain's rose) → fall back to abstract + * category color via `ROLE_CATEGORY`. + */ +export function socketColor(role: PortDef['role'], peerStyle?: string): string { + if (peerStyle) { + const style = CATEGORY_STYLE[peerStyle]; + if (style?.glow) return style.glow; + } + return CATEGORY_COLORS[ROLE_CATEGORY[role]]; +} + +export const SocketDot: React.FC<SocketDotProps> = ({ + socketId, + nodeId, + side, + role, + direction, + shape, + label, + peerStyle, + cx, + cy, + opacity, + isValidTarget = false, + state = 'idle', + extraAttrs, +}) => { + const category: ConnectionCategory = ROLE_CATEGORY[role]; + const color = socketColor(role, peerStyle); + + // Drag-aware sizing — compatible ports grow to invite, snapped grows + // most + pulses, incompatible shrinks slightly. + const r = + state === 'snapped' + ? SHAPE_RADIUS + 3 + : state === 'source-active' + ? SHAPE_RADIUS + 2 + : state === 'compatible' + ? SHAPE_RADIUS + 1 + : state === 'incompatible' + ? SHAPE_RADIUS - 1 + : isValidTarget + ? SHAPE_RADIUS + 1 + : SHAPE_RADIUS; + + const fill = state === 'snapped' ? '#22c55e' : isValidTarget ? '#22c55e' : color; + const stroke = 'var(--ice-bg-base)'; + const strokeWidth = 2; + + // Standard data attributes — every consumer agrees on the shape. + const common: Record<string, unknown> = { + className: 'connection-port', + 'data-node-id': nodeId, + 'data-socket-id': socketId, + 'data-side': side, + 'data-category': category, + 'data-port-role': role, + 'data-direction': direction, + 'data-socket-label': label, + ...(peerStyle && { 'data-peer-style': peerStyle }), + ...extraAttrs, + style: { cursor: 'crosshair' }, + ...(typeof opacity === 'number' ? { opacity } : {}), + }; + + // Native SVG <title> stays as the a11y fallback alongside the canvas- + // level hover-tooltip overlay (which reads `data-socket-label`). + const titleEl = <title>{`${label} · ${category}/${direction}`}; + + // Compatible / source-active halo — green ring outside the dot so + // "wire can land here" / "drag started from here" reads immediately. + // Brighter + pulsing when snapped. + const haloRadius = r + 5; + const haloOpacity = state === 'snapped' ? 0.95 : state === 'source-active' ? 0.75 : state === 'compatible' ? 0.45 : 0; + const haloColor = state === 'source-active' ? color : '#22c55e'; + const halo = + haloOpacity > 0 ? ( + + {(state === 'snapped' || state === 'source-active') && ( + + )} + + ) : null; + + let dot: React.ReactNode; + switch (shape) { + case 'ring': + dot = ( + + {titleEl} + + ); + break; + case 'diamond': + dot = ( + + {titleEl} + + ); + break; + case 'square': + dot = ( + + {titleEl} + + ); + break; + case 'circle': + default: + dot = ( + + {titleEl} + + ); + break; + } + return ( + + {halo} + {dot} + + ); +}; + +SocketDot.displayName = 'SocketDot'; diff --git a/packages/ui/src/features/canvas/components/nodes/_shared/socket-hover-tooltip.tsx b/packages/ui/src/features/canvas/components/nodes/_shared/socket-hover-tooltip.tsx new file mode 100644 index 00000000..5520d8bc --- /dev/null +++ b/packages/ui/src/features/canvas/components/nodes/_shared/socket-hover-tooltip.tsx @@ -0,0 +1,120 @@ +/** + * SocketHoverTooltip — instant styled chip that follows the cursor when + * the user hovers a typed socket dot. + * + * Mounted once at the canvas level (alongside ConnectionTooltip). Uses + * document-level event delegation: listens for `mouseover`/`mouseout` + * on the SVG and reads the socket's `data-socket-label` + + * `data-category` + `data-direction` attributes. Keeps `` + * a pure render-only component — no hooks, no React state inside the + * SVG tree — which preserves the existing "call as function" test + * pattern and avoids re-rendering 25 blocks on every hover. + * + * Why this rather than the native `` element: the browser's + * built-in tooltip has a ~1s show delay and is locked to OS chrome + * styling. With sockets the user is rapidly scanning what each dot + * means; the chip needs to appear instantly and read in the same + * monospace voice as the rest of the canvas. + */ + +import { CATEGORY_COLORS, type ConnectionCategory } from '@ice/constants'; +import React, { useEffect, useRef, useState } from 'react'; +import { CATEGORY_STYLE } from '../../../../../config/canvas-constants'; + +interface SocketHoverInfo { + label: string; + category: ConnectionCategory; + direction: 'in' | 'out'; + peerStyle?: string; + clientX: number; + clientY: number; +} + +export const SocketHoverTooltip: React.FC = () => { + const [info, setInfo] = useState<SocketHoverInfo | null>(null); + const lastTargetRef = useRef<Element | null>(null); + + useEffect(() => { + const onOver = (e: MouseEvent): void => { + const target = e.target as Element | null; + if (!target) return; + const socket = target.closest<SVGElement>('.connection-port[data-socket-label]'); + if (!socket) return; + const label = socket.getAttribute('data-socket-label') ?? ''; + if (!label) return; // LOD-degraded anonymous dots emit empty labels + const category = (socket.getAttribute('data-category') as ConnectionCategory | null) ?? 'traffic'; + const direction = (socket.getAttribute('data-direction') as 'in' | 'out' | null) ?? 'in'; + const peerStyle = socket.getAttribute('data-peer-style') ?? undefined; + lastTargetRef.current = socket; + setInfo({ label, category, direction, peerStyle, clientX: e.clientX, clientY: e.clientY }); + }; + const onMove = (e: MouseEvent): void => { + // Move the tooltip with the cursor while still over the same socket. + if ( + lastTargetRef.current && + (e.target as Element | null)?.closest('.connection-port') === lastTargetRef.current + ) { + setInfo((prev) => (prev ? { ...prev, clientX: e.clientX, clientY: e.clientY } : prev)); + } + }; + const onOut = (e: MouseEvent): void => { + const related = (e.relatedTarget as Element | null)?.closest?.('.connection-port[data-socket-label]') ?? null; + if (related !== lastTargetRef.current) { + lastTargetRef.current = null; + setInfo(null); + } + }; + + document.addEventListener('mouseover', onOver); + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseout', onOut); + return () => { + document.removeEventListener('mouseover', onOver); + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseout', onOut); + }; + }, []); + + if (!info) return null; + + const color = (info.peerStyle && CATEGORY_STYLE[info.peerStyle]?.glow) || CATEGORY_COLORS[info.category]; + const arrow = info.direction === 'in' ? '←' : '→'; + + return ( + <div + data-testid="socket-hover-tooltip" + style={{ + position: 'fixed', + left: info.clientX + 12, + top: info.clientY + 12, + zIndex: 1000, + pointerEvents: 'none', + display: 'flex', + alignItems: 'center', + gap: 6, + padding: '4px 8px', + borderRadius: 6, + background: 'var(--ice-bg-raised)', + border: '1px solid var(--ice-border)', + boxShadow: '0 2px 8px -2px rgba(0,0,0,0.18)', + fontFamily: "ui-monospace, 'SFMono-Regular', monospace", + fontSize: 11, + whiteSpace: 'nowrap', + color: 'var(--ice-text-primary)', + }} + > + <span + style={{ + width: 8, + height: 8, + borderRadius: '50%', + background: color, + flexShrink: 0, + }} + /> + <span style={{ fontWeight: 600 }}>{info.label}</span> + <span style={{ color: 'var(--ice-text-tertiary)' }}>{arrow}</span> + <span style={{ color: 'var(--ice-text-tertiary)', textTransform: 'lowercase' }}>{info.category}</span> + </div> + ); +}; diff --git a/packages/ui/src/features/canvas/components/nodes/_shared/typed-sockets.tsx b/packages/ui/src/features/canvas/components/nodes/_shared/typed-sockets.tsx new file mode 100644 index 00000000..f92564ae --- /dev/null +++ b/packages/ui/src/features/canvas/components/nodes/_shared/typed-sockets.tsx @@ -0,0 +1,204 @@ +/** + * TypedSockets — typed connection points on a block. + * + * Renders one SVG `<circle>` (or alternative shape) per `SocketDef` at + * its anchor side. Color from `CATEGORY_COLORS`, shape from the + * `SocketDef.shape` field. Each socket emits the data attributes + * `connection-port` + `data-node-id` + `data-socket-id` + `data-side` + + * `data-category` + `data-direction` so the canvas drag handler can + * (a) recognize the start of a port drag and (b) persist socket ids + * onto the resulting `CardEdge.data`. + * + * At LOD < 2 we degrade to anonymous L/R dots — at very low zoom the + * extra socket detail is invisible and the work is wasted. The drop- + * target glow (valid/invalid) is inherited from CardShell via opacity. + * + * Replaces the prior `ConnectionPorts` 4-side anonymous-dot component. + * `customPorts` blocks (cron, custom-domain) keep drawing their own + * sockets directly and don't go through this component. + */ + +import React, { memo } from 'react'; +import { SocketDot, type DotState } from './socket-dot'; +import type { PortDef } from '@ice/types'; + +interface TypedSocketsProps { + nodeId: string; + /** Block bounds in canvas-space. */ + x: number; + y: number; + width: number; + height: number; + /** Ports to render — output of `getPortsForNode(node)`. */ + sockets: PortDef[]; + /** Drop-target glow color for the validation green/red flash. */ + validTargetColor?: string; + isValidTarget?: boolean; + /** Master opacity gate from CardShell — faint at idle, full on hover/select. */ + opacity?: number; + /** Level of detail; sockets degrade to anonymous L/R dots at LOD < 2. */ + lod?: number; + /** + * Set of port ids on this node that ACCEPT the in-flight drag's source + * port. CardShell reads the drag context and passes this in; null when + * no drag is active or this node isn't a candidate target. + */ + compatiblePortIds?: Set<string> | null; + /** Port id currently magnet-snapped (or null). */ + snappedPortId?: string | null; + /** True when this node is the source of an in-flight drag. */ + isDragSource?: boolean; + /** Source port id when this node is the drag source — drives the pulsing halo on the started port. */ + sourcePortId?: string | null; +} + +/** Distribute sockets along a single side, evenly spaced. */ +function socketPosition( + side: 'left' | 'right' | 'top' | 'bottom', + index: number, + count: number, + x: number, + y: number, + w: number, + h: number, +): { cx: number; cy: number } { + const r = (index + 1) / (count + 1); + switch (side) { + case 'top': + return { cx: x + w * r, cy: y }; + case 'right': + return { cx: x + w, cy: y + h * r }; + case 'bottom': + return { cx: x + w * r, cy: y + h }; + case 'left': + default: + return { cx: x, cy: y + h * r }; + } +} + +// Visual logic — shape, color, halo, drag-context — lives in `./socket-dot`. +// TypedSockets is just the schema-driven distribution layer. + +export const TypedSockets: React.FC<TypedSocketsProps> = memo( + ({ + nodeId, + x, + y, + width, + height, + sockets, + isValidTarget = false, + opacity = 1, + lod = 3, + compatiblePortIds = null, + snappedPortId = null, + isDragSource = false, + sourcePortId = null, + }) => { + const dragActive = !isDragSource && compatiblePortIds !== null; + // The source-side block highlights only its source port — the dot + // the user grabbed — with a pulsing peer-color halo so they see + // exactly where the wire starts. + const sourceActive = isDragSource && sourcePortId !== null; + // LOD degrade — at very low zoom we don't render typed shapes, just + // anonymous L/R dots so the block still has a drag affordance. We + // emit the data attributes anyway so drag still produces a valid + // socket id when possible. + if (lod < 2 || sockets.length === 0) { + const fallback: Array<{ side: 'left' | 'right'; direction: 'in' | 'out'; id: string }> = sockets.length + ? [ + // Pick first IN socket for left, first OUT for right. + ...(sockets.find((s) => s.direction === 'in') + ? [{ side: 'left' as const, direction: 'in' as const, id: sockets.find((s) => s.direction === 'in')!.id }] + : []), + ...(sockets.find((s) => s.direction === 'out') + ? [ + { + side: 'right' as const, + direction: 'out' as const, + id: sockets.find((s) => s.direction === 'out')!.id, + }, + ] + : []), + ] + : [ + { side: 'left', direction: 'in', id: '' }, + { side: 'right', direction: 'out', id: '' }, + ]; + + return ( + <g className="connection-ports" style={{ opacity, transition: 'opacity 120ms ease' }}> + {fallback.map(({ side, direction, id }, idx) => { + const pos = socketPosition(side, 0, 1, x, y, width, height); + return ( + <circle + key={`${side}-${idx}`} + className="connection-port" + data-node-id={nodeId} + data-socket-id={id} + data-side={side} + data-direction={direction} + cx={pos.cx} + cy={pos.cy} + r={isValidTarget ? 6 : 5} + fill={isValidTarget ? '#22c55e' : 'var(--ice-text-tertiary)'} + stroke="var(--ice-bg-base)" + strokeWidth={2} + style={{ cursor: 'crosshair' }} + /> + ); + })} + </g> + ); + } + + // Group sockets by side for even distribution along the perimeter. + const bySide: Record<'left' | 'right' | 'top' | 'bottom', PortDef[]> = { + left: [], + right: [], + top: [], + bottom: [], + }; + for (const s of sockets) bySide[s.side].push(s); + + return ( + <g className="connection-ports" style={{ opacity, transition: 'opacity 120ms ease' }}> + {(['left', 'right', 'top', 'bottom'] as const).flatMap((side) => { + const list = bySide[side]; + return list.map((sock, idx) => { + const pos = socketPosition(side, idx, list.length, x, y, width, height); + // Derive per-port state from the drag context. When no drag + // is in progress this is always 'idle'. + let state: DotState = 'idle'; + if (sourceActive && sock.id === sourcePortId) { + state = 'source-active'; + } else if (dragActive && compatiblePortIds) { + if (snappedPortId === sock.id) state = 'snapped'; + else if (compatiblePortIds.has(sock.id)) state = 'compatible'; + else state = 'incompatible'; + } + return ( + <SocketDot + key={sock.id} + socketId={sock.id} + nodeId={nodeId} + side={side} + role={sock.role} + direction={sock.direction} + shape={sock.shape} + label={sock.label} + peerStyle={sock.peerStyle} + cx={pos.cx} + cy={pos.cy} + isValidTarget={isValidTarget} + state={state} + /> + ); + }); + })} + </g> + ); + }, +); + +TypedSockets.displayName = 'TypedSockets'; diff --git a/packages/ui/src/features/canvas/components/nodes/custom-domain/__tests__/index.test.tsx b/packages/ui/src/features/canvas/components/nodes/custom-domain/__tests__/index.test.tsx index f905f60c..796fcf2c 100644 --- a/packages/ui/src/features/canvas/components/nodes/custom-domain/__tests__/index.test.tsx +++ b/packages/ui/src/features/canvas/components/nodes/custom-domain/__tests__/index.test.tsx @@ -55,6 +55,18 @@ function* walk(node: ReactNodeLike): Generator<React.ReactElement> { } const el = node as React.ReactElement; yield el; + // Recurse into function components by invoking them — needed so the + // walker can see SocketDot's rendered <circle>/<rect>. Without this + // it stops at the SocketDot element type and the assertions miss the + // primitive SVG nodes inside. + if (typeof el.type === 'function') { + const fn = el.type as (p: typeof el.props) => React.ReactNode; + try { + yield* walk(fn(el.props)); + } catch { + /* Component used hooks or threw — leave it as the boundary. */ + } + } const children = (el.props as { children?: React.ReactNode } | undefined)?.children; if (children == null) return; yield* walk(children); @@ -523,14 +535,19 @@ describe('SvgCustomDomainNode — add route button', () => { }); describe('SvgCustomDomainNode — connection ports', () => { - it('renders no ports when not hovered/selected/valid-target', () => { - const tree = renderCD(); - expect(findByType(tree, 'circle')).toHaveLength(0); + // Domain sockets use the `square` shape (per the shared SocketDot + // component) so each port renders as a `<rect>` carrying the + // `connection-port` className — that's the predicate we filter on. + const portsOf = (tree: React.ReactNode) => + findByPredicate(tree, (el) => (el.props as { className?: string }).className === 'connection-port'); + + it('renders no row ports when there are no routes', () => { + const tree = renderCD({ node: makeNode({ data: { routes: [] } }) }); + expect(portsOf(tree)).toHaveLength(0); }); - it('renders left + per-row ports when isSelected', () => { + it('always renders one row port per route (no hover gate)', () => { const tree = renderCD({ - isSelected: true, node: makeNode({ data: { routes: [ @@ -540,75 +557,50 @@ describe('SvgCustomDomainNode — connection ports', () => { }, }), }); - // Left port + 2 row ports = 3 circles. - expect(findByType(tree, 'circle')).toHaveLength(3); - }); - - it('renders ports when hovered (mocked)', () => { - mocks.state.hoverValue = true; - const tree = renderCD({ - node: makeNode({ data: { routes: [{ id: 'r1', subdomain: 'x' }] } }), - }); - // Left port + 1 row port = 2 circles. - expect(findByType(tree, 'circle')).toHaveLength(2); - }); - - it('renders ports when valid-target drag', () => { - const tree = renderCD({ - connectionDragState: 'valid-target', - node: makeNode({ data: { routes: [{ id: 'r1', subdomain: 'x' }] } }), - }); - expect(findByType(tree, 'circle')).toHaveLength(2); + // Per-row ports are visible at idle so the user sees "one socket per + // domain" without hovering. Left "incoming" port was removed (it was + // a never-valid vestigial port). + expect(portsOf(tree)).toHaveLength(2); }); - it('per-row port has data-route-id + data-side="right"', () => { + it('per-row port carries data-socket-id matching the schema (domain-out-<route>)', () => { const tree = renderCD({ - isSelected: true, node: makeNode({ data: { routes: [{ id: 'r-abc', subdomain: 'app' }] } }), }); const rowPort = findByPredicate(tree, (el) => { - if (el.type !== 'circle') return false; - const props = el.props as { 'data-route-id'?: string }; - return props['data-route-id'] === 'r-abc'; + const props = el.props as { 'data-route-id'?: string; className?: string }; + return props.className === 'connection-port' && props['data-route-id'] === 'r-abc'; })[0]; expect(rowPort).toBeDefined(); - expect((rowPort.props as { 'data-side': string })['data-side']).toBe('right'); + const props = rowPort.props as { 'data-side': string; 'data-socket-id': string; 'data-port-role': string }; + expect(props['data-side']).toBe('right'); + expect(props['data-socket-id']).toBe('domain-out-r-abc'); + expect(props['data-port-role']).toBe('domain'); }); - it('valid-target port has r=6 + green fill', () => { + it('valid-target port has green fill and grown radius (size +1)', () => { const tree = renderCD({ connectionDragState: 'valid-target', node: makeNode({ data: { routes: [{ id: 'r1', subdomain: 'x' }] } }), }); - const ports = findByType(tree, 'circle'); + const ports = portsOf(tree); + expect(ports.length).toBeGreaterThan(0); for (const port of ports) { - const props = port.props as { r: number; fill: string }; - expect(props.r).toBe(6); + const props = port.props as { fill: string }; expect(props.fill).toBe('#22c55e'); } }); - it('non valid-target port has r=5 + categoryGlow fill', () => { - const tree = renderCD({ - isSelected: true, + it('idle port is faint (opacity 0.55), full opacity when isSelected', () => { + const idle = renderCD({ node: makeNode({ data: { routes: [{ id: 'r1', subdomain: 'x' }] } }), }); - const ports = findByType(tree, 'circle'); - for (const port of ports) { - expect((port.props as { r: number }).r).toBe(5); - } - }); - - it('left port has data-side="left"', () => { - const tree = renderCD({ + const selected = renderCD({ isSelected: true, - node: makeNode({ data: { routes: [] } }), + node: makeNode({ data: { routes: [{ id: 'r1', subdomain: 'x' }] } }), }); - const leftPort = findByPredicate(tree, (el) => { - if (el.type !== 'circle') return false; - return (el.props as { 'data-side'?: string })['data-side'] === 'left'; - })[0]; - expect(leftPort).toBeDefined(); + expect((portsOf(idle)[0].props as { opacity: number }).opacity).toBe(0.55); + expect((portsOf(selected)[0].props as { opacity: number }).opacity).toBe(1); }); }); diff --git a/packages/ui/src/features/canvas/components/nodes/custom-domain/index.tsx b/packages/ui/src/features/canvas/components/nodes/custom-domain/index.tsx index 54613e73..bfdae023 100644 --- a/packages/ui/src/features/canvas/components/nodes/custom-domain/index.tsx +++ b/packages/ui/src/features/canvas/components/nodes/custom-domain/index.tsx @@ -45,6 +45,8 @@ import { Globe, Plus, X } from 'lucide-react'; import React, { useCallback, useState } from 'react'; import { CARD_PX, CARD_WIDTH, CATEGORY_STYLE, CORNER_RADIUS } from '../../../../../config/canvas-constants'; import { t } from '../../../../../i18n'; +import { getNodeDragState } from '../_shared/connection-drag-context'; +import { SocketDot, type DotState } from '../_shared/socket-dot'; import type { SvgCompactNodeProps } from '../compact-node/types'; // Re-exported so SvgConnectionPath / tests can compute the exact y-coordinate @@ -451,46 +453,49 @@ export const SvgCustomDomainNode: React.FC<SvgCompactNodeProps> = ({ </div> </foreignObject> - {/* ── Per-row connection ports ── */} - {(isHovered || isSelected || isValidTarget) && - portPositions.map((pos, i) => { + {/* ── Per-row connection ports — one socket per route, always visible. + Uses the shared `<SocketDot>` so visual state, halos, data + attributes, and drag-context highlight stay consistent with + every other typed socket on the canvas. Custom Domain owns + only the row Y positioning; the dot's looks are not its + concern. */} + {(() => { + const dragState = getNodeDragState(node.id); + const dotOpacity = isHovered || isSelected || isValidTarget ? 1 : 0.55; + return portPositions.map((pos, i) => { const route = routes[i]; if (!route) return null; + const socketId = `domain-out-${route.id}`; + let state: DotState = 'idle'; + if (dragState.snappedPortId === socketId) state = 'snapped'; + else if (dragState.compatiblePortIds?.has(socketId)) state = 'compatible'; + else if (dragState.isSource && dragState.sourcePortId === socketId) state = 'source-active'; + else if (dragState.active && !dragState.isSource && dragState.compatiblePortIds === null) + state = 'incompatible'; return ( - <circle + <SocketDot key={route.id} - className="connection-port" - data-node-id={node.id} - data-route-id={route.id} - data-side="right" + socketId={socketId} + nodeId={node.id} + side="right" + role="domain" + direction="out" + shape="square" + label={route.subdomain || 'Subdomain'} + peerStyle="Network" cx={pos.cx} cy={pos.cy} - r={isValidTarget ? 6 : 5} - fill={isValidTarget ? '#22c55e' : categoryGlow} - stroke="var(--ice-bg-base)" - strokeWidth={2} - style={{ cursor: 'crosshair' }} + isValidTarget={isValidTarget} + state={state} + opacity={dotOpacity} + // Custom Domain's row Y math is referenced by compute-path + // via `data.routeId` — keep the attribute on the DOM so + // that legacy path still works alongside `data-socket-id`. + extraAttrs={{ 'data-route-id': route.id }} /> ); - })} - - {/* Left-side port for incoming connections (none allowed but kept - consistent with other nodes — `canConnect` rejects them - anyway). */} - {(isHovered || isSelected || isValidTarget) && ( - <circle - className="connection-port" - data-node-id={node.id} - data-side="left" - cx={x} - cy={y + H / 2} - r={isValidTarget ? 6 : 5} - fill={isValidTarget ? '#22c55e' : categoryGlow} - stroke="var(--ice-bg-base)" - strokeWidth={2} - style={{ cursor: 'crosshair' }} - /> - )} + }); + })()} </g> ); }; diff --git a/packages/ui/src/features/canvas/components/nodes/group-node/__tests__/group-lod3.test.tsx b/packages/ui/src/features/canvas/components/nodes/group-node/__tests__/group-lod3.test.tsx index ae16b3f5..33702fc4 100644 --- a/packages/ui/src/features/canvas/components/nodes/group-node/__tests__/group-lod3.test.tsx +++ b/packages/ui/src/features/canvas/components/nodes/group-node/__tests__/group-lod3.test.tsx @@ -198,11 +198,11 @@ describe('GroupLod3 — rect stroke / fill', () => { expect((findRect(renderL3({})).props as { strokeWidth: number }).strokeWidth).toBe(1); }); - it('strokeDasharray undefined when dragOver, "4 4" otherwise', () => { - expect( - (findRect(renderL3({ isDragOver: true })).props as { strokeDasharray?: string }).strokeDasharray, - ).toBeUndefined(); - expect((findRect(renderL3({})).props as { strokeDasharray: string }).strokeDasharray).toBe('4 4'); + it('strokeDasharray "8 4" when dragOver (drop affordance), undefined otherwise — Blender-frame chrome uses a solid border by default', () => { + expect((findRect(renderL3({ isDragOver: true })).props as { strokeDasharray?: string }).strokeDasharray).toBe( + '8 4', + ); + expect((findRect(renderL3({})).props as { strokeDasharray?: string }).strokeDasharray).toBeUndefined(); }); it('fill = groupTint', () => { @@ -226,11 +226,11 @@ describe('GroupLod3 — label + fold + content', () => { expect((lbl.props as { color?: string }).color).toBe('#abc123'); }); - it('FoldButton: opacity 0.8 when hovered, 0.4 otherwise', () => { + it('FoldButton: opacity 0.95 when hovered, 0.6 otherwise (raised from 0.8/0.4 with the tab restyle)', () => { expect((findByType(renderL3({ isHovered: true }), MockFoldButton)[0].props as { opacity: number }).opacity).toBe( - 0.8, + 0.95, ); - expect((findByType(renderL3({}), MockFoldButton)[0].props as { opacity: number }).opacity).toBe(0.4); + expect((findByType(renderL3({}), MockFoldButton)[0].props as { opacity: number }).opacity).toBe(0.6); }); it('FoldButton onClick = onToggleFold', () => { diff --git a/packages/ui/src/features/canvas/components/nodes/group-node/group-label-row.tsx b/packages/ui/src/features/canvas/components/nodes/group-node/group-label-row.tsx index a80e802d..957481d8 100644 --- a/packages/ui/src/features/canvas/components/nodes/group-node/group-label-row.tsx +++ b/packages/ui/src/features/canvas/components/nodes/group-node/group-label-row.tsx @@ -9,10 +9,27 @@ interface GroupLabelRowProps { childCount?: number; } +/** + * Blender-style frame tab — rounded top corners only, flush against the + * group's body border. The colored swatch on the left mirrors the + * group's user color so multi-group canvases stay readable at a glance. + */ export const GroupLabelRow: React.FC<GroupLabelRowProps> = memo(({ label, color, childCount }) => ( - <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> + <div + style={{ + display: 'inline-flex', + alignItems: 'center', + gap: 6, + padding: '2px 8px', + background: color ? `${color}1A` : 'var(--ice-bg-raised)', + borderRadius: '4px 4px 0 0', + border: color ? `1px solid ${color}55` : '1px solid var(--ice-border)', + borderBottom: 'none', + maxWidth: '100%', + }} + > {color && ( - <span style={{ width: 8, height: 8, borderRadius: '50%', background: color, opacity: 0.7, flexShrink: 0 }} /> + <span style={{ width: 8, height: 8, borderRadius: '50%', background: color, opacity: 0.85, flexShrink: 0 }} /> )} <span style={{ @@ -24,6 +41,7 @@ export const GroupLabelRow: React.FC<GroupLabelRowProps> = memo(({ label, color, textTransform: 'uppercase', pointerEvents: 'none', flex: 1, + minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', @@ -38,6 +56,9 @@ export const GroupLabelRow: React.FC<GroupLabelRowProps> = memo(({ label, color, fontSize: 10, fontWeight: 500, fontFamily: FONT_MONO, + padding: '0 4px', + borderRadius: 3, + background: 'var(--ice-bg-hover)', flexShrink: 0, }} > diff --git a/packages/ui/src/features/canvas/components/nodes/group-node/group-lod3.tsx b/packages/ui/src/features/canvas/components/nodes/group-node/group-lod3.tsx index 78c8138d..71cab4bc 100644 --- a/packages/ui/src/features/canvas/components/nodes/group-node/group-lod3.tsx +++ b/packages/ui/src/features/canvas/components/nodes/group-node/group-lod3.tsx @@ -86,7 +86,8 @@ export const GroupLod3: React.FC<GroupLod3Props> = memo( )} {isChildExiting && <ChildExitingIndicator x={x} y={y} width={nodeWidth} height={nodeHeight} />} - {/* Dashed border body */} + {/* Solid frame border — Blender-style. Drag-over still falls + back to dashed so the drop affordance is unambiguous. */} <rect x={x} y={y} @@ -96,23 +97,24 @@ export const GroupLod3: React.FC<GroupLod3Props> = memo( fill={groupTint} stroke={getBorderColor()} strokeWidth={isSelected ? 1.5 : 1} - strokeDasharray={isDragOver ? undefined : '4 4'} - strokeOpacity={0.6} + strokeDasharray={isDragOver ? '8 4' : undefined} + strokeOpacity={0.85} /> - {/* Label row above box + fold chevron */} - <foreignObject x={x} y={y} width={nodeWidth} height={22}> - <div style={{ display: 'flex', alignItems: 'center', padding: '0 4px' }}> - <div style={{ flex: 1 }}> - <GroupLabelRow label={displayLabel} color={userColor} childCount={childCount} /> - </div> - <FoldButton folded={folded} onClick={onToggleFold} opacity={isHovered ? 0.8 : 0.4} /> + {/* Label tab — anchored top-left, flush against the border, with + child-count badge. The fold chevron sits at the tab's right edge. */} + <foreignObject x={x + 8} y={y - 18} width={nodeWidth - 16} height={20}> + <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> + <GroupLabelRow label={displayLabel} color={userColor} childCount={childCount} /> + <span style={{ flex: 1 }} /> + <FoldButton folded={folded} onClick={onToggleFold} opacity={isHovered ? 0.95 : 0.6} /> </div> </foreignObject> - {/* Empty state */} + {/* Empty state — label is now a tab outside the body, so the + empty hint centers within the full frame. */} {!folded && childCount === 0 && ( - <foreignObject x={x} y={y + 24} width={nodeWidth} height={nodeHeight - 24}> + <foreignObject x={x} y={y} width={nodeWidth} height={nodeHeight}> <EmptyStateText text={t('canvas.nodes.dropHere')} /> </foreignObject> )} diff --git a/packages/ui/src/features/canvas/components/nodes/reroute-node/__tests__/passthrough.test.ts b/packages/ui/src/features/canvas/components/nodes/reroute-node/__tests__/passthrough.test.ts new file mode 100644 index 00000000..eee27c1a --- /dev/null +++ b/packages/ui/src/features/canvas/components/nodes/reroute-node/__tests__/passthrough.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { findPassthroughCategory } from '../passthrough'; +import type { CanvasConnection } from '../../../types'; + +function edge(id: string, from: string, to: string, category?: string): CanvasConnection { + return { + id, + from, + to, + data: category ? { connectionCategory: category } : undefined, + }; +} + +describe('findPassthroughCategory', () => { + it('returns the incoming edge category when present', () => { + const conns = [edge('e1', 'a', 'reroute', 'traffic'), edge('e2', 'reroute', 'b', 'config')]; + expect(findPassthroughCategory('reroute', conns)).toBe('traffic'); + }); + + it('falls back to outgoing edge category when no incoming edge', () => { + const conns = [edge('e1', 'reroute', 'b', 'config')]; + expect(findPassthroughCategory('reroute', conns)).toBe('config'); + }); + + it('returns null when reroute is disconnected', () => { + expect(findPassthroughCategory('reroute', [])).toBe(null); + }); + + it('returns null when no edge carries a connectionCategory', () => { + const conns = [edge('e1', 'a', 'reroute')]; + expect(findPassthroughCategory('reroute', conns)).toBe(null); + }); +}); diff --git a/packages/ui/src/features/canvas/components/nodes/reroute-node/index.tsx b/packages/ui/src/features/canvas/components/nodes/reroute-node/index.tsx new file mode 100644 index 00000000..340e2ee2 --- /dev/null +++ b/packages/ui/src/features/canvas/components/nodes/reroute-node/index.tsx @@ -0,0 +1,110 @@ +/** + * Reroute node — a tiny pass-through dot used to bend wires cleanly. + * + * Inspired by Blender's Reroute node (Shift+RMB). The block has no + * header / body / footer / icon: it's a colored circle 16×16 with one + * input socket on the left and one output socket on the right. The + * color of the dot is derived from the connection category of the + * passing wire (computed in `passthrough.ts`) so the user reads the + * dot the same way they read the wire — green = traffic, amber = + * config, etc. + * + * Reroute lives in the `Util` iceType namespace because it's not an + * infrastructure resource — it's a graph-routing affordance. Despite + * not appearing in the deploy graph, it participates in the canvas + * connection model just like any block: it has typed sockets, edges + * attach to it, and the magnetic-attach math runs unchanged. + */ + +import { CATEGORY_COLORS } from '@ice/constants'; +import React, { memo } from 'react'; +import { findPassthroughCategory } from './passthrough'; +import { ConnectionDragGlow } from '../_shared/connection-drag-glow'; +import { TypedSockets } from '../_shared/typed-sockets'; +import type { CanvasConnection } from '../../types'; +import type { SvgCompactNodeProps } from '../compact-node/types'; +import type { SocketDef } from '@ice/types'; + +interface RerouteNodeProps extends SvgCompactNodeProps { + /** All edges on the active card — used to derive the passthrough color. */ + allConnections?: CanvasConnection[]; +} + +export const REROUTE_SIZE = 16; + +export const SvgRerouteNode: React.FC<RerouteNodeProps> = memo( + ({ node, isSelected, connectionDragState, allConnections = [] }) => { + const { x, y } = node; + const W = node.width || REROUTE_SIZE; + const H = node.height || REROUTE_SIZE; + const cx = x + W / 2; + const cy = y + H / 2; + + const category = findPassthroughCategory(node.id, allConnections) ?? 'traffic'; + const color = CATEGORY_COLORS[category]; + + const sockets: SocketDef[] = [ + { + id: 'in', + side: 'left', + category, + direction: 'in', + label: 'Input', + shape: 'circle', + multi: true, + }, + { + id: 'out', + side: 'right', + category, + direction: 'out', + label: 'Output', + shape: 'circle', + multi: true, + }, + ]; + + const isValidTarget = connectionDragState === 'valid-target'; + const isInvalidTarget = connectionDragState === 'invalid-target'; + const ringR = REROUTE_SIZE / 2 + (isSelected ? 3 : 2); + + return ( + <g data-node-id={node.id} data-iceType="Util.Reroute"> + {isSelected && ( + <circle + cx={cx} + cy={cy} + r={ringR} + fill="none" + stroke="var(--ice-text-secondary)" + strokeWidth={1.5} + opacity={0.7} + /> + )} + <circle + cx={cx} + cy={cy} + r={REROUTE_SIZE / 2} + fill={color} + stroke="var(--ice-bg-base)" + strokeWidth={2} + opacity={isInvalidTarget ? 0.4 : 1} + /> + {isValidTarget && <ConnectionDragGlow x={x - 4} y={y - 4} width={W + 8} height={H + 8} />} + <TypedSockets + nodeId={node.id} + x={x} + y={y} + width={W} + height={H} + sockets={sockets} + isValidTarget={isValidTarget} + opacity={1} + lod={3} + /> + </g> + ); + }, +); + +SvgRerouteNode.displayName = 'SvgRerouteNode'; diff --git a/packages/ui/src/features/canvas/components/nodes/reroute-node/passthrough.ts b/packages/ui/src/features/canvas/components/nodes/reroute-node/passthrough.ts new file mode 100644 index 00000000..227ece68 --- /dev/null +++ b/packages/ui/src/features/canvas/components/nodes/reroute-node/passthrough.ts @@ -0,0 +1,22 @@ +/** + * Reroute color derivation. + * + * A reroute is a passthrough — it has no semantic category of its own. + * We pick a color by looking at the wire(s) flowing through it: the + * first incoming (or outgoing, if no incoming) edge donates its + * connection category. If the reroute is disconnected, callers fall + * back to TRAFFIC (the most common wire type) so the dot stays visible. + */ + +import type { CanvasConnection } from '../../types'; +import type { ConnectionCategory } from '@ice/constants'; + +export function findPassthroughCategory(nodeId: string, connections: CanvasConnection[]): ConnectionCategory | null { + // Prefer an incoming edge so the color flows in the direction of data. + const incoming = connections.find((c) => c.to === nodeId); + const outgoing = connections.find((c) => c.from === nodeId); + const cat = (incoming?.data?.connectionCategory ?? outgoing?.data?.connectionCategory) as + | ConnectionCategory + | undefined; + return cat ?? null; +} diff --git a/packages/ui/src/features/canvas/components/nodes/secret-store/__tests__/index.test.tsx b/packages/ui/src/features/canvas/components/nodes/secret-store/__tests__/index.test.tsx index 08893157..a028f274 100644 --- a/packages/ui/src/features/canvas/components/nodes/secret-store/__tests__/index.test.tsx +++ b/packages/ui/src/features/canvas/components/nodes/secret-store/__tests__/index.test.tsx @@ -107,20 +107,12 @@ describe('SvgSecretStoreNode', () => { expect((tree.props as { liveConfig: string }).liveConfig).toBe('3 secrets'); }); - it('appends "· auto-rotate" when data.auto_rotate is truthy', () => { + it('ignores stale data.auto_rotate (property was removed from the schema)', () => { const tree = SvgSecretStoreNode({ node: makeNode({ data: { secrets: ['X'], auto_rotate: true } }), isSelected: false, }) as React.ReactElement; - expect((tree.props as { liveConfig: string }).liveConfig).toBe('1 secret · auto-rotate'); - }); - - it('omits "· auto-rotate" when no secrets, even if flag set', () => { - const tree = SvgSecretStoreNode({ - node: makeNode({ data: { auto_rotate: true } }), - isSelected: false, - }) as React.ReactElement; - expect((tree.props as { liveConfig: string }).liveConfig).toBe('No secrets yet'); + expect((tree.props as { liveConfig: string }).liveConfig).toBe('1 secret'); }); it('uses node.label as title when present, falls back to "Secret Store"', () => { diff --git a/packages/ui/src/features/canvas/components/nodes/secret-store/index.tsx b/packages/ui/src/features/canvas/components/nodes/secret-store/index.tsx index 6b192729..97c4d1ea 100644 --- a/packages/ui/src/features/canvas/components/nodes/secret-store/index.tsx +++ b/packages/ui/src/features/canvas/components/nodes/secret-store/index.tsx @@ -40,11 +40,12 @@ export const SvgSecretStoreNode: React.FC<SvgCompactNodeProps> = ({ }) => { const keys = ((node.data?.secrets as unknown[] | undefined) || []).map(parseSecretKey).filter(Boolean); - const autoRotate = !!node.data?.auto_rotate; const liveConfig = keys.length === 0 ? t('canvas.blocks.secret.none') - : `${keys.length === 1 ? t('canvas.blocks.secret.one') : t('canvas.blocks.secret.many', { n: keys.length })}${autoRotate ? ` · ${t('canvas.blocks.secret.autoRotate')}` : ''}`; + : keys.length === 1 + ? t('canvas.blocks.secret.one') + : t('canvas.blocks.secret.many', { n: keys.length }); return ( <CardShell diff --git a/packages/ui/src/features/canvas/components/path/__tests__/compute-path.test.ts b/packages/ui/src/features/canvas/components/path/__tests__/compute-path.test.ts index 4fbf9e85..1fbdfc2f 100644 --- a/packages/ui/src/features/canvas/components/path/__tests__/compute-path.test.ts +++ b/packages/ui/src/features/canvas/components/path/__tests__/compute-path.test.ts @@ -56,6 +56,119 @@ const baseArgs = (over: Partial<ComputePathArgs> = {}): ComputePathArgs => ({ ...over, }); +describe('socket-aware magnetic routing', () => { + it('uses the socket sides from edge.data when sockets exist', () => { + // Postgres source (Database.PostgreSQL) with `traffic-out`? No — + // Postgres has only `traffic-in` by default. Use Backend with + // `traffic-out` on the right side. + const from = node({ + id: 'a', + x: 0, + y: 0, + width: 100, + height: 50, + data: { iceType: 'Compute.Worker' }, + }); + const to = node({ + id: 'b', + x: 300, + y: 0, + width: 100, + height: 50, + data: { iceType: 'Database.PostgreSQL' }, + }); + const result = computePath( + baseArgs({ + connection: conn({ data: { sourceSocket: 'traffic-out', targetSocket: 'traffic-in' } }), + fromNode: from, + toNode: to, + }), + ); + expect(result).not.toBeNull(); + expect(result!.exitSide).toBe('right'); + expect(result!.entrySide).toBe('left'); + expect(result!.start?.x).toBe(100); // right edge of source + expect(result!.end?.x).toBe(300); // left edge of target + }); + + it('migrates the attach side when the target is in the opposite half-plane', () => { + // Source on the right of canvas, target FAR LEFT — preferred socket + // side is right, but target is left → attach migrates to left. + const from = node({ + id: 'a', + x: 500, + y: 0, + width: 100, + height: 50, + data: { iceType: 'Compute.Worker' }, + }); + const to = node({ + id: 'b', + x: 0, + y: 0, + width: 100, + height: 50, + data: { iceType: 'Database.PostgreSQL' }, + }); + const result = computePath( + baseArgs({ + connection: conn({ data: { sourceSocket: 'traffic-out', targetSocket: 'traffic-in' } }), + fromNode: from, + toNode: to, + }), + ); + expect(result).not.toBeNull(); + // Source's preferred = right, but target is to the left → migrate to left. + expect(result!.exitSide).toBe('left'); + expect(result!.start?.x).toBe(500); // left edge of source (at x=500) + }); + + it('falls back to chooseSides when neither socket id is set', () => { + const from = node({ id: 'a', x: 0, y: 0, width: 100, height: 50, data: {} }); + const to = node({ id: 'b', x: 300, y: 0, width: 100, height: 50, data: {} }); + const result = computePath( + baseArgs({ + connection: conn({ data: {} }), + fromNode: from, + toNode: to, + }), + ); + expect(result).not.toBeNull(); + expect(result!.exitSide).toBe('right'); + expect(result!.entrySide).toBe('left'); + }); + + it('falls back to chooseSides when a socket id is set but the socket no longer exists', () => { + const from = node({ + id: 'a', + x: 0, + y: 0, + width: 100, + height: 50, + data: { iceType: 'Compute.Worker' }, + }); + const to = node({ + id: 'b', + x: 300, + y: 0, + width: 100, + height: 50, + data: { iceType: 'Database.PostgreSQL' }, + }); + const result = computePath( + baseArgs({ + connection: conn({ data: { sourceSocket: 'nonexistent', targetSocket: 'nonexistent' } }), + fromNode: from, + toNode: to, + }), + ); + // Dangling sockets → graceful fallback to chooseSides, never null. + expect(result).not.toBeNull(); + expect(result!.exitSide).toBe('right'); + expect(result!.entrySide).toBe('left'); + }); +}); + describe('rf-conpath-7: computePath — null/missing nodes', () => { it('returns null when fromNode is missing', () => { expect(computePath(baseArgs({ fromNode: undefined }))).toBeNull(); @@ -157,7 +270,13 @@ describe('rf-conpath-7: computePath — CustomDomain row override', () => { expect(out!.pathD).toMatch(/^M 100 \d+(\.\d+)? L 300 \d+(\.\d+)?$/); }); - it('unmatched routeId falls back to chooseSides + getEdgePoint', () => { + it('unmatched routeId — sourceSocket inference still resolves a CD row anchor', () => { + // Pre-unification this test exercised a legacy "row override + // fallback" branch. In the unified model, an edge without an + // explicit `sourceSocket` runs through `inferEdgePorts`, which on + // a Network.CustomDomain with routes picks one of the `domain-out-{id}` + // ports. The wire anchors at that port's bespoke row Y via + // `getSocketCanvasPosition`, not at the generic side midpoint. const c = conn({ data: { routeId: 'nonexistent' } }); const args = baseArgs({ connection: c, @@ -167,32 +286,13 @@ describe('rf-conpath-7: computePath — CustomDomain row override', () => { }); const out = computePath(args); expect(out).not.toBeNull(); - // Generic side selection: dx>0 dominant → exit right (x=100), enter - // left (x=300). Source is 100x200, port mid → y=100. Target is - // 100x50, port mid → y=25. - expect(out!.pathD).toBe('M 100 100 L 300 25'); - }); - - it('row override picks entry side relative to start point (not source midpoint)', () => { - // Route 0 anchors start near the top of the source. Target is below - // and to the right but vertically aligned — entry should pick top - // because dy from the row-port to the target dominates. - const c = conn({ data: { routeId: 'r1' } }); - const args = baseArgs({ - connection: c, - fromNode: cdSource({ x: 0, y: 0, width: 100, height: 200 }), - toNode: node({ id: 'b', x: 50, y: 1000, width: 100, height: 50 }), // far below - edgeStyle: 'rectangular', - }); - const out = computePath(args); - expect(out).not.toBeNull(); - // Entry side is top — rectangular path with right→top mixed branch - // (outX = startX + GAP). startX = 100 (source right edge), GAP=20. - // Path emerges with the elbow points. - expect(out!.pathD).toContain('120'); + // Source exit is the CD's right edge (x=100); the Y is row-anchored + // (matches `getCustomDomainRoutePortY`) — we don't pin the exact + // value because it changes with row-height constants. + expect(out!.pathD).toMatch(/^M 100 \d+(\.\d+)? L 300 \d+(\.\d+)?$/); }); - it('row override falls through to no special handling when iceType is not CustomDomain', () => { + it('legacy edge with no socket info on non-CustomDomain falls through to side-midpoint routing', () => { const c = conn({ data: { routeId: 'r2' } }); const args = baseArgs({ connection: c, @@ -202,33 +302,18 @@ describe('rf-conpath-7: computePath — CustomDomain row override', () => { y: 0, width: 100, height: 50, - data: { iceType: 'Compute.WebApp' }, // not CustomDomain + data: { iceType: 'Compute.WebApp' }, // not CustomDomain, no schema entry }), toNode: node({ id: 'b', x: 200, y: 0, width: 100, height: 50 }), edgeStyle: 'straight', }); const out = computePath(args); expect(out).not.toBeNull(); - expect(out!.pathD).toBe('M 100 25 L 200 25'); - }); -}); - -describe('rf-conpath-7: computePath — port-slot plumbing', () => { - it('passes sourcePortIndex/Count + targetPortIndex/Count to getEdgePoint', () => { - // Source 100x100, port 1 of 3 → r=0.5 → y=50. - // Target 100x100, port 0 of 2 → r=1/3 → y≈33.33. - const args = baseArgs({ - fromNode: node({ id: 'a', x: 0, y: 0, width: 100, height: 100 }), - toNode: node({ id: 'b', x: 200, y: 0, width: 100, height: 100 }), - sourcePortIndex: 1, - sourcePortCount: 3, - targetPortIndex: 0, - targetPortCount: 2, - edgeStyle: 'straight', - }); - const out = computePath(args); - expect(out).not.toBeNull(); - // Source y = 100 * 2/4 = 50, Target y = 100 * 1/3 ≈ 33.333… - expect(out!.pathD).toMatch(/^M 100 50 L 200 33\.\d+$/); + // Without typed sockets on either end, the path falls through to + // chooseSides + magnetic-attach. Both 100x50 blocks have port-Y + // clamped to the corner margin (12px), so y=25 (midpoint) or + // clamped value — we just check the X anchors are on the inner + // edges. + expect(out!.pathD).toMatch(/^M 100 \d+(\.\d+)? L 200 \d+(\.\d+)?$/); }); }); diff --git a/packages/ui/src/features/canvas/components/path/__tests__/magnetic-attach.test.ts b/packages/ui/src/features/canvas/components/path/__tests__/magnetic-attach.test.ts new file mode 100644 index 00000000..9e9e5eed --- /dev/null +++ b/packages/ui/src/features/canvas/components/path/__tests__/magnetic-attach.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { getMagneticAttach, slideAlong, getAnchorPoint, DEFAULT_PERIMETER_MARGIN } from '../magnetic-attach'; +import type { Bounds, Point, Side } from '../types'; + +const block: Bounds = { x: 100, y: 100, width: 200, height: 100 }; + +function center(b: Bounds): Point { + return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; +} + +describe('getMagneticAttach', () => { + it('keeps the attach on the preferred side when target is in that half-plane', () => { + // Target to the right of block; preferred = right. + const target: Point = { x: 600, y: 150 }; + const { attach, side } = getMagneticAttach(block, 'right', target); + expect(side).toBe('right'); + expect(attach.x).toBe(block.x + block.width); // right edge + }); + + it('migrates to the opposite side when target is in the opposite half-plane', () => { + // Target to the FAR left of block; preferred = right (e.g. socket + // is anchored right, but the partner sits to the left). + const target: Point = { x: -200, y: 150 }; + const { side } = getMagneticAttach(block, 'right', target); + expect(side).toBe('left'); + }); + + it('respects the preferred side when geometry only weakly disagrees (above instead of right)', () => { + // Target above block; preferred = right. We keep right (no flip to opposite half-plane). + const target: Point = { x: 200, y: -200 }; + const { side } = getMagneticAttach(block, 'right', target); + expect(side).toBe('right'); + }); + + it('slides the attach along the side toward the target projection', () => { + // Target far down-right; on the right side, y should slide down toward target.y. + const target: Point = { x: 600, y: 600 }; + const { attach } = getMagneticAttach(block, 'right', target); + expect(attach.y).toBe(block.y + block.height - DEFAULT_PERIMETER_MARGIN); + }); + + it('clamps to corner margin so the attach never reaches the literal corner', () => { + // Target far away vertically beyond the block's bottom. + const target: Point = { x: 1000, y: 9999 }; + const { attach } = getMagneticAttach(block, 'right', target, 20); + expect(attach.y).toBe(block.y + block.height - 20); + }); + + it('tie-break: dx === dy uses vertical branch (strict > matches chooseSides)', () => { + // Target at +200 dx, +200 dy from center → equal magnitudes; vertical wins. + const c = center(block); + const target: Point = { x: c.x + 200, y: c.y + 200 }; + // Preferred = right → opposite = left. Facing axis with strict > falls to vertical → bottom. + // 'bottom' !== opposite[right]==='left', so preferredSide wins → 'right'. + // We assert via slideAlong + getMagneticAttach side resolution explicitly: + const { side } = getMagneticAttach(block, 'right', target); + expect(side).toBe('right'); + }); +}); + +describe('slideAlong', () => { + it.each<[Side, (b: Bounds, t: Point) => Point]>([ + ['left', (b) => ({ x: b.x, y: 0 })], + ['right', (b) => ({ x: b.x + b.width, y: 0 })], + ['top', (b) => ({ x: 0, y: b.y })], + ['bottom', (b) => ({ x: 0, y: b.y + b.height })], + ])('places attach on the named side: %s', (side, makeExpected) => { + const target: Point = { x: 150, y: 150 }; + const p = slideAlong(block, side, target); + if (side === 'left' || side === 'right') { + expect(p.x).toBe(makeExpected(block, target).x); + } else { + expect(p.y).toBe(makeExpected(block, target).y); + } + }); +}); + +describe('getAnchorPoint', () => { + it('returns the side midpoint for the idle drag-start dot', () => { + expect(getAnchorPoint(block, 'left')).toEqual({ x: 100, y: 150 }); + expect(getAnchorPoint(block, 'right')).toEqual({ x: 300, y: 150 }); + expect(getAnchorPoint(block, 'top')).toEqual({ x: 200, y: 100 }); + expect(getAnchorPoint(block, 'bottom')).toEqual({ x: 200, y: 200 }); + }); +}); diff --git a/packages/ui/src/features/canvas/components/path/__tests__/socket-position.test.ts b/packages/ui/src/features/canvas/components/path/__tests__/socket-position.test.ts new file mode 100644 index 00000000..254e3e1c --- /dev/null +++ b/packages/ui/src/features/canvas/components/path/__tests__/socket-position.test.ts @@ -0,0 +1,97 @@ +/** + * Tests for `socket-position` — the single source of truth for where a + * socket dot lives in canvas space. + * + * Two surfaces: + * 1. `BESPOKE_SOCKET_POSITIONS` — the schema-shaped table the + * dispatcher iterates. Asserts the registered entries are what + * we expect (cardinal rule: dispatch is generic, the table IS + * the declared fact). + * 2. `getSocketCanvasPosition` — the dispatcher itself. Covers: + * bespoke hit, bespoke miss (falls through), standard layout, + * and the dangling-edge null return. + */ + +import { describe, it, expect } from 'vitest'; +import { BESPOKE_SOCKET_POSITIONS, getSocketCanvasPosition } from '../socket-position'; +import type { CanvasNode } from '../../types'; + +const makeNode = (overrides: Partial<CanvasNode> = {}): CanvasNode => ({ + id: 'n1', + type: 'resource', + x: 100, + y: 200, + width: 80, + height: 40, + label: 'Node', + data: {}, + parentId: undefined, + ...overrides, +}); + +describe('BESPOKE_SOCKET_POSITIONS table', () => { + it('registers exactly the bespoke iceTypes that need a custom layout', () => { + expect(Object.keys(BESPOKE_SOCKET_POSITIONS).sort()).toEqual(['Network.CustomDomain']); + }); + + it('Custom Domain resolver returns a point on the right edge for matching socketIds', () => { + const node = makeNode({ + data: { iceType: 'Network.CustomDomain', routes: [{ id: 'r1', subdomain: 'a' }] }, + }); + const point = BESPOKE_SOCKET_POSITIONS['Network.CustomDomain'](node, 'domain-out-r1'); + expect(point).not.toBeNull(); + expect(point!.x).toBe(node.x + node.width); + expect(point!.y).toBeGreaterThan(node.y); + }); + + it('Custom Domain resolver returns null when the route id does not exist', () => { + const node = makeNode({ + data: { iceType: 'Network.CustomDomain', routes: [{ id: 'r1', subdomain: 'a' }] }, + }); + expect(BESPOKE_SOCKET_POSITIONS['Network.CustomDomain'](node, 'domain-out-MISSING')).toBeNull(); + }); + + it('Custom Domain resolver returns null for non-route socket ids (fall-through)', () => { + const node = makeNode({ data: { iceType: 'Network.CustomDomain', routes: [] } }); + expect(BESPOKE_SOCKET_POSITIONS['Network.CustomDomain'](node, 'some-other-port')).toBeNull(); + }); + + it('per-route Y monotonically increases with row index', () => { + const node = makeNode({ + data: { + iceType: 'Network.CustomDomain', + routes: [ + { id: 'r1', subdomain: 'a' }, + { id: 'r2', subdomain: 'b' }, + { id: 'r3', subdomain: 'c' }, + ], + }, + }); + const y1 = BESPOKE_SOCKET_POSITIONS['Network.CustomDomain'](node, 'domain-out-r1')!.y; + const y2 = BESPOKE_SOCKET_POSITIONS['Network.CustomDomain'](node, 'domain-out-r2')!.y; + const y3 = BESPOKE_SOCKET_POSITIONS['Network.CustomDomain'](node, 'domain-out-r3')!.y; + expect(y2).toBeGreaterThan(y1); + expect(y3).toBeGreaterThan(y2); + }); +}); + +describe('getSocketCanvasPosition dispatch', () => { + it('routes Custom Domain row sockets through the bespoke resolver', () => { + const node = makeNode({ + data: { iceType: 'Network.CustomDomain', routes: [{ id: 'r1', subdomain: 'a' }] }, + }); + const point = getSocketCanvasPosition(node, 'domain-out-r1'); + const bespoke = BESPOKE_SOCKET_POSITIONS['Network.CustomDomain'](node, 'domain-out-r1'); + expect(point).toEqual(bespoke); + }); + + it('returns null for an unknown socket id on a standard typed-socket node', () => { + const node = makeNode({ data: { iceType: 'Compute.Container' } }); + expect(getSocketCanvasPosition(node, 'no-such-port')).toBeNull(); + }); + + it('returns null for an unknown iceType (no schema, no bespoke)', () => { + const node = makeNode({ data: { iceType: 'Wholly.Unknown' } }); + expect(getSocketCanvasPosition(node, 'anything')).toBeNull(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/path/compute-path.ts b/packages/ui/src/features/canvas/components/path/compute-path.ts index e134e29b..922cd983 100644 --- a/packages/ui/src/features/canvas/components/path/compute-path.ts +++ b/packages/ui/src/features/canvas/components/path/compute-path.ts @@ -1,56 +1,46 @@ /** * Top-level path-builder dispatcher: turns a (connection, from-node, - * to-node, port slot, edge style, lod/zoom) bundle into the - * `{ pathD, midX, midY }` triple the orchestrator hands to its `<path>`. + * to-node, edge style, lod/zoom) bundle into the `{ pathD, midX, midY, + * start, end, exitSide, entrySide }` result the orchestrator hands to + * its `<path>`. * - * Extracted as the rf-conpath-7 (orchestrator slim-down) helper of the - * svg-connection-path decomposition. The orchestrator's `useMemo` body - * was a ~70-LOC pure function over its arguments (no DOM, no React - * state) and lifted cleanly. Pulling it out gives the orchestrator one - * import in place of an inline branch tree, and exposes the dispatch - * to fixture-style tests without rendering the React tree. + * Unified resolution model (post-`socket-position`): * - * Dispatch order (preserved verbatim from the original orchestrator): - * 1. If either node is missing, return `null` (orchestrator renders - * nothing). - * 2. Determine `(exitSide, entrySide, start)`: - * a. Special case: `Network.CustomDomain` source AND the edge - * carries a `routeId` AND the source has a matching route in - * `data.routes` → anchor the start point to the row's port-Y - * (computed via `getCustomDomainRoutePortY`), force exit side - * to `'right'`, pick entry side relative to where the start - * point sits (NOT the source's bounds midpoint, so the curve - * doesn't loop back if the target is above/below the row). - * b. The "route was deleted but the edge still references it" - * fallback path runs `chooseSides(effFrom, effTo)` like the - * general case. - * c. General case: `chooseSides(effFrom, effTo)` + a - * `getEdgePoint`-based start computed from the source's port - * index/count. - * 3. Compute `end = getEdgePoint(effTo, entrySide, ...)`. - * 4. Dispatch on `edgeStyle`: - * - `'straight'` → `buildStraightPath`. - * - `'rectangular'` → if `connection.data.routePoints` has 3+ - * points, try `buildDagreRoutedPath`; if that returns null, - * fall back to `buildRectangularPath`. - * - default (`'bezier'`) → `buildBezierPath`. + * 1. Resolve both endpoints' `PortDef` from `edge.data.sourceSocket` + * / `targetSocket`. If either is missing, fill in via + * `inferEdgePorts` so legacy edges still anchor to the right + * typed dots without a data migration. + * 2. Look up each end's canvas-space position via + * `getSocketCanvasPosition` — the SINGLE function the canvas uses + * to know "where does this socket dot live?" Custom Domain row + * ports, standard typed-socket distribution, and any future + * bespoke renderer all route through it. + * 3. Fall back to chooseSides + magnetic-attach only when neither end + * has a socket id AND inference produced no port. + * 4. Dispatch on `edgeStyle` (bezier / straight / rectangular) and + * enrich the result with the resolved start/end/sides. * - * The CustomDomain-row tie-break uses strict `>` for the - * `Math.abs(dx) > Math.abs(dy)` axis pick, mirroring `chooseSides`'s - * tie-break (vertical wins on equal-magnitude). DO NOT cross-port - * with `connection-preview.ts`'s `>=` — see the dominant-axis-tie- - * breaks-are-load-bearing-do-not-cross-port learning. + * Why one path instead of three: the canvas had distinct branches for + * the CustomDomain row case, the typed-socket case, and the legacy + * case, each with its own end-Y math. They disagreed in subtle ways — + * e.g. CustomDomain-row pinned the source Y but used the side midpoint + * for the target, so wires "landed at the wrong socket" on multi-port + * blocks. Funnel everything through `getSocketCanvasPosition` and the + * wires and the dots agree by construction. */ +import { findPort, getPortsForNode, inferEdgePorts, type PortDef } from '@ice/types'; import { chooseSides, getEdgePoint, getEffectiveBounds } from './bounds-and-sides'; -import { getCustomDomainRoutePortY } from '../nodes/custom-domain'; import { buildBezierPath } from './builders/bezier'; import { buildDagreRoutedPath } from './builders/dagre-routed'; import { buildRectangularPath } from './builders/rectangular'; import { buildStraightPath } from './builders/straight'; +import { getMagneticAttach } from './magnetic-attach'; +import { getSocketCanvasPosition } from './socket-position'; import type { PathResult, Point, Side } from './types'; import type { EdgeStyle } from '../../../../store/slices/ui-slice'; import type { CanvasConnection, CanvasNode } from '../svg-canvas'; +import type { ConnectionCategory } from '@ice/constants'; export interface ComputePathArgs { connection: CanvasConnection; @@ -87,65 +77,78 @@ export function computePath(args: ComputePathArgs): PathResult | null { const effFrom = getEffectiveBounds(fromNode, lod, zoom); const effTo = getEffectiveBounds(toNode, lod, zoom); - const fromIce = (fromNode.data?.iceType as string) || ''; - const routeId = (connection.data as { routeId?: string } | undefined)?.routeId; - // Network.CustomDomain exposes per-row connection ports that the path - // should anchor to EXACTLY (not at the generic right-side midpoint). - // Works for standalone and nested-inside-PrivateNetwork usage alike — - // the CD's routes are always on its own right edge. - const isCustomDomainSource = fromIce === 'Network.CustomDomain' && !!routeId; - const isRowSource = isCustomDomainSource; + // ── Resolve socket endpoints ─────────────────────────────────────── + // + // When the edge carries socket ids, fetch each end's PortDef so we + // know its declared anchor side. For pre-port-aware edges, infer the + // best pair from the schemas + category — purely visual, the storage + // stays untouched until the user explicitly locks in. + const edgeData = (connection.data ?? {}) as { + sourceSocket?: string; + targetSocket?: string; + connectionCategory?: ConnectionCategory; + }; + let sourceSocket: PortDef | undefined = edgeData.sourceSocket ? findPort(fromNode, edgeData.sourceSocket) : undefined; + let targetSocket: PortDef | undefined = edgeData.targetSocket ? findPort(toNode, edgeData.targetSocket) : undefined; + if (!sourceSocket || !targetSocket) { + const inferred = inferEdgePorts( + sourceSocket ? [sourceSocket] : getPortsForNode(fromNode), + targetSocket ? [targetSocket] : getPortsForNode(toNode), + edgeData.connectionCategory ?? null, + ); + if (!sourceSocket) sourceSocket = inferred.sourcePort; + if (!targetSocket) targetSocket = inferred.targetPort; + } + // ── Position each end via the unified socket-position helper ─────── let exitSide: Side; let entrySide: Side; let start: Point; + let end: Point; - if (isRowSource) { - const routes = (fromNode.data?.routes as Array<{ id: string; subdomain: string }> | undefined) || []; - const rowIndex = routes.findIndex((r) => r.id === routeId); - if (rowIndex >= 0) { - exitSide = 'right'; - start = { - x: effFrom.x + effFrom.width, - y: effFrom.y + getCustomDomainRoutePortY(rowIndex), - }; - // Entry side picked relative to where the start point sits, not - // the source bounds midpoint, so the curve doesn't loop back if - // the target is above/below the row. - const dx = effTo.x + effTo.width / 2 - start.x; - const dy = effTo.y + effTo.height / 2 - start.y; - if (Math.abs(dx) > Math.abs(dy)) { - entrySide = dx > 0 ? 'left' : 'right'; - } else { - entrySide = dy > 0 ? 'top' : 'bottom'; - } - } else { - // Route was deleted but the edge still references it — fall back - // to the generic side selection. - const sides = chooseSides(effFrom, effTo); - exitSide = sides.exitSide; - entrySide = sides.entrySide; - start = getEdgePoint(effFrom, exitSide, sourcePortIndex, sourcePortCount); - } + const sourcePos = sourceSocket ? getSocketCanvasPosition(fromNode, sourceSocket.id) : null; + const targetPos = targetSocket ? getSocketCanvasPosition(toNode, targetSocket.id) : null; + + if (sourcePos && sourceSocket) { + start = sourcePos; + exitSide = sourceSocket.side; + } else if (sourceSocket) { + // Schema knew the side but the position lookup failed (rare — + // e.g. dangling port). Use the side midpoint as a safe fallback. + exitSide = sourceSocket.side; + start = getEdgePoint(effFrom, exitSide, sourcePortIndex, sourcePortCount); } else { + // Fully untyped edge — chooseSides + magnetic-attach for the + // legacy "anonymous wire" feel. const sides = chooseSides(effFrom, effTo); exitSide = sides.exitSide; + const toCenter: Point = { x: effTo.x + effTo.width / 2, y: effTo.y + effTo.height / 2 }; + start = getMagneticAttach(effFrom, exitSide, toCenter).attach; + } + + if (targetPos && targetSocket) { + end = targetPos; + entrySide = targetSocket.side; + } else if (targetSocket) { + entrySide = targetSocket.side; + end = getEdgePoint(effTo, entrySide, targetPortIndex, targetPortCount); + } else { + const sides = chooseSides(effFrom, effTo); entrySide = sides.entrySide; - start = getEdgePoint(effFrom, exitSide, sourcePortIndex, sourcePortCount); + const fromCenter: Point = { x: effFrom.x + effFrom.width / 2, y: effFrom.y + effFrom.height / 2 }; + end = getMagneticAttach(effTo, entrySide, fromCenter).attach; } - const end = getEdgePoint(effTo, entrySide, targetPortIndex, targetPortCount); - if (edgeStyle === 'straight') return buildStraightPath(start, end); + const enrich = (r: PathResult): PathResult => ({ ...r, start, end, exitSide, entrySide }); + + if (edgeStyle === 'straight') return enrich(buildStraightPath(start, end)); if (edgeStyle === 'rectangular') { - // If auto-layout left us a routed polyline on this edge, follow - // it — dagre already bent the path around obstacles. Fall back to - // a plain L when the route is absent or too short. const routePoints = (connection.data as { routePoints?: Point[] } | undefined)?.routePoints; if (routePoints && routePoints.length >= 3) { const routed = buildDagreRoutedPath(routePoints, start, end); - if (routed) return routed; + if (routed) return enrich(routed); } - return buildRectangularPath(start, end, exitSide, entrySide); + return enrich(buildRectangularPath(start, end, exitSide, entrySide)); } - return buildBezierPath(start, end, exitSide, entrySide); + return enrich(buildBezierPath(start, end, exitSide, entrySide)); } diff --git a/packages/ui/src/features/canvas/components/path/magnetic-attach.ts b/packages/ui/src/features/canvas/components/path/magnetic-attach.ts new file mode 100644 index 00000000..e81827af --- /dev/null +++ b/packages/ui/src/features/canvas/components/path/magnetic-attach.ts @@ -0,0 +1,114 @@ +/** + * Magnetic perimeter attach. + * + * Given a node's bounds and a "preferred" side (the visible socket's + * default anchor), pick the actual perimeter attach point for a wire + * heading to (or from) a target. The attach migrates around the + * perimeter to the side facing the target, then slides along that side + * to the closest projection of the target's center — clamped to a + * margin inside the corners so wires never collide with the block's + * rounded edges. + * + * This is what makes wires read like geometry nodes: typed sockets live + * on the L/R sides by default, but the actual wire endpoint takes the + * shortest visual path. Pair with `getAnchorPoint` (idle drag-start dot + * at the socket's declared side) — the two diverge whenever the + * partner is in a half-plane other than the one the anchor side faces. + * + * Diverges intentionally from `bounds-and-sides.chooseSides` only in + * what it returns: `chooseSides` is bounds-to-bounds, this is + * bounds-to-arbitrary-point and includes the slid attach point. The + * tie-break (strict `>`) is identical so the two helpers agree on + * dominant-axis classification — DO NOT cross-port from + * `connection-preview.ts` which uses `>=`. + */ + +import type { Bounds, Point, Side } from './types'; + +/** Pixels reserved at each end of a side so wires don't collide with the corner radius. */ +export const DEFAULT_PERIMETER_MARGIN = 12; + +export interface MagneticAttachResult { + /** Where the wire actually attaches to the node perimeter. */ + attach: Point; + /** Which side of the node the wire exits/enters. */ + side: Side; +} + +function clamp(v: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, v)); +} + +/** + * Picks the side of `bounds` facing `targetCenter`, then slides the + * attach point along that side to the nearest projection of the target. + * + * `preferredSide` biases the choice: when the target is in the + * half-plane on the preferred side (or on the axis perpendicular to + * it), the attach stays on the preferred side. Otherwise it migrates + * to whichever side faces the target. This gives smooth behavior — + * sockets stay where the schema put them most of the time, but step + * around the perimeter to keep wires short when geometry demands it. + */ +export function getMagneticAttach( + bounds: Bounds, + preferredSide: Side, + targetCenter: Point, + margin = DEFAULT_PERIMETER_MARGIN, +): MagneticAttachResult { + const cx = bounds.x + bounds.width / 2; + const cy = bounds.y + bounds.height / 2; + const dx = targetCenter.x - cx; + const dy = targetCenter.y - cy; + + const facing: Side = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : dy > 0 ? 'bottom' : 'top'; + + // Honor `preferredSide` when geometry doesn't strongly disagree — + // i.e. only override the preference when the target is in the + // opposite half-plane. Sliding within the preferred side handles + // small angle differences; only large ones flip to the facing side. + const opposite: Record<Side, Side> = { left: 'right', right: 'left', top: 'bottom', bottom: 'top' }; + const side: Side = facing === opposite[preferredSide] ? facing : preferredSide; + + return { attach: slideAlong(bounds, side, targetCenter, margin), side }; +} + +/** Where on `side` is the closest point to `targetCenter`, clamped to a corner margin. */ +export function slideAlong(bounds: Bounds, side: Side, targetCenter: Point, margin = DEFAULT_PERIMETER_MARGIN): Point { + switch (side) { + case 'left': + return { + x: bounds.x, + y: clamp(targetCenter.y, bounds.y + margin, bounds.y + bounds.height - margin), + }; + case 'right': + return { + x: bounds.x + bounds.width, + y: clamp(targetCenter.y, bounds.y + margin, bounds.y + bounds.height - margin), + }; + case 'top': + return { + x: clamp(targetCenter.x, bounds.x + margin, bounds.x + bounds.width - margin), + y: bounds.y, + }; + case 'bottom': + return { + x: clamp(targetCenter.x, bounds.x + margin, bounds.x + bounds.width - margin), + y: bounds.y + bounds.height, + }; + } +} + +/** Resolves the visible idle dot point at the side's midpoint — drag-start affordance. */ +export function getAnchorPoint(bounds: Bounds, side: Side): Point { + switch (side) { + case 'left': + return { x: bounds.x, y: bounds.y + bounds.height / 2 }; + case 'right': + return { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 }; + case 'top': + return { x: bounds.x + bounds.width / 2, y: bounds.y }; + case 'bottom': + return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height }; + } +} diff --git a/packages/ui/src/features/canvas/components/path/socket-position.ts b/packages/ui/src/features/canvas/components/path/socket-position.ts new file mode 100644 index 00000000..4a35cfc5 --- /dev/null +++ b/packages/ui/src/features/canvas/components/path/socket-position.ts @@ -0,0 +1,88 @@ +/** + * Socket position — the single source of truth for where a socket dot + * lives in canvas space. + * + * Both `compute-path` (drawing the persistent edge) AND any renderer + * that places socket dots MUST go through this function so the wire + * endpoints and the visible dots agree pixel-for-pixel. Otherwise + * you get the "wire ends at a different socket than the one the user + * wired" bug — silently visually misleading. + * + * Position resolution is layered: + * 1. **Bespoke per-iceType resolvers** registered in + * `BESPOKE_SOCKET_POSITIONS`. Each entry returns a `Point` or + * `null` (signalling "not my socket, fall through"). Cardinal + * rule: dispatch reads the table generically — NO `if (iceType + * === 'X')` branches in this resolver. New bespoke layouts are + * added by registering an entry. + * 2. **Standard schema-driven layout** via `getPortAnchorPoint` — + * evenly distributes ports along their declared side, in + * declaration order. Covers every block that uses + * `<TypedSockets>` for its sockets. + */ + +import { getPortAnchorPoint, getPortsForNode, type PortDef } from '@ice/types'; +import { getCustomDomainRoutePortY } from '../nodes/custom-domain'; +import type { CanvasNode } from '../types'; +import type { Point } from './types'; + +/** + * Resolver contract for a bespoke socket-position table entry. + * Returns `null` when the socket id doesn't match this resolver's + * domain (the dispatcher then falls through to the standard layout). + */ +export type BespokeSocketResolver = (node: CanvasNode, socketId: string) => Point | null; + +/** + * Schema-shaped table of bespoke socket layouts. Dispatch iterates + * this generically — no iceType-specific branches in the resolver + * function. New bespoke renderers (e.g. a future multi-row block) + * register here; the dispatcher stays untouched. + */ +export const BESPOKE_SOCKET_POSITIONS: Record<string, BespokeSocketResolver> = { + // Network.CustomDomain — per-route right-edge ports. The bespoke + // renderer (`SvgCustomDomainNode`) places one dot per route at a + // hand-computed Y via `getCustomDomainRoutePortY`; resolve back to + // the same Y so the wire and the dot share coordinates. + 'Network.CustomDomain': (node, socketId) => { + if (!socketId.startsWith('domain-out-')) return null; + const routeId = socketId.slice('domain-out-'.length); + const routes = (node.data?.routes as Array<{ id: string }> | undefined) ?? []; + const rowIndex = routes.findIndex((r) => r.id === routeId); + if (rowIndex < 0) return null; + return { x: node.x + node.width, y: node.y + getCustomDomainRoutePortY(rowIndex) }; + }, +}; + +/** + * Returns the canvas-space center of a specific socket on `node`. + * Returns `null` when the socket id doesn't resolve to a known port + * (e.g. dangling edge from a removed port — the caller falls back to + * a perimeter midpoint). + */ +export function getSocketCanvasPosition(node: CanvasNode, socketId: string): Point | null { + const iceType = (node.data?.iceType as string) || ''; + + // ── Bespoke resolvers — generic dispatch via the schema-shaped table. + const bespoke = BESPOKE_SOCKET_POSITIONS[iceType]; + if (bespoke) { + const point = bespoke(node, socketId); + if (point) return point; + } + + // ── Standard typed-socket layout ───────────────────────────────── + const ports = getPortsForNode({ id: node.id, type: node.type, data: node.data }); + const port = ports.find((p) => p.id === socketId); + if (!port) return null; + return getPortAnchorPoint({ x: node.x, y: node.y, width: node.width, height: node.height }, port, ports); +} + +/** + * Convenience — find the port shape from a node's schema. Used by + * compute-path to know each endpoint's anchor side without having to + * call `findPort` separately. + */ +export function findPortOnNode(node: CanvasNode, socketId: string): PortDef | undefined { + const ports = getPortsForNode({ id: node.id, type: node.type, data: node.data }); + return ports.find((p) => p.id === socketId); +} diff --git a/packages/ui/src/features/canvas/components/path/types.ts b/packages/ui/src/features/canvas/components/path/types.ts index d752abbf..6714f2d3 100644 --- a/packages/ui/src/features/canvas/components/path/types.ts +++ b/packages/ui/src/features/canvas/components/path/types.ts @@ -43,4 +43,12 @@ export interface PathResult { pathD: string; midX: number; midY: number; + /** Wire's actual exit point from the source — may differ from the visible socket dot when magnetic routing is active. */ + start?: Point; + /** Wire's actual entry point on the target — may differ from the visible socket dot when magnetic routing is active. */ + end?: Point; + /** Source side the wire exits — used by the orchestrator to draw tails or hover overlays. */ + exitSide?: Side; + /** Target side the wire enters. */ + entrySide?: Side; } diff --git a/packages/ui/src/features/canvas/components/svg-canvas.tsx b/packages/ui/src/features/canvas/components/svg-canvas.tsx index dc593048..153b6765 100644 --- a/packages/ui/src/features/canvas/components/svg-canvas.tsx +++ b/packages/ui/src/features/canvas/components/svg-canvas.tsx @@ -30,6 +30,8 @@ import { useCanvasDrop } from '../hooks/use-canvas-drop'; import { useCanvasEffects } from '../hooks/use-canvas-effects'; import { useCanvasHandlers } from '../hooks/use-canvas-handlers'; import { useCanvasInteractionsBindings } from '../hooks/use-canvas-interactions-bindings'; +import { Spotlight } from './add-menu/spotlight'; +import { useSpotlightShortcut } from './add-menu/use-spotlight-state'; import { useCanvasMouseRouting } from '../hooks/use-canvas-mouse-routing'; import { useCanvasDimensions } from '../hooks/use-canvas-resize'; import { useCanvasSelectors } from '../hooks/use-canvas-selectors'; @@ -43,11 +45,14 @@ import { useContainerMove } from '../hooks/use-container-move'; import { useContainerResize } from '../hooks/use-container-resize'; import { useDragTargetHighlight } from '../hooks/use-drag-target-highlight'; import { useGhostMode } from '../hooks/use-ghost-mode'; +import { useGroupShortcut } from '../hooks/use-group-shortcut'; import { usePinnedUserNode } from '../hooks/use-pinned-user-node'; import { useRenameState } from '../hooks/use-rename-state'; import { useRenderCtx } from '../hooks/use-render-ctx'; import { isContainerNode } from '../utils/node-classification'; +import { ConnectionDragProvider } from './nodes/_shared/connection-drag-context'; import { OrphanNodesProvider } from './nodes/_shared/orphan-context'; +import { SocketHoverTooltip } from './nodes/_shared/socket-hover-tooltip'; import type { AppDispatch } from '../../../store'; // rf-canv-1: re-export shim — the canonical home for these three types is @@ -337,6 +342,7 @@ export const SvgCanvas: React.FC<SvgCanvasProps> = ({ cardId, paneId, onFocus }) const { drawingConnection, connectionDragTargets, + connectionDragInfo, rejection: connectionRejection, handleConnectionPortDown, handleConnectionMove, @@ -433,52 +439,68 @@ export const SvgCanvas: React.FC<SvgCanvasProps> = ({ cardId, paneId, onFocus }) `./canvas-renderer/canvas-content`. Visual draw order, prop flow, and dep arrays are preserved verbatim. */} <OrphanNodesProvider value={orphanNodeIds}> - <CanvasContent - viewport={viewport} - dimensions={dimensions} - canvasConnections={canvasConnections} - effectiveNodes={effectiveNodes} - portMap={portMap} - animatingEdges={animatingEdges} - pipelineNodeStatus={pipelineNodeStatus} - selectedNodes={selectedNodes} - selectedEdges={selectedEdges} - hoveredNodeId={hoveredNodeId} - lod={lod} - edgeStyle={edgeStyle} - handleConnectionHover={handleConnectionHover} - handleEdgeDelete={handleEdgeDelete} - handleEdgeSelect={handleEdgeSelect} - handleContextMenu={handleContextMenu} - sortedNodes={sortedNodes} - animatingNodes={animatingNodes} - shiftDraggingNodeIds={shiftDraggingNodeIds} - dragOverGroupId={dragOverGroupId} - renderCtx={renderCtx} - drawingConnection={drawingConnection} - connectionDragTargets={connectionDragTargets} - connectionRejection={connectionRejection} - showVirtualUserNode={showVirtualUserNode} - userConnections={userConnections} - nodesWithUserNode={nodesWithUserNode} - pinnedUserPos={pinnedUserPos} - setUserNodePos={setUserNodePos} - ghosts={ghosts} - nodes={nodes} - onAcceptGhost={handleAcceptGhost} - onDismissGhost={handleDismissGhost} - /> + <ConnectionDragProvider value={connectionDragInfo}> + <CanvasContent + viewport={viewport} + dimensions={dimensions} + canvasConnections={canvasConnections} + effectiveNodes={effectiveNodes} + portMap={portMap} + animatingEdges={animatingEdges} + pipelineNodeStatus={pipelineNodeStatus} + selectedNodes={selectedNodes} + selectedEdges={selectedEdges} + hoveredNodeId={hoveredNodeId} + lod={lod} + edgeStyle={edgeStyle} + handleConnectionHover={handleConnectionHover} + handleEdgeDelete={handleEdgeDelete} + handleEdgeSelect={handleEdgeSelect} + handleContextMenu={handleContextMenu} + sortedNodes={sortedNodes} + animatingNodes={animatingNodes} + shiftDraggingNodeIds={shiftDraggingNodeIds} + dragOverGroupId={dragOverGroupId} + renderCtx={renderCtx} + drawingConnection={drawingConnection} + connectionDragTargets={connectionDragTargets} + connectionRejection={connectionRejection} + showVirtualUserNode={showVirtualUserNode} + userConnections={userConnections} + nodesWithUserNode={nodesWithUserNode} + pinnedUserPos={pinnedUserPos} + setUserNodePos={setUserNodePos} + ghosts={ghosts} + nodes={nodes} + onAcceptGhost={handleAcceptGhost} + onDismissGhost={handleDismissGhost} + /> + </ConnectionDragProvider> </OrphanNodesProvider> </svg> {/* Connection tooltip — follows mouse, rendered as HTML overlay */} <ConnectionTooltip info={connTooltip} /> + {/* Socket hover chip — instant styled tooltip on socket dot hover. */} + <SocketHoverTooltip /> + {/* Controls help button — bottom-right */} <ControlsHelpModal /> {/* Context Menu overlay */} <CanvasContextMenu /> + + {/* Shift+A spotlight add-block menu + the key listener that opens it. */} + <SpotlightMount screenToCanvas={screenToCanvas} /> </div> ); }; + +const SpotlightMount: React.FC<{ screenToCanvas: (cx: number, cy: number) => { x: number; y: number } }> = ({ + screenToCanvas, +}) => { + useSpotlightShortcut({ screenToCanvas }); + useGroupShortcut(); + return <Spotlight />; +}; diff --git a/packages/ui/src/features/canvas/components/svg-connection-path.tsx b/packages/ui/src/features/canvas/components/svg-connection-path.tsx index 24e4fe92..b55bdaf6 100644 --- a/packages/ui/src/features/canvas/components/svg-connection-path.tsx +++ b/packages/ui/src/features/canvas/components/svg-connection-path.tsx @@ -12,7 +12,10 @@ * connection type. */ +import { CATEGORY_COLORS } from '@ice/constants'; +import { findPort, getPortsForNode, hasPort, inferEdgePorts, ROLE_CATEGORY, type PortDef } from '@ice/types'; import React, { memo, useMemo, useState, useCallback, useRef } from 'react'; +import { CATEGORY_STYLE } from '../../../config/canvas-constants'; import { EDGE_COLORS } from '../../../config/color-palette'; import { useReducedMotion } from '../../../shared/hooks/use-reduced-motion'; import { inferConnectionMeta, type ConnectionCategory } from '../utils/connection-rules'; @@ -20,6 +23,17 @@ import { computePath } from './path/compute-path'; import type { CanvasNode, CanvasConnection } from './svg-canvas'; import type { EdgeStyle } from '../../../store/slices/ui-slice'; +/** Resolve a wire color from a typed port — prefers the peer block's + * category accent (matches the socket dot), falls back to the abstract + * connection-category color. */ +function portColor(port: PortDef): string { + if (port.peerStyle) { + const style = CATEGORY_STYLE[port.peerStyle]; + if (style?.glow) return style.glow; + } + return CATEGORY_COLORS[ROLE_CATEGORY[port.role]]; +} + // ─── Tooltip info passed up to canvas ─────────────────────────────────────── export interface ConnectionTooltipInfo { @@ -122,6 +136,48 @@ export const SvgConnectionPath: React.FC<SvgConnectionPathProps> = memo( const categoryColor = (connection.data?.color as string) || derivedMeta?.color || null; const trafficType = (connection.data?.trafficType as string) || derivedMeta?.trafficType || null; const isLogEdge = relationship === 'logs_to' || trafficType === 'stream'; + + // Dangling edge: the edge references a typed socket that no longer + // exists on its source or target node (because a property toggle + // removed it). Render orange dashed so the user can decide whether + // to clean it up — see properties-panel dangling sweep affordance. + const sourceSocketId = (connection.data?.sourceSocket as string) || ''; + const targetSocketId = (connection.data?.targetSocket as string) || ''; + + // Socket-derived wire color. The wire visually inherits the same + // color as the socket dots it joins — a repository wire is grey + // (Source), a domain wire is rose (Network), a database wire is + // green (Database). Falls back to the abstract category color when + // no typed sockets are present (legacy edges). + const socketColor = useMemo(() => { + let port: PortDef | undefined; + if (sourceSocketId && fromNode) { + port = findPort({ id: fromNode.id, type: fromNode.type, data: fromNode.data }, sourceSocketId); + } + if (!port && targetSocketId && toNode) { + port = findPort({ id: toNode.id, type: toNode.type, data: toNode.data }, targetSocketId); + } + if (!port && fromNode && toNode) { + const inferred = inferEdgePorts( + getPortsForNode({ id: fromNode.id, type: fromNode.type, data: fromNode.data }), + getPortsForNode({ id: toNode.id, type: toNode.type, data: toNode.data }), + connCategory, + ); + port = inferred.sourcePort ?? inferred.targetPort; + } + return port ? portColor(port) : null; + }, [sourceSocketId, targetSocketId, fromNode, toNode, connCategory]); + const isDangling = useMemo(() => { + if ( + sourceSocketId && + fromNode && + !hasPort({ id: fromNode.id, type: fromNode.type, data: fromNode.data }, sourceSocketId) + ) + return true; + if (targetSocketId && toNode && !hasPort({ id: toNode.id, type: toNode.type, data: toNode.data }, targetSocketId)) + return true; + return false; + }, [sourceSocketId, targetSocketId, fromNode, toNode]); const isDashedEdge = lineStyle === 'dashed' || isLogEdge; const isDottedEdge = lineStyle === 'dotted'; const isThinEdge = lineStyle === 'thin'; @@ -225,15 +281,21 @@ export const SvgConnectionPath: React.FC<SvgConnectionPathProps> = memo( // Styling — subtle by default, just brighten on hover const directionColor = direction ? EDGE_COLORS[direction] : null; - // Use category color as the base, fall back to relationship color - const baseColor = categoryColor || EDGE_COLORS[relationship] || EDGE_COLORS.default; + // Socket-derived color wins so the wire matches the dots it joins. + // Category color (from inferConnectionMeta) is the legacy fallback. + const baseColor = socketColor || categoryColor || EDGE_COLORS[relationship] || EDGE_COLORS.default; + // Dangling edges render in warning amber so the user can spot them + // even at idle. Selection / hover still take priority for affordance. + const danglingColor = '#d97706'; const strokeColor = isSelected ? EDGE_COLORS.selected : isHighlighted ? directionColor || baseColor || EDGE_COLORS.hover : isHover ? EDGE_COLORS.hover - : baseColor; + : isDangling + ? danglingColor + : baseColor; // Inverse-zoom scale factor — keeps strokes visible at low zoom const invZoom = 1 / Math.max(zoom, 0.1); @@ -247,19 +309,24 @@ export const SvgConnectionPath: React.FC<SvgConnectionPathProps> = memo( : lod <= 2 ? 1.2 * invZoom : baseWidth; + // Connections are first-class — they represent the architecture's + // data flow. Render them fully visible at idle so the user can + // read the graph without hovering each wire. Thin edges (e.g. log + // streams) still sit a notch quieter so they don't compete with + // primary traffic. const strokeOpacity = isSelected - ? 0.7 + ? 1 : isHighlighted - ? 0.6 + ? 0.95 : isHover - ? 0.7 + ? 1 : lod <= 1 - ? 0.4 + ? 0.7 : lod <= 2 - ? 0.35 + ? 0.8 : isThinEdge - ? 0.12 - : 0.15; + ? 0.6 + : 0.9; // Hover target must stay large enough on screen const hoverTargetWidth = lod < 3 ? Math.max(16, 24 * invZoom) : 16; const showLabels = lod >= 3; @@ -298,9 +365,9 @@ export const SvgConnectionPath: React.FC<SvgConnectionPathProps> = memo( stroke={pipelineActive ? '#3b82f6' : strokeColor} strokeWidth={pipelineActive ? 2 * (lod < 3 ? invZoom : 1) : strokeWidth} fill="none" - strokeDasharray={isDashedEdge ? '6 4' : isDottedEdge ? '2 3' : undefined} + strokeDasharray={isDangling ? '5 4' : isDashedEdge ? '6 4' : isDottedEdge ? '2 3' : undefined} strokeLinecap="round" - opacity={pipelineActive ? 0.6 : strokeOpacity} + opacity={pipelineActive ? 0.6 : isDangling ? 0.7 : strokeOpacity} /> {/* Pipeline flow animation — animated dashes flowing along the path */} diff --git a/packages/ui/src/features/canvas/hooks/use-connection-drawing.ts b/packages/ui/src/features/canvas/hooks/use-connection-drawing.ts index e48a025b..cc427261 100644 --- a/packages/ui/src/features/canvas/hooks/use-connection-drawing.ts +++ b/packages/ui/src/features/canvas/hooks/use-connection-drawing.ts @@ -73,10 +73,20 @@ * rf-canv-27 (RISK #3, RISK #5). */ +import { + chooseBestTargetPort, + findMatchingPorts, + findPort, + getBlockKind, + getPortsForNode, + ROLE_CATEGORY, + type PortDef, +} from '@ice/types'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; import { t } from '../../../i18n'; import { addEdgeToCard, type Card, type CardEdge } from '../../../store/slices/cards-slice'; +import { getSocketCanvasPosition } from '../components/path/socket-position'; import { buildRejectionMessage } from '../utils/connection-rejection'; import { inferConnectionMeta, @@ -88,6 +98,7 @@ import { import { findExistingLogSource, findExistingSpecialConnection } from '../utils/connection-special-rules'; import type { AppDispatch } from '../../../store'; import type { ConnectionRejection } from '../components/connection-rejection-overlay'; +import type { ConnectionDragInfo } from '../components/nodes/_shared/connection-drag-context'; import type { CanvasNode } from '../components/types'; /** Drag descriptor stored in state while a port drag is in progress. */ @@ -95,8 +106,25 @@ export interface DrawingConnectionState { sourceId: string; /** Route id when the drag started from a Network.CustomDomain row port. */ sourceRouteId?: string; + /** Typed-socket id when the drag started from a typed socket dot. */ + sourceSocketId?: string; sourcePoint: { x: number; y: number }; + /** + * Visible wire endpoint — equals `cursorPoint` when nothing is in + * snap range, otherwise the snapped port's position so the line + * visually locks on. + */ currentPoint: { x: number; y: number }; + /** + * Real cursor position in canvas-space. Tracked separately from + * `currentPoint` so the snap search runs against where the user + * actually is — not where the wire is parked. Without this split the + * snap is sticky: once a port wins, `currentPoint` becomes that + * port's position, distance-to-self is 0, and no neighbour can + * displace it even when the cursor has drifted closer. Invisible + * for widely spaced sockets, fatal for Custom Domain rows ~40px apart. + */ + cursorPoint: { x: number; y: number }; } export interface UseConnectionDrawingArgs { @@ -117,6 +145,13 @@ export interface UseConnectionDrawingResult { * node is `'valid-target' | 'invalid-target'` based on `canConnect`. */ connectionDragTargets: Map<string, 'valid-target' | 'invalid-target' | 'source'> | null; + /** + * Per-port compatibility info + snap-target for the active drag. + * Consumed by `ConnectionDragProvider` so TypedSockets can highlight + * matching ports across the canvas and snap the wire endpoint. Null + * while no drag is active. + */ + connectionDragInfo: ConnectionDragInfo | null; /** * Floating rejection tooltip — set when a drop is rejected, cleared * after `REJECTION_TIMEOUT_MS` or when a new drag starts. The canvas @@ -143,6 +178,9 @@ export interface UseConnectionDrawingResult { /** How long the rejection tooltip stays on-screen after a failed drop. */ const REJECTION_TIMEOUT_MS = 2500; +/** Cursor-to-port distance (canvas-space px) within which the wire snaps to the port. */ +const SNAP_RADIUS = 60; + export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnectionDrawingResult { const { effectiveNodes, card, screenToCanvas } = args; const dispatch = useDispatch<AppDispatch>(); @@ -172,6 +210,90 @@ export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnect useEffect(() => () => clearRejectionTimer(), [clearRejectionTimer]); + /** + * Per-port compatibility map for the active drag — for every node, + * the set of port ids whose role accepts the dragged source port. + * Also stores the canvas-space position of each compatible port so the + * magnetic snap calculation in `handleConnectionMove` doesn't have to + * re-walk the schemas every frame. + */ + const dragCompatibility = useMemo<{ + sourcePort: PortDef | undefined; + compatibleByNode: Map<string, Set<string>>; + positions: Map<string, { nodeId: string; portId: string; x: number; y: number }>; + } | null>(() => { + if (!drawingConnection || !drawingConnection.sourceSocketId) return null; + const sourceNode = effectiveNodes.find((n) => n.id === drawingConnection.sourceId); + if (!sourceNode) return null; + const sourcePort = findPort( + { id: sourceNode.id, type: sourceNode.type, data: sourceNode.data }, + drawingConnection.sourceSocketId, + ); + if (!sourcePort) return null; + + const srcKind = getBlockKind((sourceNode.data?.iceType as string) || ''); + const compatibleByNode = new Map<string, Set<string>>(); + const positions = new Map<string, { nodeId: string; portId: string; x: number; y: number }>(); + for (const node of effectiveNodes) { + if (node.id === drawingConnection.sourceId) continue; + const ports = getPortsForNode({ id: node.id, type: node.type, data: node.data }); + if (ports.length === 0) continue; + const tgtKind = getBlockKind((node.data?.iceType as string) || ''); + const matching = findMatchingPorts(sourcePort, ports, srcKind, tgtKind); + if (matching.length === 0) continue; + const ids = new Set<string>(); + for (const port of matching) { + ids.add(port.id); + // `getSocketCanvasPosition` honours bespoke renderer overrides + // (e.g. Network.CustomDomain's per-row Y) so the snap target + // matches the visible dot pixel-for-pixel. Using the raw + // `getPortAnchorPoint` here drifts on multi-row blocks because + // the schema's side-distribution math doesn't predict where the + // hand-laid-out renderer actually draws the dot. + const pt = getSocketCanvasPosition(node, port.id); + if (!pt) continue; + positions.set(`${node.id}::${port.id}`, { nodeId: node.id, portId: port.id, x: pt.x, y: pt.y }); + } + compatibleByNode.set(node.id, ids); + } + return { sourcePort, compatibleByNode, positions }; + }, [drawingConnection, effectiveNodes]); + + /** + * Magnetic snap target — the compatible port closest to the cursor + * within `SNAP_RADIUS`. Drives both the wire-endpoint pull (in + * `handleConnectionMove`) and the snapped-port glow (via the drag + * context). Recomputed cheaply on every `cursorPoint` change. + * + * MUST use `cursorPoint`, not `currentPoint`. `currentPoint` is the + * already-snapped endpoint, so using it would keep distance-to-self + * at 0 and lock the snap onto the first port that ever won — fatal + * for closely-spaced sockets (e.g. Custom Domain rows). + */ + const snap = useMemo<{ nodeId: string; portId: string; x: number; y: number } | null>(() => { + if (!drawingConnection || !dragCompatibility) return null; + const { x: cx, y: cy } = drawingConnection.cursorPoint; + let best: { nodeId: string; portId: string; x: number; y: number; d: number } | null = null; + for (const p of dragCompatibility.positions.values()) { + const dx = p.x - cx; + const dy = p.y - cy; + const d = Math.sqrt(dx * dx + dy * dy); + if (d > SNAP_RADIUS) continue; + if (!best || d < best.d) best = { nodeId: p.nodeId, portId: p.portId, x: p.x, y: p.y, d }; + } + return best ? { nodeId: best.nodeId, portId: best.portId, x: best.x, y: best.y } : null; + }, [drawingConnection, dragCompatibility]); + + const connectionDragInfo: ConnectionDragInfo | null = useMemo(() => { + if (!drawingConnection) return null; + return { + sourceNodeId: drawingConnection.sourceId, + sourcePortId: drawingConnection.sourceSocketId, + compatibleByNode: dragCompatibility?.compatibleByNode ?? new Map(), + snap: snap ? { nodeId: snap.nodeId, portId: snap.portId } : null, + }; + }, [drawingConnection, dragCompatibility, snap]); + /** Compute valid/invalid target states for all nodes during connection drag */ const connectionDragTargets = useMemo(() => { if (!drawingConnection) return null; @@ -180,18 +302,44 @@ export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnect const srcIceType = (sourceNode.data?.iceType as string) || ''; const srcNodeType = sourceNode.type; + // If the drag started from a typed port, resolve it so we can do + // role matching per target. A drag from the block body (no port id) + // skips role matching and falls back to category-level canConnect. + const sourcePort: PortDef | undefined = drawingConnection.sourceSocketId + ? findPort({ id: sourceNode.id, type: sourceNode.type, data: sourceNode.data }, drawingConnection.sourceSocketId) + : undefined; + const targets = new Map<string, 'valid-target' | 'invalid-target' | 'source'>(); targets.set(drawingConnection.sourceId, 'source'); + const srcKindForTargets = getBlockKind(srcIceType); + for (const node of effectiveNodes) { if (node.id === drawingConnection.sourceId) continue; const tgtIceType = (node.data?.iceType as string) || ''; - const isValid = canConnect(srcIceType, tgtIceType, srcNodeType, node.type, { + // When a typed source port is in play, role + peer-kind matching + // is the authoritative gate. The 4-category `canConnect` carries + // legacy contextual rules (e.g. "top-level Custom Domain can't + // route into a VPC") that pre-date the typed-socket model and + // sometimes block legitimate wirings the user explicitly drew + // socket-to-socket. Trusting role-matching here keeps the + // user's drag deterministic. + if (sourcePort) { + const tgtPorts = getPortsForNode({ id: node.id, type: node.type, data: node.data }); + const tgtKind = getBlockKind(tgtIceType); + const matching = findMatchingPorts(sourcePort, tgtPorts, srcKindForTargets, tgtKind); + targets.set(node.id, matching.length > 0 ? 'valid-target' : 'invalid-target'); + continue; + } + // Legacy body drag (no typed source port) — fall back to the + // category-level legality gate so blind drops still respect the + // old rules. + const categoryAllowed = canConnect(srcIceType, tgtIceType, srcNodeType, node.type, { srcNode: sourceNode, tgtNode: node, allNodes: effectiveNodes, }); - targets.set(node.id, isValid ? 'valid-target' : 'invalid-target'); + targets.set(node.id, categoryAllowed ? 'valid-target' : 'invalid-target'); } return targets; }, [drawingConnection, effectiveNodes]); @@ -214,7 +362,41 @@ export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnect // the resulting edge gets no routeId. const routeId = target.getAttribute('data-route-id') || undefined; - const canvasPos = screenToCanvas(e.clientX, e.clientY); + // Typed-socket ports carry `data-socket-id`. Empty string means + // an LOD-degraded fallback dot (no specific socket bound) — leave + // sourceSocketId undefined in that case so the edge writes no + // socket id and the renderer falls back to chooseSides. + const socketIdAttr = target.getAttribute('data-socket-id') || ''; + const sourceSocketId = socketIdAttr.length > 0 ? socketIdAttr : undefined; + + const cursorPos = screenToCanvas(e.clientX, e.clientY); + + // Anchor the wire's visible start at the actual socket dot — not + // the cursor's click position. Read the dot's center from the DOM + // via `getBoundingClientRect()` and project to canvas-space. + // + // Reading from the DOM (not the port schema) is what lets us + // support bespoke renderers like Custom Domain, whose per-route + // row ports live at hand-computed Y coordinates that the + // schema's standard side-distribution math doesn't predict. Any + // socket dot the user CLICKED has a real DOM rect — that's the + // source of truth, period. + let sourcePoint = cursorPos; + // `getBoundingClientRect` may be missing under test mocks — guard with typeof. + const dotRect = typeof target.getBoundingClientRect === 'function' ? target.getBoundingClientRect() : null; + if (dotRect && dotRect.width > 0 && dotRect.height > 0) { + sourcePoint = screenToCanvas(dotRect.left + dotRect.width / 2, dotRect.top + dotRect.height / 2); + } else if (sourceSocketId) { + // Fallback when the element has no measured rect (rare — e.g. + // off-screen). Route through `getSocketCanvasPosition` so + // bespoke renderers (Custom Domain row ports) anchor to their + // hand-laid-out Y instead of the schema's side-distribution. + const node = effectiveNodes.find((n) => n.id === nodeId); + if (node) { + const pt = getSocketCanvasPosition(node, sourceSocketId); + if (pt) sourcePoint = pt; + } + } // A fresh drag invalidates any prior rejection tooltip — drop it // immediately so the new gesture isn't visually overlapped by the @@ -225,19 +407,40 @@ export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnect setDrawingConnection({ sourceId: nodeId, sourceRouteId: routeId, - sourcePoint: canvasPos, - currentPoint: canvasPos, + sourceSocketId, + sourcePoint, + currentPoint: cursorPos, + cursorPoint: cursorPos, }); }, - [screenToCanvas, clearRejectionTimer], + [screenToCanvas, clearRejectionTimer, effectiveNodes], ); - /** Track mouse during connection drawing */ + // Magnet-snap reference — the snap target derived from the latest + // `currentPoint`. Kept as a ref so `handleConnectionMove` can magnet + // the visible cursor toward the snap point without re-rendering twice. + const snapRef = useRef(snap); + snapRef.current = snap; + + /** Track mouse during connection drawing — magnets to compatible ports within SNAP_RADIUS. */ const handleConnectionMove = useCallback( (e: React.MouseEvent) => { if (!drawingConnection) return; const canvasPos = screenToCanvas(e.clientX, e.clientY); - setDrawingConnection((prev) => (prev ? { ...prev, currentPoint: canvasPos } : null)); + // `cursorPoint` is the source of truth for the snap search (the + // `snap` useMemo reads it). `currentPoint` is the visible wire + // endpoint — equal to the cursor unless a snap target pulls it + // onto a compatible port. We re-read the snap through the ref + // because it's derived from the previous render's cursorPoint. + setDrawingConnection((prev) => { + if (!prev) return null; + const snapped = snapRef.current; + const dx = snapped ? snapped.x - canvasPos.x : 0; + const dy = snapped ? snapped.y - canvasPos.y : 0; + const distance = snapped ? Math.sqrt(dx * dx + dy * dy) : Infinity; + const endpoint = snapped && distance <= SNAP_RADIUS ? { x: snapped.x, y: snapped.y } : canvasPos; + return { ...prev, cursorPoint: canvasPos, currentPoint: endpoint }; + }); }, [drawingConnection, screenToCanvas], ); @@ -249,35 +452,38 @@ export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnect const canvasPos = screenToCanvas(e.clientX, e.clientY); - // Find node at drop position (excluding source). + // ── Target node lookup ───────────────────────────────────────── // - // Pick the SMALLEST containing node, not the first hit. The - // canvas allows nesting (Container inside Subnet inside VPC), and - // the drop position can be inside multiple stacked rectangles. - // First-hit-wins fails when the parent group happens to be later - // in the node array than its children — which is order-dependent - // on how the user dragged things around. The smallest area is - // always the most-specific (deepest) target, which is what the - // user means by "drop on this block." + // When the magnet has snapped the wire endpoint onto a compatible + // port, that node IS the target. The user saw a green snapped + // halo on a specific dot — that's the promise; release here = + // wire goes there, regardless of whether the cursor itself + // strayed a few pixels outside the block's bounds. // - // NOTE (rf-canv-6): kept inline because no predicate filters anything - // here — connection drops target ANY node, not just containers. Folding - // through `findSmallestContainerHit(... , () => true, ...)` would bury - // the no-predicate semantics. Flagged for follow-up consolidation. + // Without a snap (legacy body drop), fall back to the + // smallest-containing-node heuristic so nested layouts still + // pick the deepest (most-specific) target. First-hit-wins + // would lose to ordering. let targetNode: CanvasNode | null = null; - let targetArea = Number.POSITIVE_INFINITY; - for (const node of effectiveNodes) { - if (node.id === drawingConnection.sourceId) continue; - if ( - canvasPos.x >= node.x && - canvasPos.x <= node.x + node.width && - canvasPos.y >= node.y && - canvasPos.y <= node.y + node.height - ) { - const area = node.width * node.height; - if (area < targetArea) { - targetNode = node; - targetArea = area; + const snappedTarget = snapRef.current; + if (snappedTarget) { + targetNode = effectiveNodes.find((n) => n.id === snappedTarget.nodeId) ?? null; + } + if (!targetNode) { + let targetArea = Number.POSITIVE_INFINITY; + for (const node of effectiveNodes) { + if (node.id === drawingConnection.sourceId) continue; + if ( + canvasPos.x >= node.x && + canvasPos.x <= node.x + node.width && + canvasPos.y >= node.y && + canvasPos.y <= node.y + node.height + ) { + const area = node.width * node.height; + if (area < targetArea) { + targetNode = node; + targetArea = area; + } } } } @@ -287,8 +493,41 @@ export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnect const srcIceTypeCheck = (sourceNode?.data?.iceType as string) || ''; const tgtIceTypeCheck = (targetNode.data?.iceType as string) || ''; + // ── Role gate: if the drag started from a typed port and the + // target has no matching IN port, silently cancel — the + // drag-context already dimmed every incompatible block so + // the user knew. No tooltip for this case (verbosity that + // repeats the visual cue). + // + // When role-matching DOES find a pair, that's authoritative — + // we skip the legacy `canConnect` cascade below. Otherwise + // its contextual rules (e.g. top-level Custom Domain → VPC + // blocked) reject legitimate socket-to-socket wires the user + // explicitly drew. + let typedRoleGatePassed = false; + if (drawingConnection.sourceSocketId && sourceNode) { + const srcPort = findPort( + { id: sourceNode.id, type: sourceNode.type, data: sourceNode.data }, + drawingConnection.sourceSocketId, + ); + if (srcPort) { + const tgtPorts = getPortsForNode({ id: targetNode.id, type: targetNode.type, data: targetNode.data }); + const srcKind = getBlockKind(srcIceTypeCheck); + const tgtKind = getBlockKind(tgtIceTypeCheck); + const matching = findMatchingPorts(srcPort, tgtPorts, srcKind, tgtKind); + if (matching.length === 0) { + setDrawingConnection(null); + return; + } + typedRoleGatePassed = true; + } + } + // ── Block invalid connections based on CONNECTION_RULES ── + // Only fires for legacy body drops (no typed source port). + // Typed-socket drops trust the role gate above. if ( + !typedRoleGatePassed && !canConnect(srcIceTypeCheck, tgtIceTypeCheck, sourceNode?.type, targetNode.type, { srcNode: sourceNode, tgtNode: targetNode, @@ -419,6 +658,63 @@ export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnect // canonical orientation per the connection rules). const sourceRouteId = drawingConnection.sourceRouteId; + // Persist typed socket ids on the edge so the renderer can pick + // the right magnetic anchor side and detect dangling edges when + // a property change removes the socket later. If the drag + // started from a generic block-body click (no `data-socket-id`), + // pick the best socket on the source matching the inferred + // category + outgoing direction; same for the target picking an + // incoming socket. When no match exists, leave the field undefined + // and the renderer falls back to chooseSides. + const sourceForSocketLookup = meta.flip ? targetNode : sourceNode; + const targetForSocketLookup = meta.flip ? sourceNode : targetNode; + const draggedSocketId = drawingConnection.sourceSocketId; + + function pickByCategory( + n: CanvasNode | undefined, + direction: 'in' | 'out', + category: typeof meta.category, + ): string | undefined { + if (!n) return undefined; + const list = getPortsForNode({ id: n.id, type: n.type, data: n.data }); + return list.find((p) => p.direction === direction && ROLE_CATEGORY[p.role] === category)?.id; + } + + // When we know the dragged port, the partner's best socket is + // the one matching its role — not just any port of the right + // category. This is what makes the wire deterministic. + const draggedPort: PortDef | undefined = + draggedSocketId && sourceNode + ? findPort({ id: sourceNode.id, type: sourceNode.type, data: sourceNode.data }, draggedSocketId) + : undefined; + + let pickedPartner: string | undefined; + // Magnet snap: when the user released within snap radius of a + // compatible port, that port wins over the chooseBestTargetPort + // fallback — the visible snap glow already promised it. + const activeSnap = snapRef.current; + if (activeSnap && activeSnap.nodeId === targetNode.id) { + pickedPartner = activeSnap.portId; + } else if (draggedPort && targetNode && sourceNode) { + const partnerPorts = getPortsForNode({ + id: targetNode.id, + type: targetNode.type, + data: targetNode.data, + }); + const srcKind = getBlockKind((sourceNode.data?.iceType as string) || ''); + const tgtKind = getBlockKind((targetNode.data?.iceType as string) || ''); + pickedPartner = chooseBestTargetPort(draggedPort, partnerPorts, srcKind, tgtKind)?.id; + } + + const sourceSocketResolved = + (!meta.flip && draggedSocketId) || + (meta.flip ? pickedPartner : undefined) || + pickByCategory(sourceForSocketLookup, 'out', meta.category); + const targetSocketResolved = + (meta.flip && draggedSocketId) || + (!meta.flip ? pickedPartner : undefined) || + pickByCategory(targetForSocketLookup, 'in', meta.category); + const edgeId = `edge-${Date.now()}`; const newEdge: CardEdge = { id: edgeId, @@ -433,6 +729,8 @@ export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnect ...(meta.lineStyle !== 'solid' && { lineStyle: meta.lineStyle }), ...(meta.color && { color: meta.color }), ...(sourceRouteId && { routeId: sourceRouteId }), + ...(sourceSocketResolved && { sourceSocket: sourceSocketResolved }), + ...(targetSocketResolved && { targetSocket: targetSocketResolved }), }, }; dispatch(addEdgeToCard(newEdge)); @@ -457,6 +755,7 @@ export function useConnectionDrawing(args: UseConnectionDrawingArgs): UseConnect return { drawingConnection, connectionDragTargets, + connectionDragInfo, rejection, handleConnectionPortDown, handleConnectionMove, diff --git a/packages/ui/src/features/canvas/hooks/use-group-shortcut.ts b/packages/ui/src/features/canvas/hooks/use-group-shortcut.ts new file mode 100644 index 00000000..4d44b205 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/use-group-shortcut.ts @@ -0,0 +1,46 @@ +/** + * useGroupShortcut + * + * Window-level Cmd+G / Ctrl+J listener that wraps the current node + * selection in a `Group.Custom` container. Backed by the existing + * `groupSelectedNodes` reducer in the cards slice, so the operation + * is undoable for free. + * + * Ignored while an input/textarea is focused (so Cmd+G in the + * properties panel finds the next match in the browser's native + * find-in-page instead of grouping nodes the user can't see). + */ + +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { groupSelectedNodes } from '../../../store/slices/cards-slice'; +import type { AppDispatch, RootState } from '../../../store'; + +export function useGroupShortcut(): void { + const dispatch = useDispatch<AppDispatch>(); + const selectedNodeIds = useSelector((s: RootState) => s.selection.selectedNodes); + + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + e.target instanceof HTMLSelectElement || + (e.target instanceof HTMLElement && e.target.isContentEditable) + ) + return; + + // Cmd+G on Mac, Ctrl+J on Windows/Linux (Blender's frame-around- + // selection binding is Ctrl+J — Cmd+G is the Mac convention for + // "group these things together"). + const isCmdG = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'g' && !e.shiftKey; + const isCtrlJ = (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'j' && !e.shiftKey; + if (!isCmdG && !isCtrlJ) return; + if (selectedNodeIds.length < 2) return; + e.preventDefault(); + dispatch(groupSelectedNodes(selectedNodeIds)); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [dispatch, selectedNodeIds]); +} diff --git a/packages/ui/src/features/canvas/utils/__tests__/canvas-node-sizing.test.ts b/packages/ui/src/features/canvas/utils/__tests__/canvas-node-sizing.test.ts index d9a1e856..fdfc4071 100644 --- a/packages/ui/src/features/canvas/utils/__tests__/canvas-node-sizing.test.ts +++ b/packages/ui/src/features/canvas/utils/__tests__/canvas-node-sizing.test.ts @@ -34,7 +34,7 @@ vi.mock('../../components/nodes/private-network', () => ({ computePrivateNetworkHeight: vi.fn((current: number) => Math.max(current, 200)), })); -import { computeNodeSizes, toLocalCanvasNode, type SizingInputNode } from '../canvas-node-sizing'; +import { BESPOKE_NODE_SIZING, computeNodeSizes, toLocalCanvasNode, type SizingInputNode } from '../canvas-node-sizing'; /** Minimal Redux-shape node factory — only the fields these utils read. */ function n(overrides: Partial<SizingInputNode> & Pick<SizingInputNode, 'id'>): SizingInputNode { @@ -48,6 +48,26 @@ function n(overrides: Partial<SizingInputNode> & Pick<SizingInputNode, 'id'>): S }; } +// ============================================================================= +// BESPOKE_NODE_SIZING — schema-shaped table contract +// ============================================================================= + +describe('BESPOKE_NODE_SIZING table', () => { + it('registers exactly the iceTypes that need a bespoke sizing rule', () => { + expect(Object.keys(BESPOKE_NODE_SIZING).sort()).toEqual([ + 'Compute.CronJob', + 'Network.CustomDomain', + 'Network.PrivateNetwork', + ]); + }); + + it('every entry is alwaysExpanded (dynamic content would be hidden by folding)', () => { + for (const entry of Object.values(BESPOKE_NODE_SIZING)) { + expect(entry.alwaysExpanded).toBe(true); + } + }); +}); + // ============================================================================= // computeNodeSizes — dispatch arms // ============================================================================= diff --git a/packages/ui/src/features/canvas/utils/canvas-node-sizing.ts b/packages/ui/src/features/canvas/utils/canvas-node-sizing.ts index adfb41ff..09b41628 100644 --- a/packages/ui/src/features/canvas/utils/canvas-node-sizing.ts +++ b/packages/ui/src/features/canvas/utils/canvas-node-sizing.ts @@ -1,19 +1,25 @@ /** * Pure size-computation helpers for the canvas's `nodes → canvasNodes` pipeline. * - * `computeNodeSizes` dispatches over iceType to pick the right width/height - * trio (compact / custom-domain / private-network), then folds in the - * folded-state short-circuits so callers get visually-correct dimensions: + * `computeNodeSizes` dispatches via the schema-shaped `BESPOKE_NODE_SIZING` + * table to pick the right width/height pair (compact / custom-domain / + * private-network / cron / …), then folds in the folded-state short-circuits + * so callers get visually-correct dimensions: * - * - Custom Domain + Private Network NEVER collapse to a 36/38px folded - * pill — folding them would hide the route slots which are the entire - * point of the block. Their `expandedHeight` and `visualHeight` both - * equal `defaultHeight`, so the rest of the pipeline can't observe the - * fold flag for these two iceTypes. + * - Bespoke entries with `alwaysExpanded: true` NEVER collapse to the + * 36/38px folded pill — folding them would hide their dynamic content + * (route slots, per-task ports, ingress toggle), which is the entire + * point of the block. `expandedHeight` and `visualHeight` both equal + * `defaultHeight`, so the rest of the pipeline can't observe the fold + * flag for these iceTypes. * - All other nodes use `Math.max(node.height, defaultHeight)` for * expanded height (caller-stretched containers) and a 36/38px folded * height (group=36, block/resource=38) when `node.data.folded === true`. * + * Cardinal rule: dispatch reads the schema-shaped table generically — NO + * `if (iceType === 'X')` branches in this file. New bespoke sizing is + * added by registering a table entry. + * * `toLocalCanvasNode` then projects a Redux-shape node + the precomputed * sizes into the canvas's `CanvasNode` (formerly `LocalCanvasNode`) shape, * with the verbatim fallbacks the orchestrator's inline reducer used: @@ -30,7 +36,7 @@ * (rf-canv-5). Pure — no React, no Redux, no module state. */ -import { isGroupContainer, isPrivateNetwork as isPrivateNetworkIce } from './node-classification'; +import { isGroupContainer } from './node-classification'; import { computeCompactNodeHeight, computeCompactNodeWidth } from '../components/nodes/compact-node'; import { computeCustomDomainHeight, computeCustomDomainWidth } from '../components/nodes/custom-domain'; import { computePrivateNetworkHeight, computePrivateNetworkWidth } from '../components/nodes/private-network'; @@ -56,6 +62,41 @@ export interface NodeSizes { visualHeight: number; } +/** + * Sizing contract for a bespoke block renderer. `width`/`height` close + * over the renderer's own dynamic state (route count, task count, etc.); + * `alwaysExpanded: true` opts out of folding so the block can't collapse + * to a pill that hides its dynamic content. + */ +export interface BespokeSizingEntry { + width: (node: SizingInputNode, nodeData: Record<string, unknown>) => number; + height: (node: SizingInputNode, nodeData: Record<string, unknown>) => number; + alwaysExpanded: boolean; +} + +/** + * Schema-shaped table of bespoke node sizing. The dispatcher iterates + * this generically — no iceType branches. Adding a new bespoke + * renderer's sizing rules adds an entry; this file stays unchanged. + */ +export const BESPOKE_NODE_SIZING: Record<string, BespokeSizingEntry> = { + 'Network.CustomDomain': { + width: () => computeCustomDomainWidth(), + height: (_node, nodeData) => computeCustomDomainHeight(nodeData), + alwaysExpanded: true, + }, + 'Network.PrivateNetwork': { + width: (node) => computePrivateNetworkWidth(node.width || 0), + height: (node) => computePrivateNetworkHeight(node.height || 0), + alwaysExpanded: true, + }, + 'Compute.CronJob': { + width: () => computeCronJobWidth(), + height: (_node, nodeData) => computeCronJobHeight(nodeData), + alwaysExpanded: true, + }, +}; + /** * Verbatim port of the inline `defaultWidth`/`defaultHeight`/`expandedHeight`/ * `visualHeight` reducer (svg-canvas.tsx L437–459). `hasPipelineStatus` @@ -64,36 +105,22 @@ export interface NodeSizes { */ export function computeNodeSizes(node: SizingInputNode, hasPipelineStatus: boolean): NodeSizes { const iceType = (node.data?.iceType as string) || 'Resource.Unknown'; - const isCustomDomain = iceType === 'Network.CustomDomain'; - const isPrivateNetwork = isPrivateNetworkIce(iceType); - const isCronJob = iceType === 'Compute.CronJob'; const isGroup = isGroupContainer(node); const isBlock = node.type === 'block'; const folded = !!node.data?.folded; const nodeData = (node.data as Record<string, unknown>) || {}; - const defaultWidth = isCustomDomain - ? computeCustomDomainWidth() - : isPrivateNetwork - ? computePrivateNetworkWidth(node.width || 0) - : isCronJob - ? computeCronJobWidth() - : computeCompactNodeWidth(isBlock || isGroup); - const defaultHeight = isCustomDomain - ? computeCustomDomainHeight(nodeData) - : isPrivateNetwork - ? computePrivateNetworkHeight(node.height || 0) - : isCronJob - ? computeCronJobHeight(nodeData) - : computeCompactNodeHeight(nodeData, isBlock || isGroup, hasPipelineStatus); + const bespoke = BESPOKE_NODE_SIZING[iceType]; + const defaultWidth = bespoke ? bespoke.width(node, nodeData) : computeCompactNodeWidth(isBlock || isGroup); + const defaultHeight = bespoke + ? bespoke.height(node, nodeData) + : computeCompactNodeHeight(nodeData, isBlock || isGroup, hasPipelineStatus); - // Cron, like custom-domain, has dynamic height tied to its task count. - // We never let folding collapse it to a 38px pill — folding hides the - // per-task port circles, which are the entire point of the block. - const expandedHeight = - isCustomDomain || isPrivateNetwork || isCronJob ? defaultHeight : Math.max(node.height || 0, defaultHeight); - const visualHeight = - folded && !isCustomDomain && !isPrivateNetwork && !isCronJob ? (isGroup ? 36 : 38) : expandedHeight; + // `alwaysExpanded` blocks ignore caller-stretched height AND folding — + // their height is whatever the bespoke renderer says, full stop. + const alwaysExpanded = bespoke?.alwaysExpanded ?? false; + const expandedHeight = alwaysExpanded ? defaultHeight : Math.max(node.height || 0, defaultHeight); + const visualHeight = folded && !alwaysExpanded ? (isGroup ? 36 : 38) : expandedHeight; return { defaultWidth, defaultHeight, expandedHeight, visualHeight }; } diff --git a/packages/ui/src/features/canvas/utils/connection-rejection.ts b/packages/ui/src/features/canvas/utils/connection-rejection.ts index 200d337e..be8e5c9a 100644 --- a/packages/ui/src/features/canvas/utils/connection-rejection.ts +++ b/packages/ui/src/features/canvas/utils/connection-rejection.ts @@ -14,7 +14,13 @@ import { t } from '../../../i18n'; export type RejectionCause = | { kind: 'no-rule' } | { kind: 'special-conflict'; label: string } - | { kind: 'validation-error'; message: string }; + | { kind: 'validation-error'; message: string } + /** + * The drag started from a typed port (e.g. `repository-out`) and the + * target block has no matching IN port for that role. Carries the + * source port's role so the message can be specific. + */ + | { kind: 'role-mismatch'; role: string }; /** "Database.MySQL" → "MySQL"; "Compute.ServerlessFunction" → "Serverless Function". * @@ -33,6 +39,13 @@ export function buildRejectionMessage(srcIceType: string, tgtIceType: string, ca if (cause.kind === 'special-conflict') { return t('canvas.rejection.specialConflict', { label: cause.label }); } + if (cause.kind === 'role-mismatch') { + const tgt = humanizeIceType(tgtIceType) || t('canvas.rejection.fallbackTgt'); + // No i18n key for this yet — inline English with the role surfaced + // so the user sees exactly what's expected. Translators can take + // it later via the standard key extraction pass. + return `${tgt} has no ${cause.role} input`; + } const src = humanizeIceType(srcIceType) || t('canvas.rejection.fallbackSrc'); const tgt = humanizeIceType(tgtIceType) || t('canvas.rejection.fallbackTgt'); return t('canvas.rejection.noRule', { src, tgt }); diff --git a/packages/ui/src/features/deploy/utils/security-rules.ts b/packages/ui/src/features/deploy/utils/security-rules.ts index e94b1c02..6d2249bd 100644 --- a/packages/ui/src/features/deploy/utils/security-rules.ts +++ b/packages/ui/src/features/deploy/utils/security-rules.ts @@ -19,43 +19,75 @@ export interface PreDeployWarning { dismissible: boolean; } -// ─── Classifiers ──────────────────────────────────────────────────────────── - -const isDatabase = (n: CardNode): boolean => { - const iceType = (n.data?.iceType as string) || ''; - return iceType.startsWith('Database.'); -}; - -const isStorage = (n: CardNode): boolean => { - const iceType = (n.data?.iceType as string) || ''; - return iceType === 'Storage.Bucket'; -}; - -const isGateway = (n: CardNode): boolean => { - const iceType = (n.data?.iceType as string) || ''; - return iceType === 'Network.Gateway'; +// ─── Schema-shaped security role table ────────────────────────────────────── +// +// Per-iceType + per-category-prefix declarations of which security role +// each block plays. The rule evaluator (below) consults this generically +// — no iceType strings appear in rule code. New blocks join a role by +// adding a table entry or by inheriting a category prefix (e.g. any new +// `Database.*` iceType is automatically a database for security purposes). + +type SecurityRole = + | 'database' + | 'storage' + | 'gateway' + | 'compute' + | 'auth' + | 'secretManager' + | 'monitoringSink' + /** Satisfies "this node is nested inside an isolation container" — VPC, + * Subnet (always inside a VPC), or PrivateNetwork. */ + | 'isolatesNestedChildren' + /** Top-level boundary you can sensibly drop at the canvas root to + * isolate everything inside it — VPC or PrivateNetwork. Subnet alone + * does NOT count: a Subnet at the root is meaningless without a VPC + * parent. */ + | 'topLevelNetworkBoundary'; + +const SECURITY_ROLES_BY_ICE_TYPE: Record<string, ReadonlyArray<SecurityRole>> = { + 'Storage.Bucket': ['storage'], + 'Network.Gateway': ['gateway'], + 'Security.Identity': ['auth'], + 'Security.Secret': ['secretManager'], + // Three iceTypes act as "inside a private network" for the ancestor + // check — the high-level PrivateNetwork (auto-mode VPC) plus the + // explicit VPC + Subnet primitives. Only VPC + PrivateNetwork are + // top-level boundaries (a lone Subnet at the canvas root doesn't + // isolate anything). + 'Network.VPC': ['isolatesNestedChildren', 'topLevelNetworkBoundary'], + 'Network.Subnet': ['isolatesNestedChildren'], + 'Network.PrivateNetwork': ['isolatesNestedChildren', 'topLevelNetworkBoundary'], }; -const isService = (n: CardNode): boolean => { - const iceType = (n.data?.iceType as string) || ''; - return iceType.startsWith('Compute.'); -}; +// Category-prefix inheritance. Any iceType matching one of these prefixes +// gets the corresponding role for free, so adding a new database/compute/ +// monitoring block doesn't require a table edit. +const SECURITY_ROLES_BY_PREFIX: ReadonlyArray<{ prefix: string; role: SecurityRole }> = [ + { prefix: 'Database.', role: 'database' }, + { prefix: 'Compute.', role: 'compute' }, + { prefix: 'Monitoring.', role: 'monitoringSink' }, +]; + +function hasSecurityRole(iceType: string, role: SecurityRole): boolean { + if (SECURITY_ROLES_BY_ICE_TYPE[iceType]?.includes(role)) return true; + for (const entry of SECURITY_ROLES_BY_PREFIX) { + if (entry.role === role && iceType.startsWith(entry.prefix)) return true; + } + return false; +} -const isAuth = (n: CardNode): boolean => (n.data?.iceType as string) === 'Security.Identity'; -const isSecret = (n: CardNode): boolean => (n.data?.iceType as string) === 'Security.Secret'; -const isMonitoring = (n: CardNode): boolean => { - const iceType = (n.data?.iceType as string) || ''; - return iceType.startsWith('Monitoring.') || iceType === 'Monitoring.Log'; -}; -// Three iceTypes count as "inside a private network" for security rules: -// the high-level Network.PrivateNetwork (auto-mode VPC), the explicit -// Network.VPC, and Network.Subnet (which is itself always inside a VPC). -// Stock templates use PrivateNetwork; power users compose VPC + Subnet -// directly. Both isolation models satisfy the public-reachability check. -const isVpc = (n: CardNode): boolean => (n.data?.iceType as string) === 'Network.VPC'; -const isSubnet = (n: CardNode): boolean => (n.data?.iceType as string) === 'Network.Subnet'; -const isPrivateNetwork = (n: CardNode): boolean => (n.data?.iceType as string) === 'Network.PrivateNetwork'; -const isVpcLike = (n: CardNode): boolean => isVpc(n) || isSubnet(n) || isPrivateNetwork(n); +// Thin role-readers used by the rule evaluator. Each is a one-line +// lookup against the schema-shaped table — no iceType strings here. +const ice = (n: CardNode): string => (n.data?.iceType as string) || ''; +const isDatabase = (n: CardNode): boolean => hasSecurityRole(ice(n), 'database'); +const isStorage = (n: CardNode): boolean => hasSecurityRole(ice(n), 'storage'); +const isGateway = (n: CardNode): boolean => hasSecurityRole(ice(n), 'gateway'); +const isService = (n: CardNode): boolean => hasSecurityRole(ice(n), 'compute'); +const isAuth = (n: CardNode): boolean => hasSecurityRole(ice(n), 'auth'); +const isSecret = (n: CardNode): boolean => hasSecurityRole(ice(n), 'secretManager'); +const isMonitoring = (n: CardNode): boolean => hasSecurityRole(ice(n), 'monitoringSink'); +const isVpcLike = (n: CardNode): boolean => hasSecurityRole(ice(n), 'isolatesNestedChildren'); +const isTopLevelBoundary = (n: CardNode): boolean => hasSecurityRole(ice(n), 'topLevelNetworkBoundary'); function isInsideVpc(node: CardNode, allNodes: CardNode[]): boolean { let cur: CardNode | undefined = node; @@ -171,11 +203,12 @@ export function analyzeSecurityWarnings(nodes: CardNode[], edges: CardEdge[]): P }); } - // Rule 6: No private network — info. Counts both PrivateNetwork (the - // user-facing default) and VPC (the lower-level primitive) so this - // doesn't fire when the canvas is already wrapped in either. + // Rule 6: No private network — info. Counts only top-level boundaries + // (VPC + PrivateNetwork). Subnet alone doesn't satisfy this rule + // because a Subnet at the canvas root has no parent VPC and isolates + // nothing — see the `topLevelNetworkBoundary` role. const serviceCount = nodes.filter(isService).length; - const hasNetworkBoundary = nodes.some((n) => isVpc(n) || isPrivateNetwork(n)); + const hasNetworkBoundary = nodes.some(isTopLevelBoundary); if (serviceCount >= 2 && !hasNetworkBoundary) { warnings.push({ id: 'bp-no-vpc', diff --git a/packages/ui/src/features/palette/__tests__/blocks-section.test.tsx b/packages/ui/src/features/palette/__tests__/blocks-section.test.tsx index 2eaa2a7d..525ae0ac 100644 --- a/packages/ui/src/features/palette/__tests__/blocks-section.test.tsx +++ b/packages/ui/src/features/palette/__tests__/blocks-section.test.tsx @@ -220,7 +220,7 @@ function makeProps(overrides: Partial<Parameters<typeof BlocksSection>[0]> = {}) setLocalSearch: vi.fn(), selectedProvider: 'all', setSelectedProvider: vi.fn(), - projectProvider: null, + availableProviderIds: new Set<string>(['aws', 'gcp', 'azure']), searchInputRef: { current: null }, filteredComponents: [comp], categorizedItems: [{ category: cat, items: [comp] }], @@ -334,8 +334,8 @@ describe('BlocksSection — provider dropdown', () => { expect(items).toHaveLength(3); }); - it('disables non-matching items when projectProvider is set', () => { - const tree = renderSection(makeProps({ projectProvider: 'gcp', selectedProvider: 'gcp' })); + it('disables providers that are not in availableProviderIds (no blocks for them)', () => { + const tree = renderSection(makeProps({ availableProviderIds: new Set(['gcp', 'azure']) })); const items = findByPredicate(tree, (el) => { const props = el.props as Record<string, unknown>; return el.type === 'div' && props['data-radix'] === 'Item'; @@ -343,14 +343,14 @@ describe('BlocksSection — provider dropdown', () => { const allItem = items.find((i) => (i.props as { value?: string }).value === 'all'); const awsItem = items.find((i) => (i.props as { value?: string }).value === 'aws'); const gcpItem = items.find((i) => (i.props as { value?: string }).value === 'gcp'); - // 'all' is never locked; aws is locked (project is gcp); gcp is selected, not locked. + // 'all' is never locked; aws has no blocks → locked; gcp has blocks → enabled. expect((allItem?.props as { disabled?: boolean }).disabled).toBe(false); expect((awsItem?.props as { disabled?: boolean }).disabled).toBe(true); expect((gcpItem?.props as { disabled?: boolean }).disabled).toBe(false); }); - it('does not lock any item when projectProvider is null', () => { - const tree = renderSection(makeProps({ projectProvider: null })); + it('does not lock any item when every provider has at least one available block', () => { + const tree = renderSection(makeProps({ availableProviderIds: new Set(['aws', 'gcp', 'azure']) })); const items = findByPredicate(tree, (el) => { const props = el.props as Record<string, unknown>; return el.type === 'div' && props['data-radix'] === 'Item'; diff --git a/packages/ui/src/features/palette/__tests__/components-data.test.ts b/packages/ui/src/features/palette/__tests__/components-data.test.ts index 25c3f1f6..84d1ff8d 100644 --- a/packages/ui/src/features/palette/__tests__/components-data.test.ts +++ b/packages/ui/src/features/palette/__tests__/components-data.test.ts @@ -128,8 +128,8 @@ describe('def — fallback branch', () => { // ─── COMPONENTS data ───────────────────────────────────────────────────────── describe('COMPONENTS — count', () => { - it('declares 24 blocks (verbatim from source — the source comment says "25" but the array has 24)', () => { - expect(COMPONENTS).toHaveLength(24); + it('declares 25 blocks (Reroute added under Util in the geometry-nodes refactor)', () => { + expect(COMPONENTS).toHaveLength(25); }); }); @@ -160,6 +160,7 @@ describe('COMPONENTS — declaration order by type', () => { 'Monitoring.Log', 'Source.Repository', 'Config.Environment', + 'Util.Reroute', ]); }); }); @@ -235,6 +236,7 @@ describe('COMPONENTS — category', () => { 'Monitoring', 'Source', 'Config', + 'Util', ]), ); }); diff --git a/packages/ui/src/features/palette/components/resource-palette.tsx b/packages/ui/src/features/palette/components/resource-palette.tsx index 65221de5..b24fefeb 100644 --- a/packages/ui/src/features/palette/components/resource-palette.tsx +++ b/packages/ui/src/features/palette/components/resource-palette.tsx @@ -125,6 +125,23 @@ export const ResourcePalette: React.FC<ResourcePaletteProps> = ({ [components, localSearch, selectedProvider], ); + // Providers with at least one concept whose (category × provider) gate + // is open. The palette dropdown enables a provider option iff its id is + // in this set — so AWS shows up the moment any of its categories has a + // block, even if the active project's provider is something else. + const availableProviderIds = useMemo(() => { + const set = new Set<string>(); + for (const c of components) { + for (const p of c.providers) { + if (set.has(p)) continue; + if (ENABLED_PROVIDER_IDS.has(p) && isCategoryEnabledForProvider(c.category as CategoryId, p as Provider)) { + set.add(p); + } + } + } + return set; + }, [components]); + // Group filtered items by category, preserving order const categorizedItems = useMemo(() => { const groups: { category: CategoryDef; items: ComponentDef[] }[] = []; @@ -182,7 +199,7 @@ export const ResourcePalette: React.FC<ResourcePaletteProps> = ({ setLocalSearch={setLocalSearch} selectedProvider={selectedProvider} setSelectedProvider={setSelectedProvider} - projectProvider={projectProvider} + availableProviderIds={availableProviderIds} searchInputRef={searchInputRef} filteredComponents={filteredComponents} categorizedItems={categorizedItems} diff --git a/packages/ui/src/features/palette/data/components.ts b/packages/ui/src/features/palette/data/components.ts index 9ec95b00..75164eaa 100644 --- a/packages/ui/src/features/palette/data/components.ts +++ b/packages/ui/src/features/palette/data/components.ts @@ -163,5 +163,11 @@ export function getComponents(t: Translator): ComponentDef[] { def(t, 'Source.Repository', GitBranch, ['aws', 'gcp', 'azure'], 'Source'), // ── Config ── def(t, 'Config.Environment', Cog, ['aws', 'gcp', 'azure'], 'Config'), + // ── Util ── + def(t, 'Util.Reroute', Waypoints, ['aws', 'gcp', 'azure'], 'Util', undefined, { + name: 'Reroute', + description: 'Pass-through dot to bend wires cleanly. No deploy footprint.', + tooltip: 'Pass-through routing dot — keeps wires tidy without altering the graph.', + }), ]; } diff --git a/packages/ui/src/features/palette/sections/blocks-section.tsx b/packages/ui/src/features/palette/sections/blocks-section.tsx index 704ac4fd..4447ccb6 100644 --- a/packages/ui/src/features/palette/sections/blocks-section.tsx +++ b/packages/ui/src/features/palette/sections/blocks-section.tsx @@ -29,7 +29,13 @@ interface BlocksSectionProps { setLocalSearch: (v: string) => void; selectedProvider: string; setSelectedProvider: (v: string) => void; - projectProvider: string | null; + /** + * Providers with at least one concept whose category is enabled for + * that provider. The dropdown disables any provider option not in + * this set — so a provider with zero blocks shows up greyed instead + * of being silently selectable into an empty list. + */ + availableProviderIds: ReadonlySet<string>; searchInputRef: React.RefObject<HTMLInputElement | null>; filteredComponents: ComponentDef[]; categorizedItems: { category: CategoryDef; items: ComponentDef[] }[]; @@ -46,7 +52,7 @@ export const BlocksSection: React.FC<BlocksSectionProps> = ({ setLocalSearch, selectedProvider, setSelectedProvider, - projectProvider, + availableProviderIds, searchInputRef, filteredComponents, categorizedItems, @@ -114,7 +120,7 @@ export const BlocksSection: React.FC<BlocksSectionProps> = ({ > <SelectPrimitive.Viewport className="p-0.5"> {providers.map((provider) => { - const isLocked = !!projectProvider && provider.id !== 'all' && provider.id !== projectProvider; + const isLocked = provider.id !== 'all' && !availableProviderIds.has(provider.id); const brand = provider.id !== 'all' ? getBrandIcon(provider.id) : null; return ( <SelectPrimitive.Item diff --git a/packages/ui/src/features/properties/components/fields/index.tsx b/packages/ui/src/features/properties/components/fields/index.tsx index 89a16123..396100fd 100644 --- a/packages/ui/src/features/properties/components/fields/index.tsx +++ b/packages/ui/src/features/properties/components/fields/index.tsx @@ -20,13 +20,14 @@ * Extracted from `properties-panel.tsx` lines 296-553 in rf-props-6. */ -import { Clock, Info, List } from 'lucide-react'; +import { Clock, Globe, Info, Key, List, Network } from 'lucide-react'; import React from 'react'; import { useSelector } from 'react-redux'; import { t } from '../../../../i18n'; import { IceSelect, type IceSelectOption } from '../../../../shared/components/ui/ice-select'; import { cn } from '../../../../shared/utils/cn'; import { selectActiveCard } from '../../../../store/slices/cards-slice'; +import { type PortProtocol, type PortSpec, parsePort, stringifyPort } from '../../utils/port-spec'; import { type QueueSpec, parseQueue, stringifyQueue } from '../../utils/queue-spec'; import { type TaskSpec, emptyTask, parseTask, stringifyTask } from '../../utils/task-spec'; import type { CustomInputConfig } from './render-property-field'; @@ -214,6 +215,179 @@ export const QueueListField: React.FC<{ ); }; +// ─── Exposed ports list — multi-port HTTP/TCP listeners on a service ────── + +const PORT_PROTOCOL_OPTIONS: PortProtocol[] = ['http', 'https', 'tcp']; + +export const PortListField: React.FC<{ + label: string; + value: string[]; + onChange: (v: string[]) => void; + addLabel?: string; +}> = ({ label, value, onChange, addLabel }) => { + const ports = value.map(parsePort); + const update = (i: number, next: PortSpec): void => { + const arr = [...value]; + arr[i] = stringifyPort(next); + onChange(arr); + }; + return ( + <div className="py-1 space-y-2"> + <span className="text-ice-xs text-ice-text-3">{label}</span> + <div className="space-y-1.5"> + {ports.map((p, i) => ( + <div + key={i} + className="flex items-center gap-2 px-2 py-1.5 rounded-md border border-ice-border/40 bg-ice-bg-raised/40 hover:border-ice-accent/40 transition-colors group" + > + {/* Port icon */} + <div + className="flex-shrink-0 w-6 h-6 rounded flex items-center justify-center" + style={{ + background: 'linear-gradient(135deg, rgba(236,72,153,0.18), rgba(236,72,153,0.06))', + border: '1px solid rgba(236,72,153,0.35)', + }} + > + {p.protocol === 'tcp' ? ( + <Network className="w-3 h-3 text-rose-300" /> + ) : ( + <Globe className="w-3 h-3 text-rose-300" /> + )} + </div> + {/* Protocol */} + <select + value={p.protocol} + onChange={(e) => update(i, { ...p, protocol: e.target.value as PortProtocol })} + className="bg-transparent text-ice-2xs text-ice-text-2 font-mono outline-none border-b border-ice-border/40 cursor-pointer" + > + {PORT_PROTOCOL_OPTIONS.map((opt) => ( + <option key={opt} value={opt}> + {opt} + </option> + ))} + </select> + {/* Port number */} + <input + type="number" + min={1} + max={65535} + value={p.port} + onChange={(e) => { + const n = Number(e.target.value); + if (Number.isFinite(n)) update(i, { ...p, port: n }); + }} + className="w-16 bg-transparent text-ice-xs text-ice-text-1 font-mono outline-none border-b border-ice-border/40 focus:border-ice-accent text-right" + /> + {/* Label */} + <input + type="text" + value={p.label ?? ''} + onChange={(e) => update(i, { ...p, label: e.target.value })} + placeholder="label" + className="flex-1 min-w-0 bg-transparent text-ice-xs text-ice-text-2 font-mono outline-none placeholder:text-ice-text-3/40" + /> + {/* Remove */} + <button + onClick={() => onChange(value.filter((_, j) => j !== i))} + className="flex-shrink-0 p-0.5 text-ice-text-3/40 hover:text-red-400 transition-colors text-ice-sm opacity-0 group-hover:opacity-100" + title="Remove port" + > + × + </button> + </div> + ))} + </div> + <button + onClick={() => onChange([...value, stringifyPort({ port: 8080, protocol: 'http' })])} + className="w-full text-ice-2xs text-ice-text-3/60 hover:text-ice-accent transition-colors py-1.5 rounded border border-dashed border-ice-border/40 hover:border-ice-accent/40" + > + + {addLabel || 'Add port'} + </button> + </div> + ); +}; + +// ─── Secret bindings — env var name ↔ upstream secret manager ref ──────── +// +// The Secret Store block does NOT hold secret values — the cloud's secret +// manager (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault) does. +// Each row here binds: +// - `key`: the environment variable name the service sees at runtime +// - `ref`: the id of the entry in the upstream secret manager +// When `ref` is blank, the deploy translator falls back to `key` so the +// common case (same name on both sides) needs no extra typing. + +export interface SecretBinding { + key: string; + ref?: string; +} + +export const SecretBindingsField: React.FC<{ + label: string; + value: SecretBinding[]; + onChange: (v: SecretBinding[]) => void; + addLabel?: string; +}> = ({ label, value, onChange, addLabel }) => { + const update = (i: number, next: SecretBinding) => { + const arr = [...value]; + arr[i] = next; + onChange(arr); + }; + return ( + <div className="py-1 space-y-2"> + <span className="text-ice-xs text-ice-text-3">{label}</span> + <div className="space-y-1.5"> + {value.map((row, i) => ( + <div + key={i} + className="flex items-center gap-2 px-2 py-1.5 rounded-md border border-ice-border/40 bg-ice-bg-raised/40 hover:border-ice-accent/40 transition-colors group" + > + <div + className="flex-shrink-0 w-6 h-6 rounded flex items-center justify-center" + style={{ + background: 'linear-gradient(135deg, rgba(234,179,8,0.18), rgba(234,179,8,0.06))', + border: '1px solid rgba(234,179,8,0.35)', + }} + > + <Key className="w-3 h-3 text-amber-300" /> + </div> + <input + type="text" + value={row.key} + onChange={(e) => update(i, { ...row, key: e.target.value })} + placeholder="STRIPE_API_KEY" + title={t('properties.secretBindings.keyTooltip')} + className="flex-1 min-w-0 bg-transparent text-ice-xs text-ice-text-1 font-mono outline-none border-b border-ice-border/40 focus:border-ice-accent placeholder:text-ice-text-3/40" + /> + <span className="text-ice-2xs text-ice-text-3/50 shrink-0">←</span> + <input + type="text" + value={row.ref ?? ''} + onChange={(e) => update(i, { ...row, ref: e.target.value })} + placeholder={row.key || 'prod-stripe-key'} + title={t('properties.secretBindings.refTooltip')} + className="flex-1 min-w-0 bg-transparent text-ice-xs text-ice-text-2 font-mono outline-none border-b border-ice-border/40 focus:border-ice-accent placeholder:text-ice-text-3/40" + /> + <button + onClick={() => onChange(value.filter((_, j) => j !== i))} + className="flex-shrink-0 p-0.5 text-ice-text-3/40 hover:text-red-400 transition-colors text-ice-sm opacity-0 group-hover:opacity-100" + title={t('properties.secretBindings.removeTitle')} + > + × + </button> + </div> + ))} + </div> + <button + onClick={() => onChange([...value, { key: '', ref: '' }])} + className="w-full text-ice-2xs text-ice-text-3/60 hover:text-ice-accent transition-colors py-1.5 rounded border border-dashed border-ice-border/40 hover:border-ice-accent/40" + > + + {addLabel || t('properties.secretBindings.add')} + </button> + </div> + ); +}; + // ─── Cron task list ──────────────────────────────────────────────────────── // HTTP methods are universal API verbs — not translated. diff --git a/packages/ui/src/features/properties/components/fields/render-property-field.tsx b/packages/ui/src/features/properties/components/fields/render-property-field.tsx index 1600beb8..204bd08f 100644 --- a/packages/ui/src/features/properties/components/fields/render-property-field.tsx +++ b/packages/ui/src/features/properties/components/fields/render-property-field.tsx @@ -32,7 +32,18 @@ */ import React from 'react'; -import { Section, SelectField, ListField, QueueListField, TaskListField, PropertyLabel, CustomValueInput } from '.'; +import { + Section, + SelectField, + ListField, + PortListField, + QueueListField, + SecretBindingsField, + TaskListField, + PropertyLabel, + CustomValueInput, + type SecretBinding, +} from '.'; import { t } from '../../../../i18n'; import { IceSelect } from '../../../../shared/components/ui/ice-select'; import { cn } from '../../../../shared/utils/cn'; @@ -60,7 +71,16 @@ export interface CustomInputConfig { export interface HighLevelProperty { name: string; label: string; - type: 'string' | 'number' | 'boolean' | 'select' | 'list' | 'queue_list' | 'task_list'; + type: + | 'string' + | 'number' + | 'boolean' + | 'select' + | 'list' + | 'queue_list' + | 'task_list' + | 'port_list' + | 'secret_bindings'; required: boolean; description: string; options?: string[]; @@ -200,6 +220,38 @@ export function renderPropertyField( /> ); } + if (prop.type === 'port_list') { + const listVal = Array.isArray(value) ? (value as string[]) : []; + return ( + <PortListField + key={prop.name} + label={prop.label} + value={listVal} + onChange={(v) => onChange(prop.name, v)} + addLabel={prop.addLabel} + /> + ); + } + if (prop.type === 'secret_bindings') { + // Tolerates the legacy `string[]` shape ("Add a secret" used to be + // a flat ListField) by lifting each string into `{ key, ref: '' }` + // so old projects don't lose data on the first edit. + const raw = Array.isArray(value) ? value : []; + const rows: SecretBinding[] = raw.map((r) => { + if (typeof r === 'string') return { key: r, ref: '' }; + const o = (r as Record<string, unknown>) ?? {}; + return { key: String(o.key ?? ''), ref: typeof o.ref === 'string' ? o.ref : undefined }; + }); + return ( + <SecretBindingsField + key={prop.name} + label={prop.label} + value={rows} + onChange={(v) => onChange(prop.name, v)} + addLabel={prop.addLabel} + /> + ); + } if (prop.type === 'select' && prop.options) { return ( <SelectField diff --git a/packages/ui/src/features/properties/components/sections/node-properties-section.tsx b/packages/ui/src/features/properties/components/sections/node-properties-section.tsx index a9e91f95..3cb470ba 100644 --- a/packages/ui/src/features/properties/components/sections/node-properties-section.tsx +++ b/packages/ui/src/features/properties/components/sections/node-properties-section.tsx @@ -77,6 +77,86 @@ import { updateCardNodeData, type Card, type CardNode } from '../../../../store/ import { toggleProperties } from '../../../../store/slices/ui-slice'; import { buildVisibleTabs } from '../../utils/build-visible-tabs'; import { nodeHasSourceTab, resolveNodeIconUrl } from '../../utils/node-properties-derivations'; +import { + getBlockPropertyPanelConfig, + type PropertyPanelSectionId, + type PropertyPanelTabId, +} from '../../utils/property-panel-config'; + +// ============================================================================= +// Schema-driven per-tab section dispatch +// ============================================================================= +// +// Each entry maps a `PropertyPanelSectionId` (registered on +// `BLOCK_PROPERTY_PANEL_CONFIGS[iceType].sections[tab]`) to a factory +// that renders the corresponding section component. The panel body +// iterates this table generically — no `if (iceType === 'X')` branches +// in the JSX. Adding a new bespoke section adds an entry here AND in +// the schema config; the dispatcher stays untouched. + +interface SectionRenderCtx { + selectedNode: CardNode; + activeCard: Card; + outgoingEdges: Card['edges']; + updateNodeField: (field: string, value: unknown) => void; + dispatch: AppDispatch; + nodeRepo: string; + activeEnvName: string; +} + +type SectionFactory = (ctx: SectionRenderCtx) => React.ReactNode; + +const SECTION_COMPONENTS: Record<PropertyPanelSectionId, SectionFactory> = { + 'public-endpoint-domain': (ctx) => ( + <PublicEndpointDomainSection selectedNode={ctx.selectedNode} updateNodeField={ctx.updateNodeField} /> + ), + 'custom-domain-panel': (ctx) => ( + <CustomDomainPanel + selectedNode={ctx.selectedNode} + outgoingEdges={ctx.outgoingEdges} + activeCard={ctx.activeCard} + updateNodeField={ctx.updateNodeField} + dispatch={ctx.dispatch} + /> + ), + 'private-network-panel': (ctx) => ( + <PrivateNetworkPanel selectedNode={ctx.selectedNode} updateNodeField={ctx.updateNodeField} /> + ), + 'env-vars-editor': (ctx) => ( + <EnvVarsEditor + variables={ + (ctx.selectedNode?.data?.variables as Array<{ name: string; value: string; isSecret?: boolean }>) || [] + } + onChange={(vars) => ctx.updateNodeField('variables', vars)} + /> + ), + 'source-repository': (ctx) => ( + <SourceRepositorySection + nodeRepo={ctx.nodeRepo} + nodeBranch={(ctx.selectedNode?.data?.branch as string) || 'main'} + buildCommand={(ctx.selectedNode?.data?.buildCommand as string) || ''} + outputDirectory={(ctx.selectedNode?.data?.outputDirectory as string) || ''} + onUpdateField={ctx.updateNodeField} + sourceNodeId={ctx.selectedNode.id} + activeCard={ctx.activeCard} + activeEnvName={ctx.activeEnvName} + /> + ), + 'monitoring-log': (ctx) => <MonitoringLogSection nodeId={ctx.selectedNode.id} />, +}; + +/** + * Render every schema-declared section configured under `tab` for the + * given iceType. Returns an array of ReactNodes (one per section); + * generic iteration, no iceType-specific branches. + */ +function renderSectionsForTab(iceType: string, tab: PropertyPanelTabId, ctx: SectionRenderCtx): React.ReactNode[] { + const ids = getBlockPropertyPanelConfig(iceType).sections?.[tab] ?? []; + return ids.map((id, idx) => { + const factory = SECTION_COMPONENTS[id]; + return factory ? <React.Fragment key={`${id}-${idx}`}>{factory(ctx)}</React.Fragment> : null; + }); +} import { PropertyFields } from '../fields/render-property-field'; import type { AppDispatch } from '../../../../store'; import type { CanvasIssue } from '../../../../store/slices/validation-slice'; @@ -161,9 +241,13 @@ export const NodePropertiesSection: React.FC<{ {/* ── Deployment target (provider + region) ── Hidden for symbolic block types that don't deploy to a cloud - (Source.Repository points at GitHub; Network.PublicTraffic is - a canvas-only Internet terminator). */} - {iceType !== 'Source.Repository' && iceType !== 'Network.PublicTraffic' && ( + (e.g. Source.Repository points at GitHub; Network.PublicTraffic is + a canvas-only Internet terminator). Whether a block is symbolic + is a per-iceType fact declared on the schema-shaped + `BLOCK_PROPERTY_PANEL_CONFIGS.skipDeploymentTarget` — this + render decision iterates that fact, never names a specific + iceType. */} + {!getBlockPropertyPanelConfig(iceType).skipDeploymentTarget && ( <DeploymentTargetCard provider={provider} region={(selectedNode.data?.region as string) || ''} @@ -248,18 +332,18 @@ export const NodePropertiesSection: React.FC<{ /> </> )} - {iceType === 'Source.Repository' && ( - <SourceRepositorySection - nodeRepo={nodeRepo} - nodeBranch={(selectedNode?.data?.branch as string) || 'main'} - buildCommand={(selectedNode?.data?.buildCommand as string) || ''} - outputDirectory={(selectedNode?.data?.outputDirectory as string) || ''} - onUpdateField={updateNodeField} - sourceNodeId={selectedNode!.id} - activeCard={activeCard} - activeEnvName={activeEnvName} - /> - )} + {/* Bespoke sections registered for the source tab in + BLOCK_PROPERTY_PANEL_CONFIGS — currently the + Source.Repository section. */} + {renderSectionsForTab(iceType, 'source', { + selectedNode, + activeCard, + outgoingEdges, + updateNodeField, + dispatch, + nodeRepo, + activeEnvName, + })} </div> )} @@ -268,21 +352,20 @@ export const NodePropertiesSection: React.FC<{ <ScalingSection selectedNode={selectedNode} updateNodeField={updateNodeField} /> )} - {/* ════ DOMAIN TAB ════ */} - {activeTab === 'domain' && iceType === 'Network.PublicEndpoint' && ( - <PublicEndpointDomainSection selectedNode={selectedNode} updateNodeField={updateNodeField} /> - )} - - {/* ════ CUSTOM DOMAIN — DOMAIN TAB ════ */} - {activeTab === 'domain' && iceType === 'Network.CustomDomain' && ( - <CustomDomainPanel - selectedNode={selectedNode} - outgoingEdges={outgoingEdges} - activeCard={activeCard} - updateNodeField={updateNodeField} - dispatch={dispatch} - /> - )} + {/* ════ DOMAIN TAB ════ + Bespoke sections registered for the domain tab — currently + the PublicEndpoint + CustomDomain panels. Dispatch is + schema-driven; no iceType branches. */} + {activeTab === 'domain' && + renderSectionsForTab(iceType, 'domain', { + selectedNode, + activeCard, + outgoingEdges, + updateNodeField, + dispatch, + nodeRepo, + activeEnvName, + })} {/* ════ CONNECTIONS TAB ════ */} {activeTab === 'connections' && (incomingEdges.length > 0 || outgoingEdges.length > 0) && ( @@ -348,21 +431,11 @@ export const NodePropertiesSection: React.FC<{ /> )} - {/* Source.Repository (when no tabs) */} - {visibleTabs.length <= 1 && iceType === 'Source.Repository' && ( - <SourceRepositorySection - nodeRepo={nodeRepo} - nodeBranch={(selectedNode?.data?.branch as string) || 'main'} - buildCommand={(selectedNode?.data?.buildCommand as string) || ''} - outputDirectory={(selectedNode?.data?.outputDirectory as string) || ''} - onUpdateField={updateNodeField} - sourceNodeId={selectedNode!.id} - activeCard={activeCard} - activeEnvName={activeEnvName} - /> - )} - - {/* Source (when no tabs) */} + {/* Source (when no tabs) — kept as a dynamic fallback for + service blocks that have a connected Source.Repository + but no dedicated source tab. The Source.Repository + block itself now always has its own source tab via + BLOCK_PROPERTY_PANEL_CONFIGS.forceTabs. */} {visibleTabs.length <= 1 && hasSource && ( <> <ServiceSourceSection @@ -380,37 +453,20 @@ export const NodePropertiesSection: React.FC<{ </> )} - {/* Environment Variables */} - {iceType === 'Config.Environment' && ( - <EnvVarsEditor - variables={ - (selectedNode?.data?.variables as Array<{ name: string; value: string; isSecret?: boolean }>) || - [] - } - onChange={(vars) => updateNodeField('variables', vars)} - /> - )} - - {/* Custom Domain — config tab mirrors the domain tab so - the user sees the root domain field + subdomain - routing list as soon as they click the block. */} - {iceType === 'Network.CustomDomain' && ( - <CustomDomainPanel - selectedNode={selectedNode} - outgoingEdges={outgoingEdges} - activeCard={activeCard} - updateNodeField={updateNodeField} - dispatch={dispatch} - /> - )} - - {/* Private Network — outbound internet (egress) policy */} - {iceType === 'Network.PrivateNetwork' && ( - <PrivateNetworkPanel selectedNode={selectedNode} updateNodeField={updateNodeField} /> - )} - - {/* Monitoring.Log — streaming mode + source override + status pill */} - {iceType === 'Monitoring.Log' && <MonitoringLogSection nodeId={selectedNode!.id} />} + {/* Bespoke sections registered for the config tab in + BLOCK_PROPERTY_PANEL_CONFIGS — env-vars editor for + Config.Environment, mirrored Custom Domain panel, + Private Network egress panel, Monitoring.Log section. + Dispatch is generic — no iceType branches. */} + {renderSectionsForTab(iceType, 'config', { + selectedNode, + activeCard, + outgoingEdges, + updateNodeField, + dispatch, + nodeRepo, + activeEnvName, + })} {/* Cost */} {estimatedCost && ( diff --git a/packages/ui/src/features/properties/utils/__tests__/property-panel-config.test.ts b/packages/ui/src/features/properties/utils/__tests__/property-panel-config.test.ts new file mode 100644 index 00000000..951b9d86 --- /dev/null +++ b/packages/ui/src/features/properties/utils/__tests__/property-panel-config.test.ts @@ -0,0 +1,57 @@ +/** + * Tests for `property-panel-config` — the schema-shaped per-iceType + * config that drives the properties panel's tab visibility and the + * deployment-target visibility. + * + * Cardinal rule check: the config is the single declarative fact. + * Callers iterate it generically; they MUST NOT name a specific iceType. + */ + +import { describe, it, expect } from 'vitest'; +import { BLOCK_PROPERTY_PANEL_CONFIGS, getBlockPropertyPanelConfig } from '../property-panel-config'; + +describe('BLOCK_PROPERTY_PANEL_CONFIGS', () => { + it('registers exactly the iceTypes that need a bespoke panel experience', () => { + expect(Object.keys(BLOCK_PROPERTY_PANEL_CONFIGS).sort()).toEqual([ + 'Config.Environment', + 'Monitoring.Log', + 'Network.CustomDomain', + 'Network.PrivateNetwork', + 'Network.PublicEndpoint', + 'Network.PublicTraffic', + 'Source.Repository', + ]); + }); + + it('every registered section id resolves to a configured tab', () => { + // Sanity check: every section listed in the table targets one of + // the known tab ids. Catches typos before they silently no-op. + const validTabs: ReadonlySet<string> = new Set(['config', 'domain', 'scaling', 'source', 'connections', 'deploy']); + for (const [iceType, cfg] of Object.entries(BLOCK_PROPERTY_PANEL_CONFIGS)) { + for (const tab of Object.keys(cfg.sections ?? {})) { + expect(validTabs.has(tab), `${iceType} → unknown tab "${tab}"`).toBe(true); + } + } + }); + + it('forces config + domain tabs for both kinds of public DNS block', () => { + expect(BLOCK_PROPERTY_PANEL_CONFIGS['Network.PublicEndpoint'].forceTabs).toEqual(['config', 'domain']); + expect(BLOCK_PROPERTY_PANEL_CONFIGS['Network.CustomDomain'].forceTabs).toEqual(['config', 'domain']); + }); + + it('skips the deployment-target card for symbolic / GitHub-backed blocks', () => { + expect(BLOCK_PROPERTY_PANEL_CONFIGS['Source.Repository'].skipDeploymentTarget).toBe(true); + expect(BLOCK_PROPERTY_PANEL_CONFIGS['Network.PublicTraffic'].skipDeploymentTarget).toBe(true); + }); +}); + +describe('getBlockPropertyPanelConfig', () => { + it('returns the registered entry when present', () => { + expect(getBlockPropertyPanelConfig('Source.Repository').skipDeploymentTarget).toBe(true); + }); + + it('returns an empty config for unknown iceTypes (no exception)', () => { + expect(getBlockPropertyPanelConfig('Wholly.Unknown')).toEqual({}); + expect(getBlockPropertyPanelConfig('')).toEqual({}); + }); +}); diff --git a/packages/ui/src/features/properties/utils/build-visible-tabs.ts b/packages/ui/src/features/properties/utils/build-visible-tabs.ts index 06742041..8f5b20c5 100644 --- a/packages/ui/src/features/properties/utils/build-visible-tabs.ts +++ b/packages/ui/src/features/properties/utils/build-visible-tabs.ts @@ -6,8 +6,15 @@ * the ordered list of visible tabs. The orchestrator still owns the * setState-during-render fallback (BEHAVIOR-RISK FLAG #2) — see the doc * comment on `node-properties-section.tsx` for why that line stays inline. + * + * Cardinal-rule schema-driven: per-iceType tab visibility comes from + * `BLOCK_PROPERTY_PANEL_CONFIGS.forceTabs`. NO `if (iceType === 'X')` + * branches in this builder. Adding a new block that needs a forced tab + * adds an entry to the config table; this code stays unchanged. */ +import { getBlockPropertyPanelConfig, type PropertyPanelTabId } from './property-panel-config'; + export interface VisibleTab { id: string; label: string; @@ -37,23 +44,23 @@ export function buildVisibleTabs({ outgoingEdgesCount, t, }: BuildVisibleTabsArgs): VisibleTab[] { + const forced = new Set<PropertyPanelTabId>(getBlockPropertyPanelConfig(iceType).forceTabs ?? []); const tabs: VisibleTab[] = []; - if ( - dbPropertiesCount > 0 || - iceType === 'Config.Environment' || - iceType === 'Network.PublicEndpoint' || - iceType === 'Network.CustomDomain' || - iceType === 'Network.PrivateNetwork' - ) { + // Config tab: dynamic when the block has DB-declared properties OR + // when the schema-shaped table forces it for a bespoke panel. + if (dbPropertiesCount > 0 || forced.has('config')) { tabs.push({ id: 'config', label: t('properties.tabs.config'), show: true }); } if (isScalable) { tabs.push({ id: 'scaling', label: t('properties.tabs.scaling'), show: true }); } - if (iceType === 'Network.PublicEndpoint' || iceType === 'Network.CustomDomain') { + // Domain tab: schema-shaped table only — no dynamic signal drives it. + if (forced.has('domain')) { tabs.push({ id: 'domain', label: t('properties.tabs.domain'), show: true }); } - if (hasSource || iceType === 'Source.Repository') { + // Source tab: dynamic when the block participates in a build pipeline + // OR when the schema-shaped table forces it for the repo block itself. + if (hasSource || forced.has('source')) { tabs.push({ id: 'source', label: t('properties.tabs.source'), show: true }); } if (incomingEdgesCount > 0 || outgoingEdgesCount > 0) { diff --git a/packages/ui/src/features/properties/utils/port-spec.ts b/packages/ui/src/features/properties/utils/port-spec.ts new file mode 100644 index 00000000..39f72bfd --- /dev/null +++ b/packages/ui/src/features/properties/utils/port-spec.ts @@ -0,0 +1,62 @@ +/** + * PortListField — each entry is a port the block exposes to the network. + * + * Stored as JSON strings in `node.data.exposed_ports[]` so the existing + * list-based property machinery (undo/redo coalescing, persistence) keeps + * working without changes. The compact text form `https:443` is accepted + * on read for hand-edited values. + */ + +export type PortProtocol = 'http' | 'https' | 'tcp'; + +export interface PortSpec { + /** Listener port number. */ + port: number; + protocol: PortProtocol; + /** Optional human-readable label, e.g. "API" / "Healthcheck". */ + label?: string; +} + +const DEFAULT: PortSpec = { port: 8080, protocol: 'http' }; + +export function parsePort(raw: string): PortSpec { + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && typeof parsed.port === 'number') { + const protocol: PortProtocol = + parsed.protocol === 'https' || parsed.protocol === 'tcp' ? parsed.protocol : 'http'; + return { + port: parsed.port, + protocol, + ...(typeof parsed.label === 'string' && parsed.label ? { label: parsed.label } : {}), + }; + } + } catch { + /* fall through to text form */ + } + // Compact text form: "https:443" / "8080" / "tcp:22:ssh" + const parts = raw.split(':'); + if (parts.length >= 2 && (parts[0] === 'http' || parts[0] === 'https' || parts[0] === 'tcp')) { + const port = Number(parts[1]); + if (Number.isFinite(port)) { + return { protocol: parts[0] as PortProtocol, port, ...(parts[2] ? { label: parts[2] } : {}) }; + } + } + const portNum = Number(raw); + if (Number.isFinite(portNum)) return { port: portNum, protocol: 'http' }; + return DEFAULT; +} + +export function stringifyPort(p: PortSpec): string { + return JSON.stringify({ + port: p.port, + protocol: p.protocol, + ...(p.label ? { label: p.label } : {}), + }); +} + +/** Default label for a port — used by the port schema when no user label is set. */ +export function defaultPortLabel(p: PortSpec): string { + const proto = p.protocol.toUpperCase(); + return p.label ? `${proto} :${p.port} (${p.label})` : `${proto} :${p.port}`; +} diff --git a/packages/ui/src/features/properties/utils/property-panel-config.ts b/packages/ui/src/features/properties/utils/property-panel-config.ts new file mode 100644 index 00000000..14e415bb --- /dev/null +++ b/packages/ui/src/features/properties/utils/property-panel-config.ts @@ -0,0 +1,98 @@ +/** + * Per-iceType configuration for the properties panel. + * + * Cardinal-rule schema-driven dispatch. Both the visible-tabs builder + * (`build-visible-tabs.ts`) AND the per-tab section rendering inside + * `node-properties-section.tsx` read this table generically — no + * `if (iceType === 'X')` branches in either layer. + * + * The table is the single declarative fact. Adding a new bespoke + * properties experience for a block means adding an entry here; both + * the tab builder and the panel pick it up automatically. + */ + +/** + * Tab identifiers the properties panel knows about. Tab visibility is + * driven by a mix of dynamic signals (edge counts, scalable behaviour, + * deployment state) AND per-block declarations from this table. + */ +export type PropertyPanelTabId = 'config' | 'domain' | 'scaling' | 'source' | 'connections' | 'deploy'; + +/** + * Identifies a bespoke section component the panel can render inside a + * tab. The component itself is wired in `node-properties-section.tsx` + * via the `SECTION_COMPONENTS` factory map — this string is the + * registry key. + */ +export type PropertyPanelSectionId = + | 'public-endpoint-domain' + | 'custom-domain-panel' + | 'private-network-panel' + | 'env-vars-editor' + | 'source-repository' + | 'monitoring-log'; + +export interface BlockPropertyPanelConfig { + /** + * Tabs to FORCE visible for this block regardless of dynamic signals. + * Combined with the dynamic tabs (e.g. `connections` always shows + * when edges exist). Use this when a block has zero DB-defined + * properties but still needs a config tab to host a bespoke section. + */ + forceTabs?: PropertyPanelTabId[]; + /** + * Suppress the deployment-target card (provider + region) at the top + * of the panel. Use for symbolic blocks that don't deploy to a cloud + * (Source.Repository points at GitHub; Network.PublicTraffic is + * canvas-only). + */ + skipDeploymentTarget?: boolean; + /** + * Bespoke sections to render inside specific tabs. The panel renders + * each entry whose tab matches `activeTab`. Same id can appear under + * multiple tabs (e.g. Custom Domain's panel mirrors on both `config` + * and `domain`). + */ + sections?: Partial<Record<PropertyPanelTabId, PropertyPanelSectionId[]>>; +} + +export const BLOCK_PROPERTY_PANEL_CONFIGS: Record<string, BlockPropertyPanelConfig> = { + 'Network.PublicEndpoint': { + forceTabs: ['config', 'domain'], + sections: { domain: ['public-endpoint-domain'] }, + }, + 'Network.CustomDomain': { + forceTabs: ['config', 'domain'], + // The config tab mirrors the domain tab so the user sees the root + // domain field + subdomain routing list as soon as they click the block. + sections: { domain: ['custom-domain-panel'], config: ['custom-domain-panel'] }, + }, + 'Network.PrivateNetwork': { + forceTabs: ['config'], + sections: { config: ['private-network-panel'] }, + }, + 'Network.PublicTraffic': { + skipDeploymentTarget: true, + }, + 'Config.Environment': { + forceTabs: ['config'], + sections: { config: ['env-vars-editor'] }, + }, + 'Source.Repository': { + forceTabs: ['source'], + skipDeploymentTarget: true, + // SourceRepositorySection renders inside the source tab. (The + // previous code also showed it in the config tab when no other tabs + // existed; with the source tab now always forced for this block, + // that fallback became dead code and was dropped.) + sections: { source: ['source-repository'] }, + }, + 'Monitoring.Log': { + sections: { config: ['monitoring-log'] }, + }, +}; + +/** Convenience accessor — returns an empty config when no entry exists. */ +export function getBlockPropertyPanelConfig(iceType: string): BlockPropertyPanelConfig { + return BLOCK_PROPERTY_PANEL_CONFIGS[iceType] ?? {}; +} diff --git a/packages/ui/src/i18n/en.json b/packages/ui/src/i18n/en.json index c4994c8e..b61f4ae7 100644 --- a/packages/ui/src/i18n/en.json +++ b/packages/ui/src/i18n/en.json @@ -1301,7 +1301,13 @@ "noServiceHint": "Connect this block to a service to configure deploy triggers.", "addItem": "Add item", "removeConnection": "Remove connection", - "noLogsRecorded": "No logs recorded" + "noLogsRecorded": "No logs recorded", + "secretBindings": { + "keyTooltip": "Environment variable name exposed to the service at runtime.", + "refTooltip": "Id of the entry in the upstream secret manager. Defaults to the env var name when blank.", + "removeTitle": "Remove binding", + "add": "Add a binding" + } }, "project": { "settings": { diff --git a/packages/ui/src/i18n/zh.json b/packages/ui/src/i18n/zh.json index 4305563d..c0ea5cb6 100644 --- a/packages/ui/src/i18n/zh.json +++ b/packages/ui/src/i18n/zh.json @@ -1300,7 +1300,13 @@ "noServiceHint": "将此区块连接到服务以配置部署触发器。", "addItem": "添加项目", "removeConnection": "移除连接", - "noLogsRecorded": "暂无日志记录" + "noLogsRecorded": "暂无日志记录", + "secretBindings": { + "keyTooltip": "运行时注入服务的环境变量名。", + "refTooltip": "上游密钥管理器中的条目 ID。留空时默认使用环境变量名。", + "removeTitle": "移除绑定", + "add": "添加绑定" + } }, "project": { "settings": { diff --git a/packages/ui/src/store/slices/cards/types.ts b/packages/ui/src/store/slices/cards/types.ts index 137082ab..657380e3 100644 --- a/packages/ui/src/store/slices/cards/types.ts +++ b/packages/ui/src/store/slices/cards/types.ts @@ -25,7 +25,14 @@ export interface CardEdge { id: string; source: string; target: string; - data?: { relationship?: string; [key: string]: unknown }; + data?: { + relationship?: string; + /** Identifier of the typed socket on the source node this edge attaches to. */ + sourceSocket?: string; + /** Identifier of the typed socket on the target node this edge attaches to. */ + targetSocket?: string; + [key: string]: unknown; + }; } export interface CardViewport { diff --git a/packages/ui/src/store/slices/ui-slice.ts b/packages/ui/src/store/slices/ui-slice.ts index 0f48ef33..cd798c15 100644 --- a/packages/ui/src/store/slices/ui-slice.ts +++ b/packages/ui/src/store/slices/ui-slice.ts @@ -100,6 +100,19 @@ export interface UIState { * `maxWidth` on entry and clears them on exit so guided steps fit. */ sidebarOverride: { left: number | null; right: number | null }; + + /** + * Shift+A "spotlight" add-block menu — Blender-style fuzzy-search palette + * spawned at the cursor. `canvasX`/`canvasY` are canvas-space coords used + * to position the spawned block. `recentTypes` is a small LRU of recently + * picked iceTypes for pinning at the top of the menu. + */ + spotlight: { + open: boolean; + canvasX: number; + canvasY: number; + recentTypes: string[]; + }; } // ============================================================================= @@ -165,6 +178,12 @@ const initialState: UIState = { }, splitView: PANES_DEFAULT, sidebarOverride: { left: null, right: null }, + spotlight: { + open: false, + canvasX: 0, + canvasY: 0, + recentTypes: [], + }, }; const uiSlice = createSlice({ @@ -256,6 +275,20 @@ const uiSlice = createSlice({ toggleCanvasLocked: (state) => { state.canvasLocked = !state.canvasLocked; }, + openSpotlight: (state, action: PayloadAction<{ canvasX: number; canvasY: number }>) => { + state.spotlight.open = true; + state.spotlight.canvasX = action.payload.canvasX; + state.spotlight.canvasY = action.payload.canvasY; + }, + closeSpotlight: (state) => { + state.spotlight.open = false; + }, + /** Mark an iceType as recently-used and bubble it to the top of the LRU. */ + pushSpotlightRecent: (state, action: PayloadAction<string>) => { + const iceType = action.payload; + const filtered = state.spotlight.recentTypes.filter((t) => t !== iceType); + state.spotlight.recentTypes = [iceType, ...filtered].slice(0, 8); + }, openContextMenu: ( state, action: PayloadAction<{ @@ -482,6 +515,9 @@ export const { toggleCanvasLocked, toggleValidation, openValidation, + openSpotlight, + closeSpotlight, + pushSpotlightRecent, } = uiSlice.actions; export default uiSlice.reducer;