Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Running a Compiled ValidatingAdmissionPolicy

Use runtime::Builder exactly as you would for any other compiled CEL module. Pass the variable bindings as a JSON object:

#![allow(unused)]
fn main() {
use ferricel_core::{compiler::Builder, runtime};

let wasm_bytes = Builder::new().build().compile_vap(&yaml)?;

let result_str = runtime::Builder::new()
    .with_wasm(wasm_bytes)
    .build()?
    .eval(Some(&bindings_json))?;

let result: serde_json::Value = serde_json::from_str(&result_str)?;
// result["accepted"] == true / false
}

build() (and build_pre()) check the module’s ABI version before they link it. A module compiled by a ferricel with an incompatible ABI makes build() return Err, naming both ABI versions. See ABI Version in the Wasm Spec chapter.

Runtime Errors and failurePolicy

The host passes the failurePolicy of the policy in the bindings, as the failurePolicy key. The value is "Fail" or "Ignore". A missing or null key means Fail. The module does not read spec.failurePolicy from the VAP.

Make sure that the value is valid before it reaches the bindings. If the value is anything else (for example "ignore", in lowercase), the module traps with a CEL runtime error before the first expression runs. A host that maps every CelRuntimeError to “allow” under Ignore turns its own configuration error into an accept.

Under Fail, eval() returns Err when a validations expression evaluates to a runtime error. Examples: division by zero, an unbound variable, or a kw.k8s extension that returns an error. The error downcasts to ferricel_core::CelRuntimeError. Its Display text starts with CEL runtime error:. The module never turns such an error into an accept or a reject response.

A matchConditions expression works the same way, but only when no condition in the same param is false. A false result always wins, even over an error in another condition of the same param. See matchConditions for the exact rule.

Under Ignore, the module skips the expression that evaluates to an error and continues. eval() returns Ok, and the response carries a warnings list with one entry per skipped expression. See Runtime Errors for the exact rules. Under Ignore, eval() still returns Err when the params or namespaceObject lookup fails, and when the failurePolicy value is invalid.

eval() can also fail for reasons that are not CEL runtime errors: an epoch-deadline interrupt, a memory limit, a Wasm trap, or a bug in a host extension. These errors do not downcast to CelRuntimeError.

With downcast_ref, the host can tell a CEL runtime error apart from other failures:

#![allow(unused)]
fn main() {
use ferricel_core::CelRuntimeError;

match engine.eval(Some(&bindings_json)) {
    Ok(result_str) => {
        let result: serde_json::Value = serde_json::from_str(&result_str)?;
        // result["accepted"] == true / false
        // result["warnings"]: present under `Ignore` when the module skipped
        // an expression. Forward it to the client and the audit log.
    }
    Err(err) => match err.downcast_ref::<CelRuntimeError>() {
        Some(cel_err) => {
            // A CEL expression evaluated to an error under `Fail`, or the
            // `params` or `namespaceObject` lookup failed, or the
            // `failurePolicy` binding is invalid. Apply failurePolicy:
            //   Fail   -> deny the request, report `cel_err.message`
            //   Ignore -> allow the request
        }
        None => {
            // Not a CEL runtime error: a deadline, a resource limit, a trap,
            // or a host bug. The host decides how to handle it.
        }
    },
}
}

CelRuntimeError has two fields:

FieldTypeDescription
messageStringThe error message, for example divide by zero.
originOption<ExtensionOrigin>The host extension that produced the error, or None.

ExtensionOrigin holds the namespace (for example Some("kw.k8s")) and the function (for example get) of the extension call. When the params lookup fails in the host, origin is kw.k8s.get or kw.k8s.list. When the namespaceObject lookup fails, origin is kw.k8s.get. A host can use this to tell a failed params or namespaceObject lookup apart from other runtime errors.

See Runtime Errors for the exact rules.

Required Bindings

BindingRequired when…
objectAlways (the resource being admitted)
oldObjectPolicy expressions reference oldObject
requestPolicy expressions reference request or namespaceObject, or paramKind is set
paramRefparamKind is set (see below)
failurePolicyOptional. "Fail" (the default when missing or null) or "Ignore". See Runtime Errors and failurePolicy

object, oldObject, and request correspond directly to the fields of the Kubernetes AdmissionReview request object. The module does not need namespaceObject in the bindings: it resolves the Namespace itself from request, through the gated kw.k8s.get host extension. See Namespace object below.

Determining whether a given policy actually references oldObject, request, or namespaceObject — without evaluating the policy or re-parsing its CEL/YAML source — is exactly what the ferricel.vap-variables custom section is for. Read it with ferricel_core::vap_variables_used() (or ferricel inspect --json) at policy-setup time. This is particularly relevant for namespaceObject: a host that finds it in the section must register kw.k8s.get and grant the policy access to v1/Namespace — see Custom sections and inspection for details.

Kubernetes Resource Fetching

Namespace object

When a policy references namespaceObject, the compiled module resolves it itself at evaluation time, the same way it resolves params. The host does not supply namespaceObject in the bindings; it supplies request instead (see Required Bindings). A host that still passes namespaceObject is not an error — the module’s own value overwrites it — but a 0.11+ host should stop doing so.

The module applies these rules, in order:

  1. request missing, or not a map: the module traps with request binding is missing. The host contract requires request whenever the policy references namespaceObject.
  2. request.kind is the core v1/Namespace GroupVersionKind (the resource under admission is itself a Namespace): namespaceObject is null, and the host is not called. On CREATE, request.namespace equals the new Namespace’s own name, and the Namespace does not exist yet — fetching it would 404.
  3. request.namespace missing or empty (a cluster-scoped request): namespaceObject is null, and the host is not called.
  4. Otherwise: one kw.k8s.get call for {apiVersion: "v1", kind: "Namespace", name: request.namespace}. Unlike the params request map, this map has no namespace key — Namespace is cluster-scoped. A host error traps with origin kw.k8s.get, and the host applies its failurePolicy.

The fetch runs once per evaluation, before params, so matchConditions and params-dependent expressions can read namespaceObject too.

Register kw.k8s.get the same way as for params:

#![allow(unused)]
fn main() {
use ferricel_core::compiler::vap;

runtime::Builder::new()
    .with_extension(vap::kw_k8s_get_extension(), |args| {
        let map = &args[0];
        let name        = map["name"].as_str().unwrap();
        let api_version = map["apiVersion"].as_str().unwrap(); // "v1"
        let kind        = map["kind"].as_str().unwrap();       // "Namespace"

        // Fetch the Namespace from Kubernetes and return it as JSON.
        let namespace = fetch_from_k8s(api_version, kind, "", name)?;
        Ok(namespace)
    })
    // ... .with_wasm(...), other extensions, .build()?
}

A host that derives required capabilities from ferricel.extensions grants kubernetes/get_resource for kw.k8s.get automatically; it must also grant the policy access to v1/Namespace in its own authorization model (the ferricel.vap-variables section names the policies that need this grant).

Params

When a policy sets paramKind, the compiled module fetches the param resources itself at evaluation time. It calls the host-provided kw.k8s.get or kw.k8s.list extension. The host does not supply params directly in the bindings.

The host must supply paramRef in the bindings. paramRef is the spec.paramRef of the ValidatingAdmissionPolicyBinding, as JSON. The host must also supply request, so the module can default the namespace (see below).

The module supports the two ways Kubernetes selects param resources:

  • paramRef.name: one resource, by name. The module calls kw.k8s.get.
  • paramRef.selector: every resource that matches a label selector. The module formats the selector as a label selector string, for example app=web,tier in (api,web), and calls kw.k8s.list. Each item in the items list of the response is one param resource.

If paramRef has both name and selector, name wins. If it has neither, the module traps with the error paramRef must have either name or selector.

Namespace defaulting

The namespace in the request map is paramRef.namespace. If that is empty, the module uses request.namespace. If that is also empty, namespace is "". The params request map always contains the namespace key. This is different from a kw.k8s chain written in CEL, where namespace is present only when the policy calls .namespace().

Per-param evaluation

The module evaluates the policy once per param resource. For each param, it sets the params binding, then evaluates matchConditions, variables, and validations. The first rejection stops the evaluation and becomes the response. When every param passes, or the param list is empty, the module returns {"accepted": true}. See Evaluation Order.

parameterNotFoundAction

When the host call fails, or kw.k8s.list returns zero items, the module reads paramRef.parameterNotFoundAction:

  • Allow: the param list is empty. The module returns {"accepted": true}.
  • Deny, or unset: the module traps with a CelRuntimeError. The host then applies its failurePolicy. A host error keeps its origin (kw.k8s.get or kw.k8s.list). An empty list produces the message no parameters found.

Under Allow, a host error of any kind counts as “not found”. The module cannot tell a missing resource apart from an authorization error. See LIMITATIONS.md.

Bindings and host registration

A binding with paramRef.name:

{
  "paramRef": {
    "name": "my-params",
    "namespace": "default",
    "parameterNotFoundAction": "Deny"
  },
  "request": { "namespace": "team-a", ... },
  "object": { ... }
}

A binding with paramRef.selector:

{
  "paramRef": {
    "selector": {
      "matchLabels": { "app": "web" },
      "matchExpressions": [
        { "key": "tier", "operator": "In", "values": ["api", "web"] }
      ]
    },
    "parameterNotFoundAction": "Allow"
  },
  "request": { "namespace": "team-a", ... },
  "object": { ... }
}

The host must register both kw.k8s.get and kw.k8s.list on the runtime builder. Which one the module calls depends on the binding, not on the policy:

#![allow(unused)]
fn main() {
use ferricel_core::{compiler::Builder, runtime, compiler::vap};

let wasm_bytes = Builder::new().build().compile_vap(&yaml)?;

let result_str = runtime::Builder::new()
    .with_wasm(wasm_bytes)
    .with_extension(vap::kw_k8s_get_extension(), |args| {
        // args[0] is the request map (see shape below)
        let map = &args[0];
        let name        = map["name"].as_str().unwrap();
        let namespace   = map["namespace"].as_str().unwrap(); // can be ""
        let api_version = map["apiVersion"].as_str().unwrap();
        let kind        = map["kind"].as_str().unwrap();

        // Fetch from Kubernetes and return the resource as a JSON value.
        let resource = fetch_from_k8s(api_version, kind, namespace, name)?;
        Ok(resource)
    })
    .with_extension(vap::kw_k8s_list_extension(), |args| {
        let map = &args[0];
        let label_selector = map["labelSelector"].as_str().unwrap();
        let namespace      = map["namespace"].as_str().unwrap(); // can be ""
        let api_version    = map["apiVersion"].as_str().unwrap();
        let kind           = map["kind"].as_str().unwrap();

        // List from Kubernetes and return `{"items": [...]}`.
        let list = list_from_k8s(api_version, kind, namespace, label_selector)?;
        Ok(list)
    })
    .build()?
    .eval(Some(&bindings_json))?;
}

Fetching Data from the Kubernetes API

The kw.k8s API is implemented as a builder chain. See the Host Extensions chapter for general documentation on declaring and consuming builder chains.

Policy variables (and other expressions) can call kw.k8s directly to fetch arbitrary resources. The API mirrors the kw.k8s Kubernetes library provided by the Kubewarden CEL policy:

kw.k8s
  .apiVersion(<string>)     → kw.k8s.ClientBuilder
  .kind(<string>)           → kw.k8s.Client
  .namespace(<string>)      → kw.k8s.Client   (optional)
  .labelSelector(<string>)  → kw.k8s.Client   (optional)
  .fieldSelector(<string>)  → kw.k8s.Client   (optional)
  .fieldMask(<string>)      → kw.k8s.Client   (optional, repeatable)
  .get(<string>)            → dyn              (host call — returns one resource)
  .list()                   → dyn              (host call — returns a list)

Example — fetch a ConfigMap in a variable, then check a field in a validation:

// variables entry
kw.k8s.apiVersion('v1').kind('ConfigMap').namespace('default').get('my-config')

// validation expression
variables.cfg.data.allowedTeam == request.userInfo.groups[0]

Host Extension Request Map

When a kw.k8s.get or kw.k8s.list terminal is called, the host receives a single argument — a JSON object containing the accumulated builder state:

KeySet by chain stepNotes
apiVersion.apiVersion()Always present
kind.kind()Always present
namespace.namespace()Present only if .namespace() was called
labelSelector.labelSelector()Present only if called
fieldSelector.fieldSelector()Present only if called
fieldMasks.fieldMask()Array; present only if called
name.get(<name>)Present only for get terminal

For the params lookup, the module builds this map itself. It always sets apiVersion, kind, and namespace. It sets name for paramRef.name and labelSelector for paramRef.selector. See Params.

For the namespaceObject lookup, the module also builds the map itself, but never sets namespace — Namespace is cluster-scoped. It sets apiVersion ("v1"), kind ("Namespace"), and name (request.namespace). See Namespace object.

Register the extensions using the helpers from ferricel_core::compiler::vap:

#![allow(unused)]
fn main() {
use ferricel_core::compiler::vap;

// For policies that call .get(...), or set paramKind
runtime::Builder::new()
    .with_extension(vap::kw_k8s_get_extension(), |args| { ... })

// For policies that call .list(), or set paramKind
runtime::Builder::new()
    .with_extension(vap::kw_k8s_list_extension(), |args| { ... })
}

Example

This example mirrors the scenario from the Kubewarden CEL policy README: a policy that enforces a maximum replica count read from a ConfigMap parameter resource.

The ValidatingAdmissionPolicyBinding

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: "demo-policy-binding.example.com"
spec:
  policyName: "demo-policy.example.com"
  validationActions: [Deny]
  paramRef:
    name: "my-params"
    namespace: "default"
    parameterNotFoundAction: Deny
  matchResources:
    namespaceSelector:
      matchLabels:
        environment: test

The ConfigMap Parameter Resource

apiVersion: v1
kind: ConfigMap
metadata:
  name: my-params
  namespace: default
data:
  maxreplicas: "5"

The Incoming Deployment

This is the resource being admitted:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
  namespace: default
spec:
  replicas: 3
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      containers:
        - name: my-app
          image: my-app:latest

Rust Integration

#![allow(unused)]
fn main() {
use ferricel_core::{compiler::{Builder, vap}, runtime};

// The host extracts these from the AdmissionReview and the PolicyBinding.
let bindings = serde_json::json!({
    "paramRef": {
        "name": "my-params",
        "namespace": "default",
        "parameterNotFoundAction": "Deny"
    },
    "request": request_json,
    "object": object_json,
});

let wasm_bytes = Builder::new().build().compile_vap(vap_yaml)?;

let result_str = runtime::Builder::new()
    .with_wasm(wasm_bytes)
    .with_extension(vap::kw_k8s_get_extension(), |args| {
        // The module calls this to fetch the ConfigMap.
        // In production, make a real Kubernetes API call here.
        let map = &args[0];
        assert_eq!(map["apiVersion"], "v1");
        assert_eq!(map["kind"], "ConfigMap");
        assert_eq!(map["name"], "my-params");
        assert_eq!(map["namespace"], "default");
        Ok(serde_json::json!({
            "apiVersion": "v1",
            "kind": "ConfigMap",
            "metadata": { "name": "my-params", "namespace": "default" },
            "data": { "maxreplicas": "5" }
        }))
    })
    .with_extension(vap::kw_k8s_list_extension(), |args| {
        // Not called for this binding, because `paramRef.name` is set.
        // A binding with `paramRef.selector` calls this instead.
        unreachable!("this binding uses paramRef.name")
    })
    .build()?
    .eval(Some(&bindings.to_string()))?;

let result: serde_json::Value = serde_json::from_str(&result_str)?;
assert_eq!(result["accepted"], true);  // replicas 3 <= maxreplicas 5
}