Ingress and Workloads
Cosmonic Control uses Envoy as its HTTP ingress proxy and a set of Kubernetes CRDs for declaring workloads. This page explains how to configure ingress and deploy WebAssembly workloads.
What are Cosmonic Control deployment manifests?
Deploying a workload to Cosmonic Control is the same as deploying any other resource to Kubernetes: declarative Custom Resource Definition (CRD) manifests written in YAML.
Workload manifests can be applied manually with kubectl and managed in GitOps pipelines with tools like Argo CD. Manifests are composed according to the runtime.wasmcloud.dev/v1alpha1 API for workloads and the control.cosmonic.io/v1alpha2 API for HTTP routing.
How ingress works
Cosmonic Control routes external HTTP traffic to Wasm workloads in two stages:
- An edge proxy terminates external connections. Traefik is deployed by default; Istio is an alternative; operators can also bypass the edge proxy and expose Envoy directly.
- Envoy sits behind the edge proxy on a
ClusterIPKubernetes Service namedingress. Envoy matches the request'sHostheader against the XDS routing configuration and forwards the request to the Wasm workload.
Envoy's routing table is pushed over gRPC from the XDS cache whenever workloads or HTTPTriggers change. If a workload runs on multiple hosts, requests are load balanced round robin. If a host crashes, it drops out of the rotation and the workload continues to serve from the remaining hosts.
Traefik (default)
Installing the chart with no overrides deploys Traefik as the edge proxy and creates a Traefik IngressClass marked as the cluster default. Traefik's Service is a NodePort on ports 30080 (http) and 30443 (https):
helm install cosmonic-control oci://ghcr.io/cosmonic/cosmonic-control \
--version 0.14.0 \
--namespace cosmonic-system \
--create-namespaceFor a local Kind cluster, forward host ports 80 and 443 to those NodePorts:
# kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 30080
hostPort: 80
protocol: TCP
- containerPort: 30443
hostPort: 443
protocol: TCPThe chart auto-creates an Ingress for the Perses dashboard at perses.localhost.cosmonic.sh. To auto-create Ingress resources for additional hosts at install time, list them under ingress.hosts:
# values.yaml
ingress:
hosts:
- host: "welcome-tour.localhost.cosmonic.sh"
- host: "hello.localhost.cosmonic.sh"For workloads deployed after the install, add a standard Kubernetes Ingress that points at the ingress Service (Envoy) on port 80. Envoy resolves the request to the right workload based on the Host header:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: welcome-tour
namespace: cosmonic-system
annotations:
# "web" for http, "websecure" for https. List both to accept both.
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
traefik.ingress.kubernetes.io/router.observability.accesslogs: "true"
traefik.ingress.kubernetes.io/router.observability.metrics: "true"
traefik.ingress.kubernetes.io/router.observability.tracing: "true"
spec:
ingressClassName: traefik
rules:
- host: welcome-tour.localhost.cosmonic.sh
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ingress
port:
number: 80Istio
To route through an existing Istio IngressGateway instead, set ingress.provider=istio. The chart creates a Gateway (cosmonic-gateway by default, configurable via ingress.istio.gatewayName) and a VirtualService for each entry under ingress.hosts, plus a VirtualService for the Perses dashboard:
helm install cosmonic-control oci://ghcr.io/cosmonic/cosmonic-control \
--version 0.14.0 \
--namespace cosmonic-system \
--create-namespace \
--set ingress.provider=istio \
--set 'ingress.hosts[0].host=*.localhost.cosmonic.sh'If the IngressGateway pods use labels other than istio: ingressgateway, override ingress.istio.ingressGatewaySelector. Istio itself (control plane and IngressGateway) must be installed before the Cosmonic Control chart; istioctl install --set profile=minimal plus a separate components.ingressGateways[0] install covers the baseline setup.
Direct Envoy exposure
To bypass the edge proxy altogether and expose Envoy directly, set ingress.enabled=false and set envoy.service.type to the appropriate service type. Traefik is not deployed in this mode, and the Perses Ingress is not auto-created.
On a cloud cluster, expose Envoy as a LoadBalancer:
helm install cosmonic-control oci://ghcr.io/cosmonic/cosmonic-control \
--version 0.14.0 \
--namespace cosmonic-system \
--create-namespace \
--set ingress.enabled=false \
--set envoy.service.type=LoadBalancerCloud-specific annotations go on envoy.service.annotations. For example, to pin AWS to an internet-facing NLB:
--set-json 'envoy.service.annotations={"service.beta.kubernetes.io/aws-load-balancer-type":"nlb","service.beta.kubernetes.io/aws-load-balancer-scheme":"internet-facing"}'On a bare-metal cluster, expose a NodePort and front it with your own load balancer or proxy:
--set ingress.enabled=false \
--set envoy.service.type=NodePort \
--set envoy.service.httpNodePort=30950TLS
Envoy handles plain HTTP. TLS terminates at the edge:
- Traefik: set
ingress.traefik.tls.enabled: trueto serve HTTPS on thewebsecureentrypoint for the Perses host and everyingress.hostsentry, with a certificate from cert-manager or an existing Secret incosmonic-system(v0.13.0 and later; see HTTPS on the bundled Traefik ingress for the values). For Ingress objects you add yourself, configure TLS with annotations such astraefik.ingress.kubernetes.io/router.tls: "true"and acertresolver, or supply Secrets viaspec.tls. See the Traefik TLS routing docs. - Istio: configure TLS on the Gateway resource.
- Direct Envoy exposure: terminate TLS upstream, for example via Cloudflare or a cloud load balancer, and forward plain HTTP to the
ingressService.
How to deploy WebAssembly workloads with ingress
An HTTP workload is two resources: a WorkloadDeployment that runs the component, and an HTTPTrigger that routes requests to it. The minimum required fields per component are:
name: Name of the componentimage: OCI address for the component image
The HTTPTrigger Helm chart renders both resources from one values file (chart 0.2.0 and later, for Cosmonic Control v0.13.0 and later). Additional interfaces (beyond wasi:http) and the ingress host are typically supplied there:
components:
- name: blobby
image: ghcr.io/cosmonic-labs/components/blobby:0.2.0
ingress:
host: 'blobby.wasmworkloads.io'
hostInterfaces:
- namespace: wasi
package: blobstore
version: 0.2.0-draft
interfaces:
- blobstore
- namespace: wasi
package: logging
version: 0.1.0-draft
interfaces:
- loggingIn an Argo CD deployment:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: demo-hello
namespace: argocd
spec:
project: default
source:
path: .
repoURL: oci://ghcr.io/cosmonic-labs/charts/http-trigger
targetRevision: 0.2.0
helm:
values: |
components:
- name: http
image: ghcr.io/cosmonic-labs/control-demos/hello-world:0.1.2
ingress:
host: "hello.localhost.cosmonic.sh"
destination:
name: in-cluster
namespace: hello
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- ServerSideApply=true
- CreateNamespace=true
- RespectIgnoreDifferences=true
retry:
limit: -1
backoff:
duration: 30s
factor: 2
maxDuration: 5mThe chart renders a WorkloadDeployment and an HTTPTrigger with the release name. Written by hand, the same workload is:
apiVersion: runtime.wasmcloud.dev/v1alpha1
kind: WorkloadDeployment
metadata:
name: demo-hello
namespace: hello
spec:
replicas: 1
template:
spec:
hostInterfaces:
- namespace: wasi
package: http
interfaces:
- incoming-handler
config:
host: hello.localhost.cosmonic.sh
components:
- name: http
image: ghcr.io/cosmonic-labs/control-demos/hello-world:0.1.2
---
apiVersion: control.cosmonic.io/v1alpha2
kind: HTTPTrigger
metadata:
name: demo-hello
namespace: hello
spec:
hostnames:
- hello.localhost.cosmonic.sh
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: demo-helloDeclare the inbound wasi:http interface on the WorkloadDeployment (incoming-handler for a WASI P2 component, handler for a WASI P3 component), along with any other capability imports, under spec.template.spec.hostInterfaces. config.host names the hostname the workload serves. Components that fetch over the network add wasi:http/outgoing-handler and scope outbound destinations with localResources.allowedHosts:
apiVersion: runtime.wasmcloud.dev/v1alpha1
kind: WorkloadDeployment
metadata:
name: api
spec:
replicas: 1
template:
spec:
hostInterfaces:
- namespace: wasi
package: http
interfaces:
- incoming-handler
- outgoing-handler
config:
host: api.example.com
- namespace: wasi
package: logging
version: 0.1.0-draft
interfaces:
- logging
components:
- name: api
image: ghcr.io/example/api:0.1.0
localResources:
allowedHosts:
- 'https://upstream.example.com'The inbound entry needs no version. Without it, the workload still reports ready and the trigger still reports Programmed, but the host has nothing to dispatch the request to and every request gets a 404. If a newly deployed HTTP workload answers only 404, check this entry first.
The WorkloadDeployment manages WorkloadReplicaSet and Workload resources. The HTTPTrigger only routes: several triggers can share a hostname with different paths, several triggers can point at the same WorkloadDeployment, and deleting a trigger never deletes the workload. Before v0.13.0 the trigger embedded the workload and created the WorkloadDeployment itself; see Upgrading from v0.12.2 to v0.13.0.
Request timeouts
To bound how long Envoy waits for a response, set timeouts on the trigger rule:
spec:
rules:
- backendRefs:
- name: api
timeouts:
request: 300s # whole request, default 15s
backendRequest: 60s # each try against the hostDurations use the Gateway API format: up to four of h, m, s and ms, such as 300s or 5m. backendRequest cannot be longer than request. When Envoy gives up it returns upstream request timeout.
Splitting traffic
A rule can list several backendRefs with a weight each, for blue/green and canary rollouts. Weights are relative, a missing weight counts as 1, and 0 sends a backend nothing:
spec:
hostnames:
- hello.example.com
rules:
- backendRefs:
- name: hello-v1
weight: 90
- name: hello-v2
weight: 10A backend whose WorkloadDeployment does not exist answers its share with a 500, and a backend with no ready workloads answers its share with a 503 rather than shifting it to the other backends, so a broken canary shows up at its own weight. Backends in one rule that set different config.host values cannot share the rule.
Path matching
Each rule lists matches, and a request that satisfies any one of them uses the rule. A match is a path with a type and a value. A rule with no matches matches every path (PathPrefix /). Two path types are routed, following the Gateway API HTTPPathMatch:
Exact: the request path must match exactly.
matches:
- path:
type: Exact
value: /api/users/api/users matches only /api/users. /api/users/123 does not match.
PathPrefix: the request path must begin with the prefix, compared by whole path segments.
matches:
- path:
type: PathPrefix
value: /api/api matches /api, /api/users, and /api/v1/users. A trailing slash on a prefix is ignored, so /api/ and /api are the same match.
The schema also accepts RegularExpression, but Control does not route it yet: a match that uses it is dropped and reported in the trigger's status. Paths must start with /, be at most 1024 characters, and must not contain //, /./, /../, %2f or #.
Conflict detection
When two triggers claim the same hostname and path match, the oldest trigger keeps it, following HTTPRoute precedence. The newer trigger reports the lost match as Conflicted in its status and keeps serving its other rules (PartiallyInvalid), or reports Accepted: False if nothing is left. Within a trigger, an earlier rule wins over a later one. Envoy checks exact matches before prefixes, and longer prefixes before shorter ones. Before v0.13.0 the newest trigger won instead.
Trigger status
kubectl get httptriggers prints HOSTNAMES, ACCEPTED, RESOLVED and PROGRAMMED:
ACCEPTEDisFalsewhen no rule can be routed.RESOLVEDisFalsewhen abackendRefsentry names aWorkloadDeploymentthat does not exist.PROGRAMMEDisTrueonce every Envoy replica has accepted the configuration containing the trigger's current routes.
status.backends lists each referenced WorkloadDeployment with its weight and readyWorkloads. Use kubectl describe httptrigger <name> to read the condition messages.
Non-HTTP workloads
Workloads that do not serve inbound HTTP are deployed directly as a WorkloadDeployment, without an HTTPTrigger. The most common shape is a NATS-driven worker that exports wasmcloud:messaging/handler@0.2.0 and replies on the reply_to subject.
apiVersion: runtime.wasmcloud.dev/v1alpha1
kind: WorkloadDeployment
metadata:
name: task-worker
namespace: default
spec:
replicas: 1
template:
spec:
hostInterfaces:
- namespace: wasi
package: logging
version: 0.1.0-draft
interfaces:
- logging
- namespace: wasmcloud
package: messaging
version: '0.2.0'
interfaces:
- handler
- consumer
- types
config:
subscriptions: tasks.worker
components:
- name: task-worker
image: ghcr.io/example/task-worker:0.1.0
poolSize: 1config.subscriptions on the messaging interface is the NATS subject the host subscribes to on the component's behalf. Inbound messages on that subject invoke the component's wasmcloud:messaging/handler.handle_message export.
A typical two-component application pairs an HTTP workload (a WorkloadDeployment behind an HTTPTrigger) with one of these WorkloadDeployments: the HTTP workload publishes a request via wasmcloud:messaging/consumer.request, and the worker receives it on the subscribed subject and publishes a reply.
Service-plus-component workloads
For workloads that need a long-running, in-memory state alongside per-request component invocations (a parsed corpus, a search index, a cache), use template.spec.service to colocate a service with the components on the same host. The service exports wasi:cli/run and stays resident; components make loopback TCP calls to it, typically on 127.0.0.1.
spec:
template:
spec:
volumes:
- name: data
ephemeral: {}
service:
image: ghcr.io/example/index-service:0.1.0
localResources:
volumeMounts:
- name: data
mountPath: /data
components:
- name: api
image: ghcr.io/example/api:0.1.0
localResources:
volumeMounts:
- name: data
mountPath: /dataThe shared ephemeral volume is the canonical way to hand snapshots from the service side to the component side without reaching out to external storage. volumeMounts defaults to read-write; the readOnly field accepts only true or omission.
hostInterfaces is a scheduling filter
spec.template.spec.hostInterfaces is matched against the set of interfaces the host advertises. If a workload declares an interface the host does not advertise, the workload fails placement with WORKLOAD_STATE_ERROR rather than failing at link time inside a host pod.
Two consequences worth knowing:
- A workload that imports
wasi:webgpu/webgpu@0.0.1requires--set gpu=trueon the hostgroup chart so the host advertises WebGPU. Without it, the workload sticks atWORKLOAD_STATE_ERRORand never schedules. - If a workload declares only the interfaces it actually imports — and the host advertises at least those — the workload will place. Declaring extra interfaces that the host does not provide will block placement, even if the component does not actually use them.
Warm instance pools
A component can keep a pool of warm instances ready so requests avoid a cold start. Set poolSize on the component (spec.template.spec.components[].poolSize) in a WorkloadDeployment; the pool grows to that size under load. Warm pools benefit a WASI p3 handler component; a p2 incoming-handler accepts poolSize but ignores it and serves every request from a fresh instance.
spec:
template:
spec:
components:
- name: api
poolSize: 8 # warm instances kept ready
reclaimWindowSeconds: 300 # retire instances an idle sweep finds unused
reclaimMinInstances: 2 # never sweep below this floorTwo optional fields, added in Cosmonic Control v0.9.3, tune how an idle pool shrinks:
reclaimWindowSeconds: how long the pool watches its own peak concurrency before retiring the warm instances that peak did not need. A pool grows topoolSizeunder load and, left unset, stays there, so a spike's high-water mark outlives the spike. Set this and the pool sweeps every window, keeps the instances its measured peak actually needed, and drains the rest. Unset, warm instances are never reclaimed for idleness. Only meaningful alongsidepoolSize.reclaimMinInstances: a floor the idle sweep never retires below, capped atpoolSize. It is a floor on reclaim, not a target to grow to: instances are still built only when a call needs one. Unset lets a fully idle pool empty out, so the first call after a quiet spell starts cold. Only meaningful alongsidereclaimWindowSeconds.
Further reading
- Browse the Template Catalog for more
HTTPTriggerandWorkloadDeploymentexamples. - See Custom Resources for the full CRD reference.
- See GitOps with Argo CD for a complete GitOps workflow.
Many existing wasmCloud applications include OAM-formatted YAML manifests designed for vanilla wasmCloud deployments. These OAM manifests are not Kubernetes-native and are not to be confused with the CRD manifests used by Cosmonic Control. (OAM manifests can be identified by the API version core.oam.dev/v1beta1.)