Documentation
¶
Overview ¶
Package ingress translates Kubernetes Ingress objects into a Cadishfile and runs the in-cluster controller that keeps cadish's live routing in sync with them.
The translator is PURE: Translate emits Cadishfile TEXT (one site per host) and feeds it back through the existing internal/config compiler — it never bypasses the parser or teaches it Kubernetes concepts. Backends become `upstream … { to k8s://svc.ns:port }` (Layer 1 resolves the pods), pathTypes become `path` matchers, and Ingress most-specific-wins is reproduced via cadish's first-match-wins `route` ordering. A malformed object never panics: it is collected as a Reject and skipped, so one bad Ingress can never take serving down.
Index ¶
- Constants
- func Combine(base, generated string) string
- func CombineSites(base, generated string) string
- func HostPolicyUnion(ingresses []*networkingv1.Ingress) []string
- func Matches(ing *networkingv1.Ingress, className string, isDefaultClass bool) bool
- func SanitizeName(s string) string
- func TLSPlan(ingresses []*networkingv1.Ingress, secretExists func(ns, name string) bool, ...) (acmeHosts []string, secretRefs []SecretRef, rejects []Reject)
- func TranslateSites(in Inputs) ([]RenderedSite, []Reject)
- func ValidateLabelSelector(selector string) error
- type ACMEDomainPolicy
- type Applier
- type Config
- type Controller
- type Inputs
- type PathKind
- type RedirectInjector
- type Reject
- type RenderRoute
- type RenderedSite
- type ResourceCaps
- type SecretRef
- type Stats
- type TLSInjector
- type Warmer
Constants ¶
const ControllerName = "cadi.sh/ingress-controller"
ControllerName is the value an IngressClass's spec.controller must carry for cadish to own it (the IngressClass → controller binding, design §15).
Variables ¶
This section is empty.
Functions ¶
func Combine ¶
Combine concatenates a base Cadishfile (globals: cache/admin/tls defaults) with the translator-generated sites, base first so global blocks precede the generated sites.
func CombineSites ¶
CombineSites is the exported Combine: prepend a base (globals-only) Cadishfile to the generated sites. The Gateway controller uses it exactly as the Ingress controller does.
func HostPolicyUnion ¶
func HostPolicyUnion(ingresses []*networkingv1.Ingress) []string
HostPolicyUnion is the full set of hostnames cadish is willing to terminate TLS for: every host named in any watched Ingress's TLS block (BYO and ACME alike), plus every rule host. It bounds the ACME issuer to known hosts (never open). Deduplicated and sorted.
func Matches ¶
func Matches(ing *networkingv1.Ingress, className string, isDefaultClass bool) bool
Matches reports whether this controller (named className) should serve ing, honoring the three Kubernetes selection mechanisms in precedence order (design §15):
- spec.ingressClassName == className (the modern, authoritative field);
- the legacy kubernetes.io/ingress.class annotation == className (used only when spec.ingressClassName is unset);
- the default-class fallback: an Ingress that sets NEITHER is served iff this controller's IngressClass is marked default (isDefaultClass).
A spec.ingressClassName that names a DIFFERENT class is never overridden by the legacy annotation or the default fallback.
func SanitizeName ¶
SanitizeName exposes the Cadishfile-token sanitizer so a sibling controller can build matching upstream/identifier names. (Used by the Gateway translator's tests.)
func TLSPlan ¶
func TLSPlan(ingresses []*networkingv1.Ingress, secretExists func(ns, name string) bool, certCovers func(ns, name, host string) bool) (acmeHosts []string, secretRefs []SecretRef, rejects []Reject)
TLSPlan projects every Ingress's spec.tls[] into the cadish TLS model (Secrets-if-present-else-ACME, design §19):
- a spec.tls[] entry whose Secret EXISTS (secretExists reports true) becomes a SecretRef — cadish serves that BYO/cert-manager certificate directly;
- a spec.tls[] entry whose Secret is MISSING contributes its hosts to acmeHosts — cadish auto-provisions them via ACME.
The returned acmeHosts is the ACME HostPolicy allow-set (deduplicated, sorted): it is NEVER an open issuer — only hosts explicitly named in a watched Ingress's TLS block are eligible. secretExists is the controller's Secret-lister membership check (it is injected so the projection is pure and unit-testable).
TLS host ownership is ALIGNED TO ROUTING (FIX 2, confused-deputy guard): a host's routing is owned by the OLDEST Ingress that declares a rule for it (first-claim / oldest-wins, matching the translator's merge). A spec.tls host may only contribute a BYO cert (or an ACME host) from the SAME namespace that owns the host's routing — otherwise a tenant in namespace A could register a cert for a host that namespace B routes (cross-namespace cert hijack). Cross-namespace entries are returned as rejects (surfaced as Events) and never registered. First-claim also wins on dynamic-cert collisions within the owner namespace: a host is claimed by exactly one BYO secret — the one on the oldest Ingress — so the served cert is deterministic, never sort-order or last-writer dependent.
Spec.TLS-only hosts (hosts with no routing rule) are also subject to cross-namespace ownership (FIX B): routingHostOwners seeds ownership from TLS entries too, so a host that appears only in spec.tls gets a namespace owner and the existing guard below rejects a different namespace's later claim. certCovers, when non-nil, reports whether the Secret ns/name's certificate SANs actually cover host (F10). A BYO host the cert does NOT cover is NOT registered with the wrong cert: it is surfaced as a mismatch Reject (warning Event) and falls through to ACME like a Secret-less host. A nil certCovers disables the check (coverage assumed) — used by older unit tests that only exercise existence/ownership.
func TranslateSites ¶
func TranslateSites(in Inputs) ([]RenderedSite, []Reject)
TranslateSites is Translate decomposed into per-host RenderedSite blocks (FIX 3). The concatenation of the blocks' Text (in order) is byte-identical to Translate's output.
func ValidateLabelSelector ¶
ValidateLabelSelector reports whether selector is a parseable Kubernetes label selector (empty is valid = off). The CLI calls it so a typo fails loudly at startup rather than silently degrading to watch-all.
Types ¶
type ACMEDomainPolicy ¶
ACMEDomainPolicy maps a namespace to the domain suffixes it is permitted to auto-issue ACME certificates for. A nil or empty map disables the allow-list (all watched hosts eligible). The suffixes are matched at a label boundary (apex or subdomain); a bare "*.suffix" entry is normalised to "suffix" (suffix matching already covers subdomains).
func ParseACMEDomainPolicy ¶
func ParseACMEDomainPolicy(spec string) (ACMEDomainPolicy, error)
ParseACMEDomainPolicy parses the operator-curated allow-list flag value into an ACMEDomainPolicy. The format is a ';'-separated list of "namespace=suffix[,suffix…]" entries, e.g. "team-a=team-a.example.com,*.svc.example.com ; team-b=team-b.example.com". An empty spec yields a nil policy (OFF — the default). A malformed entry (no '=' or an empty namespace) is a configuration error.
func (ACMEDomainPolicy) Allowed ¶
func (p ACMEDomainPolicy) Allowed(ns, host string) bool
Allowed reports whether namespace ns is permitted to auto-issue a certificate for host. A nil/empty policy permits everything (off). Otherwise ns must have an entry and host must fall under one of its permitted suffixes (apex or any subdomain).
type Applier ¶
Applier is the swap seam the controller drives: *server.Server satisfies it via Server.ApplyConfig (Task 1).
type Config ¶
type Config struct {
// ClassName is the IngressClass this controller serves (e.g. "cadish").
ClassName string
// Namespaces, when non-empty, restricts which namespaces' Ingresses are served
// (the informers still watch cluster-wide; reconcile filters). Empty ⇒ all.
Namespaces []string
// PublishService is "ns/name" of the Service whose address is written back to
// matched Ingresses' status.loadBalancer (Task 7).
PublishService string
// ResyncDebounce is the quiet window after the last watched change before a
// reconcile runs (default 250ms).
ResyncDebounce time.Duration
// Kubeconfig is the explicit kubeconfig path for the resolver client (out-of-cluster).
Kubeconfig string
// LeaderElection enables the leader-elected status writer (Task 7). When false the
// status writer runs unconditionally (single-replica / tests).
LeaderElection bool
// LeaderNamespace/LeaderName name the coordination.k8s.io Lease (Task 7).
LeaderNamespace string
LeaderName string
// Identity uniquely names this replica for leader election (e.g. the pod name).
Identity string
// ACMEEmail is the ACME account contact rendered into generated `tls acme`
// directives for spec.tls hosts without a Secret (optional).
ACMEEmail string
// ACMEDomainPolicy is the per-namespace ACME domain allow-list (A2,
// hostile-multi-tenant hardening). When nil/empty the allow-list is OFF and every
// watched ACME host is eligible (single-trust-domain default — unchanged). When set,
// a host whose OWNING namespace is not permitted that domain is excluded from the ACME
// issuer HostPolicy (no `tls acme` directive) and surfaced as a warning Event.
ACMEDomainPolicy ACMEDomainPolicy
// Caps holds the operator-configured per-namespace resource caps (B1,
// hostile-multi-tenant hardening): max sites/routes per namespace and max policy
// fragment bytes. The zero value disables every cap (default off = unlimited =
// unchanged behaviour). Excess is rejected oldest-Ingress-first with a per-Ingress
// Event; other namespaces are unaffected.
Caps ResourceCaps
// SecretLabelSelector, when non-empty, is a Kubernetes label selector applied to the
// Secret informer's list/watch (C1, hostile-multi-tenant hardening: bound the
// cluster-wide-Secret blast radius). Only Secrets MATCHING the selector ever enter
// the controller's cache, so a compromise cannot read every Secret in the cluster —
// only the so-labelled ones cadish is meant to consume (e.g. cadi.sh/managed=true).
// Empty (default) ⇒ OFF: every Secret is watched (current behaviour, unchanged).
// When set, operators MUST label their BYO/cert-manager TLS Secrets accordingly or
// the controller will not see them (the host then falls through to ACME).
SecretLabelSelector string
// ConfigMapLabelSelector mirrors SecretLabelSelector for the ConfigMap informer
// (cadi.sh/policy fragments). Empty (default) ⇒ OFF (watch all). When set, operators
// MUST label their policy ConfigMaps or the fragment is treated as not-found.
ConfigMapLabelSelector string
}
Config tunes the controller.
type Controller ¶
type Controller struct {
// contains filtered or unexported fields
}
Controller watches Ingress/IngressClass/Secret/ConfigMap (+ Layer-1 EndpointSlices), debounces change events, re-renders the Cadishfile and applies it via Applier. A bad render never takes serving down: the last good config stays live and a warning Event is emitted.
func New ¶
func New(cs kubernetes.Interface, applier Applier, base string, opts Config) *Controller
New builds a Controller. cs is the shared clientset (the controller builds its own informer factory from it); applier is the live server; base is the globals-only Cadishfile (cache/admin/tls defaults — sites come from Ingresses).
func (*Controller) Run ¶
func (c *Controller) Run(ctx context.Context) error
Run starts the informers, waits for cache sync, performs an initial reconcile, then reconciles on every debounced change until ctx is cancelled. Returns when ctx ends or the caches fail to sync.
func (*Controller) SetLogger ¶
func (c *Controller) SetLogger(l *slog.Logger)
SetLogger overrides the controller's logger (defaults to slog.Default()).
func (*Controller) Stats ¶
func (c *Controller) Stats() Stats
Stats returns the current reconcile snapshot (safe for concurrent use).
func (*Controller) UpdateBase ¶
func (c *Controller) UpdateBase(base string)
UpdateBase swaps the base (globals-only) Cadishfile and triggers a reconcile. The `cadish ingress` SIGHUP handler calls it after re-reading the base file from disk.
type Inputs ¶
type Inputs struct {
// Ingresses are the candidate Ingress objects (the controller pre-filters by class
// via identity.Matches; Translate additionally guards on spec.ingressClassName).
Ingresses []*networkingv1.Ingress
// Policies maps "ns/name" -> a cadi.sh/policy ConfigMap's Cadishfile fragment.
Policies map[string]string
// ClassName is the controller's IngressClass name (e.g. "cadish").
ClassName string
// DefaultClass is true when this controller's IngressClass is marked default, so an
// Ingress that sets no class at all is still ours (see identity.Matches).
DefaultClass bool
// ACMEHosts is the set of hosts (lowercased) whose spec.tls Secret is ABSENT, so
// cadish auto-issues their certificate via ACME (Secrets-if-present-else-ACME,
// design §19). The translator emits a `tls acme` directive on those sites, which
// flows through cfg.TLS into the manager's HostPolicy on apply. Hosts WITH a Secret
// get their cert via the controller's typed side-channel (Server.SetDynamicCerts)
// and so get NO `tls` directive here.
ACMEHosts map[string]bool
// ACMEEmail is the ACME account contact rendered into generated `tls acme`
// directives (optional).
ACMEEmail string
// Caps holds the operator-configured per-namespace resource caps (B1,
// hostile-multi-tenant hardening). The zero value disables every cap, so the
// translator behaves exactly as before (default off = unlimited = unchanged).
Caps ResourceCaps
}
Inputs is the snapshot the translator renders: the matched Ingress objects, the referenced policy fragments (keyed "ns/name"), and the controller's class name.
type PathKind ¶
type PathKind int
PathKind is the neutral path-match kind shared by both controllers. It maps onto the same Cadishfile matcher semantics regardless of which API surface produced it:
- PathExact → matches ONLY the exact path (Ingress Exact, Gateway Exact);
- PathPrefix → element-wise prefix: "/api" matches "/api" and "/api/…" but NOT "/apiother" (Ingress Prefix, Gateway PathPrefix). This is the Kubernetes Prefix semantics both APIs require.
type RedirectInjector ¶
type RedirectInjector interface {
SetForceRedirectHosts([]string)
}
RedirectInjector is the OPTIONAL side-channel for the per-host HTTP→HTTPS redirect opt-in (the `cadi.sh/ssl-redirect` annotation). *server.Server satisfies it via SetForceRedirectHosts. In Ingress mode the redirect is TLS-gated (only TLS hosts are 301'd); this lets an operator force the redirect on a non-TLS host when TLS is terminated upstream (LB/Cloudflare). A fake applier that does not implement it simply has no forced hosts.
type Reject ¶
Reject records one Ingress (or fragment) element that was skipped, with a reason. The controller turns each into a Kubernetes warning Event; serving is unaffected.
func FilterACMEDomains ¶
func FilterACMEDomains(acmeHosts []string, owner map[string]string, policy ACMEDomainPolicy) (kept []string, rejects []Reject)
FilterACMEDomains splits acmeHosts into the hosts that are PERMITTED for ACME issuance (kept) and a Reject for each host whose OWNER namespace is not allowed its domain by the policy. owner maps host → owning namespace (routingHostOwners). A nil/empty policy keeps every host (off-by-default — unchanged single-trust-domain behaviour). A host with no known owner is kept (it cannot be attributed to a namespace, so the allow-list cannot apply — it stays bounded by the watched-host union as before). kept preserves input order; rejects are sorted for deterministic Events.
type RenderRoute ¶
type RenderRoute struct {
// Host is the lowercased site hostname.
Host string
// Path is the normalized request path ("/" for a catch-all). An empty Path marks a
// host-only entry (the host still becomes a site, with no route).
Path string
// Kind is the path-match kind (Prefix or Exact).
Kind PathKind
// Namespace/Service/Port name the k8s:// backend (resolved by Layer 1 at apply).
Namespace string
Service string
Port string
}
RenderRoute is one (host, path) → Service backend mapping, in neutral terms. The Gateway translator builds a slice of these and hands them to RenderHTTPSites; the Ingress translator continues to use its richer internal routeEntry directly.
type RenderedSite ¶
type RenderedSite struct {
Host string
Text string // the full "host { … }\n" block
Ingresses []string // contributing Ingress keys "ns/name" (sorted)
}
RenderedSite is one host's rendered Cadishfile block plus the keys of the Ingresses that contributed a rule (or policy) to it. The controller uses these to compile site-by-site and DROP ONLY a site that breaks the combined compile (FIX 3), emitting a targeted Event for each contributing Ingress, instead of freezing all routing.
func RenderHTTPSites ¶
func RenderHTTPSites(routes []RenderRoute) []RenderedSite
RenderHTTPSites renders the given neutral routes into the same RenderedSite model (one `host { … }` block per host) the Ingress translator produces. It reuses renderSites/writeSite, so the output carries the identical upstream dedup, specificity ordering, element-wise PathPrefix matchers, and the F7 terminal no-match 404 — a Gateway-rendered site 404s on an unmatched path exactly like an Ingress-rendered one (it never falls through to the first upstream). RenderedSite.Ingresses is left empty for the caller to populate with its own attribution (e.g. HTTPRoute keys).
type ResourceCaps ¶
type ResourceCaps struct {
// MaxSitesPerNamespace bounds the number of distinct hosts (sites) a single
// namespace may render. 0 = unlimited.
MaxSitesPerNamespace int
// MaxRoutesPerNamespace bounds the number of routes (accepted path rules) a single
// namespace may render. 0 = unlimited.
MaxRoutesPerNamespace int
// MaxFragmentBytes bounds the size in bytes of a single cadi.sh/policy fragment. An
// over-size fragment is rejected (with an Event) BEFORE it is validated/compiled.
// 0 = unlimited.
MaxFragmentBytes int
}
ResourceCaps holds the operator-configured per-namespace resource caps. The zero value (every field 0) disables all caps, so the controller behaves exactly as before. Caps are evaluated per namespace, oldest-Ingress-first, so the excess (not the earlier claims) is what gets rejected.
type SecretRef ¶
SecretRef is one TLS Secret to load as a bring-your-own / cert-manager certificate, with the hosts it terminates.
type Stats ¶
type Stats struct {
// WatchedIngresses is the number of Ingress objects this controller owns.
WatchedIngresses int
// LastAppliedHash is a short hash of the last successfully applied rendered config
// (changes whenever the live routing changes; "" before the first apply).
LastAppliedHash string
// Rejects is the number of per-Ingress rejects in the last reconcile.
Rejects int
// LastError is the last render/apply error message ("" when healthy).
LastError string
// IsLeader reports whether THIS replica currently writes Ingress status.
IsLeader bool
}
Stats is a point-in-time snapshot of the controller's reconcile state, surfaced for observability (the admin dashboard reconcile panel; design §20).
type TLSInjector ¶
type TLSInjector interface {
SetDynamicCerts([]tlsacme.DynamicCert) error
}
TLSInjector is the OPTIONAL typed side-channel for BYO / cert-manager certs from Ingress spec.tls Secrets (D55). *server.Server satisfies it via SetDynamicCerts. When the Applier also implements it, the controller injects Secret certs on every reconcile (a hot swap — no restart); a fake applier that does not implement it simply disables BYO-Secret TLS (ACME-via-Cadishfile still works).
type Warmer ¶ added in v0.2.1
type Warmer interface {
MarkWarm()
}
Warmer is the OPTIONAL seam the controller uses to flip the data plane's warm-readiness flag AFTER its FIRST successful reconcile builds the routing table from synced listers. *server.Server satisfies it via MarkWarm. Until marked warm the reserved /.cadish/readyz probe returns 503, so the pod's readiness/startup probe keeps Kubernetes from routing to a not-yet-reconciled pod (no rollout 502). A bare-Applier test fake that does not implement it simply has no warm gate. MarkWarm is idempotent, so calling it on every successful reconcile is safe.