provisioning-code/schemas/lib/exposure.ncl

202 lines
9.8 KiB
Text
Raw Permalink Normal View History

# Service Exposure — typed declarative surface for how a component is reached.
# Sibling of ServiceConcerns (concerns.ncl): where concerns answer "what does
# this service do about tls/dns/backup", exposure answers "from where is it
# reachable, over which datapath, on which ports, terminating TLS where".
#
# It unifies the six exposure idioms that today live in parallel scalar fields
# (gateway_fip · nested gateway{} · gateway_enabled/gateway_ip · private_lb_ip ·
# routes[] · fip_name/external_addr) into one closed record so a misspelled key
# blames at `nickel export` instead of silently deriving.
#
# Records are CLOSED (no `..`): extra/misspelled fields are a contract error.
# The Reachability variants are the exact kinds `prvng net map` classifies, so
# the runtime view and the static schema share one taxonomy.
#
# `Exposure` carries invariants (from_validator) that fail at `nickel export`.
# Each one encodes a real failure this session produced.
let _gateway_l7_kinds = [ 'gateway_l7_public, 'gateway_l7_private, 'gateway_l7_nested ] in
let _public_kinds = [ 'gateway_l7_public, 'l4_lb_public, 'fip_hostnet ] in
let _member = fun x xs => std.array.any (fun e => e == x) xs in
# Invariants forced when an Exposure is evaluated. Runs after the record shape
# (defaults filled), so `ports`/`strict_chain_clients` are always present.
let _valid_exposure = std.contract.from_validator (fun exp =>
let k = exp.binding.kind in
# 1. reg-class bug: a VPN/private hostname must not ride a Cilium Gateway VIP —
# the eBPF TPROXY redirect never fires for WireGuard host-netns traffic.
if (exp.surface == 'vpn || exp.surface == 'private) && (_member k _gateway_l7_kinds) then
'Error { message = "vpn/private surface cannot use a gateway_l7 binding: Cilium Gateway VIPs are unreachable from the WireGuard datapath (the reg bug). Use 'l4_host_router or 'l4_lb." }
# 2. a public hostname must land on a public path.
else if exp.surface == 'public && !(_member k _public_kinds) then
'Error { message = "public surface must bind via 'gateway_l7_public, 'l4_lb_public or 'fip_hostnet." }
# 2b. a shared-FIP gateway binding must name the FIP it rides.
else if k == 'gateway_l7_public && !(std.record.has_field "fip_name" exp.binding) then
'Error { message = "'gateway_l7_public binding must declare binding.fip_name." }
# 3. Sozu/lian-build failure: strict-chain clients must terminate on a hop we
# control the chain on ('pod_terminate), never at the shared Gateway.
else if exp.strict_chain_clients && !(std.array.all (fun p => p.tls != 'gateway_terminate) exp.ports) then
'Error { message = "strict_chain_clients forbids tls='gateway_terminate: the terminating hop must serve the full chain (Sozu leaf-only broke lian-build pushes to reg). Use tls='pod_terminate." }
else 'Ok
) in
{
# === Enums ================================================================
# Intended audience / datapath the hostnames must be reachable from. This is
# the axis that no existing field captures: `vpn` = reachable over WireGuard.
# Distinct from security.posture (public/private/internal), which states intent
# but not the datapath that actually carries the traffic.
Surface = [| 'cluster, 'private, 'vpn, 'public |],
# How entry traffic lands on the workload. One variant per idiom found in the
# census; the two Cilium-Gateway variants are split public/private because they
# sit on different VLANs with different WireGuard reachability (the reg-class bug
# lives entirely in the gateway_l7_* variants).
Reachability = [|
'gateway_l7_public, # shared-FIP Cilium Gateway on the public VLAN VIP (gateway_fip / gateway_enabled)
'gateway_l7_private, # private Cilium Gateway (private_gateway_*): unreachable from WireGuard datapath
'gateway_l7_nested, # nested gateway{}/tcp_gateway{} block (radicle)
'l4_host_router, # Sozu private_ingress routes[]: L4 LB DNAT, WireGuard-reachable
'l4_lb, # per-service private LoadBalancer (private_lb_ip)
'l4_lb_public, # public LoadBalancer via LB-IPAM (lb_ipam_ip)
'fip_hostnet, # dedicated FIP / hostNetwork (fip_name / external_addr / host_network)
'cluster, # ClusterIP only — flat wuwei, no external entry
|],
# Where the TLS session terminates. Only modes observed in the current census
# are variants; `gateway_mode` is always "terminate" today, no passthrough
# exists. `'passthrough` is intentionally absent until a component declares it —
# adding a speculative variant would let invariants pass on unobserved states.
TlsMode = [|
'gateway_terminate, # Cilium Gateway/Envoy terminates (gateway_mode = "terminate")
'pod_terminate, # the workload pod terminates (pod_tls, sozu sidecar)
'sidecar_reload, # a reloader sidecar rotates the cert in place (tls_rotation = 'reloader)
'none, # no TLS at this hop (plain HTTP / cluster-internal)
|],
# === Records (all closed) =================================================
PortMap = {
name | String | optional | doc "Human label (e.g. \"https\", \"ssh\", \"smtp\")",
listen | Number | doc "Port the entry point listens on",
target | Number | doc "Container/service port traffic is forwarded to",
protocol | [| 'TCP, 'UDP |] | default = 'TCP,
tls | TlsMode | doc "TLS termination for this port" | default = 'none,
},
# The entry point: which datapath, and the statically-known address/name when
# there is one. entry_ip is optional because FIP-backed entries only resolve
# to a concrete IP at the cluster (surfaced by `prvng net map --live`).
Binding = {
kind | Reachability,
entry_ip | String | optional | doc "VIP / LoadBalancer IP when known at eval time",
fip_name | String | optional | doc "Hetzner FIP resource name (gateway_l7_public, fip_hostnet)",
node | String | optional | doc "Pinned node for hostNetwork / FIP-follows-pod",
},
# One reachability path for a set of hostnames. A service with both a public
# gateway path and a VPN L4 path declares two Exposure entries.
ExposureShape = {
hostnames | Array String | doc "FQDNs that resolve to this binding",
surface | Surface,
binding | Binding,
ports | Array PortMap | default = [],
strict_chain_clients | Bool
| doc "Clients require the full cert chain (not just the leaf) — forbids terminating on a hop that serves an incomplete chain"
| default = false,
ingress_restrictions | Array String
| doc "Source constraints (CIDRs, NetworkPolicy refs) applied at the entry point"
| default = [],
dns_zone | String
| doc "Explicit split-DNS zone for these hostnames; defaults to the hostname minus its first label (apex domains must set it)"
| optional,
},
# The public contract: shape + invariants. `Array Exposure` in ServiceExposure
# forces every entry through both, so a bad surface/kind pair fails export.
Exposure = std.contract.Sequence [ ExposureShape, _valid_exposure ],
ServiceExposure = {
service | String,
namespace | String,
exposures | Array Exposure,
},
# === Builders =============================================================
# Mirror concerns.ncl's builder style: components/migrations construct entries
# without repeating the Binding plumbing.
gateway_public = fun fip hostnames ports => {
surface = 'public,
binding = { kind = 'gateway_l7_public, fip_name = fip },
ports = ports,
},
gateway_private = fun entry hostnames ports => {
surface = 'vpn,
binding = { kind = 'gateway_l7_private, entry_ip = entry },
ports = ports,
},
l4_private = fun entry hostnames ports => {
surface = 'vpn,
binding = { kind = 'l4_lb, entry_ip = entry },
ports = ports,
},
host_router = fun entry hostnames ports => {
surface = 'vpn,
binding = { kind = 'l4_host_router, entry_ip = entry },
ports = ports,
},
cluster_only = fun hostnames ports => {
surface = 'cluster,
binding = { kind = 'cluster },
ports = ports,
},
# Build a host-router ServiceExposure from a list of hostnames served by one
# L4 entry point (e.g. the Sozu private_ingress at .6). One vpn exposure per
# host; used as the single source that both routes[] and dns_internal follow,
# so a fresh deploy cannot drift between the router config and the split DNS.
host_router_exposure = fun svc ns ip hs =>
{
service = svc,
namespace = ns,
exposures =
std.array.map
(fun h => {
hostnames = [h],
surface = 'vpn,
binding = { kind = 'l4_host_router, entry_ip = ip },
ports = [{ name = "https", listen = 443, target = 443, tls = 'pod_terminate }],
})
hs,
} | ServiceExposure,
# === Derivation ===========================================================
# Split-DNS routes derived from the exposure surface, so `dns_internal` stops
# being a hand-maintained parallel field. Emits { host, zone, target, ttl } —
# the exact shape dns_private.make_route produces — for every hostname of every
# exposure that has a statically-known entry_ip. Zone = explicit dns_zone, or
# the hostname minus its first label. Exposures with no entry_ip (pure-FIP,
# resolved at the cluster) contribute nothing.
derive_dns_internal = fun svc_exp =>
std.array.flat_map
(fun exp =>
if std.record.has_field "entry_ip" exp.binding then
let zone_of = fun h =>
if std.record.has_field "dns_zone" exp then exp.dns_zone
else
let parts = std.string.split "." h in
std.string.join "." (std.array.slice 1 (std.array.length parts) parts)
in
std.array.map
(fun h => { host = h, zone = zone_of h, target = exp.binding.entry_ip, ttl = 300 })
exp.hostnames
else [])
svc_exp.exposures,
}