From 2c422d78e16881a7055b03b522ca30bc913f4633 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Wed, 7 Oct 2026 15:04:22 -0400 Subject: [PATCH] feat: plan native endpoints and local host bindings --- .github/workflows/ci.yml | 3 + docs/reference/native-config-compiler.md | 74 +- packages/config-compiler/README.md | 59 +- .../generated/hack.local.schema.json | 156 ++- .../generated/hack.project.schema.json | 161 +++ .../generated/native-config.ts | 22 +- packages/config-compiler/src/endpoint.rs | 403 ++++++ packages/config-compiler/src/environment.rs | 51 +- packages/config-compiler/src/lib.rs | 31 +- packages/config-compiler/src/local.rs | 50 +- packages/config-compiler/src/model.rs | 14 + packages/config-compiler/src/routing.rs | 9 + packages/config-compiler/src/shape.rs | 27 +- packages/config-compiler/src/validate.rs | 2 + packages/config-compiler/tests/endpoint.rs | 459 +++++++ .../tests/fixtures/local-schema-corpus.json | 120 ++ .../tests/fixtures/schema-corpus.json | 489 +++++++ packages/config-compiler/tests/protocol.rs | 2 +- scripts/check-native-endpoint-plan-cli.ts | 485 +++++++ src/lib/native-config-compiler.ts | 106 +- src/lib/native-endpoint-plan-protocol.ts | 784 +++++++++++ src/lib/native-env-plan-protocol.ts | 4 + src/lib/native-project-validation.ts | 6 +- tests/native-endpoint-plan-transport.test.ts | 1181 +++++++++++++++++ tests/native-endpoint-project.test.ts | 231 ++++ 25 files changed, 4870 insertions(+), 59 deletions(-) create mode 100644 packages/config-compiler/src/endpoint.rs create mode 100644 packages/config-compiler/tests/endpoint.rs create mode 100644 scripts/check-native-endpoint-plan-cli.ts create mode 100644 src/lib/native-endpoint-plan-protocol.ts create mode 100644 tests/native-endpoint-plan-transport.test.ts create mode 100644 tests/native-endpoint-project.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cc9efce5..afde00463 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,9 @@ jobs: bunx ultracite check scripts/check-native-routing-plan-cli.ts src/lib/native-routing-plan-protocol.ts src/lib/native-routing-inputs.ts tests/native-routing-plan-transport.test.ts tests/native-routing-inputs.test.ts tests/native-routing-input-race.test.ts tests/native-routing-project.test.ts bun test tests/native-routing-plan-transport.test.ts tests/native-routing-inputs.test.ts tests/native-routing-input-race.test.ts tests/native-routing-project.test.ts bun scripts/check-native-routing-plan-cli.ts + bunx ultracite check scripts/check-native-endpoint-plan-cli.ts src/lib/native-endpoint-plan-protocol.ts tests/native-endpoint-plan-transport.test.ts tests/native-endpoint-project.test.ts + bun test tests/native-endpoint-plan-transport.test.ts tests/native-endpoint-project.test.ts + bun scripts/check-native-endpoint-plan-cli.ts state-models: name: Runtime state models diff --git a/docs/reference/native-config-compiler.md b/docs/reference/native-config-compiler.md index 9b7bc2a38..816460582 100644 --- a/docs/reference/native-config-compiler.md +++ b/docs/reference/native-config-compiler.md @@ -107,7 +107,7 @@ backslashes and drive syntax. Lexical `.` and repeated separators normalize; no filesystem or symlink resolution occurs. Working directories and mount targets must be absolute POSIX container paths without `..`. -Container shutdown/restart policies, endpoint references, +Container shutdown/restart policies, network/security/resources, cache protocols, backend options, arbitrary extensions, and other local settings are not yet implemented. They refuse rather than being silently dropped. This foundation does not replace the full native contract or @@ -177,7 +177,72 @@ choice and selected routes. The CLI cross-checks the report against authored declarations and request context. Routing changes affect the local resolution hash; local policy never rewrites the authored semantic hash. This is offline planning: it does not configure DNS, trust certificates, run hooks, bind ports or admit a -runtime. Endpoint bindings and execution remain later integration steps. +runtime. Execution remains a later integration step. + +## Endpoint references and host bindings + +Environment destinations can reference a route, service or logical host binding: + +```json +{ + "API": { "endpoint": { "kind": "service", "name": "api", "port": 3000, "protocol": "http" } }, + "APP_ORIGIN": { "endpoint": { "kind": "route", "name": "web" } }, + "SEARCH": { "endpoint": { "kind": "host_binding", "name": "search" } } +} +``` + +Service references require a declared service, a port from 1 to 65,535 and an +explicit `http`, `https` or `tcp` protocol. Jobs cannot be service targets. Route +references name an entry in `routes.http`. Unknown references and active consumers +of inactive service targets refuse; disabled declarations still receive static +validation. A route endpoint uses the selected origin from `routing_resolution`. +The port and protocol of a direct service endpoint remain typed rather than being +turned into a guessed URL or credentials-bearing connection string. + +Optional project `host_bindings` declares targets in a separate logical namespace: + +```json +{ + "host_bindings": { + "search": { "kind": "host", "port": 9200, "protocol": "http" }, + "remote": { "kind": "external", "hostname": "search.example.com", "port": 443, "protocol": "https" } + } +} +``` + +`host` identifies an endpoint on the machine running the project. Its report +retains `context:"host"` or `context:"workload"`; an execution backend must choose +the appropriate loopback or guest-to-host address. `external` preserves a validated +hostname, port and protocol. Neither definition starts a tunnel, grants ownership +of a process, probes connectivity or configures DNS. Commands, credentials, +resource IDs and source paths are not binding targets. External hostnames cannot +contain credentials, schemes, ports, paths, queries or fragments. + +Local `host_bindings` merges by logical name: project, inherited verified primary, +then checkout. A local `null` removes that binding; a later target restores it. +There is no recursive patch or command injection. Actual resolution reports +`host_binding_resolution:{bindings:{NAME:{target,origin}},removed:{NAME:origin}}`, +where `origin` is `project`, `primary_local` or `checkout_local`. +Context-free `--file` validation keeps local-only binding references symbolic. +Project-aware validation rejects missing or removed referenced bindings before +reading managed environment documents. CI, slim and inheritance opt-out rules +apply to binding inheritance just as they do to other local settings. + +`config plan` returns endpoint bindings as +`{kind:"endpoint",reference:{...},target:{...}}`. An existing managed key at the +destination produces `env_endpoint_collision`, keeps the managed binding intact, +and makes the plan incomplete with exit 1. Endpoint and unset tags cannot share +an entry. A direct service endpoint in a host invocation produces +`unsupported_endpoint_context` rather than assuming guest DNS works on the host. +This is a planning refusal, not a failure to launch a process. + +Endpoint support requires the sidecar's `endpoint_plan_version:1` capability. +Consumers validate report targets against their authored references and existing +routing/binding reports. Authored identity includes project binding declarations; +local binding selection affects only resolution identity. The shared output budget +covers expanded binding reports and endpoint entries. No managed values, ciphertext, +keys or host paths enter these reports. Late hook-produced bindings and runtime +address delivery still require separate execution and generation-fence work. ## Host declarations @@ -379,8 +444,9 @@ The optional `.hack/hack.local.json` is a separate versioned document: {"schema_version":1,"environment":{"default_overlay":null}} ``` -Local settings permit `environment.default_overlay`, `routes.domain` and -`open.prefer`. Omission inherits; overlay null selects base, while domain/open null +Local settings permit `environment.default_overlay`, `routes.domain`, +`open.prefer` and `host_bindings`. Omission inherits; overlay null selects base, +binding null removes a logical target, while domain/open null refuses. A canonical overlay name selects that overlay. Other fields, workload definitions, unversioned documents, duplicate keys and unknown versions refuse. The effective selection is project default/base, then verified primary local, diff --git a/packages/config-compiler/README.md b/packages/config-compiler/README.md index 683ee6809..7396bbfde 100644 --- a/packages/config-compiler/README.md +++ b/packages/config-compiler/README.md @@ -32,7 +32,7 @@ are rejected. Each document is limited to 1 MiB, their combined decoded size to These are parser resource bounds, not runtime workload limits. A local document permits `schema_version: 1` and optional `environment`, -`routes`, and `open`. Environment permits only `default_overlay`. Omission inherits, `null` selects base, +`routes`, `open`, and `host_bindings`. Environment permits only `default_overlay`. Omission inherits, `null` selects base, and a string selects a canonical overlay matching `[a-z0-9]+(?:-[a-z0-9]+)*`. No normalization silently changes names. @@ -270,3 +270,60 @@ bindings. Oversized expansion returns fixed redacted `plan_too_large`; this is a input-amplification bound, not a runtime route or workload capacity limit. This package does not register DNS, acquire certificates, bind ports, configure proxies, open browsers or claim that an origin is reachable. + +## Structured endpoints and local host bindings + +`endpoint_plan_version: 1` adds one exclusive authored environment form: +`{endpoint: {kind, ...}}`. References are `{kind: "route", name}`, a declared +service `{kind: "service", name, port, protocol}`, or a logical binding +`{kind: "host_binding", name}`. Ports are explicit integers from 1–65535 and +protocol is `http`, `https`, or `tcp`. References accept no credentials, path, +query, command, or arbitrary scope. A route selects its centrally derived project +origin, not its OAuth alias. Services and routes must exist in the authored +namespace and cannot target jobs. Every reference is validated before profile +filtering; an active invocation cannot reference an inactive service or route. + +Project `host_bindings` is an optional map from canonical logical names (lowercase +letters/digits, single separating hyphens, at most 63 bytes) to either +`{kind: "host", port, protocol}` or +`{kind: "external", hostname, port, protocol}`. External hostname is literal, +canonical lowercase DNS (including a single label), strict dotted IPv4, or +compressed hexadecimal IPv6 in brackets. It has no authority port, credentials, +path, wildcard, query, fragment, or normalization. Explicit external loopback +addresses retain their meaning in the calling context; they do not imply host +gateway access. Use the typed `host` intent for that purpose. + +Local maps merge by logical name: project, verified primary when inheritance is +enabled, then current checkout. Local `null` removes that binding, including an +inherited binding; a later target readds it. Null is not a project target and the +whole map cannot be null. Binding definitions contain no process command, resource +ID, source replacement, or credential. Empty maps explicitly enable binding +reporting. Ignored primary maps are still validated and enter resolution identity. + +Context-free compile keeps logical host-binding references symbolic, allowing +local provisioning without tracked host addresses. Actual resolve requires every +referenced binding, including those in inactive invocations, to exist after local +merging. Missing references refuse with `unknown_host_binding`; tombstoned +references refuse with `removed_host_binding` at the removing local document's +original location. The authored plan retains only project definitions. Optional +`host_binding_resolution: {bindings, removed}` reports effective typed targets and +each winning/removing `project`, `primary_local`, or `checkout_local` source. +The probe performs this merge/reference check while deferring routing expansion. + +Metadata planning produces `{kind: "endpoint", reference, target}` without a +string `value`. Route targets contain a centrally derived `origin`; direct service +targets remain symbolic `{kind: "service", name, port, protocol}`. Typed host +targets add `context: "host" | "workload"`; they require backend-qualified loopback +or gateway translation during execution. External targets retain the canonical +hostname, port and protocol. Direct service references in host invocations produce +an incomplete `unsupported_endpoint_context` diagnostic and omit the destination, +because guest service names are not host addresses. + +Endpoints cannot replace a same-key managed baseline entry: the plan preserves +that managed entry, reports `env_endpoint_collision`, and remains incomplete. +Combining endpoint and unset forms refuses; unsetting another key grants no +replacement permission. Existing remapped managed-reference collision rules stay +unchanged. The shared 8 MiB report budget counts binding reports and every expanded +endpoint before insertion. No new fields alter old hashes or replies when absent. +This compiler does not resolve DNS, execute hooks, start services, or prove endpoint +reachability; native execution and backend translation remain separate work. diff --git a/packages/config-compiler/generated/hack.local.schema.json b/packages/config-compiler/generated/hack.local.schema.json index 358a8256a..085452eb0 100644 --- a/packages/config-compiler/generated/hack.local.schema.json +++ b/packages/config-compiler/generated/hack.local.schema.json @@ -1,68 +1,154 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "LocalConfig", - "type": "object", - "properties": { - "environment": { - "$ref": "#/$defs/LocalEnvironment", - "default": {} - }, - "open": { - "$ref": "#/$defs/LocalOpen" + "$defs": { + "EndpointProtocol": { + "enum": [ + "http", + "https", + "tcp" + ], + "type": "string" }, - "routes": { - "$ref": "#/$defs/LocalRoutes" + "HostBindingTarget": { + "description": "`host` is the host loopback/gateway intent; an external hostname is a literal address.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "host", + "type": "string" + }, + "port": { + "format": "uint16", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "protocol": { + "$ref": "#/$defs/EndpointProtocol" + } + }, + "required": [ + "kind", + "port", + "protocol" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "hostname": { + "maxLength": 253, + "minLength": 1, + "type": "string" + }, + "kind": { + "const": "external", + "type": "string" + }, + "port": { + "format": "uint16", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "protocol": { + "$ref": "#/$defs/EndpointProtocol" + } + }, + "required": [ + "kind", + "hostname", + "port", + "protocol" + ], + "type": "object" + } + ] }, - "schema_version": { - "type": "integer", - "format": "uint32", - "maximum": 1, - "minimum": 1 - } - }, - "additionalProperties": false, - "required": [ - "schema_version" - ], - "$defs": { "LocalEnvironment": { - "type": "object", + "additionalProperties": false, "properties": { "default_overlay": { + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "type": [ "string", "null" - ], - "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + ] } }, - "additionalProperties": false + "type": "object" }, "LocalOpen": { - "type": "object", + "additionalProperties": false, "properties": { "prefer": { "$ref": "#/$defs/OpenPreference" } }, - "additionalProperties": false + "type": "object" }, "LocalRoutes": { - "type": "object", + "additionalProperties": false, "properties": { "domain": { "type": "string" } }, - "additionalProperties": false + "type": "object" }, "OpenPreference": { - "type": "string", "enum": [ "auto", "alias", "dev" - ] + ], + "type": "string" } - } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "environment": { + "$ref": "#/$defs/LocalEnvironment", + "default": {} + }, + "host_bindings": { + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/HostBindingTarget" + }, + { + "type": "null" + } + ] + }, + "propertyNames": { + "maxLength": 63, + "minLength": 1, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "type": "object" + }, + "open": { + "$ref": "#/$defs/LocalOpen" + }, + "routes": { + "$ref": "#/$defs/LocalRoutes" + }, + "schema_version": { + "format": "uint32", + "maximum": 1, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "schema_version" + ], + "title": "LocalConfig", + "type": "object" } diff --git a/packages/config-compiler/generated/hack.project.schema.json b/packages/config-compiler/generated/hack.project.schema.json index a4446b5bf..baf3787ee 100644 --- a/packages/config-compiler/generated/hack.project.schema.json +++ b/packages/config-compiler/generated/hack.project.schema.json @@ -94,6 +94,84 @@ } ] }, + "EndpointProtocol": { + "enum": [ + "http", + "https", + "tcp" + ], + "type": "string" + }, + "EndpointReference": { + "description": "References select a named public route, a guest service, or a locally provisioned binding.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "route", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "kind", + "name" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "service", + "type": "string" + }, + "name": { + "type": "string" + }, + "port": { + "format": "uint16", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "protocol": { + "$ref": "#/$defs/EndpointProtocol" + } + }, + "required": [ + "kind", + "name", + "port", + "protocol" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "host_binding", + "type": "string" + }, + "name": { + "maxLength": 63, + "minLength": 1, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + } + }, + "required": [ + "kind", + "name" + ], + "type": "object" + } + ] + }, "EnvironmentSelection": { "additionalProperties": false, "properties": { @@ -154,6 +232,77 @@ "unset" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "endpoint": { + "$ref": "#/$defs/EndpointReference" + } + }, + "required": [ + "endpoint" + ], + "type": "object" + } + ] + }, + "HostBindingTarget": { + "description": "`host` is the host loopback/gateway intent; an external hostname is a literal address.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "host", + "type": "string" + }, + "port": { + "format": "uint16", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "protocol": { + "$ref": "#/$defs/EndpointProtocol" + } + }, + "required": [ + "kind", + "port", + "protocol" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "hostname": { + "maxLength": 253, + "minLength": 1, + "type": "string" + }, + "kind": { + "const": "external", + "type": "string" + }, + "port": { + "format": "uint16", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "protocol": { + "$ref": "#/$defs/EndpointProtocol" + } + }, + "required": [ + "kind", + "hostname", + "port", + "protocol" + ], + "type": "object" } ] }, @@ -779,6 +928,18 @@ "host": { "$ref": "#/$defs/HostConfig" }, + "host_bindings": { + "additionalProperties": { + "$ref": "#/$defs/HostBindingTarget" + }, + "propertyNames": { + "maxLength": 63, + "minLength": 1, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "type": "object" + }, "jobs": { "additionalProperties": { "$ref": "#/$defs/Workload" diff --git a/packages/config-compiler/generated/native-config.ts b/packages/config-compiler/generated/native-config.ts index b48216913..b9aa3ab1b 100644 --- a/packages/config-compiler/generated/native-config.ts +++ b/packages/config-compiler/generated/native-config.ts @@ -4,9 +4,17 @@ export type EnvMetadata = { metadata_version: 1, overlay: string | null, overlay export type HostMetadata = { default?: { [key in string]: ManagedBindingMetadata }, workloads: { [key in string]: { [key in string]: ManagedBindingMetadata } }, }; export type HostEnvironmentPlan = { env_target: HostEnvTarget, bindings: { [key in string]: EnvironmentBinding }, }; export type EnvPlanRequest = { request_version: 1, project: string, primary_local?: string, checkout_local?: string, explicit_overlay?: string | null, global_domain?: string, explicit_domain?: string, branch?: string, env_metadata: EnvMetadata, }; -export type EnvironmentBinding = { "kind": "managed", key: string, scope: string, secret: boolean, } | { "kind": "literal", value: string, } | { "kind": "default", value: string, }; +export type EnvironmentBinding = { "kind": "managed", key: string, scope: string, secret: boolean, } | { "kind": "literal", value: string, } | { "kind": "default", value: string, } | { "kind": "endpoint", reference: EndpointReference, target: EndpointTarget, }; +export type EndpointProtocol = "http" | "https" | "tcp"; +export type EndpointReference = { "kind": "route", name: string, } | { "kind": "service", name: string, port: number, protocol: EndpointProtocol, } | { "kind": "host_binding", name: string, }; +export type HostBindingTarget = { "kind": "host", port: number, protocol: EndpointProtocol, } | { "kind": "external", hostname: string, port: number, protocol: EndpointProtocol, }; +export type HostBindingOrigin = "project" | "primary_local" | "checkout_local"; +export type ResolvedHostBinding = { target: HostBindingTarget, origin: HostBindingOrigin, }; +export type HostBindingResolution = { bindings: { [key in string]: ResolvedHostBinding }, removed: { [key in string]: HostBindingOrigin }, }; +export type EndpointContext = "workload" | "host"; +export type EndpointTarget = { "kind": "route", origin: string, } | { "kind": "service", name: string, port: number, protocol: EndpointProtocol, } | { "kind": "host", context: EndpointContext, port: number, protocol: EndpointProtocol, } | { "kind": "external", hostname: string, port: number, protocol: EndpointProtocol, }; export type EnvironmentPlan = { plan_version: 1, overlay: string | null, overlay_exists: boolean, complete: boolean, workloads: { [key in string]: { [key in string]: EnvironmentBinding } }, host?: { [key in string]: HostEnvironmentPlan }, warnings: Array, diagnostics: Array, }; -export type PlanResult = { transport_version: 1, ok: true, plan: Plan, semantic_hash: string, declared_workloads: { [key in string]: WorkloadKind }, host_env_targets?: HostEnvTargets, local_resolution: LocalResolution, routing_resolution?: RoutingResolution, environment_plan: EnvironmentPlan, } | { transport_version: 1, ok: false, diagnostics: Array, }; +export type PlanResult = { transport_version: 1, ok: true, plan: Plan, semantic_hash: string, declared_workloads: { [key in string]: WorkloadKind }, host_env_targets?: HostEnvTargets, local_resolution: LocalResolution, routing_resolution?: RoutingResolution, host_binding_resolution?: HostBindingResolution, environment_plan: EnvironmentPlan, } | { transport_version: 1, ok: false, diagnostics: Array, }; export type HostConfig = { up?: HostHooks, down?: HostHooks, processes?: { [key in string]: HostProcess }, }; export type HostHooks = { before?: Array, after?: Array, }; export type HostHook = { name: string, command: Command, cwd?: string, environment?: { [key in string]: EnvironmentValue }, env_target?: HostEnvTarget, }; @@ -36,7 +44,7 @@ export type EnvironmentSelection = { default_overlay?: string, }; export type Build = { context: string, dockerfile?: string, target?: string, }; export type Command = { exec: Array, } | { shell: string, }; export type True = true; -export type EnvironmentValue = { literal: string, } | { default: string, } | { env_ref: string, } | { unset: True, }; +export type EnvironmentValue = { literal: string, } | { default: string, } | { env_ref: string, } | { unset: True, } | { endpoint: EndpointReference, }; export type StorageKind = "persistent"; export type StorageScope = "worktree"; export type Storage = { kind: StorageKind, scope: StorageScope, }; @@ -47,16 +55,16 @@ export type JobCondition = "completed"; export type Dependency = { service: string, condition: ServiceCondition, } | { job: string, condition: JobCondition, }; export type Readiness = { "kind": "exec", command: Command, interval: string, timeout: string, retries: number, } | { "kind": "http", port: number, path: string, interval: string, timeout: string, retries: number, } | { "kind": "tcp", port: number, interval: string, timeout: string, retries: number, }; export type Workload = { image?: string, build?: Build, command?: Command, working_directory?: string, mounts?: Array, environment?: { [key in string]: EnvironmentValue }, depends_on?: Array, profiles?: Array, readiness?: Readiness, }; -export type Project = { schema_version: 1, name: string, source?: Source, services?: { [key in string]: Workload }, jobs?: { [key in string]: Workload }, storage?: { [key in string]: Storage }, profiles?: Array, environment?: EnvironmentSelection, worktree?: WorktreePolicy, host?: HostConfig, routes?: Routes, open?: OpenConfig, }; -export type Plan = { plan_version: 1, name: string, source: Source, environment: EnvironmentSelection, worktree: WorktreePolicy, host?: HostConfig, routes?: Routes, open?: OpenConfig, selected_profiles: Array, storage: { [key in string]: Storage }, services: { [key in string]: Workload }, jobs: { [key in string]: Workload }, }; +export type Project = { schema_version: 1, name: string, source?: Source, services?: { [key in string]: Workload }, jobs?: { [key in string]: Workload }, storage?: { [key in string]: Storage }, profiles?: Array, environment?: EnvironmentSelection, worktree?: WorktreePolicy, host?: HostConfig, routes?: Routes, open?: OpenConfig, host_bindings?: { [key in string]: HostBindingTarget }, }; +export type Plan = { plan_version: 1, name: string, source: Source, environment: EnvironmentSelection, worktree: WorktreePolicy, host?: HostConfig, routes?: Routes, open?: OpenConfig, host_bindings?: { [key in string]: HostBindingTarget }, selected_profiles: Array, storage: { [key in string]: Storage }, services: { [key in string]: Workload }, jobs: { [key in string]: Workload }, }; export type Diagnostic = { code: string, message: string, pointer: string, line: number, column: number, }; export type WorkloadKind = "service" | "job"; export type CompileResult = { transport_version: 1, ok: true, plan: Plan, declared_workloads: { [key in string]: WorkloadKind }, host_env_targets?: HostEnvTargets, semantic_hash: string, } | { transport_version: 1, ok: false, diagnostics: Array, }; export type LocalEnvironment = { default_overlay?: string | null, }; -export type LocalConfig = { schema_version: 1, environment?: LocalEnvironment, routes?: LocalRoutes, open?: LocalOpen, }; +export type LocalConfig = { schema_version: 1, environment?: LocalEnvironment, routes?: LocalRoutes, open?: LocalOpen, host_bindings?: { [key in string]: HostBindingTarget | null }, }; export type ResolveRequest = { request_version: 1, project: string, primary_local?: string, checkout_local?: string, explicit_overlay?: string | null, global_domain?: string, explicit_domain?: string, branch?: string, routing_probe?: boolean, }; export type DocumentRole = "project" | "primary_local" | "checkout_local" | "request"; export type ResolveDiagnostic = { document: DocumentRole, code: string, message: string, pointer: string, line: number, column: number, }; export type OverlayOrigin = "project" | "primary_local" | "checkout_local" | "explicit"; export type LocalResolution = { overlay: string | null, origin: OverlayOrigin, auto_branch: boolean, inherit_local: boolean, resolution_hash: string, }; -export type ResolveResult = { transport_version: 1, ok: true, plan: Plan, semantic_hash: string, declared_workloads: { [key in string]: WorkloadKind }, host_env_targets?: HostEnvTargets, local_resolution: LocalResolution, routing_resolution?: RoutingResolution, routing_inputs_required?: true, } | { transport_version: 1, ok: false, diagnostics: Array, }; +export type ResolveResult = { transport_version: 1, ok: true, plan: Plan, semantic_hash: string, declared_workloads: { [key in string]: WorkloadKind }, host_env_targets?: HostEnvTargets, local_resolution: LocalResolution, routing_resolution?: RoutingResolution, host_binding_resolution?: HostBindingResolution, routing_inputs_required?: true, } | { transport_version: 1, ok: false, diagnostics: Array, }; diff --git a/packages/config-compiler/src/endpoint.rs b/packages/config-compiler/src/endpoint.rs new file mode 100644 index 000000000..da78fdd03 --- /dev/null +++ b/packages/config-compiler/src/endpoint.rs @@ -0,0 +1,403 @@ +//! Offline endpoint intent and logical host bindings. Addresses needing backend ownership remain symbolic. +use crate::{Diagnostic, diagnostic_at, json::child, local, model, validate}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use ts_rs::TS; + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +pub enum EndpointProtocol { + Http, + Https, + Tcp, +} + +/// References select a named public route, a guest service, or a locally provisioned binding. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum EndpointReference { + Route { + name: String, + }, + Service { + name: String, + #[schemars(range(min = 1, max = 65535))] + port: u16, + protocol: EndpointProtocol, + }, + HostBinding { + #[schemars( + length(min = 1, max = 63), + regex(pattern = "^[a-z0-9]+(?:-[a-z0-9]+)*$") + )] + name: String, + }, +} + +/// `host` is the host loopback/gateway intent; an external hostname is a literal address. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum HostBindingTarget { + Host { + #[schemars(range(min = 1, max = 65535))] + port: u16, + protocol: EndpointProtocol, + }, + External { + #[schemars(length(min = 1, max = 253))] + hostname: String, + #[schemars(range(min = 1, max = 65535))] + port: u16, + protocol: EndpointProtocol, + }, +} + +#[derive(Debug, Clone, Copy, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum HostBindingOrigin { + Project, + PrimaryLocal, + CheckoutLocal, +} + +#[derive(Debug, Clone, Serialize, JsonSchema, TS)] +pub struct ResolvedHostBinding { + pub target: HostBindingTarget, + pub origin: HostBindingOrigin, +} + +#[derive(Debug, Serialize, JsonSchema, TS)] +pub struct HostBindingResolution { + pub bindings: BTreeMap, + pub removed: BTreeMap, +} + +#[derive(Debug, Clone, Copy, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum EndpointContext { + Workload, + Host, +} + +/// This is an execution-independent plan, never a claim that an address is reachable. +#[derive(Debug, Serialize, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum EndpointTarget { + Route { + origin: String, + }, + Service { + name: String, + port: u16, + protocol: EndpointProtocol, + }, + Host { + context: EndpointContext, + port: u16, + protocol: EndpointProtocol, + }, + External { + hostname: String, + port: u16, + protocol: EndpointProtocol, + }, +} + +pub(crate) struct EndpointUse { + reference: EndpointReference, + pointer: String, + workload: Option, +} + +/// Retain every authored reference before profile pruning, with authored diagnostic positions. +pub(crate) fn uses(project: &model::Project) -> Vec { + let mut result = Vec::new(); + for (kind, workloads) in [("services", &project.services), ("jobs", &project.jobs)] { + for (name, workload) in workloads { + let pointer = child(&format!("/{kind}"), name); + collect(&mut result, &workload.environment, &pointer, Some(name)); + } + } + if let Some(host) = &project.host { + for entry in host.entries() { + collect(&mut result, entry.environment, &entry.pointer, None); + } + } + result +} +fn collect( + result: &mut Vec, + environment: &BTreeMap, + pointer: &str, + workload: Option<&String>, +) { + for (key, directive) in environment { + if let model::EnvironmentValue::Endpoint { endpoint } = directive { + result.push(EndpointUse { + reference: endpoint.clone(), + pointer: format!("{}/endpoint", child(&format!("{pointer}/environment"), key)), + workload: workload.cloned(), + }); + } + } +} +pub(crate) fn binding_name(value: &str) -> bool { + value.len() <= 63 && validate::overlay_name(value) +} +pub(crate) fn binding_name_schema() -> serde_json::Value { + serde_json::json!({"type":"string","minLength":1,"maxLength":63,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"}) +} +pub(crate) fn validate_binding( + name: &str, + target: Option<&HostBindingTarget>, + pointer: &str, + at: &dyn Fn(&str, &str) -> Diagnostic, +) -> Result<(), Diagnostic> { + if !binding_name(name) { + return Err(at("invalid_name", pointer)); + } + if let Some(target) = target { + let port = match target { + HostBindingTarget::Host { port, .. } => port, + HostBindingTarget::External { hostname, port, .. } => { + if !crate::routing::hostname(hostname) { + return Err(at("invalid_host_binding", &child(pointer, "hostname"))); + } + port + } + }; + if *port == 0 { + return Err(at("invalid_host_binding", &child(pointer, "port"))); + } + } + Ok(()) +} +pub(crate) fn validate_project( + project: &model::Project, + selected: &[String], + at: &dyn Fn(&str, &str) -> Diagnostic, +) -> Result<(), Diagnostic> { + if let Some(bindings) = &project.host_bindings { + for (name, target) in bindings { + validate_binding(name, Some(target), &child("/host_bindings", name), at)?; + } + } + for usage in uses(project) { + let (name, port, target) = match &usage.reference { + EndpointReference::HostBinding { name } => { + if !binding_name(name) { + return Err(at("invalid_endpoint", &child(&usage.pointer, "name"))); + } + continue; + } + EndpointReference::Service { name, port, .. } => (name, Some(*port), Some(name)), + EndpointReference::Route { name } => { + let route = project + .routes + .as_ref() + .and_then(|routes| routes.http.get(name)); + (name, None, route.map(|r| &r.service)) + } + }; + if !validate::name(name) || port == Some(0) { + return Err(at("invalid_endpoint", &usage.pointer)); + } + let service = target + .and_then(|name| project.services.get(name)) + .ok_or_else(|| at("unknown_endpoint_target", &usage.pointer))?; + let source_active = usage + .workload + .as_ref() + .and_then(|name| { + project + .services + .get(name) + .or_else(|| project.jobs.get(name)) + }) + .is_none_or(|source| { + source.profiles.is_empty() || source.profiles.iter().any(|p| selected.contains(p)) + }); + let target_active = + service.profiles.is_empty() || service.profiles.iter().any(|p| selected.contains(p)); + if source_active && !target_active { + return Err(at("inactive_endpoint_target", &usage.pointer)); + } + } + Ok(()) +} + +/// Merge only verified local inputs supplied by the adapter; no filesystem lookup occurs here. +pub(crate) fn resolve_bindings( + compiled: &crate::Compiled, + primary: Option<&local::ParsedLocal>, + checkout: Option<&local::ParsedLocal>, +) -> Result, local::ResolveDiagnostic> { + let project = &compiled.plan.host_bindings; + let primary = primary.filter(|_| compiled.plan.worktree.inherit_local); + if project.is_none() + && [primary, checkout] + .iter() + .all(|input| input.is_none_or(|input| input.config.host_bindings.is_none())) + { + check_bindings(compiled, None, &BTreeMap::new())?; + return Ok(None); + } + let mut output = HostBindingResolution { + bindings: BTreeMap::new(), + removed: BTreeMap::new(), + }; + let mut removed_locations = BTreeMap::new(); + let mut remaining = crate::environment::MAX_PLAN_OUTPUT_BYTES; + let at = |code: &str, pointer: &str| { + local::with_role( + local::DocumentRole::Project, + diagnostic_at(&compiled.positions, code, pointer), + ) + }; + charge(&mut remaining, &compiled.plan, &at)?; + charge(&mut remaining, &compiled.declared_workloads, &at)?; + remaining = remaining + .checked_sub(4096) + .ok_or_else(|| at("plan_too_large", "/host_bindings"))?; + if let Some(bindings) = project { + for (name, target) in bindings { + charge(&mut remaining, &(name, target, "project"), &at)?; + output.bindings.insert( + name.clone(), + ResolvedHostBinding { + target: target.clone(), + origin: HostBindingOrigin::Project, + }, + ); + } + } + for (input, origin, role) in [ + ( + primary, + HostBindingOrigin::PrimaryLocal, + local::DocumentRole::PrimaryLocal, + ), + ( + checkout, + HostBindingOrigin::CheckoutLocal, + local::DocumentRole::CheckoutLocal, + ), + ] { + if let Some(input) = input + && let Some(bindings) = &input.config.host_bindings + { + let at = |code: &str, pointer: &str| { + local::with_role(role, diagnostic_at(&input.positions, code, pointer)) + }; + for (name, target) in bindings { + let pointer = child("/host_bindings", name); + charge(&mut remaining, &(name, target, origin), &at)?; + if let Some(target) = target { + output.bindings.insert( + name.clone(), + ResolvedHostBinding { + target: target.clone(), + origin, + }, + ); + output.removed.remove(name); + removed_locations.remove(name); + } else { + output.bindings.remove(name); + output.removed.insert(name.clone(), origin); + removed_locations.insert(name.clone(), at("removed_host_binding", &pointer)); + } + } + } + } + check_bindings(compiled, Some(&output), &removed_locations)?; + Ok(Some(output)) +} +fn charge( + remaining: &mut usize, + value: &impl Serialize, + at: &dyn Fn(&str, &str) -> local::ResolveDiagnostic, +) -> Result<(), local::ResolveDiagnostic> { + let bytes = crate::environment::serialized_size(value) + .map_err(|_| at("plan_too_large", "/host_bindings"))?; + *remaining = remaining + .checked_sub(bytes + 96) + .ok_or_else(|| at("plan_too_large", "/host_bindings"))?; + Ok(()) +} +fn check_bindings( + compiled: &crate::Compiled, + output: Option<&HostBindingResolution>, + removed: &BTreeMap, +) -> Result<(), local::ResolveDiagnostic> { + for usage in &compiled.endpoint_uses { + if let EndpointReference::HostBinding { name } = &usage.reference + && !output.is_some_and(|output| output.bindings.contains_key(name)) + { + return Err(removed.get(name).cloned().unwrap_or_else(|| { + local::with_role( + local::DocumentRole::Project, + diagnostic_at(&compiled.positions, "unknown_host_binding", &usage.pointer), + ) + })); + } + } + Ok(()) +} + +pub(crate) fn target( + reference: &EndpointReference, + context: EndpointContext, + resolved: &local::Resolved, +) -> Result { + match reference { + EndpointReference::Route { name } => { + let route = resolved + .routing_resolution + .as_ref() + .and_then(|routes| routes.routes.get(name)) + .ok_or("unknown_endpoint_target")?; + Ok(EndpointTarget::Route { + origin: route.origin.clone(), + }) + } + EndpointReference::Service { + name, + port, + protocol, + } => { + if matches!(context, EndpointContext::Host) { + return Err("unsupported_endpoint_context"); + } + Ok(EndpointTarget::Service { + name: name.clone(), + port: *port, + protocol: protocol.clone(), + }) + } + EndpointReference::HostBinding { name } => { + let binding = resolved + .host_binding_resolution + .as_ref() + .and_then(|bindings| bindings.bindings.get(name)) + .ok_or("unknown_host_binding")?; + Ok(match &binding.target { + HostBindingTarget::Host { port, protocol } => EndpointTarget::Host { + context, + port: *port, + protocol: protocol.clone(), + }, + HostBindingTarget::External { + hostname, + port, + protocol, + } => EndpointTarget::External { + hostname: hostname.clone(), + port: *port, + protocol: protocol.clone(), + }, + }) + } + } +} diff --git a/packages/config-compiler/src/environment.rs b/packages/config-compiler/src/environment.rs index 7cb0bea9c..d4074793c 100644 --- a/packages/config-compiler/src/environment.rs +++ b/packages/config-compiler/src/environment.rs @@ -94,6 +94,10 @@ pub enum EnvironmentBinding { Default { value: String, }, + Endpoint { + reference: crate::endpoint::EndpointReference, + target: crate::endpoint::EndpointTarget, + }, } #[derive(Debug, Serialize, JsonSchema, TS)] pub struct EnvironmentPlan { @@ -127,6 +131,9 @@ pub enum PlanResult { #[serde(skip_serializing_if = "Option::is_none")] #[ts(optional, type = "RoutingResolution")] routing_resolution: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "HostBindingResolution")] + host_binding_resolution: Option>, environment_plan: Box, }, Failure { @@ -166,6 +173,7 @@ pub fn plan(bytes: &[u8], profiles: &[String]) -> PlanResult { declared_workloads: resolved.compiled.declared_workloads, local_resolution: resolved.local_resolution, routing_resolution: resolved.routing_resolution.map(Box::new), + host_binding_resolution: resolved.host_binding_resolution.map(Box::new), environment_plan: Box::new(environment_plan), }, Err(error) => PlanResult::failure(error), @@ -396,7 +404,10 @@ fn bind( &pointer, &mut output.diagnostics, &mut budget, - &format!("workload/{name}"), + BindingOwner { + cache_key: &format!("workload/{name}"), + endpoint_context: crate::endpoint::EndpointContext::Workload, + }, )?; output.workloads.insert(name.clone(), bindings); } @@ -425,7 +436,10 @@ fn bind( &entry.pointer, &mut output.diagnostics, &mut budget, - &cache_key, + BindingOwner { + cache_key: &cache_key, + endpoint_context: crate::endpoint::EndpointContext::Host, + }, )?; plans.insert( entry.name.to_owned(), @@ -442,6 +456,10 @@ fn bind( Ok(output) } +struct BindingOwner<'a> { + cache_key: &'a str, + endpoint_context: crate::endpoint::EndpointContext, +} fn bind_environment( resolved: &local::Resolved, baseline: &BTreeMap, @@ -449,9 +467,9 @@ fn bind_environment( pointer: &str, diagnostics: &mut Vec, budget: &mut ReportBudget, - baseline_key: &str, + owner: BindingOwner<'_>, ) -> Result, local::ResolveDiagnostic> { - budget.baseline(baseline_key, baseline, resolved, pointer)?; + budget.baseline(owner.cache_key, baseline, resolved, pointer)?; let mut bindings: BTreeMap = baseline .iter() .map(|(key, b)| (key.clone(), managed(key, b))) @@ -506,6 +524,30 @@ fn bind_environment( diagnostics.push(diagnostic); } } + EnvironmentValue::Endpoint { endpoint } => { + let target = if baseline.contains_key(dest) { + Err("env_endpoint_collision") + } else { + crate::endpoint::target(endpoint, owner.endpoint_context, resolved) + }; + match target { + Ok(target) => { + budget.value(&target, resolved, &pointer)?; + bindings.insert( + dest.clone(), + EnvironmentBinding::Endpoint { + reference: endpoint.clone(), + target, + }, + ); + } + Err(code) => { + let diagnostic = project_diagnostic(resolved, code, &pointer); + budget.value(&diagnostic, resolved, &pointer)?; + diagnostics.push(diagnostic); + } + } + } } } Ok(bindings) @@ -571,6 +613,7 @@ impl ReportBudget { result.value(&resolved.compiled.declared_workloads, resolved, "")?; result.value(&resolved.local_resolution, resolved, "")?; result.value(&resolved.routing_resolution, resolved, "")?; + result.value(&resolved.host_binding_resolution, resolved, "")?; result.value(&metadata.overlay, resolved, "")?; if let Some(host) = &resolved.compiled.plan.host { result.value(&host.targets(), resolved, "")?; diff --git a/packages/config-compiler/src/lib.rs b/packages/config-compiler/src/lib.rs index 422765bf7..3c37ef085 100644 --- a/packages/config-compiler/src/lib.rs +++ b/packages/config-compiler/src/lib.rs @@ -1,4 +1,5 @@ //! Pure, bounded native configuration compiler. It performs no host admission or secret lookup. +pub mod endpoint; pub mod environment; pub mod host; mod json; @@ -45,6 +46,22 @@ fn diagnostic_message(code: &str) -> &'static str { "env_reference_collision" => { "A remapped reference conflicts with an existing managed destination." } + "env_endpoint_collision" => "An endpoint cannot replace an existing managed destination.", + "unsupported_endpoint_context" => { + "A direct service endpoint has no qualified host address." + } + "unknown_endpoint_target" => "The endpoint must name a declared route or service.", + "inactive_endpoint_target" => { + "An active invocation references a disabled service endpoint." + } + "unknown_host_binding" => { + "The logical host binding is absent from the effective local selection." + } + "removed_host_binding" => "The local selection removes a referenced logical host binding.", + "invalid_host_binding" => { + "Use a canonical hostname and explicit nonzero port and protocol." + } + "invalid_endpoint" => "Use a canonical reference name and explicit nonzero service port.", "missing_overlay" => { "The selected overlay is missing; available base and local layers are used." } @@ -159,6 +176,7 @@ struct Compiled { semantic_hash: String, declared_workloads: BTreeMap, positions: BTreeMap, + endpoint_uses: Vec, } fn compile_inner(bytes: &[u8], profiles: &[String]) -> Result { let document = json::parse(bytes)?; @@ -204,6 +222,7 @@ fn compile_inner(bytes: &[u8], profiles: &[String]) -> Result Result Result<(String, String), serde_json::Error> { ]); schema["$defs"]["HostSingleton"]["properties"]["ports"]["items"]["minimum"] = serde_json::json!(1); + schema["properties"]["host_bindings"]["propertyNames"] = endpoint::binding_name_schema(); let schema = serde_json::to_string_pretty(&schema)? + "\n"; let cfg = ts_rs::Config::default(); let declarations = [ @@ -252,6 +273,14 @@ pub fn artifacts() -> Result<(String, String), serde_json::Error> { environment::HostEnvironmentPlan::decl(&cfg), environment::EnvPlanRequest::decl(&cfg), environment::EnvironmentBinding::decl(&cfg), + endpoint::EndpointProtocol::decl(&cfg), + endpoint::EndpointReference::decl(&cfg), + endpoint::HostBindingTarget::decl(&cfg), + endpoint::HostBindingOrigin::decl(&cfg), + endpoint::ResolvedHostBinding::decl(&cfg), + endpoint::HostBindingResolution::decl(&cfg), + endpoint::EndpointContext::decl(&cfg), + endpoint::EndpointTarget::decl(&cfg), environment::EnvironmentPlan::decl(&cfg), environment::PlanResult::decl(&cfg), host::HostConfig::decl(&cfg), @@ -322,5 +351,5 @@ pub fn artifacts() -> Result<(String, String), serde_json::Error> { } pub fn protocol() -> Value { - serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1,"routing_plan_version":1}) + serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1,"routing_plan_version":1,"endpoint_plan_version":1}) } diff --git a/packages/config-compiler/src/local.rs b/packages/config-compiler/src/local.rs index bcaa47d7c..d2d6ae8cd 100644 --- a/packages/config-compiler/src/local.rs +++ b/packages/config-compiler/src/local.rs @@ -53,6 +53,17 @@ pub struct LocalConfig { #[schemars(with = "crate::routing::LocalOpen")] #[ts(optional, type = "LocalOpen")] pub open: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars( + with = "std::collections::BTreeMap>" + )] + #[ts(optional, type = "{ [key in string]: HostBindingTarget | null }")] + pub host_bindings: + Option>>, } #[derive(Debug, Deserialize, Serialize, JsonSchema, TS)] @@ -171,6 +182,9 @@ pub enum ResolveResult { #[ts(optional, type = "RoutingResolution")] routing_resolution: Option>, #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "HostBindingResolution")] + host_binding_resolution: Option>, + #[serde(skip_serializing_if = "Option::is_none")] #[ts(optional, type = "true")] routing_inputs_required: Option, }, @@ -251,12 +265,36 @@ fn read_local(text: &str, role: DocumentRole) -> Result ResolveResult { declared_workloads: resolved.compiled.declared_workloads, local_resolution: resolved.local_resolution, routing_resolution: resolved.routing_resolution.map(Box::new), + host_binding_resolution: resolved.host_binding_resolution.map(Box::new), routing_inputs_required: resolved.routing_inputs_required, }, Err(diagnostic) => ResolveResult::Failure { @@ -329,6 +368,7 @@ pub(crate) struct Resolved { pub(crate) local_resolution: LocalResolution, pub(crate) overlay_location: ResolveDiagnostic, pub(crate) routing_resolution: Option, + pub(crate) host_binding_resolution: Option, pub(crate) routing_inputs_required: Option, } fn resolve_inner(bytes: &[u8], profiles: &[String]) -> Result { @@ -465,12 +505,15 @@ pub(crate) fn resolve_document( diagnostic_at(&document.positions, "missing_overlay", "/explicit_overlay"), ); } + let host_binding_resolution = + crate::endpoint::resolve_bindings(&compiled, primary.as_ref(), checkout.as_ref())?; let routing_context = crate::routing::Context { compiled: &compiled, request: &request, primary: primary.as_ref(), checkout: checkout.as_ref(), overlay: &overlay, + host_binding_resolution: host_binding_resolution.as_ref(), }; let routing_active = crate::routing::required(&routing_context); let routing_inputs_required = @@ -521,10 +564,13 @@ pub(crate) fn resolve_document( local_resolution, overlay_location, routing_resolution, + host_binding_resolution, routing_inputs_required, }) } pub fn local_schema() -> Result { - Ok(serde_json::to_string_pretty(&schemars::schema_for!(LocalConfig))? + "\n") + let mut schema = serde_json::to_value(schemars::schema_for!(LocalConfig))?; + schema["properties"]["host_bindings"]["propertyNames"] = crate::endpoint::binding_name_schema(); + Ok(serde_json::to_string_pretty(&schema)? + "\n") } diff --git a/packages/config-compiler/src/model.rs b/packages/config-compiler/src/model.rs index 69cb5aa5b..8567f056e 100644 --- a/packages/config-compiler/src/model.rs +++ b/packages/config-compiler/src/model.rs @@ -64,6 +64,14 @@ pub struct Project { #[schemars(with = "crate::routing::OpenConfig")] #[ts(optional, type = "OpenConfig")] pub open: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "BTreeMap")] + #[ts(optional, type = "{ [key in string]: HostBindingTarget }")] + pub host_bindings: Option>, } fn enabled() -> bool { @@ -233,6 +241,9 @@ pub enum EnvironmentValue { Unset { unset: True, }, + Endpoint { + endpoint: crate::endpoint::EndpointReference, + }, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] #[serde(try_from = "bool", into = "bool")] @@ -368,6 +379,9 @@ pub struct Plan { #[serde(skip_serializing_if = "Option::is_none")] #[ts(optional, type = "OpenConfig")] pub open: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "{ [key in string]: HostBindingTarget }")] + pub host_bindings: Option>, pub selected_profiles: Vec, pub storage: BTreeMap, diff --git a/packages/config-compiler/src/routing.rs b/packages/config-compiler/src/routing.rs index 344a754bc..78e6b6d68 100644 --- a/packages/config-compiler/src/routing.rs +++ b/packages/config-compiler/src/routing.rs @@ -284,6 +284,13 @@ fn parse_origin(value: &str) -> Option { ip, }) } + +/// Canonical DNS or IP literal host, without authority syntax or implicit normalization. +pub(crate) fn hostname(value: &str) -> bool { + value.len() <= 253 + && parse_origin(&format!("http://{value}")) + .is_some_and(|origin| origin.port.is_none() && origin.host == value) +} fn origin(value: &str) -> Option { Some(parse_origin(value)?.text()) } @@ -386,6 +393,7 @@ pub(crate) struct Context<'a> { pub primary: Option<&'a crate::local::ParsedLocal>, pub checkout: Option<&'a crate::local::ParsedLocal>, pub overlay: &'a Option, + pub host_binding_resolution: Option<&'a crate::endpoint::HostBindingResolution>, } fn at( positions: &BTreeMap, @@ -481,6 +489,7 @@ pub(crate) fn resolve( budget.value(plan, &ctx)?; budget.value(&ctx.compiled.declared_workloads, &ctx)?; budget.value(ctx.overlay, &ctx)?; + budget.value(&ctx.host_binding_resolution, &ctx)?; let mut domain_value = "hack.local".to_owned(); let mut domain_origin = DomainOrigin::Default; if let Some(value) = &ctx.request.global_domain { diff --git a/packages/config-compiler/src/shape.rs b/packages/config-compiler/src/shape.rs index 683f86228..5cc2ad037 100644 --- a/packages/config-compiler/src/shape.rs +++ b/packages/config-compiler/src/shape.rs @@ -21,9 +21,25 @@ fn optional_object(document: &json::Document, pointer: &str) -> Result<(), Diagn pub(crate) fn project(document: &json::Document) -> Result<(), Diagnostic> { object(document, "")?; - for key in ["source", "environment", "worktree", "routes", "open"] { + for key in [ + "source", + "environment", + "worktree", + "routes", + "open", + "host_bindings", + ] { optional_object(document, &json::child("", key))?; } + if let Some(bindings) = document + .value + .get("host_bindings") + .and_then(Value::as_object) + { + for name in bindings.keys() { + object(document, &json::child("/host_bindings", name))?; + } + } if let Some(storage) = document.value.get("storage").and_then(Value::as_object) { for key in storage.keys() { object(document, &json::child("/storage", key))?; @@ -45,10 +61,12 @@ pub(crate) fn project(document: &json::Document) -> Result<(), Diagnostic> { .and_then(Value::as_object) { for key in env.keys() { + let entry = json::child(&format!("{pointer}/environment"), key); object( document, &json::child(&format!("{pointer}/environment"), key), )?; + optional_object(document, &format!("{entry}/endpoint"))?; } } for field in ["mounts", "depends_on"] { @@ -115,6 +133,13 @@ fn host_invocation(document: &json::Document, pointer: &str) -> Result<(), Diagn document, &json::child(&format!("{pointer}/environment"), name), )?; + optional_object( + document, + &format!( + "{}/endpoint", + json::child(&format!("{pointer}/environment"), name) + ), + )?; } } Ok(()) diff --git a/packages/config-compiler/src/validate.rs b/packages/config-compiler/src/validate.rs index 831511131..482982e9a 100644 --- a/packages/config-compiler/src/validate.rs +++ b/packages/config-compiler/src/validate.rs @@ -196,6 +196,7 @@ pub fn lower(mut project: Project, profiles: &[String], at: &At) -> Result Result Value { + json!({"schema_version":1,"name":"example","profiles":["dev"],"services":{"web":{"image":"web:1"},"db":{"image":"db:1","profiles":["dev"]}},"jobs":{"init":{"image":"init:1"}}}) +} +fn request(project: &Value) -> Value { + json!({"request_version":1,"project":project.to_string()}) +} +fn compiled(project: &Value, profiles: &[String]) -> Value { + serde_json::to_value(compile(project.to_string().as_bytes(), profiles)).unwrap() +} +fn resolved(request: &Value) -> Value { + serde_json::to_value(resolve(request.to_string().as_bytes(), &[])).unwrap() +} +fn planned(request: &Value) -> Value { + let mut request = request.clone(); + let project: Value = serde_json::from_str(request["project"].as_str().unwrap()).unwrap(); + let mut workloads = serde_json::Map::new(); + for namespace in ["services", "jobs"] { + for name in project[namespace] + .as_object() + .into_iter() + .flat_map(|map| map.keys()) + { + workloads.insert(name.clone(), json!({})); + } + } + request["env_metadata"] = json!({"metadata_version":1,"overlay":null,"overlay_exists":false,"workloads":workloads,"inactive_scopes":[]}); + if project.get("host").is_some() { + request["env_metadata"]["host"] = json!({"default":{},"workloads":{}}); + } + serde_json::to_value(plan(request.to_string().as_bytes(), &[])).unwrap() +} +fn endpoint(reference: Value) -> Value { + json!({"endpoint":reference}) +} +fn host(port: u16) -> Value { + json!({"kind":"host","port":port,"protocol":"tcp"}) +} +fn local(bindings: Value) -> Value { + json!({"schema_version":1,"host_bindings":bindings}) +} +fn refusal(output: &Value, code: &str, role: Option<&str>) { + assert_eq!( + output["ok"], false, + "diagnostics: {}", + output["diagnostics"] + ); + assert_eq!(output["diagnostics"][0]["code"], code, "{output}"); + if let Some(role) = role { + assert_eq!(output["diagnostics"][0]["document"], role, "{output}"); + } +} + +#[test] +fn absent_fields_keep_historical_plan_and_resolution_bytes() { + let p = json!({"schema_version":1,"name":"example"}); + let output = resolved(&request(&p)); + assert!(output["plan"].get("host_bindings").is_none()); + assert!(output.get("host_binding_resolution").is_none()); + assert_eq!(output["semantic_hash"], compiled(&p, &[])["semantic_hash"]); + let plan = planned(&request(&p)); + assert_eq!(plan["plan"], output["plan"]); + assert_eq!(plan["local_resolution"], output["local_resolution"]); + assert!(plan.get("host_binding_resolution").is_none()); +} + +#[test] +fn external_host_service_and_route_endpoint_targets_remain_typed() { + let mut p = project(); + p["host_bindings"] = json!({"database":host(5432),"external":{"kind":"external","hostname":"api.example.com","port":443,"protocol":"https"}}); + p["routes"] = json!({"domain":"hack.gy","http":{"app":{"service":"web","port":3000,"hostname":"app","protocol":"http"}}}); + p["services"]["web"]["environment"] = json!({ + "PUBLIC":endpoint(json!({"kind":"route","name":"app"})), + "SERVICE":endpoint(json!({"kind":"service","name":"web","port":3000,"protocol":"http"})), + "DATABASE":endpoint(json!({"kind":"host_binding","name":"database"})), + "EXTERNAL":endpoint(json!({"kind":"host_binding","name":"external"})) + }); + p["host"] = json!({"processes":{"watch":{"command":{"shell":"exit 97"},"environment":{"DATABASE":endpoint(json!({"kind":"host_binding","name":"database"}))}}}}); + let mut r = request(&p); + r["branch"] = json!("feature"); + let output = planned(&r); + assert_eq!(output["environment_plan"]["complete"], true, "{output}"); + let bindings = &output["environment_plan"]["workloads"]["web"]; + assert_eq!( + bindings["PUBLIC"]["target"], + json!({"kind":"route","origin":"https://app.feature.example.hack.gy"}) + ); + assert_eq!( + bindings["SERVICE"]["target"], + json!({"kind":"service","name":"web","port":3000,"protocol":"http"}) + ); + assert_eq!( + bindings["DATABASE"]["target"], + json!({"kind":"host","context":"workload","port":5432,"protocol":"tcp"}) + ); + assert_eq!( + bindings["EXTERNAL"]["target"], + json!({"kind":"external","hostname":"api.example.com","port":443,"protocol":"https"}) + ); + assert_eq!( + output["environment_plan"]["host"]["watch"]["bindings"]["DATABASE"]["target"]["context"], + "host" + ); + for value in bindings.as_object().unwrap().values() { + assert_eq!(value["kind"], "endpoint"); + assert!(value.get("value").is_none()); + } +} + +#[test] +fn host_bindings_merge_per_name_with_tombstone_and_readdition_provenance() { + let mut p = project(); + p["host_bindings"] = + json!({"database":host(1),"retained":host(2),"removed":host(3),"readded":host(4)}); + let mut r = request(&p); + r["primary_local"] = + json!(local(json!({"database":host(10),"removed":null,"readded":null})).to_string()); + r["checkout_local"] = json!( + local(json!({"database":host(11),"readded":host(12),"new":host(13),"absent":null})) + .to_string() + ); + let output = resolved(&r); + assert_eq!(output["ok"], true, "{output}"); + let bindings = &output["host_binding_resolution"]; + assert_eq!( + bindings["bindings"]["database"], + json!({"target":host(11),"origin":"checkout_local"}) + ); + assert_eq!(bindings["bindings"]["retained"]["origin"], "project"); + assert_eq!(bindings["bindings"]["readded"]["target"], host(12)); + assert_eq!( + bindings["removed"], + json!({"removed":"primary_local","absent":"checkout_local"}) + ); + assert_eq!(output["plan"]["host_bindings"], p["host_bindings"]); + assert_eq!(planned(&r)["host_binding_resolution"], *bindings); + assert_eq!(output["semantic_hash"], compiled(&p, &[])["semantic_hash"]); +} + +#[test] +fn local_changes_affect_only_resolution_generation_and_inheritance_optout() { + let mut p = project(); + p["host_bindings"] = json!({"database":host(1)}); + let mut r = request(&p); + let base = resolved(&r); + r["primary_local"] = json!(local(json!({"database":host(2)})).to_string()); + let changed = resolved(&r); + assert_eq!(base["semantic_hash"], changed["semantic_hash"]); + assert_ne!( + base["local_resolution"]["resolution_hash"], + changed["local_resolution"]["resolution_hash"] + ); + p["worktree"] = json!({"inherit_local":false}); + r["project"] = json!(p.to_string()); + let output = resolved(&r); + assert_eq!( + output["host_binding_resolution"]["bindings"]["database"]["target"], + host(1) + ); + let omitted = resolved(&request(&p)); + assert_ne!( + output["local_resolution"]["resolution_hash"], + omitted["local_resolution"]["resolution_hash"] + ); + r["checkout_local"] = json!(local(json!({"database":host(3)})).to_string()); + assert_eq!( + resolved(&r)["host_binding_resolution"]["bindings"]["database"]["target"], + host(3) + ); +} + +#[test] +fn invalid_ignored_primary_binding_still_refuses_with_redacted_location() { + let mut p = project(); + p["worktree"] = json!({"inherit_local":false}); + let mut r = request(&p); + r["primary_local"] = json!(local(json!({"database":{"kind":"external","hostname":"private-sentinel/secret","port":2,"protocol":"tcp"}})).to_string()); + let output = resolved(&r); + refusal(&output, "invalid_host_binding", Some("primary_local")); + assert!(!output.to_string().contains("private-sentinel")); + assert_eq!( + output["diagnostics"][0]["pointer"], + "/host_bindings/database/hostname" + ); +} + +#[test] +fn context_free_logical_refs_can_be_locally_provisioned_but_not_guessed() { + let mut p = project(); + p["services"]["web"]["environment"] = + json!({"DATABASE":endpoint(json!({"kind":"host_binding","name":"database"}))}); + assert_eq!(compiled(&p, &[])["ok"], true); + let mut r = request(&p); + refusal(&resolved(&r), "unknown_host_binding", Some("project")); + r["checkout_local"] = json!(local(json!({"database":host(5432)})).to_string()); + assert_eq!(resolved(&r)["ok"], true); + assert_eq!(planned(&r)["environment_plan"]["complete"], true); + r["checkout_local"] = json!(local(json!({"database":null})).to_string()); + for probe in [false, true] { + r["routing_probe"] = json!(probe); + let output = resolved(&r); + refusal(&output, "removed_host_binding", Some("checkout_local")); + assert_eq!( + output["diagnostics"][0]["pointer"], + "/host_bindings/database" + ); + } +} + +#[test] +fn missing_binding_in_an_inactive_workload_is_still_checked() { + let mut p = project(); + p["services"]["db"]["environment"] = + json!({"DATABASE":endpoint(json!({"kind":"host_binding","name":"database"}))}); + let mut r = request(&p); + refusal(&resolved(&r), "unknown_host_binding", Some("project")); + r["primary_local"] = json!(local(json!({"database":host(2)})).to_string()); + assert_eq!(resolved(&r)["ok"], true); + assert!( + planned(&r)["environment_plan"]["workloads"] + .get("db") + .is_none() + ); +} + +#[test] +fn all_declared_route_and_service_refs_are_validated_before_profile_filtering() { + for reference in [ + json!({"kind":"service","name":"init","port":2,"protocol":"tcp"}), + json!({"kind":"service","name":"missing","port":2,"protocol":"tcp"}), + json!({"kind":"route","name":"missing"}), + ] { + let mut p = project(); + p["services"]["db"]["environment"] = json!({"TARGET":endpoint(reference)}); + refusal(&compiled(&p, &[]), "unknown_endpoint_target", None); + } + for reference in [ + json!({"kind":"service","name":"db","port":2,"protocol":"tcp"}), + json!({"kind":"route","name":"database"}), + ] { + let mut p = project(); + p["routes"] = json!({"http":{"database":{"service":"db","port":2,"hostname":"database"}}}); + p["services"]["web"]["environment"] = json!({"TARGET":endpoint(reference.clone())}); + refusal(&compiled(&p, &[]), "inactive_endpoint_target", None); + assert_eq!(compiled(&p, &["dev".into()])["ok"], true); + p["services"]["web"]["profiles"] = json!(["dev"]); + assert_eq!(compiled(&p, &[])["ok"], true); + } +} + +#[test] +fn route_probe_defers_origin_collisions_while_binding_refs_are_validated() { + let mut p = project(); + p["routes"] = json!({"aliases":{"pinned":{"origin":"https://example.hack.local"}},"http":{"app":{"service":"web","port":3000,"hostname":"app"}}}); + p["services"]["web"]["environment"] = + json!({"PUBLIC":endpoint(json!({"kind":"route","name":"app"}))}); + p["host_bindings"] = json!({}); + let mut r = request(&p); + r["routing_probe"] = json!(true); + let probe = resolved(&r); + assert_eq!(probe["ok"], true, "{probe}"); + assert_eq!(probe["routing_inputs_required"], true); + assert!(probe.get("routing_resolution").is_none()); + assert_eq!( + probe["host_binding_resolution"], + json!({"bindings":{},"removed":{}}) + ); + r["routing_probe"] = json!(false); + r["global_domain"] = json!("hack.gy"); + let output = resolved(&r); + assert_eq!(output["ok"], true); + r.as_object_mut().unwrap().remove("routing_probe"); + let planned = planned(&r); + assert_eq!(planned["environment_plan"]["complete"], true); + assert_eq!( + planned["environment_plan"]["workloads"]["web"]["PUBLIC"]["target"]["origin"], + "https://app.example.hack.gy" + ); +} + +#[test] +fn service_endpoint_for_host_context_is_incomplete_without_guessing_a_url() { + let mut p = project(); + p["host"] = json!({"processes":{"watch":{"command":{"exec":["false"]},"environment":{"SERVICE":endpoint(json!({"kind":"service","name":"web","port":3000,"protocol":"http"}))}}}}); + let output = planned(&request(&p)); + assert_eq!(output["ok"], true, "{output}"); + assert_eq!(output["environment_plan"]["complete"], false); + assert_eq!( + output["environment_plan"]["diagnostics"][0]["code"], + "unsupported_endpoint_context" + ); + assert_eq!( + output["environment_plan"]["diagnostics"][0]["pointer"], + "/host/processes/watch/environment/SERVICE" + ); + assert!( + output["environment_plan"]["host"]["watch"]["bindings"] + .get("SERVICE") + .is_none() + ); +} + +#[test] +fn endpoint_never_replaces_the_same_managed_key_and_other_unset_cannot_authorize_it() { + let mut p = project(); + p["services"]["web"]["environment"] = json!({"TARGET":endpoint(json!({"kind":"service","name":"web","port":3000,"protocol":"http"})),"OTHER":{"unset":true}}); + let mut r = request(&p); + r["env_metadata"] = json!({"metadata_version":1,"overlay":null,"overlay_exists":false,"workloads":{"web":{"TARGET":{"scope":"global","secret":true},"OTHER":{"scope":"web","secret":true}},"db":{},"init":{}},"inactive_scopes":[]}); + let output = serde_json::to_value(plan(r.to_string().as_bytes(), &[])).unwrap(); + assert_eq!(output["environment_plan"]["complete"], false); + assert_eq!( + output["environment_plan"]["diagnostics"][0]["code"], + "env_endpoint_collision" + ); + assert_eq!( + output["environment_plan"]["workloads"]["web"]["TARGET"], + json!({"kind":"managed","key":"TARGET","scope":"global","secret":true}) + ); + assert!( + output["environment_plan"]["workloads"]["web"] + .get("OTHER") + .is_none() + ); + p["services"]["web"]["environment"]["TARGET"]["unset"] = json!(true); + refusal(&compiled(&p, &[]), "invalid_shape", None); +} + +#[test] +fn canonical_external_hosts_accept_dns_and_ip_but_not_ambiguous_authorities() { + for hostname in [ + "api.example.com", + "localhost", + "internal", + "127.0.0.1", + "[::1]", + "[::ffff:7f00:1]", + ] { + let mut p = project(); + p["host_bindings"] = + json!({"external":{"kind":"external","hostname":hostname,"port":80,"protocol":"http"}}); + assert_eq!(compiled(&p, &[])["ok"], true, "{hostname}"); + } + for hostname in [ + "API.example.com", + "foo.", + "a..com", + "1", + "0x7f000001", + "127.1", + "127.00.0.1", + "[0:0:0:0:0:0:0:1]", + "[::ffff:127.0.0.1]", + "localhost:3000", + "user@host", + "host/path", + "host?query", + "host#secret", + "*.example.com", + "https://example.com", + "host%2ecom", + ] { + let mut p = project(); + p["host_bindings"] = + json!({"external":{"kind":"external","hostname":hostname,"port":80,"protocol":"http"}}); + refusal(&compiled(&p, &[]), "invalid_host_binding", None); + } +} + +#[test] +fn strict_endpoint_and_binding_shapes_refuse_tuple_and_secret_input_without_echo() { + for binding in [ + json!([]), + json!(["host", 2, "tcp"]), + json!(null), + json!({"kind":"host","port":0,"protocol":"tcp"}), + json!({"kind":"host","port":1,"protocol":"udp"}), + json!({"kind":"host","port":1,"protocol":"tcp","command":"private-sentinel"}), + json!({"kind":"external","hostname":"api.example.com","port":1,"protocol":"https","credentials":"private-sentinel"}), + ] { + let mut p = project(); + p["host_bindings"] = json!({"database":binding}); + let output = compiled(&p, &[]); + assert_eq!(output["ok"], false, "{output}"); + assert!(!output.to_string().contains("private-sentinel")); + } + for reference in [ + json!([]), + json!(["service", "web", 2, "tcp"]), + json!(null), + json!({"kind":"service","name":"web","port":2}), + json!({"kind":"service","name":"web","port":2,"protocol":"tcp","path":"private-sentinel"}), + json!({"kind":"route","name":"web","port":2}), + json!({"kind":"host_binding","name":"database","credential":"private-sentinel"}), + ] { + let mut p = project(); + p["services"]["web"]["environment"] = json!({"TARGET":endpoint(reference.clone())}); + let output = compiled(&p, &[]); + assert_eq!(output["ok"], false, "{output}"); + assert!(!output.to_string().contains("private-sentinel")); + p["services"]["web"]["environment"] = json!({}); + p["host"] = json!({"processes":{"watch":{"command":{"exec":["false"]},"environment":{"TARGET":endpoint(reference)}}}}); + assert_eq!(compiled(&p, &[])["ok"], false); + } + for name in [ + "DB", + "db_name", + "db.name", + "db-", + "-db", + "db--name", + "", + &"a".repeat(64), + ] { + let mut p = project(); + p["host_bindings"] = json!({name:host(2)}); + refusal(&compiled(&p, &[]), "invalid_name", None); + let mut r = request(&project()); + r["checkout_local"] = json!(local(json!({name:null})).to_string()); + refusal(&resolved(&r), "invalid_name", Some("checkout_local")); + } +} + +#[test] +fn endpoint_replication_uses_shared_preallocation_budget() { + let mut p = project(); + let hostname = (0..4).map(|_| "a".repeat(60)).collect::>().join("."); + p["host_bindings"] = + json!({"external":{"kind":"external","hostname":hostname,"port":443,"protocol":"https"}}); + let mut processes = serde_json::Map::new(); + let mut directives = serde_json::Map::new(); + for index in 0..100 { + directives.insert( + format!("TARGET{index}"), + endpoint(json!({"kind":"host_binding","name":"external"})), + ); + } + for index in 0..130 { + processes.insert( + format!("p{index}"), + json!({"command":{"exec":["false"]},"environment":directives}), + ); + } + p["host"] = json!({"processes":processes}); + assert!(p.to_string().len() < hack_config_compiler::MAX_INPUT_BYTES); + let mut r = request(&p); + let mut baseline = serde_json::Map::new(); + for index in 0..100 { + baseline.insert( + format!("BASE{index}{}", "A".repeat(200)), + json!({"scope":"global","secret":true}), + ); + } + r["env_metadata"] = json!({"metadata_version":1,"overlay":null,"overlay_exists":false,"workloads":{"web":{},"db":{},"init":{}},"inactive_scopes":[],"host":{"default":baseline,"workloads":{}}}); + let output = serde_json::to_value(plan(r.to_string().as_bytes(), &[])).unwrap(); + refusal(&output, "plan_too_large", Some("project")); + assert!(output.to_string().len() < 1024); +} diff --git a/packages/config-compiler/tests/fixtures/local-schema-corpus.json b/packages/config-compiler/tests/fixtures/local-schema-corpus.json index 02ba261e4..ebd9f1e8e 100644 --- a/packages/config-compiler/tests/fixtures/local-schema-corpus.json +++ b/packages/config-compiler/tests/fixtures/local-schema-corpus.json @@ -419,5 +419,125 @@ "extra": true } } + }, + { + "name": "typed host bindings", + "valid": true, + "input": { + "schema_version": 1, + "host_bindings": { + "database": { + "kind": "host", + "port": 2, + "protocol": "tcp" + }, + "external": { + "kind": "external", + "hostname": "api.example.com", + "port": 443, + "protocol": "https" + } + } + } + }, + { + "name": "host binding tombstone", + "valid": true, + "input": { + "schema_version": 1, + "host_bindings": { + "database": null + } + } + }, + { + "name": "empty host binding map", + "valid": true, + "input": { + "schema_version": 1, + "host_bindings": {} + } + }, + { + "name": "null binding map", + "valid": false, + "input": { + "schema_version": 1, + "host_bindings": null + } + }, + { + "name": "array binding map", + "valid": false, + "input": { + "schema_version": 1, + "host_bindings": [] + } + }, + { + "name": "tuple binding target", + "valid": false, + "input": { + "schema_version": 1, + "host_bindings": { + "database": ["host", 2, "tcp"] + } + } + }, + { + "name": "invalid local binding name", + "valid": false, + "input": { + "schema_version": 1, + "host_bindings": { + "DATABASE": null + } + } + }, + { + "name": "invalid local binding kind", + "valid": false, + "input": { + "schema_version": 1, + "host_bindings": { + "database": { + "kind": "service", + "name": "web", + "port": 2, + "protocol": "tcp" + } + } + } + }, + { + "name": "host binding command refuses", + "valid": false, + "input": { + "schema_version": 1, + "host_bindings": { + "database": { + "kind": "host", + "port": 2, + "protocol": "tcp", + "command": "false" + } + } + } + }, + { + "name": "host binding credential refuses", + "valid": false, + "input": { + "schema_version": 1, + "host_bindings": { + "database": { + "kind": "external", + "hostname": "api.example.com", + "port": 2, + "protocol": "https", + "credential": "sentinel" + } + } + } } ] diff --git a/packages/config-compiler/tests/fixtures/schema-corpus.json b/packages/config-compiler/tests/fixtures/schema-corpus.json index efc3f520f..839ef83d9 100644 --- a/packages/config-compiler/tests/fixtures/schema-corpus.json +++ b/packages/config-compiler/tests/fixtures/schema-corpus.json @@ -2720,5 +2720,494 @@ "extra": true } } + }, + { + "name": "endpoint service reference", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1", + "environment": { + "TARGET": { + "endpoint": { + "kind": "service", + "name": "web", + "port": 3000, + "protocol": "http" + } + } + } + } + }, + "host_bindings": { + "database": { + "kind": "host", + "port": 5432, + "protocol": "tcp" + } + } + } + }, + { + "name": "endpoint host binding reference", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1", + "environment": { + "TARGET": { + "endpoint": { + "kind": "host_binding", + "name": "database" + } + } + } + } + }, + "host_bindings": { + "database": { + "kind": "host", + "port": 5432, + "protocol": "tcp" + } + } + } + }, + { + "name": "endpoint route reference", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1", + "environment": { + "TARGET": { + "endpoint": { + "kind": "route", + "name": "app" + } + } + } + } + }, + "host_bindings": { + "database": { + "kind": "host", + "port": 5432, + "protocol": "tcp" + } + }, + "routes": { + "http": { + "app": { + "service": "web", + "port": 3000, + "hostname": "app" + } + } + } + } + }, + { + "name": "endpoint invalid binding shape 0", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1" + } + }, + "host_bindings": { + "database": null + } + } + }, + { + "name": "endpoint invalid binding shape 1", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1" + } + }, + "host_bindings": { + "database": [] + } + } + }, + { + "name": "endpoint invalid binding shape 2", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1" + } + }, + "host_bindings": { + "database": ["host", 2, "tcp"] + } + } + }, + { + "name": "endpoint invalid binding shape 3", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1" + } + }, + "host_bindings": { + "database": { + "kind": "host", + "port": 0, + "protocol": "tcp" + } + } + } + }, + { + "name": "endpoint invalid binding shape 4", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1" + } + }, + "host_bindings": { + "database": { + "kind": "host", + "port": 2, + "protocol": "udp" + } + } + } + }, + { + "name": "endpoint invalid binding shape 5", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1" + } + }, + "host_bindings": { + "database": { + "kind": "host", + "port": 2, + "protocol": "tcp", + "command": "false" + } + } + } + }, + { + "name": "endpoint invalid binding shape 6", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1" + } + }, + "host_bindings": { + "database": { + "kind": "external", + "hostname": "api.example.com", + "port": 2, + "protocol": "https", + "secret": "sentinel" + } + } + } + }, + { + "name": "endpoint invalid reference shape 0", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1", + "environment": { + "TARGET": { + "endpoint": null + } + } + } + }, + "host_bindings": { + "database": { + "kind": "host", + "port": 5432, + "protocol": "tcp" + } + } + } + }, + { + "name": "endpoint invalid reference shape 1", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1", + "environment": { + "TARGET": { + "endpoint": [] + } + } + } + }, + "host_bindings": { + "database": { + "kind": "host", + "port": 5432, + "protocol": "tcp" + } + } + } + }, + { + "name": "endpoint invalid reference shape 2", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1", + "environment": { + "TARGET": { + "endpoint": ["route", "app"] + } + } + } + }, + "host_bindings": { + "database": { + "kind": "host", + "port": 5432, + "protocol": "tcp" + } + } + } + }, + { + "name": "endpoint invalid reference shape 3", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1", + "environment": { + "TARGET": { + "endpoint": { + "kind": "service", + "name": "web", + "port": 2 + } + } + } + } + }, + "host_bindings": { + "database": { + "kind": "host", + "port": 5432, + "protocol": "tcp" + } + } + } + }, + { + "name": "endpoint invalid reference shape 4", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1", + "environment": { + "TARGET": { + "endpoint": { + "kind": "service", + "name": "web", + "port": 2, + "protocol": "udp" + } + } + } + } + }, + "host_bindings": { + "database": { + "kind": "host", + "port": 5432, + "protocol": "tcp" + } + } + } + }, + { + "name": "endpoint invalid reference shape 5", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1", + "environment": { + "TARGET": { + "endpoint": { + "kind": "service", + "name": "web", + "port": 0, + "protocol": "tcp" + } + } + } + } + }, + "host_bindings": { + "database": { + "kind": "host", + "port": 5432, + "protocol": "tcp" + } + } + } + }, + { + "name": "endpoint invalid reference shape 6", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1", + "environment": { + "TARGET": { + "endpoint": { + "kind": "host_binding", + "name": "database", + "command": "false" + } + } + } + } + }, + "host_bindings": { + "database": { + "kind": "host", + "port": 5432, + "protocol": "tcp" + } + } + } + }, + { + "name": "endpoint unset mixed refuses", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1", + "environment": { + "TARGET": { + "unset": true, + "endpoint": { + "kind": "host_binding", + "name": "database" + } + } + } + } + }, + "host_bindings": { + "database": { + "kind": "host", + "port": 5432, + "protocol": "tcp" + } + } + } + }, + { + "name": "endpoint invalid logical binding key", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1" + } + }, + "host_bindings": { + "DATABASE": { + "kind": "host", + "port": 2, + "protocol": "tcp" + } + } + } + }, + { + "name": "endpoint local provisioning compile symbolic", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "web:1", + "environment": { + "TARGET": { + "endpoint": { + "kind": "host_binding", + "name": "locally-provided" + } + } + } + } + } + } } ] diff --git a/packages/config-compiler/tests/protocol.rs b/packages/config-compiler/tests/protocol.rs index f7e2b9a0b..c1a5c2c25 100644 --- a/packages/config-compiler/tests/protocol.rs +++ b/packages/config-compiler/tests/protocol.rs @@ -21,7 +21,7 @@ fn handshake_and_compile_need_no_environment_or_host_tools() { let protocol: Value = serde_json::from_slice(&handshake.stdout).unwrap(); assert_eq!( protocol, - serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1,"routing_plan_version":1}) + serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1,"routing_plan_version":1,"endpoint_plan_version":1}) ); let result = run(&["compile"], br#"{"schema_version":1,"name":"example"}"#); assert!(result.status.success()); diff --git a/scripts/check-native-endpoint-plan-cli.ts b/scripts/check-native-endpoint-plan-cli.ts new file mode 100644 index 000000000..de50d3c46 --- /dev/null +++ b/scripts/check-native-endpoint-plan-cli.ts @@ -0,0 +1,485 @@ +#!/usr/bin/env bun +import { + copyFile, + mkdir, + mkdtemp, + readdir, + realpath, + rm, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { isRecord } from "../src/lib/guards.ts"; + +/** Exercise pure endpoint planning through the relocated CLI and real Git linkage. */ +const root = resolve(import.meta.dir, ".."); +const directory = await realpath( + await mkdtemp(join(tmpdir(), "hack-endpoint-cli-")) +); +const bundle = join(directory, "bundle"); +const home = join(directory, "home"); +const primary = join(directory, "primary"); +const checkout = join(directory, "checkout"); +const marker = join(directory, "hook-must-not-run"); +const canary = "private-synthetic-endpoint-value"; +const hostTarget = { kind: "host", port: 9200, protocol: "http" }; +const externalTarget = { + kind: "external", + hostname: "search.example.invalid", + port: 9443, + protocol: "https", +}; +const project = { + schema_version: 1, + name: "endpoint-fixture", + profiles: ["optional"], + host_bindings: { search: hostTarget }, + services: { + web: { + image: "example/web:1", + environment: { + API: { + endpoint: { + kind: "service", + name: "api", + port: 3000, + protocol: "http", + }, + }, + ORIGIN: { endpoint: { kind: "route", name: "web" } }, + SEARCH: { endpoint: { kind: "host_binding", name: "search" } }, + }, + }, + api: { image: "example/api:1" }, + admin: { image: "example/admin:1", profiles: ["optional"] }, + }, + jobs: { + seed: { + image: "example/seed:1", + environment: { + SEARCH: { endpoint: { kind: "host_binding", name: "search" } }, + }, + }, + }, + routes: { + domain: "project.example", + http: { web: { service: "web", port: 3000, hostname: "project" } }, + }, + host: { + up: { + before: [ + { + name: "prepare", + command: { exec: ["/usr/bin/touch", marker] }, + environment: { + SEARCH: { endpoint: { kind: "host_binding", name: "search" } }, + ORIGIN: { endpoint: { kind: "route", name: "web" } }, + }, + }, + ], + }, + }, +}; +let checks = 0; +try { + await Promise.all([ + mkdir(bundle), + mkdir(home), + mkdir(join(primary, ".hack"), { recursive: true }), + ]); + for (const name of ["hack", "hack-config-compiler"]) { + await copyFile(join(root, "dist", name), join(bundle, name)); + } + await authored(primary, project); + await metadata(primary, {}); + const initial = await success(["config", "plan", "--json"], primary); + const initialBindings = record(record(initial.environment_plan).workloads); + const web = record(initialBindings.web); + assert( + record(web.API).kind === "endpoint" && + record(record(web.API).target).name === "api", + "service endpoint stays typed" + ); + assert( + record(record(web.SEARCH).target).context === "workload", + "guest host target stays symbolic in workload context" + ); + assert( + record(record(web.ORIGIN).target).origin === + "https://endpoint-fixture.project.example", + "route endpoint derives centrally" + ); + const host = Object.values(record(record(initial.environment_plan).host)).map( + record + ); + assert( + host.some( + (entry) => + record(record(record(entry.bindings).SEARCH).target).context === "host" + ), + "host invocation retains its own endpoint context" + ); + const offline = await success(["config", "validate", "--json"], primary); + assert( + offline.semantic_hash === initial.semantic_hash && + record(offline.local_resolution).resolution_hash === + record(initial.local_resolution).resolution_hash, + "validate and plan identities agree" + ); + await local(primary, { search: externalTarget, unused: hostTarget }); + await git(["init", "-b", "main"], primary); + await git(["add", ".hack/hack.project.json"], primary); + await git( + [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-m", + "fixture", + ], + primary + ); + await git(["worktree", "add", "-b", "feature/endpoints", checkout], primary); + const inherited = await success(["config", "plan", "--json"], checkout); + const inheritedSearch = binding(inherited, "search"); + assert( + inheritedSearch.origin === "primary_local" && + record(inheritedSearch.target).hostname === externalTarget.hostname, + "verified linked worktree inherits typed primary binding" + ); + assert( + inherited.semantic_hash === initial.semantic_hash && + record(inherited.local_resolution).resolution_hash !== + record(initial.local_resolution).resolution_hash, + "local inheritance changes only resolution identity" + ); + await local(checkout, { + search: { ...externalTarget, port: 9555 }, + unused: null, + }); + const overridden = await success(["config", "plan", "--json"], checkout); + assert( + binding(overridden, "search").origin === "checkout_local" && + record(binding(overridden, "search").target).port === 9555, + "current checkout overrides inherited target" + ); + assert( + record(record(overridden.host_binding_resolution).removed).unused === + "checkout_local", + "local removal retains provenance" + ); + await local(checkout, { search: null }); + await Bun.write( + join(checkout, ".hack/hack.env.default.yaml"), + `broken [${canary}` + ); + const tombstone = await invoke(["config", "plan", "--json"], checkout); + assert( + tombstone.exit === 1 && tombstone.value.ok === false, + "removed referenced binding refuses before poisoned managed documents" + ); + await rm(join(checkout, ".hack/hack.local.json")); + await rm(join(checkout, ".hack/hack.env.default.yaml")); + for (const extra of [{ CI: "1" }, { HACK_EXECUTION_MODE: "codex" }]) { + const restricted = await success( + ["config", "plan", "--json"], + checkout, + extra + ); + assert( + binding(restricted, "search").origin === "project" && + record(binding(restricted, "search").target).kind === "host", + "CI/slim does not inherit primary bindings" + ); + } + await authored(checkout, { ...project, worktree: { inherit_local: false } }); + const optedOut = await success(["config", "plan", "--json"], checkout); + assert( + binding(optedOut, "search").origin === "project", + "explicit opt-out excludes primary binding" + ); + await authored(checkout, project); + await metadata(checkout, { + ORIGIN: { secure: `invalid-ciphertext-${canary}` }, + }); + const collision = await invoke(["config", "plan", "--json"], checkout); + assert( + collision.exit === 1 && + collision.value.ok === true && + record(collision.value.environment_plan).complete === false, + "managed destination collision makes plan incomplete" + ); + const collidingWorkloads = record( + record(collision.value.environment_plan).workloads + ); + assert( + record(record(collidingWorkloads.web).ORIGIN).kind === "managed", + "endpoint cannot replace managed destination" + ); + await metadata(checkout, {}); + const unsupported = { + ...project, + host: { + processes: { + tunnel: { + command: { exec: ["must-not-run"] }, + environment: { + API: { + endpoint: { + kind: "service", + name: "api", + port: 3000, + protocol: "http", + }, + }, + }, + }, + }, + }, + }; + await authored(checkout, unsupported); + assert( + (await success(["config", "validate", "--json"], checkout)).ok === true, + "context-free service reference remains symbolic" + ); + const hostService = await invoke(["config", "plan", "--json"], checkout); + assert( + hostService.exit === 1 && + hostService.value.ok === true && + record(hostService.value.environment_plan).complete === false, + "host service DNS cannot be guessed during planning" + ); + for (const [label, directive] of [ + [ + "unknown service", + { + endpoint: { + kind: "service", + name: "missing", + port: 3000, + protocol: "http", + }, + }, + ], + [ + "job used as service", + { + endpoint: { + kind: "service", + name: "seed", + port: 3000, + protocol: "http", + }, + }, + ], + ["unknown route", { endpoint: { kind: "route", name: "missing" } }], + [ + "inactive target", + { + endpoint: { + kind: "service", + name: "admin", + port: 3000, + protocol: "http", + }, + }, + ], + ["mixed tags", { endpoint: { kind: "route", name: "web" }, unset: true }], + [ + "unsupported protocol", + { + endpoint: { kind: "service", name: "api", port: 3000, protocol: "udp" }, + }, + ], + ] as const) { + await authored(checkout, { + ...project, + services: { + ...project.services, + web: { ...project.services.web, environment: { TEST: directive } }, + }, + }); + const invalid = await invoke(["config", "validate", "--json"], checkout); + assert( + invalid.exit === 1 && invalid.value.ok === false, + `${label} refuses` + ); + } + await authored(checkout, { + ...project, + services: { + ...project.services, + web: { + ...project.services.web, + environment: { + ADMIN: { + endpoint: { + kind: "service", + name: "admin", + port: 3000, + protocol: "http", + }, + }, + }, + }, + }, + }); + await success( + ["config", "plan", "--profile", "optional", "--json"], + checkout + ); + await authored(checkout, project); + for (const bindings of [ + { search: { command: { exec: [canary] } } }, + { search: { ...externalTarget, hostname: `${canary}@example.invalid` } }, + { "bad name": hostTarget }, + ]) { + await local(checkout, bindings); + const refused = await invoke(["config", "validate", "--json"], checkout); + assert( + refused.exit === 1 && refused.value.ok === false, + "local command/credential/name injection refuses" + ); + } + await rm(join(checkout, ".hack/hack.local.json")); + const localOnly = { ...project }; + Reflect.deleteProperty(localOnly, "host_bindings"); + await authored(checkout, localOnly); + await success( + [ + "config", + "validate", + "--file", + join(checkout, ".hack/hack.project.json"), + "--json", + ], + checkout + ); + // The inherited primary supplies a missing authored logical binding at actual resolution. + await success(["config", "plan", "--json"], checkout); + const missing = await invoke(["config", "validate", "--json"], checkout, { + CI: "1", + }); + assert( + missing.exit === 1 && missing.value.ok === false, + "actual resolution rejects unbound logical reference" + ); + await authored(checkout, project); + const runtime = await invoke(["up"], checkout, {}, false); + assert( + runtime.exit !== 0 && + (runtime.stdout + runtime.stderr).includes( + "E_NATIVE_PROJECT_UNSUPPORTED" + ), + "native-authored execution remains fenced" + ); + assert( + !(await Bun.file(marker).exists()), + "planning runs no preparation hook" + ); + for (const dir of [primary, checkout]) { + assert( + !(await readdir(join(dir, ".hack"))).some( + (name) => name === ".internal" || name === ".branch" + ), + "no generated runtime state" + ); + } + const homeEntries = await readdir(join(home, ".hack")).catch(() => []); + assert(homeEntries.length === 0, "no registry or runtime home writes"); + process.stdout.write( + `Relocated native endpoint planning: ${checks} checks passed; no hooks/DNS/trust/runtime effects\n` + ); +} finally { + await rm(directory, { recursive: true, force: true }); +} + +async function authored(cwd: string, value: unknown) { + await Bun.write(join(cwd, ".hack/hack.project.json"), JSON.stringify(value)); +} +async function local(cwd: string, hostBindings: unknown) { + await Bun.write( + join(cwd, ".hack/hack.local.json"), + JSON.stringify({ schema_version: 1, host_bindings: hostBindings }) + ); +} +async function metadata(cwd: string, values: unknown) { + await Bun.write( + join(cwd, ".hack/hack.env.default.yaml"), + JSON.stringify({ + version: 1, + environment: "default", + secretsprovider: "project_key", + values: { global: values }, + }) + ); +} +function record(value: unknown): Record { + assert(isRecord(value), "expected report object"); + return value; +} +function binding(value: Record, name: string) { + return record(record(record(value.host_binding_resolution).bindings)[name]); +} +async function success( + args: readonly string[], + cwd: string, + extra: Readonly> = {} +) { + const result = await invoke(args, cwd, extra); + assert( + result.exit === 0 && result.value.ok === true, + "successful endpoint command" + ); + return result.value; +} +async function invoke( + args: readonly string[], + cwd: string, + extra: Readonly> = {}, + json = true +) { + const child = Bun.spawn([join(bundle, "hack"), ...args], { + cwd, + env: { + PATH: "/usr/bin:/bin", + HOME: home, + HACK_LOGGER: "console", + ...extra, + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exit] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + assert(!(stdout + stderr).includes(canary), "synthetic secret is redacted"); + const value: unknown = json ? JSON.parse(stdout) : {}; + return { exit, stdout, stderr, value: record(value) }; +} +async function git(args: readonly string[], cwd: string) { + const child = Bun.spawn(["/usr/bin/git", ...args], { + cwd, + env: { PATH: "/usr/bin:/bin", HOME: home }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exit] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + assert(exit === 0, `isolated Git fixture ${args[0]}: ${stdout}${stderr}`); +} +function assert(condition: unknown, message: string): asserts condition { + checks += 1; + if (!condition) { + throw new Error(message); + } +} diff --git a/src/lib/native-config-compiler.ts b/src/lib/native-config-compiler.ts index e82eae711..ffb435f82 100644 --- a/src/lib/native-config-compiler.ts +++ b/src/lib/native-config-compiler.ts @@ -1,6 +1,14 @@ import { stat } from "node:fs/promises"; import { dirname, isAbsolute, join } from "node:path"; import { isRecord } from "./guards.ts"; +import { + type NativeHostBindingResolution, + nativeEndpointEnvironmentMatches, + nativeEndpointPlanIsValid, + nativeEndpointPlanningRequired, + nativeHostBindingResolutionMatches, + parseNativeHostBindingResolution, +} from "./native-endpoint-plan-protocol.ts"; import { type NativeDeclaredWorkloads, type NativeEnvironmentPlan, @@ -57,6 +65,7 @@ export type NativeConfigResolveResult = readonly local_resolution: NativeLocalResolution; readonly routing_resolution?: NativeRoutingResolution; readonly routing_inputs_required?: true; + readonly host_binding_resolution?: NativeHostBindingResolution; }) | Extract; @@ -155,6 +164,7 @@ export async function compileNativeConfig(opts: { readonly requireEnvPlanning?: boolean; readonly requireHostPlanning?: boolean; readonly requireRoutingPlanning?: boolean; + readonly requireEndpointPlanning?: boolean; }): Promise { if (opts.input.byteLength > NATIVE_CONFIG_INPUT_LIMIT) { throw failure( @@ -169,6 +179,7 @@ export async function compileNativeConfig(opts: { requireEnvPlanning: opts.requireEnvPlanning, requireHostPlanning: opts.requireHostPlanning, requireRoutingPlanning: opts.requireRoutingPlanning, + requireEndpointPlanning: opts.requireEndpointPlanning, }); const response = await invokeCompiler({ ...request, @@ -179,6 +190,13 @@ export async function compileNativeConfig(opts: { if (result.ok && hasRouting(result.plan) && !opts.requireRoutingPlanning) { await checkProtocol({ ...request, requireRoutingPlanning: true }); } + if ( + result.ok && + nativeEndpointPlanningRequired(result.plan) && + !opts.requireEndpointPlanning + ) { + await checkProtocol({ ...request, requireEndpointPlanning: true }); + } if (opts.requireEnvPlanning && result.ok && !result.declared_workloads) { throw failure( "E_COMPILER_RESPONSE", @@ -204,6 +222,7 @@ export async function resolveNativeConfig(opts: { readonly requireEnvPlanning?: boolean; readonly requireHostPlanning?: boolean; readonly requireRoutingPlanning?: boolean; + readonly requireEndpointPlanning?: boolean; readonly probeRoutingInputs?: boolean; }): Promise { const plainInput = encodeResolveRequest(opts); @@ -215,6 +234,7 @@ export async function resolveNativeConfig(opts: { requireHostPlanning: opts.requireHostPlanning, requireRoutingPlanning: opts.requireRoutingPlanning || hasRoutingInputs(opts), + requireEndpointPlanning: opts.requireEndpointPlanning, }); const routingProbe = opts.probeRoutingInputs === true && capabilities.routingPlanning; @@ -233,6 +253,8 @@ export async function resolveNativeConfig(opts: { explicitDomain: opts.explicitDomain, globalDomain: opts.globalDomain, routingProbe, + primaryLocal: opts.primaryLocal, + checkoutLocal: opts.checkoutLocal, }); if (!parsed.ok) { return parsed; @@ -240,6 +262,12 @@ export async function resolveNativeConfig(opts: { if (parsed.routing_resolution || parsed.routing_inputs_required) { await checkProtocol({ ...request, requireRoutingPlanning: true }); } + if ( + nativeEndpointPlanningRequired(parsed.plan) || + parsed.host_binding_resolution + ) { + await checkProtocol({ ...request, requireEndpointPlanning: true }); + } if (opts.requireEnvPlanning && !parsed.declared_workloads) { throw failure( "E_COMPILER_RESPONSE", @@ -326,6 +354,7 @@ export async function planNativeConfig( readonly timeoutMs?: number; readonly signal?: AbortSignal; readonly requireRoutingPlanning?: boolean; + readonly requireEndpointPlanning?: boolean; } ): Promise { const metadata = parseNativeEnvMetadata(opts.envMetadata); @@ -348,6 +377,7 @@ export async function planNativeConfig( requireHostPlanning: metadata.host !== undefined, requireRoutingPlanning: opts.requireRoutingPlanning || hasRoutingInputs(opts), + requireEndpointPlanning: opts.requireEndpointPlanning, }); const response = await invokeCompiler({ ...request, @@ -361,6 +391,8 @@ export async function planNativeConfig( branch: opts.branch, explicitDomain: opts.explicitDomain, globalDomain: opts.globalDomain, + primaryLocal: opts.primaryLocal, + checkoutLocal: opts.checkoutLocal, }); if (!parsed.ok) { return parsed; @@ -368,6 +400,12 @@ export async function planNativeConfig( if (parsed.routing_resolution) { await checkProtocol({ ...request, requireRoutingPlanning: true }); } + if ( + nativeEndpointPlanningRequired(parsed.plan) || + parsed.host_binding_resolution + ) { + await checkProtocol({ ...request, requireEndpointPlanning: true }); + } const environmentPlan = parseNativeEnvironmentPlan({ value: parsed.envelope.environment_plan, parseDiagnostic, @@ -389,6 +427,14 @@ export async function planNativeConfig( report: environmentPlan.host, requireReport: true, }) || + !nativeEndpointEnvironmentMatches({ + plan: parsed.plan, + declared: parsed.declared_workloads, + environmentPlan, + routing: parsed.routing_resolution, + resolution: parsed.host_binding_resolution, + metadata, + }) || response.exitCode !== (environmentPlan.complete ? 0 : 1) ) { throw failure( @@ -443,6 +489,8 @@ function parseResolveResponse(opts: { readonly explicitDomain?: string; readonly globalDomain?: string; readonly routingProbe?: boolean; + readonly primaryLocal?: Uint8Array; + readonly checkoutLocal?: Uint8Array; }): | (Extract & { readonly envelope: Readonly>; @@ -476,6 +524,14 @@ function parseResolveResponse(opts: { ? undefined : parseNativeRoutingResolution(envelope.routing_resolution); const routingInputsRequired = envelope.routing_inputs_required; + const local = parseLocalResolution(envelope.local_resolution); + const hostBindings = parseEndpointResolution({ + envelope, + result, + local, + primaryLocal: opts.primaryLocal, + checkoutLocal: opts.checkoutLocal, + }); if ( (routingInputsRequired !== undefined && !(opts.routingProbe && routingInputsRequired === true)) || @@ -513,7 +569,10 @@ function parseResolveResponse(opts: { } return { ...result, - local_resolution: parseLocalResolution(envelope.local_resolution), + local_resolution: local, + ...(hostBindings === undefined + ? {} + : { host_binding_resolution: hostBindings }), ...(routing === undefined ? {} : { routing_resolution: routing }), ...(routingInputsRequired === true ? { routing_inputs_required: true as const } @@ -522,6 +581,40 @@ function parseResolveResponse(opts: { }; } +function parseEndpointResolution(opts: { + readonly envelope: Record; + readonly result: Extract; + readonly local: NativeLocalResolution; + readonly primaryLocal?: Uint8Array; + readonly checkoutLocal?: Uint8Array; +}): NativeHostBindingResolution | undefined { + const resolution = Object.hasOwn(opts.envelope, "host_binding_resolution") + ? parseNativeHostBindingResolution(opts.envelope.host_binding_resolution) + : undefined; + if ( + resolution === null || + !nativeHostBindingResolutionMatches({ + plan: opts.result.plan, + resolution, + primaryLocal: opts.primaryLocal, + checkoutLocal: opts.checkoutLocal, + inheritLocal: opts.local.inherit_local, + }) || + !nativeEndpointPlanIsValid({ + plan: opts.result.plan, + declared: opts.result.declared_workloads, + resolution, + resolved: true, + }) + ) { + throw failure( + "E_COMPILER_RESPONSE", + "Native endpoint resolution returned an invalid result." + ); + } + return resolution; +} + function documentText( input: Uint8Array, role: NativeConfigDocumentRole @@ -571,6 +664,7 @@ async function checkProtocol(opts: { readonly requireEnvPlanning?: boolean; readonly requireHostPlanning?: boolean; readonly requireRoutingPlanning?: boolean; + readonly requireEndpointPlanning?: boolean; }): Promise<{ readonly routingPlanning: boolean }> { const handshake = await invokeCompiler({ ...opts, args: ["--protocol"] }); const protocol = parseControlJson(handshake.output); @@ -583,6 +677,7 @@ async function checkProtocol(opts: { (opts.requireEnvPlanning && protocol.env_plan_version !== 1) || (opts.requireHostPlanning && protocol.host_env_plan_version !== 1) || (opts.requireRoutingPlanning && protocol.routing_plan_version !== 1) || + (opts.requireEndpointPlanning && protocol.endpoint_plan_version !== 1) || (opts.requireLocalResolution && (protocol.resolve_version !== 1 || protocol.local_version !== 1)) ) { @@ -819,7 +914,8 @@ function parseCompileResponse(opts: { if ( isRecord(value) && (Object.hasOwn(value, "routing_resolution") || - Object.hasOwn(value, "routing_inputs_required")) + Object.hasOwn(value, "routing_inputs_required") || + Object.hasOwn(value, "host_binding_resolution")) ) { throw failure( "E_COMPILER_RESPONSE", @@ -874,6 +970,12 @@ function parseCompileValue(opts: { "Native compiler returned invalid routing declarations." ); } + if (!nativeEndpointPlanIsValid({ plan: value.plan, declared })) { + throw failure( + "E_COMPILER_RESPONSE", + "Native compiler returned invalid endpoint declarations." + ); + } return { transport_version: 1, ok: true, diff --git a/src/lib/native-endpoint-plan-protocol.ts b/src/lib/native-endpoint-plan-protocol.ts new file mode 100644 index 000000000..44713c50b --- /dev/null +++ b/src/lib/native-endpoint-plan-protocol.ts @@ -0,0 +1,784 @@ +import { isIP } from "node:net"; +import type { + EndpointProtocol, + EndpointReference, + EndpointTarget, + EnvironmentBinding, + HostBindingOrigin, + HostBindingTarget, +} from "../../packages/config-compiler/generated/native-config.ts"; +import { isRecord } from "./guards.ts"; +import type { + NativeDeclaredWorkloads, + NativeEnvBinding, + NativeEnvironmentPlan, + NativeEnvMetadata, + NativeManagedEntry, +} from "./native-env-plan-protocol.ts"; +import type { NativeRoutingResolution } from "./native-routing-plan-protocol.ts"; + +const NAME = /^[a-z0-9][a-z0-9._-]{0,62}$/; +const BINDING_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const NUMERIC_LABEL = /^(?:\d+|0x[0-9a-f]*)$/; +const DESTINATION = /^[A-Za-z_][A-Za-z0-9_]*$/; + +type BindingOrigin = HostBindingOrigin; +export type NativeEndpointReference = Readonly; +export type NativeHostBindingTarget = Readonly; +export type NativeEndpointTarget = Readonly; +export type NativeEndpointBinding = Readonly< + Omit< + Extract, + "reference" | "target" + > +> & { + readonly reference: NativeEndpointReference; + readonly target: NativeEndpointTarget; +}; +export type NativeHostBindingResolution = { + readonly bindings: Readonly< + Record< + string, + { + readonly target: NativeHostBindingTarget; + readonly origin: BindingOrigin; + } + > + >; + readonly removed: Readonly>; +}; + +function only( + value: Record, + allowed: readonly string[] +): boolean { + return Object.keys(value).every((key) => allowed.includes(key)); +} + +function protocol(value: unknown): value is EndpointProtocol { + return value === "http" || value === "https" || value === "tcp"; +} + +function port(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isInteger(value) && + value >= 1 && + value <= 65_535 + ); +} + +function hostname(value: unknown): value is string { + if (typeof value !== "string" || value.length > 253) { + return false; + } + if (value.startsWith("[")) { + try { + return ( + isIP(value.slice(1, -1)) === 6 && + new URL(`http://${value}`).hostname === value + ); + } catch { + return false; + } + } + if (isIP(value) === 4) { + return true; + } + return ( + value.split(".").every((label) => DNS_LABEL.test(label)) && + !NUMERIC_LABEL.test(value.split(".").at(-1) ?? "") + ); +} + +function origin(value: unknown): value is string { + if (typeof value !== "string" || value.length > 2048) { + return false; + } + try { + const parsed = new URL(value); + return ( + (parsed.protocol === "http:" || parsed.protocol === "https:") && + parsed.origin === value && + parsed.username === "" && + parsed.password === "" && + parsed.pathname === "/" && + parsed.search === "" && + parsed.hash === "" + ); + } catch { + return false; + } +} + +/** Wire validation only. Rust owns authored normalization, selection and local policy. */ +export function parseNativeEndpointReference( + value: unknown +): NativeEndpointReference | null { + if ( + !( + isRecord(value) && + typeof value.name === "string" && + NAME.test(value.name) + ) + ) { + return null; + } + if ( + (value.kind === "route" || value.kind === "host_binding") && + only(value, ["kind", "name"]) + ) { + if (value.kind === "host_binding" && !BINDING_NAME.test(value.name)) { + return null; + } + return { kind: value.kind, name: value.name }; + } + if ( + value.kind === "service" && + only(value, ["kind", "name", "port", "protocol"]) && + port(value.port) && + protocol(value.protocol) + ) { + return { + kind: "service", + name: value.name, + port: value.port, + protocol: value.protocol, + }; + } + return null; +} + +export function parseNativeHostBindingTarget( + value: unknown +): NativeHostBindingTarget | null { + if (!(isRecord(value) && port(value.port) && protocol(value.protocol))) { + return null; + } + if (value.kind === "host" && only(value, ["kind", "port", "protocol"])) { + return { kind: "host", port: value.port, protocol: value.protocol }; + } + if ( + value.kind === "external" && + only(value, ["kind", "hostname", "port", "protocol"]) && + hostname(value.hostname) + ) { + return { + kind: "external", + hostname: value.hostname, + port: value.port, + protocol: value.protocol, + }; + } + return null; +} + +function parseTarget(value: unknown): NativeEndpointTarget | null { + if (!isRecord(value)) { + return null; + } + if ( + value.kind === "route" && + only(value, ["kind", "origin"]) && + origin(value.origin) + ) { + return { kind: "route", origin: value.origin }; + } + if (value.kind === "service") { + const reference = parseNativeEndpointReference(value); + return reference?.kind === "service" ? reference : null; + } + if ( + value.kind === "host" && + only(value, ["kind", "context", "port", "protocol"]) && + (value.context === "host" || value.context === "workload") && + port(value.port) && + protocol(value.protocol) + ) { + return { + kind: "host", + context: value.context, + port: value.port, + protocol: value.protocol, + }; + } + const binding = parseNativeHostBindingTarget(value); + return binding?.kind === "external" ? binding : null; +} + +/** Endpoint bindings remain symbolic; this transport never invents a runtime address. */ +export function parseNativeEndpointBinding( + value: unknown +): NativeEndpointBinding | null { + if ( + !( + isRecord(value) && + value.kind === "endpoint" && + only(value, ["kind", "reference", "target"]) + ) + ) { + return null; + } + const reference = parseNativeEndpointReference(value.reference); + const target = parseTarget(value.target); + return reference && target ? { kind: "endpoint", reference, target } : null; +} + +function bindingOrigin(value: unknown): value is BindingOrigin { + return ( + value === "project" || + value === "primary_local" || + value === "checkout_local" + ); +} + +export function parseNativeHostBindingResolution( + value: unknown +): NativeHostBindingResolution | null { + if ( + !( + isRecord(value) && + only(value, ["bindings", "removed"]) && + isRecord(value.bindings) && + isRecord(value.removed) + ) + ) { + return null; + } + const bindings: [string, NativeHostBindingResolution["bindings"][string]][] = + []; + for (const [name, entry] of Object.entries(value.bindings)) { + if ( + !( + NAME.test(name) && + BINDING_NAME.test(name) && + isRecord(entry) && + only(entry, ["target", "origin"]) && + bindingOrigin(entry.origin) + ) + ) { + return null; + } + const target = parseNativeHostBindingTarget(entry.target); + if (!target) { + return null; + } + bindings.push([name, { target, origin: entry.origin }]); + } + const removed: [string, BindingOrigin][] = []; + for (const [name, source] of Object.entries(value.removed)) { + if ( + !(NAME.test(name) && BINDING_NAME.test(name) && bindingOrigin(source)) || + source === "project" || + Object.hasOwn(value.bindings, name) + ) { + return null; + } + removed.push([name, source]); + } + return { + bindings: Object.fromEntries(bindings), + removed: Object.fromEntries(removed), + }; +} + +type EnvironmentEntry = { + readonly name: string; + readonly host: boolean; + readonly environment: Record; + readonly pointer: string; +}; + +function environmentEntries( + plan: Readonly> +): EnvironmentEntry[] | null { + const output: EnvironmentEntry[] = []; + for (const field of ["services", "jobs"] as const) { + const workloads = plan[field]; + if (!isRecord(workloads)) { + return null; + } + for (const [name, entry] of Object.entries(workloads)) { + if (!isRecord(entry)) { + return null; + } + if (entry.environment !== undefined && !isRecord(entry.environment)) { + return null; + } + output.push({ + name, + host: false, + environment: isRecord(entry.environment) ? entry.environment : {}, + pointer: `/${field}/${escapePointer(name)}/environment`, + }); + } + } + if (!isRecord(plan.host)) { + return output; + } + const hooks = hookEnvironments(plan.host); + const processes = processEnvironments(plan.host.processes); + if (!(hooks && processes)) { + return null; + } + output.push(...hooks, ...processes); + return output; +} + +function hookEnvironments( + host: Record +): EnvironmentEntry[] | null { + const output: EnvironmentEntry[] = []; + for (const action of ["up", "down"]) { + const hooks = host[action]; + if (!isRecord(hooks)) { + continue; + } + for (const phase of ["before", "after"]) { + const entries = hooks[phase]; + if (!Array.isArray(entries)) { + continue; + } + for (const [index, entry] of entries.entries()) { + if ( + !( + isRecord(entry) && + typeof entry.name === "string" && + isRecord(entry.environment) + ) + ) { + return null; + } + output.push({ + name: entry.name, + host: true, + environment: entry.environment, + pointer: `/host/${action}/${phase}/${index}/environment`, + }); + } + } + } + return output; +} + +function processEnvironments(processes: unknown): EnvironmentEntry[] | null { + if (processes === undefined) { + return []; + } + if (!isRecord(processes)) { + return null; + } + const output: EnvironmentEntry[] = []; + for (const [name, entry] of Object.entries(processes)) { + if (!(isRecord(entry) && isRecord(entry.environment))) { + return null; + } + output.push({ + name, + host: true, + environment: entry.environment, + pointer: `/host/processes/${escapePointer(name)}/environment`, + }); + } + return output; +} + +function escapePointer(value: string): string { + return value.replaceAll("~", "~0").replaceAll("/", "~1"); +} + +/** Detect additive capability intent in a normalized plan, without reading local documents. */ +export function nativeEndpointPlanningRequired( + plan: Readonly> +): boolean { + if (Object.hasOwn(plan, "host_bindings")) { + return true; + } + return (environmentEntries(plan) ?? []).some((entry) => + Object.values(entry.environment).some( + (value) => isRecord(value) && Object.hasOwn(value, "endpoint") + ) + ); +} + +function validReference(opts: { + readonly reference: NativeEndpointReference; + readonly plan: Readonly>; + readonly declared?: NativeDeclaredWorkloads; + readonly resolution?: NativeHostBindingResolution; + readonly resolved?: boolean; +}): boolean { + const reference = opts.reference; + if (reference.kind === "host_binding") { + return ( + !opts.resolved || + (opts.resolution !== undefined && + Object.hasOwn(opts.resolution.bindings, reference.name)) + ); + } + let service = reference.name; + if (reference.kind === "route") { + const routes = opts.plan.routes; + const route = + isRecord(routes) && + isRecord(routes.http) && + Object.hasOwn(routes.http, reference.name) + ? routes.http[reference.name] + : undefined; + if (!(isRecord(route) && typeof route.service === "string")) { + return false; + } + service = route.service; + } + return ( + opts.declared?.[service] === "service" && + isRecord(opts.plan.services) && + Object.hasOwn(opts.plan.services, service) + ); +} + +/** Check normalized references and declarations, not the source-language policy. */ +export function nativeEndpointPlanIsValid(opts: { + readonly plan: Readonly>; + readonly declared?: NativeDeclaredWorkloads; + readonly resolution?: NativeHostBindingResolution; + readonly resolved?: boolean; +}): boolean { + const authoredBindings = opts.plan.host_bindings; + if (authoredBindings !== undefined && !isRecord(authoredBindings)) { + return false; + } + if ( + isRecord(authoredBindings) && + Object.entries(authoredBindings).some( + ([name, value]) => + !( + NAME.test(name) && + BINDING_NAME.test(name) && + parseNativeHostBindingTarget(value) + ) + ) + ) { + return false; + } + const entries = environmentEntries(opts.plan); + if (!entries) { + return !nativeEndpointPlanningRequired(opts.plan); + } + for (const entry of entries) { + for (const [name, value] of Object.entries(entry.environment)) { + if (!(isRecord(value) && Object.hasOwn(value, "endpoint"))) { + continue; + } + const reference = parseNativeEndpointReference(value.endpoint); + if ( + !( + DESTINATION.test(name) && + only(value, ["endpoint"]) && + reference && + validReference({ ...opts, reference }) + ) + ) { + return false; + } + } + } + return true; +} + +/** Reconcile report provenance and declared project entries without implementing merge precedence. */ +export function nativeHostBindingResolutionMatches(opts: { + readonly plan: Readonly>; + readonly resolution?: NativeHostBindingResolution; + readonly primaryLocal?: Uint8Array; + readonly checkoutLocal?: Uint8Array; + readonly inheritLocal: boolean; +}): boolean { + const authored = opts.plan.host_bindings; + const primary = bindingSource(opts.primaryLocal); + const checkout = bindingSource(opts.checkoutLocal); + if (primary === null || checkout === null) { + return false; + } + const sources = { + project: isRecord(authored) ? authored : undefined, + primary_local: opts.inheritLocal ? primary : undefined, + checkout_local: checkout, + }; + const active = Object.values(sources).some((source) => source !== undefined); + const resolution = opts.resolution; + if (resolution === undefined) { + return !active; + } + if (!active) { + return false; + } + for (const [name, entry] of Object.entries(resolution.bindings)) { + const source = sources[entry.origin]; + if ( + !( + source && + Object.hasOwn(source, name) && + sameTarget(source[name], entry.target) + ) + ) { + return false; + } + } + for (const [name, role] of Object.entries(resolution.removed)) { + const source = sources[role]; + if (!(source && Object.hasOwn(source, name) && source[name] === null)) { + return false; + } + } + return Object.values(sources).every( + (source) => + source === undefined || + Object.keys(source).every( + (name) => + Object.hasOwn(resolution.bindings, name) || + Object.hasOwn(resolution.removed, name) + ) + ); +} + +/** Cross-check only the claimed source member. Rust remains the document and merge authority. */ +function bindingSource( + input: Uint8Array | undefined +): Record | null | undefined { + if (input === undefined) { + return undefined; + } + try { + const document: unknown = JSON.parse( + new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(input) + ); + if (!isRecord(document)) { + return undefined; + } + if (!Object.hasOwn(document, "host_bindings")) { + return undefined; + } + return isRecord(document.host_bindings) ? document.host_bindings : null; + } catch { + // The Rust owner validates the whole document; an unparseable projection cannot support a claim. + return undefined; + } +} + +function sameTarget( + left: unknown, + right: NativeHostBindingTarget | NativeEndpointReference +): boolean { + return ( + isRecord(left) && + Object.keys(left).length === Object.keys(right).length && + Object.entries(right).every(([key, value]) => left[key] === value) + ); +} + +/** Bind emitted targets to the normalized reference and its existing resolver report. */ +type EndpointEnvironmentInputs = { + readonly plan: Readonly>; + readonly declared: NativeDeclaredWorkloads; + readonly environmentPlan: NativeEnvironmentPlan; + readonly routing?: NativeRoutingResolution; + readonly resolution?: NativeHostBindingResolution; + readonly metadata: NativeEnvMetadata; +}; + +export function nativeEndpointEnvironmentMatches( + opts: EndpointEnvironmentInputs +): boolean { + const entries = environmentEntries(opts.plan); + const reportedEndpoints = [ + ...Object.values(opts.environmentPlan.workloads), + ...Object.values(opts.environmentPlan.host ?? {}).map( + (entry) => entry.bindings + ), + ].some((bindings) => + Object.values(bindings).some((binding) => binding.kind === "endpoint") + ); + const authoredEndpoints = (entries ?? []).some((entry) => + Object.values(entry.environment).some( + (value) => isRecord(value) && Object.hasOwn(value, "endpoint") + ) + ); + if (!(authoredEndpoints || reportedEndpoints)) { + return true; + } + if (!entries) { + return false; + } + for (const entry of entries) { + const reports = entry.host + ? opts.environmentPlan.host?.[entry.name]?.bindings + : opts.environmentPlan.workloads[entry.name]; + if (!reports) { + return false; + } + if ( + !( + reportedEndpointsMatch({ ...opts, entry, reports }) && + endpointOmissionsMatch({ ...opts, entry, reports }) + ) + ) { + return false; + } + } + return true; +} + +function reportedEndpointsMatch( + opts: EndpointEnvironmentInputs & { + readonly entry: EnvironmentEntry; + readonly reports: Readonly>; + } +): boolean { + for (const [name, binding] of Object.entries(opts.reports)) { + if (binding.kind !== "endpoint") { + continue; + } + const authored = opts.entry.environment[name]; + if ( + !( + isRecord(authored) && + only(authored, ["endpoint"]) && + sameTarget(authored.endpoint, binding.reference) && + validReference({ + ...opts, + reference: binding.reference, + resolved: true, + }) && + endpointTargetMatches({ ...opts, binding, host: opts.entry.host }) + ) || + baselineMetadata({ ...opts, name }) !== undefined + ) { + return false; + } + } + return true; +} + +function endpointOmissionsMatch( + opts: EndpointEnvironmentInputs & { + readonly entry: EnvironmentEntry; + readonly reports: Readonly>; + } +): boolean { + for (const [name, value] of Object.entries(opts.entry.environment)) { + if ( + !(isRecord(value) && Object.hasOwn(value, "endpoint")) || + opts.reports[name]?.kind === "endpoint" + ) { + continue; + } + const reference = parseNativeEndpointReference(value.endpoint); + if (!reference) { + return false; + } + const pointer = `${opts.entry.pointer}/${escapePointer(name)}`; + const diagnostic = opts.environmentPlan.diagnostics.find( + (item) => item.document === "project" && item.pointer === pointer + ); + if ( + diagnostic?.code === "unsupported_endpoint_context" && + opts.entry.host && + reference.kind === "service" && + !Object.hasOwn(opts.reports, name) && + baselineMetadata({ ...opts, name }) === undefined + ) { + continue; + } + if ( + diagnostic?.code !== "env_endpoint_collision" || + !collisionPreservesBaseline({ ...opts, name }) + ) { + return false; + } + } + return true; +} + +function collisionPreservesBaseline( + opts: EndpointEnvironmentInputs & { + readonly entry: EnvironmentEntry; + readonly reports: Readonly>; + readonly name: string; + } +): boolean { + const baseline = baselineMetadata(opts); + const binding = opts.reports[opts.name]; + return ( + baseline !== undefined && + binding?.kind === "managed" && + binding.key === opts.name && + binding.scope === baseline.scope && + binding.secret === baseline.secret + ); +} + +function baselineMetadata( + opts: EndpointEnvironmentInputs & { + readonly entry: EnvironmentEntry; + readonly name: string; + } +): NativeManagedEntry | undefined { + if (!opts.entry.host) { + return Object.hasOwn(opts.metadata.workloads, opts.entry.name) && + Object.hasOwn(opts.metadata.workloads[opts.entry.name] ?? {}, opts.name) + ? opts.metadata.workloads[opts.entry.name]?.[opts.name] + : undefined; + } + const target = opts.environmentPlan.host?.[opts.entry.name]?.env_target; + let entries: Readonly> | undefined; + if (target?.kind === "host") { + entries = opts.metadata.host?.default; + } else if (target?.kind === "workload") { + entries = opts.metadata.host?.workloads[target.name]; + } + return entries && Object.hasOwn(entries, opts.name) + ? entries[opts.name] + : undefined; +} + +function endpointTargetMatches(opts: { + readonly binding: NativeEndpointBinding; + readonly host: boolean; + readonly routing?: NativeRoutingResolution; + readonly resolution?: NativeHostBindingResolution; +}): boolean { + const { reference, target } = opts.binding; + if (reference.kind === "route") { + return ( + target.kind === "route" && + opts.routing !== undefined && + Object.hasOwn(opts.routing.routes, reference.name) && + opts.routing?.routes[reference.name]?.origin === target.origin + ); + } + if (reference.kind === "service") { + return ( + !opts.host && target.kind === "service" && sameTarget(reference, target) + ); + } + const resolved = + opts.resolution !== undefined && + Object.hasOwn(opts.resolution.bindings, reference.name) + ? opts.resolution.bindings[reference.name]?.target + : undefined; + if (resolved?.kind === "host") { + return ( + target.kind === "host" && + target.context === (opts.host ? "host" : "workload") && + target.port === resolved.port && + target.protocol === resolved.protocol + ); + } + return ( + resolved?.kind === "external" && + target.kind === "external" && + sameTarget(resolved, target) + ); +} diff --git a/src/lib/native-env-plan-protocol.ts b/src/lib/native-env-plan-protocol.ts index 94557bf66..6bcda60bb 100644 --- a/src/lib/native-env-plan-protocol.ts +++ b/src/lib/native-env-plan-protocol.ts @@ -7,6 +7,7 @@ import type { } from "../../packages/config-compiler/generated/native-config.ts"; import { isRecord } from "./guards.ts"; import type { NativeConfigDiagnostic } from "./native-config-compiler.ts"; +import { parseNativeEndpointBinding } from "./native-endpoint-plan-protocol.ts"; import { type NativeHostEnvironmentPlan, type NativeHostMetadata, @@ -155,6 +156,9 @@ function parseBinding(value: unknown): NativeEnvBinding | null { if (!isRecord(value)) { return null; } + if (value.kind === "endpoint") { + return parseNativeEndpointBinding(value); + } if ( value.kind === "managed" && hasOnly(value, ["kind", "key", "scope", "secret"]) && diff --git a/src/lib/native-project-validation.ts b/src/lib/native-project-validation.ts index 348073f79..2445dedaf 100644 --- a/src/lib/native-project-validation.ts +++ b/src/lib/native-project-validation.ts @@ -7,6 +7,7 @@ import { planNativeConfig, resolveNativeConfig, } from "./native-config-compiler.ts"; +import { nativeEndpointPlanningRequired } from "./native-endpoint-plan-protocol.ts"; import { acquireNativeLocalInputs, acquireNativeProjectInput, @@ -104,7 +105,9 @@ export async function planNativeProject( result.local_resolution.resolution_hash !== resolved.local_resolution.resolution_hash || JSON.stringify(result.routing_resolution) !== - JSON.stringify(resolved.routing_resolution)) + JSON.stringify(resolved.routing_resolution) || + JSON.stringify(result.host_binding_resolution) !== + JSON.stringify(resolved.host_binding_resolution)) ) { throw new NativeConfigCompilerError( "E_COMPILER_RESPONSE", @@ -167,6 +170,7 @@ async function prepareNativeProject( opts.explicitDomain !== undefined || Object.hasOwn(compiled.plan, "routes") || Object.hasOwn(compiled.plan, "open"), + requireEndpointPlanning: nativeEndpointPlanningRequired(compiled.plan), }; const checkResolvedIdentity = (result: NativeConfigResolveResult) => { if ( diff --git a/tests/native-endpoint-plan-transport.test.ts b/tests/native-endpoint-plan-transport.test.ts new file mode 100644 index 000000000..cac14f2c9 --- /dev/null +++ b/tests/native-endpoint-plan-transport.test.ts @@ -0,0 +1,1181 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { chmod, mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + compileNativeConfig, + NativeConfigCompilerError, + planNativeConfig, + resolveNativeConfig, +} from "../src/lib/native-config-compiler.ts"; +import { + parseNativeEndpointBinding, + parseNativeHostBindingTarget, +} from "../src/lib/native-endpoint-plan-protocol.ts"; +import type { NativeEnvMetadata } from "../src/lib/native-env-plan-protocol.ts"; + +const PROTOCOL = { + transport_version: 1, + authored_version: 1, + plan_version: 1, + resolve_version: 1, + local_version: 1, + env_plan_version: 1, + host_env_plan_version: 1, + routing_plan_version: 1, + endpoint_plan_version: 1, +}; +const INPUT = new TextEncoder().encode('{"schema_version":1,"name":"fixture"}'); +const CANARY = "endpoint-private-canary"; +const METADATA = { + metadata_version: 1, + overlay: null, + overlay_exists: false, + workloads: { web: {}, api: {} }, + inactive_scopes: [], +} as const satisfies NativeEnvMetadata; +const HOST_TARGET = { kind: "host", port: 9443, protocol: "https" } as const; +const EXTERNAL_TARGET = { + kind: "external", + hostname: "qa.example.test", + port: 443, + protocol: "https", +} as const; +const ROUTE_REFERENCE = { kind: "route", name: "web" } as const; +const SERVICE_REFERENCE = { + kind: "service", + name: "api", + port: 3000, + protocol: "http", +} as const; +const BINDING_REFERENCE = { kind: "host_binding", name: "tunnel" } as const; +const ENVIRONMENT = { + PUBLIC: { endpoint: ROUTE_REFERENCE }, + API: { endpoint: SERVICE_REFERENCE }, + TUNNEL: { endpoint: BINDING_REFERENCE }, + EXTERNAL: { endpoint: { kind: "host_binding", name: "qa" } }, +}; +const PLAN = { + plan_version: 1, + name: "fixture", + services: { web: { environment: ENVIRONMENT }, api: {} }, + jobs: {}, + host_bindings: { tunnel: HOST_TARGET, qa: EXTERNAL_TARGET }, + routes: { + aliases: {}, + http: { + web: { + service: "web", + port: 3000, + protocol: "http", + hostname: "project", + }, + }, + }, +}; +const COMPILED = { + transport_version: 1, + ok: true, + semantic_hash: "a".repeat(64), + declared_workloads: { + web: "service", + api: "service", + inactive: "service", + seed: "job", + }, + plan: PLAN, +}; +const RESOLUTION = { + bindings: { + tunnel: { target: HOST_TARGET, origin: "project" }, + qa: { target: EXTERNAL_TARGET, origin: "project" }, + }, + removed: {}, +}; +const LOCAL = { + overlay: null, + origin: "project", + auto_branch: false, + inherit_local: true, + resolution_hash: "b".repeat(64), +}; +const ROUTING = { + domain: "hack.local", + domain_origin: "default", + project_origin: "https://fixture.hack.local", + aliases: {}, + oauth_alias: null, + open_preference: "auto", + open_preference_origin: "default", + open_origin: "https://fixture.hack.local", + routes: { + web: { + service: "web", + port: 3000, + protocol: "http", + origin: "https://fixture.hack.local", + aliases: {}, + }, + }, +}; +const BINDINGS = { + PUBLIC: { + kind: "endpoint", + reference: ROUTE_REFERENCE, + target: { kind: "route", origin: "https://fixture.hack.local" }, + }, + API: { + kind: "endpoint", + reference: SERVICE_REFERENCE, + target: SERVICE_REFERENCE, + }, + TUNNEL: { + kind: "endpoint", + reference: BINDING_REFERENCE, + target: { ...HOST_TARGET, context: "workload" }, + }, + EXTERNAL: { + kind: "endpoint", + reference: { kind: "host_binding", name: "qa" }, + target: EXTERNAL_TARGET, + }, +}; +const SUCCESS = { + ...COMPILED, + local_resolution: LOCAL, + host_binding_resolution: RESOLUTION, + routing_resolution: ROUTING, + environment_plan: { + plan_version: 1, + overlay: null, + overlay_exists: false, + complete: true, + workloads: { web: BINDINGS, api: {} }, + warnings: [], + diagnostics: [], + }, +}; +let directory = ""; +beforeEach(async () => { + directory = await realpath( + await mkdtemp(join(tmpdir(), "native-endpoint-transport-")) + ); +}); +afterEach(async () => { + await rm(directory, { recursive: true, force: true }); +}); + +async function fixture( + opts: { + readonly response?: unknown; + readonly protocol?: Record; + readonly body?: string; + readonly exit?: number; + } = {} +) { + const binary = join(directory, "compiler"); + await Bun.write( + binary, + `#!${process.execPath}\nif(process.argv[2]==='--protocol'){console.log(${JSON.stringify(JSON.stringify(opts.protocol ?? PROTOCOL))})}else{${opts.body ?? `console.log(${JSON.stringify(JSON.stringify(opts.response ?? SUCCESS))});process.stderr.write(${JSON.stringify(CANARY)});process.exitCode=${opts.exit ?? 0}`}}` + ); + await chmod(binary, 0o700); + return binary; +} + +async function failure( + operation: Promise, + code = "E_COMPILER_RESPONSE" +) { + const error: unknown = await operation.catch((value: unknown) => value); + expect(error).toBeInstanceOf(NativeConfigCompilerError); + expect(error).toMatchObject({ code }); + expect(String(error)).not.toContain(CANARY); + expect(String(error)).not.toContain(directory); +} + +function withBinding(name: string, value: unknown) { + return { + ...SUCCESS, + environment_plan: { + ...SUCCESS.environment_plan, + workloads: { + ...SUCCESS.environment_plan.workloads, + web: { ...BINDINGS, [name]: value }, + }, + }, + }; +} + +test.each([ + undefined, + null, + 2, +])("endpoint capability %s is fenced before payload", async (version) => { + const receipt = join(directory, "payload"); + const binary = await fixture({ + protocol: { ...PROTOCOL, endpoint_plan_version: version }, + body: `await Bun.write(${JSON.stringify(receipt)},await Bun.stdin.text())`, + }); + const options = { input: INPUT, binary, requireEndpointPlanning: true }; + await failure(compileNativeConfig(options), "E_COMPILER_VERSION"); + await failure(resolveNativeConfig(options), "E_COMPILER_VERSION"); + await failure( + planNativeConfig({ ...options, envMetadata: METADATA }), + "E_COMPILER_VERSION" + ); + expect(await Bun.file(receipt).exists()).toBe(false); +}); + +test("endpoint declarations require capability even when the caller did not predict them", async () => { + const binary = await fixture({ + response: COMPILED, + protocol: { ...PROTOCOL, endpoint_plan_version: undefined }, + }); + await failure( + compileNativeConfig({ input: INPUT, binary }), + "E_COMPILER_VERSION" + ); +}); + +test("old endpoint-free compilers remain compatible with all three calls", async () => { + const legacy = { + ...COMPILED, + plan: { plan_version: 1, services: { web: {} }, jobs: {} }, + }; + const protocol = { + ...PROTOCOL, + endpoint_plan_version: undefined, + routing_plan_version: undefined, + }; + const binary = await fixture({ + protocol, + body: `const result=${JSON.stringify(legacy)};if(process.argv[2]!=='compile'){result.local_resolution=${JSON.stringify(LOCAL)}}if(process.argv[2]==='plan'){result.environment_plan={plan_version:1,overlay:null,overlay_exists:false,complete:true,workloads:{web:{}},warnings:[],diagnostics:[]}}console.log(JSON.stringify(result))`, + }); + expect((await compileNativeConfig({ input: INPUT, binary })).ok).toBe(true); + expect((await resolveNativeConfig({ input: INPUT, binary })).ok).toBe(true); + expect( + ( + await planNativeConfig({ + input: INPUT, + binary, + envMetadata: { ...METADATA, workloads: { web: {} } }, + }) + ).ok + ).toBe(true); +}); + +test("typed service, public route, host and external endpoints cross-check without inventing runtime URLs", async () => { + const binary = await fixture(); + const result = await planNativeConfig({ + input: INPUT, + binary, + envMetadata: METADATA, + }); + expect(result).toHaveProperty("environment_plan.workloads.web", BINDINGS); + expect(result).toHaveProperty("host_binding_resolution", RESOLUTION); + const output = JSON.stringify(result); + expect(output).not.toContain(CANARY); + expect(output).not.toContain("host.docker.internal"); +}); + +test("routing input probes still validate effective bindings and postpone public-origin resolution", async () => { + const { routing_resolution, environment_plan, ...probe } = SUCCESS; + void routing_resolution; + void environment_plan; + const binary = await fixture({ + response: { ...probe, routing_inputs_required: true }, + }); + const result = await resolveNativeConfig({ + input: INPUT, + binary, + probeRoutingInputs: true, + }); + expect(result).toHaveProperty("routing_inputs_required", true); + expect(result).toHaveProperty("host_binding_resolution", RESOLUTION); + expect(result).not.toHaveProperty("routing_resolution"); +}); + +test.each([ + { name: "unknown", reference: { ...SERVICE_REFERENCE, name: "missing" } }, + { name: "inactive", reference: { ...SERVICE_REFERENCE, name: "inactive" } }, + { name: "job", reference: { ...SERVICE_REFERENCE, name: "seed" } }, + { name: "unknown route", reference: { kind: "route", name: "missing" } }, + { name: "unknown tag", reference: { kind: "database", name: "api" } }, + { name: "extra field", reference: { ...SERVICE_REFERENCE, value: CANARY } }, + { name: "zero port", reference: { ...SERVICE_REFERENCE, port: 0 } }, + { name: "fractional port", reference: { ...SERVICE_REFERENCE, port: 1.5 } }, + { + name: "bad protocol", + reference: { ...SERVICE_REFERENCE, protocol: "udp" }, + }, +])("refuses invalid normalized endpoint: $name", async ({ reference }) => { + const response = { + ...COMPILED, + plan: { + ...PLAN, + services: { + ...PLAN.services, + web: { environment: { API: { endpoint: reference } } }, + }, + }, + }; + const binary = await fixture({ response }); + await failure(compileNativeConfig({ input: INPUT, binary })); +}); + +test("endpoint plus unset in a normalized environment destination is refused", async () => { + const response = { + ...COMPILED, + plan: { + ...PLAN, + services: { + ...PLAN.services, + web: { + environment: { API: { endpoint: SERVICE_REFERENCE, unset: true } }, + }, + }, + }, + }; + await failure( + compileNativeConfig({ input: INPUT, binary: await fixture({ response }) }) + ); +}); + +test.each([ + { + name: "foreign public origin", + binding: { + ...BINDINGS.PUBLIC, + target: { kind: "route", origin: "https://foreign.example.test" }, + }, + }, + { + name: "forged reference", + binding: { ...BINDINGS.PUBLIC, reference: { kind: "route", name: "api" } }, + }, + { + name: "wrong target kind", + binding: { ...BINDINGS.PUBLIC, target: SERVICE_REFERENCE }, + }, + { + name: "extra secret value", + binding: { ...BINDINGS.PUBLIC, value: CANARY }, + }, + { + name: "credential origin", + binding: { + ...BINDINGS.PUBLIC, + target: { kind: "route", origin: `https://user:${CANARY}@example.test` }, + }, + }, + { + name: "origin with path", + binding: { + ...BINDINGS.PUBLIC, + target: { kind: "route", origin: "https://fixture.hack.local/path" }, + }, + }, +])("refuses forged route report: $name", async ({ binding }) => { + await failure( + planNativeConfig({ + input: INPUT, + binary: await fixture({ response: withBinding("PUBLIC", binding) }), + envMetadata: METADATA, + }) + ); +}); + +test.each([ + { + name: "wrong target service", + key: "API", + target: { ...SERVICE_REFERENCE, name: "web" }, + }, + { + name: "wrong service port", + key: "API", + target: { ...SERVICE_REFERENCE, port: 3001 }, + }, + { + name: "wrong service protocol", + key: "API", + target: { ...SERVICE_REFERENCE, protocol: "tcp" }, + }, + { + name: "wrong host context", + key: "TUNNEL", + target: { ...HOST_TARGET, context: "host" }, + }, + { name: "absent host context", key: "TUNNEL", target: HOST_TARGET }, + { + name: "wrong host port", + key: "TUNNEL", + target: { ...HOST_TARGET, port: 9444, context: "workload" }, + }, + { + name: "wrong external hostname", + key: "EXTERNAL", + target: { ...EXTERNAL_TARGET, hostname: "foreign.test" }, + }, +])("refuses forged contextual target: $name", async ({ key, target }) => { + const original = BINDINGS[key as keyof typeof BINDINGS]; + await failure( + planNativeConfig({ + input: INPUT, + binary: await fixture({ + response: withBinding(key, { ...original, target }), + }), + envMetadata: METADATA, + }) + ); +}); + +test("refuses an endpoint at an unauthored environment destination", async () => { + await failure( + planNativeConfig({ + input: INPUT, + binary: await fixture({ response: withBinding("FORGED", BINDINGS.API) }), + envMetadata: METADATA, + }) + ); +}); + +test("refuses a complete report omitting an authored endpoint", async () => { + const { API, ...bindings } = BINDINGS; + void API; + const response = { + ...SUCCESS, + environment_plan: { + ...SUCCESS.environment_plan, + workloads: { web: bindings, api: {} }, + }, + }; + await failure( + planNativeConfig({ + input: INPUT, + binary: await fixture({ response }), + envMetadata: METADATA, + }) + ); +}); + +test("managed same-key endpoint collisions retain the baseline and remain incomplete", async () => { + const baseline = { scope: "web", secret: true }; + const metadata = { + ...METADATA, + workloads: { ...METADATA.workloads, web: { API: baseline } }, + }; + const response = withBinding("API", { + kind: "managed", + key: "API", + ...baseline, + }); + const result = await planNativeConfig({ + input: INPUT, + binary: await fixture({ + response: { + ...response, + environment_plan: { + ...response.environment_plan, + complete: false, + diagnostics: [ + { + document: "project", + code: "env_endpoint_collision", + pointer: "/services/web/environment/API", + message: "Endpoint conflicts with managed environment.", + line: 1, + column: 1, + }, + ], + }, + }, + exit: 1, + }), + envMetadata: metadata, + }); + expect(result).toHaveProperty("environment_plan.complete", false); + expect(result).toHaveProperty("environment_plan.workloads.web.API", { + kind: "managed", + key: "API", + ...baseline, + }); + await failure( + planNativeConfig({ + input: INPUT, + binary: await fixture(), + envMetadata: metadata, + }) + ); +}); + +function withHost(opts: { + readonly environment: Record; + readonly bindings: Record; + readonly diagnostics?: readonly unknown[]; +}) { + const diagnostics = opts.diagnostics ?? []; + return { + ...SUCCESS, + plan: { + ...PLAN, + host: { + up: { + before: [ + { + name: "prepare", + command: ["printf", "fixture"], + cwd: ".", + environment: opts.environment, + env_target: { kind: "host" }, + }, + ], + }, + }, + }, + host_env_targets: { include_default: true, workloads: [] }, + environment_plan: { + ...SUCCESS.environment_plan, + complete: diagnostics.length === 0, + diagnostics, + host: { + prepare: { env_target: { kind: "host" }, bindings: opts.bindings }, + }, + }, + }; +} + +const HOST_METADATA = { + ...METADATA, + host: { default: {}, workloads: {} }, +} as const satisfies NativeEnvMetadata; +const UNSUPPORTED_HOST_ENDPOINT = { + document: "project", + code: "unsupported_endpoint_context", + pointer: "/host/up/before/0/environment/API", + message: "A host invocation cannot use a workload-only endpoint.", + line: 1, + column: 1, +}; + +test("host invocations preserve host context and route origins in typed endpoints", async () => { + const response = withHost({ + environment: { PUBLIC: ENVIRONMENT.PUBLIC, TUNNEL: ENVIRONMENT.TUNNEL }, + bindings: { + PUBLIC: BINDINGS.PUBLIC, + TUNNEL: { + ...BINDINGS.TUNNEL, + target: { ...HOST_TARGET, context: "host" }, + }, + }, + }); + const result = await planNativeConfig({ + input: INPUT, + binary: await fixture({ response }), + envMetadata: HOST_METADATA, + }); + expect(result).toHaveProperty( + "environment_plan.host.prepare.bindings.TUNNEL.target.context", + "host" + ); +}); + +test("unsupported direct service endpoints in host commands are incomplete and symbolic", async () => { + const response = withHost({ + environment: { API: ENVIRONMENT.API }, + bindings: {}, + diagnostics: [UNSUPPORTED_HOST_ENDPOINT], + }); + const result = await planNativeConfig({ + input: INPUT, + binary: await fixture({ response, exit: 1 }), + envMetadata: HOST_METADATA, + }); + expect(result).toHaveProperty("environment_plan.complete", false); + expect(result).not.toHaveProperty( + "environment_plan.host.prepare.bindings.API" + ); +}); + +test.each([ + "host", + "workload", +] as const)("unsupported host endpoint cannot discard same-key managed %s metadata", async (kind) => { + const original = withHost({ + environment: { API: ENVIRONMENT.API }, + bindings: {}, + diagnostics: [UNSUPPORTED_HOST_ENDPOINT], + }); + const target = + kind === "host" ? { kind: "host" } : { kind: "workload", name: "api" }; + const response = { + ...original, + plan: { + ...original.plan, + host: { + up: { + before: [{ ...original.plan.host.up.before[0], env_target: target }], + }, + }, + }, + host_env_targets: { + include_default: kind === "host", + workloads: kind === "workload" ? ["api"] : [], + }, + environment_plan: { + ...original.environment_plan, + host: { prepare: { env_target: target, bindings: {} } }, + }, + }; + const baseline = { + API: { scope: kind === "host" ? "host" : "api", secret: true }, + }; + const metadata: NativeEnvMetadata = { + ...METADATA, + host: + kind === "host" + ? { default: baseline, workloads: {} } + : { workloads: { api: baseline } }, + }; + await failure( + planNativeConfig({ + input: INPUT, + binary: await fixture({ response, exit: 1 }), + envMetadata: metadata, + }) + ); +}); + +test.each([ + { + name: "guessed workload address", + bindings: { API: BINDINGS.API }, + diagnostics: [], + }, + { + name: "missing destination without diagnostic", + bindings: {}, + diagnostics: [], + }, + { + name: "diagnostic at foreign pointer", + bindings: {}, + diagnostics: [ + { + ...UNSUPPORTED_HOST_ENDPOINT, + pointer: "/host/up/before/0/environment/OTHER", + }, + ], + }, + { + name: "unrelated diagnostic", + bindings: {}, + diagnostics: [ + { ...UNSUPPORTED_HOST_ENDPOINT, code: "missing_environment_binding" }, + ], + }, +])("refuses unsupported host endpoint substitution: $name", async ({ + bindings, + diagnostics, +}) => { + const response = withHost({ + environment: { API: ENVIRONMENT.API }, + bindings, + diagnostics, + }); + await failure( + planNativeConfig({ + input: INPUT, + binary: await fixture({ + response, + exit: diagnostics.length === 0 ? 0 : 1, + }), + envMetadata: HOST_METADATA, + }) + ); +}); + +test("host endpoint collision retains only the managed metadata baseline", async () => { + const baseline = { scope: "host", secret: true }; + const metadata = { + ...HOST_METADATA, + host: { default: { TUNNEL: baseline }, workloads: {} }, + }; + const response = withHost({ + environment: { TUNNEL: ENVIRONMENT.TUNNEL }, + bindings: { TUNNEL: { kind: "managed", key: "TUNNEL", ...baseline } }, + diagnostics: [ + { + ...UNSUPPORTED_HOST_ENDPOINT, + code: "env_endpoint_collision", + pointer: "/host/up/before/0/environment/TUNNEL", + }, + ], + }); + const result = await planNativeConfig({ + input: INPUT, + binary: await fixture({ response, exit: 1 }), + envMetadata: metadata, + }); + expect(result).toHaveProperty( + "environment_plan.host.prepare.bindings.TUNNEL", + { kind: "managed", key: "TUNNEL", ...baseline } + ); + const forged = withHost({ + environment: { TUNNEL: ENVIRONMENT.TUNNEL }, + bindings: { + TUNNEL: { + ...BINDINGS.TUNNEL, + target: { ...HOST_TARGET, context: "host" }, + }, + }, + }); + await failure( + planNativeConfig({ + input: INPUT, + binary: await fixture({ response: forged }), + envMetadata: metadata, + }) + ); +}); + +test.each([ + { name: "missing", resolution: undefined }, + { name: "null", resolution: null }, + { name: "extra keys", resolution: { ...RESOLUTION, private: CANARY } }, + { + name: "unknown origin", + resolution: { + ...RESOLUTION, + bindings: { + ...RESOLUTION.bindings, + tunnel: { target: HOST_TARGET, origin: "global" }, + }, + }, + }, + { + name: "forged project target", + resolution: { + ...RESOLUTION, + bindings: { + ...RESOLUTION.bindings, + tunnel: { target: { ...HOST_TARGET, port: 9000 }, origin: "project" }, + }, + }, + }, + { + name: "missing authored binding", + resolution: { ...RESOLUTION, bindings: { qa: RESOLUTION.bindings.qa } }, + }, + { + name: "project tombstone", + resolution: { ...RESOLUTION, removed: { absent: "project" } }, + }, + { + name: "same name retained and removed", + resolution: { ...RESOLUTION, removed: { tunnel: "checkout_local" } }, + }, + { + name: "absent primary provenance", + resolution: { + ...RESOLUTION, + bindings: { + ...RESOLUTION.bindings, + tunnel: { target: HOST_TARGET, origin: "primary_local" }, + }, + }, + }, + { + name: "absent checkout provenance", + resolution: { + ...RESOLUTION, + bindings: { + ...RESOLUTION.bindings, + tunnel: { target: HOST_TARGET, origin: "checkout_local" }, + }, + }, + }, +])("refuses malformed binding resolution: $name", async ({ resolution }) => { + const response = { ...SUCCESS, host_binding_resolution: resolution }; + await failure( + resolveNativeConfig({ input: INPUT, binary: await fixture({ response }) }) + ); +}); + +test("compile accepts symbolic local binding names but resolution refuses missing or removed references", async () => { + const localPlan = { + plan_version: 1, + services: { + web: { environment: { TUNNEL: { endpoint: BINDING_REFERENCE } } }, + }, + jobs: {}, + }; + const compiled = { ...COMPILED, plan: localPlan }; + expect( + ( + await compileNativeConfig({ + input: INPUT, + binary: await fixture({ response: compiled }), + }) + ).ok + ).toBe(true); + const response = { + ...compiled, + local_resolution: LOCAL, + host_binding_resolution: { + bindings: {}, + removed: { tunnel: "checkout_local" }, + }, + }; + await failure( + resolveNativeConfig({ + input: INPUT, + binary: await fixture({ response }), + checkoutLocal: new TextEncoder().encode( + '{"schema_version":1,"host_bindings":{"tunnel":null}}' + ), + }) + ); +}); + +test("an absent constructor binding cannot resolve through the Object prototype", async () => { + const plan = { + plan_version: 1, + services: { + web: { + environment: { + API: { endpoint: { kind: "host_binding", name: "constructor" } }, + }, + }, + }, + jobs: {}, + host_bindings: {}, + }; + const response = { + ...COMPILED, + plan, + local_resolution: LOCAL, + host_binding_resolution: { bindings: {}, removed: {} }, + }; + await failure( + resolveNativeConfig({ input: INPUT, binary: await fixture({ response }) }) + ); +}); + +test("an explicitly declared constructor binding remains an own logical name", async () => { + const reference = { kind: "host_binding", name: "constructor" }; + const plan = { + plan_version: 1, + services: { web: { environment: { API: { endpoint: reference } } } }, + jobs: {}, + host_bindings: { constructor: HOST_TARGET }, + }; + const response = { + ...COMPILED, + plan, + local_resolution: LOCAL, + host_binding_resolution: { + bindings: { constructor: { target: HOST_TARGET, origin: "project" } }, + removed: {}, + }, + environment_plan: { + plan_version: 1, + overlay: null, + overlay_exists: false, + complete: true, + workloads: { + web: { + API: { + kind: "endpoint", + reference, + target: { ...HOST_TARGET, context: "workload" }, + }, + }, + }, + warnings: [], + diagnostics: [], + }, + }; + const result = await planNativeConfig({ + input: INPUT, + binary: await fixture({ response }), + envMetadata: { ...METADATA, workloads: { web: {} } }, + }); + expect(result).toHaveProperty( + "environment_plan.workloads.web.API.target.port", + HOST_TARGET.port + ); +}); + +test("local-only binding report negotiates endpoint capability before callers may fetch metadata", async () => { + const response = { + ...COMPILED, + plan: { plan_version: 1, services: {}, jobs: {} }, + local_resolution: LOCAL, + host_binding_resolution: { + bindings: { local: { target: HOST_TARGET, origin: "checkout_local" } }, + removed: {}, + }, + }; + await failure( + resolveNativeConfig({ + input: INPUT, + binary: await fixture({ + response, + protocol: { ...PROTOCOL, endpoint_plan_version: undefined }, + }), + checkoutLocal: new TextEncoder().encode( + JSON.stringify({ + schema_version: 1, + host_bindings: { local: HOST_TARGET }, + }) + ), + }), + "E_COMPILER_VERSION" + ); +}); + +test.each([ + { + name: "missing local map", + document: { schema_version: 1 }, + source: "checkout_local", + target: HOST_TARGET, + }, + { + name: "missing own local member", + document: { schema_version: 1, host_bindings: {} }, + source: "checkout_local", + target: HOST_TARGET, + }, + { + name: "changed local target", + document: { + schema_version: 1, + host_bindings: { local: { ...HOST_TARGET, port: 9444 } }, + }, + source: "checkout_local", + target: HOST_TARGET, + }, + { + name: "null local target", + document: { schema_version: 1, host_bindings: { local: null } }, + source: "checkout_local", + target: HOST_TARGET, + }, + { + name: "missing primary member", + document: { schema_version: 1, host_bindings: {} }, + source: "primary_local", + target: HOST_TARGET, + }, +])("provenance cannot invent a source claim: $name", async ({ + document, + source, + target, +}) => { + const input = new TextEncoder().encode(JSON.stringify(document)); + const response = { + ...COMPILED, + plan: { plan_version: 1, services: {}, jobs: {} }, + local_resolution: LOCAL, + host_binding_resolution: { + bindings: { local: { target, origin: source } }, + removed: {}, + }, + }; + await failure( + resolveNativeConfig({ + input: INPUT, + binary: await fixture({ response }), + ...(source === "primary_local" + ? { primaryLocal: input } + : { checkoutLocal: input }), + }) + ); +}); + +test.each([ + { schema_version: 1 }, + { schema_version: 1, host_bindings: {} }, + { schema_version: 1, host_bindings: { local: HOST_TARGET } }, +])("provenance cannot invent a local tombstone from %j", async (document) => { + const response = { + ...COMPILED, + plan: { plan_version: 1, services: {}, jobs: {} }, + local_resolution: LOCAL, + host_binding_resolution: { + bindings: {}, + removed: { local: "checkout_local" }, + }, + }; + await failure( + resolveNativeConfig({ + input: INPUT, + binary: await fixture({ response }), + checkoutLocal: new TextEncoder().encode(JSON.stringify(document)), + }) + ); +}); + +test("a supplied local map requires a binding report even when empty", async () => { + const response = { + ...COMPILED, + plan: { plan_version: 1, services: {}, jobs: {} }, + local_resolution: LOCAL, + }; + await failure( + resolveNativeConfig({ + input: INPUT, + binary: await fixture({ response }), + checkoutLocal: new TextEncoder().encode( + '{"schema_version":1,"host_bindings":{}}' + ), + }) + ); +}); + +test("ignored primary bindings cannot manufacture an effective report", async () => { + const response = { + ...COMPILED, + plan: { plan_version: 1, services: {}, jobs: {} }, + local_resolution: { ...LOCAL, inherit_local: false }, + host_binding_resolution: { + bindings: { local: { target: HOST_TARGET, origin: "primary_local" } }, + removed: {}, + }, + }; + await failure( + resolveNativeConfig({ + input: INPUT, + binary: await fixture({ response }), + primaryLocal: new TextEncoder().encode( + JSON.stringify({ + schema_version: 1, + host_bindings: { local: HOST_TARGET }, + }) + ), + }) + ); +}); + +test("source-verified local target and tombstone projections retain their provenance", async () => { + const response = { + ...COMPILED, + plan: { plan_version: 1, services: {}, jobs: {} }, + local_resolution: LOCAL, + host_binding_resolution: { + bindings: { local: { target: HOST_TARGET, origin: "primary_local" } }, + removed: { removed: "checkout_local" }, + }, + }; + const result = await resolveNativeConfig({ + input: INPUT, + binary: await fixture({ response }), + primaryLocal: new TextEncoder().encode( + JSON.stringify({ + schema_version: 1, + host_bindings: { local: HOST_TARGET }, + }) + ), + checkoutLocal: new TextEncoder().encode( + '{"schema_version":1,"host_bindings":{"removed":null}}' + ), + }); + expect(result).toHaveProperty( + "host_binding_resolution", + response.host_binding_resolution + ); +}); + +test("a BOM-prefixed source cannot justify a local binding claim", async () => { + const document = `\ufeff${JSON.stringify({ schema_version: 1, host_bindings: { local: HOST_TARGET } })}`; + const response = { + ...COMPILED, + plan: { plan_version: 1, services: {}, jobs: {} }, + local_resolution: LOCAL, + host_binding_resolution: { + bindings: { local: { target: HOST_TARGET, origin: "checkout_local" } }, + removed: {}, + }, + }; + await failure( + resolveNativeConfig({ + input: INPUT, + binary: await fixture({ response }), + checkoutLocal: new TextEncoder().encode(document), + }) + ); +}); + +test.each([ + "localhost", + "external", + "qa.example.test", + "127.0.0.1", + "[::1]", + "[2001:db8::1]", +])("preserves canonical external hostname %s", (hostname) => { + const target = { ...EXTERNAL_TARGET, hostname }; + expect(parseNativeHostBindingTarget(target)).toEqual(target); +}); + +test.each([ + "localhost:9000", + "UPPER.test", + "*.test", + "qa.test.", + "1", + "0x7f000001", + "qa.123", + "::1", + "[0:0:0:0:0:0:0:1]", + "127.00.0.1", + "user@qa.test", + "qa.test/path", +])("refuses noncanonical external hostname %s", (hostname) => { + expect( + parseNativeHostBindingTarget({ ...EXTERNAL_TARGET, hostname }) + ).toBeNull(); +}); + +test("endpoint parsing never strips an extra stored value", () => { + expect( + parseNativeEndpointBinding({ ...BINDINGS.API, value: CANARY }) + ).toBeNull(); + expect( + parseNativeEndpointBinding({ + ...BINDINGS.API, + reference: { ...SERVICE_REFERENCE, secret: CANARY }, + }) + ).toBeNull(); + expect( + parseNativeEndpointBinding({ + ...BINDINGS.API, + target: { ...SERVICE_REFERENCE, value: CANARY }, + }) + ).toBeNull(); +}); + +test.each([ + "UPPER", + "dot.name", + "under_score", + "double--dash", + "trailing-", + "a".repeat(64), +])("logical binding names refuse noncanonical spelling %s", async (name) => { + const response = { + ...COMPILED, + plan: { ...PLAN, host_bindings: { [name]: HOST_TARGET } }, + }; + await failure( + compileNativeConfig({ input: INPUT, binary: await fixture({ response }) }) + ); + expect( + parseNativeEndpointBinding({ + ...BINDINGS.TUNNEL, + reference: { kind: "host_binding", name }, + }) + ).toBeNull(); +}); diff --git a/tests/native-endpoint-project.test.ts b/tests/native-endpoint-project.test.ts new file mode 100644 index 000000000..cec0ffa94 --- /dev/null +++ b/tests/native-endpoint-project.test.ts @@ -0,0 +1,231 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + planNativeProject, + validateNativeProject, +} from "../src/lib/native-project-validation.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +const PROTOCOL = { + transport_version: 1, + authored_version: 1, + plan_version: 1, + resolve_version: 1, + local_version: 1, + env_plan_version: 1, + endpoint_plan_version: 1, +}; +const KEYS = [ + "HACK_HOME", + "HACK_GLOBAL_CONFIG_PATH", + "HACK_CONFIG_COMPILER_BINARY", + "CI", + "HACK_EXECUTION_MODE", +] as const; +const CANARY = "private-endpoint-project-canary"; +const TARGET = { kind: "host", port: 9443, protocol: "https" }; +let root = ""; +let projectRoot = ""; +let receipt = ""; +let saved: Record; +beforeEach(async () => { + saved = Object.fromEntries(KEYS.map((key) => [key, process.env[key]])); + for (const key of KEYS) { + Reflect.deleteProperty(process.env, key); + } + root = await realpath( + await mkdtemp(join(tmpdir(), "native-endpoint-project-")) + ); + projectRoot = join(root, "project"); + await mkdir(join(projectRoot, ".hack"), { recursive: true }); + await writeFile( + join(projectRoot, ".hack/hack.project.json"), + '{"schema_version":1,"name":"fixture"}' + ); + process.env.HACK_HOME = join(root, "home"); + receipt = join(root, "requests.jsonl"); +}); +afterEach(async () => { + for (const key of KEYS) { + restoreEnv(key, saved[key]); + } + await rm(root, { recursive: true, force: true }); +}); + +async function mockCompiler( + opts: { + readonly capability?: boolean; + readonly localOnly?: boolean; + readonly changedPlan?: boolean; + readonly probe?: boolean; + } = {} +) { + const binary = join(root, "compiler"); + const protocol = { + ...PROTOCOL, + endpoint_plan_version: opts.capability === false ? undefined : 1, + routing_plan_version: opts.probe ? 1 : undefined, + }; + await writeFile( + binary, + `#!${process.execPath} +import {appendFile} from 'node:fs/promises'; +if(process.argv[2]==='--protocol'){console.log(${JSON.stringify(JSON.stringify(protocol))})} +else{ + const operation=process.argv[2];const raw=await Bun.stdin.text(); + const request=operation==='compile'?{}:JSON.parse(raw); + await appendFile(${JSON.stringify(receipt)},JSON.stringify({operation,request})+'\\n'); + const target=${JSON.stringify(TARGET)}; + const plan={plan_version:1,name:'fixture',services:{web:{environment:{${opts.localOnly ? "" : "TUNNEL:{endpoint:{kind:'host_binding',name:'tunnel'}}"}}}},jobs:{},worktree:{inherit_local:false,auto_branch:false}${opts.localOnly ? "" : ",host_bindings:{tunnel:target}"}}; + const result={transport_version:1,ok:true,semantic_hash:'a'.repeat(64),declared_workloads:{web:'service'},plan}; + if(operation!=='compile'){ + result.local_resolution={overlay:null,origin:'project',auto_branch:false,inherit_local:false,resolution_hash:'b'.repeat(64)}; + const local=${Boolean(opts.localOnly)}; const changed=${Boolean(opts.changedPlan)}&&operation==='plan'; + result.host_binding_resolution={bindings:{tunnel:{target:changed?{...target,port:9444}:target,origin:local||changed?'checkout_local':'project'}},removed:{}}; + if(${Boolean(opts.probe)}&&request.routing_probe===true){result.routing_inputs_required=true} + } + if(operation==='plan'){ + const target=result.host_binding_resolution.bindings.tunnel.target; + result.environment_plan={plan_version:1,overlay:null,overlay_exists:false,complete:true,workloads:{web:${opts.localOnly ? "{}" : "{TUNNEL:{kind:'endpoint',reference:{kind:'host_binding',name:'tunnel'},target:{...target,context:'workload'}}}"}},warnings:[],diagnostics:[]}; + } + console.log(JSON.stringify(result)) +}` + ); + await chmod(binary, 0o700); + process.env.HACK_CONFIG_COMPILER_BINARY = binary; +} + +async function calls() { + return (await readFile(receipt, "utf8")) + .trim() + .split("\n") + .map( + (line) => + JSON.parse(line) as { + operation: string; + request: Record; + } + ); +} +async function failure(operation: Promise, code: string) { + const error: unknown = await operation.catch((value: unknown) => value); + expect(error).toMatchObject({ code }); + expect(String(error)).not.toContain(CANARY); + expect(String(error)).not.toContain(root); +} +async function poisonMetadataAndGlobal() { + await writeFile( + join(projectRoot, ".hack/hack.env.default.yaml"), + `invalid [${CANARY}` + ); + await mkdir(join(root, "home"), { recursive: true }); + await writeFile(join(root, "home/hack.config.json"), `invalid ${CANARY}`); +} + +test("authored endpoint capability fence precedes local, global and managed metadata reads", async () => { + await mockCompiler({ capability: false }); + await mkdir(join(projectRoot, ".hack/hack.local.json")); + await poisonMetadataAndGlobal(); + await failure( + planNativeProject({ startDir: projectRoot }), + "E_COMPILER_VERSION" + ); + expect((await calls()).map((call) => call.operation)).toEqual(["compile"]); +}); + +test("local-only endpoint capability fence precedes global policy and managed metadata", async () => { + await mockCompiler({ localOnly: true, capability: false, probe: true }); + await writeFile( + join(projectRoot, ".hack/hack.local.json"), + JSON.stringify({ schema_version: 1, host_bindings: { tunnel: TARGET } }) + ); + await poisonMetadataAndGlobal(); + await failure( + planNativeProject({ startDir: projectRoot }), + "E_COMPILER_VERSION" + ); + expect((await calls()).map((call) => call.operation)).toEqual([ + "compile", + "resolve", + ]); +}); + +test("endpoint validation does not read managed YAML or unrelated global policy", async () => { + await mockCompiler(); + await poisonMetadataAndGlobal(); + const result = await validateNativeProject({ startDir: projectRoot }); + expect(result).toHaveProperty("host_binding_resolution.bindings.tunnel", { + target: TARGET, + origin: "project", + }); + expect((await calls()).map((call) => call.operation)).toEqual([ + "compile", + "resolve", + ]); + expect(await Bun.file(join(projectRoot, ".hack/.internal")).exists()).toBe( + false + ); +}); + +test("unchanged endpoint metadata planning preserves the prepared binding report", async () => { + await mockCompiler(); + const result = await planNativeProject({ startDir: projectRoot }); + expect(result).toHaveProperty("host_binding_resolution.bindings.tunnel", { + target: TARGET, + origin: "project", + }); + expect(result).toHaveProperty( + "environment_plan.workloads.web.TUNNEL.target", + { ...TARGET, context: "workload" } + ); + expect((await calls()).map((call) => call.operation)).toEqual([ + "compile", + "resolve", + "plan", + ]); +}); + +test("metadata planning cannot alter a prepared binding target despite equal hashes", async () => { + await mockCompiler({ changedPlan: true }); + await writeFile( + join(projectRoot, ".hack/hack.local.json"), + JSON.stringify({ + schema_version: 1, + host_bindings: { tunnel: { ...TARGET, port: 9444 } }, + }) + ); + await failure( + planNativeProject({ startDir: projectRoot }), + "E_COMPILER_RESPONSE" + ); + expect((await calls()).map((call) => call.operation)).toEqual([ + "compile", + "resolve", + "plan", + ]); +}); + +test("local-only bindings retain a separate namespace without inventing host invocations", async () => { + await mockCompiler({ localOnly: true }); + await writeFile( + join(projectRoot, ".hack/hack.local.json"), + JSON.stringify({ schema_version: 1, host_bindings: { tunnel: TARGET } }) + ); + const result = await planNativeProject({ startDir: projectRoot }); + expect(result).toHaveProperty("host_binding_resolution.bindings.tunnel", { + target: TARGET, + origin: "checkout_local", + }); + expect(result).not.toHaveProperty("host_env_targets"); + expect(result).not.toHaveProperty("environment_plan.host"); +});