A Go CEL Policy Engine in Java, with Quarkus Chicory

A few days ago, we released the first version of Quarkus Chicory, an extension that brings the power of the Chicory WebAssembly runtime to Quarkus applications.

While iterating on development, we felt the need to implement an integration test based on a real world use case.

After some research and experiments on popular scenarios we finally landed on a tasty one :-)

A Kubernetes-style CEL Policy Engine

CEL allows expression based policy validation of Kubernetes resources, and this requirement is implemented by operators…​ which are generally written in Go :-)

But Quarkus Java based operators exist too - like the Keycloak operator - so what? Are we forced to implement a CEL policy validation engine in Java, from scratch? Or should we find a suitable Java library, for example https://github.com/projectnessie/cel-java?

None of the above: this is where Chicory, a WebAssembly runtime for Java, comes to help.

Let’s just re-use a broadly used and well tested Go library, calling exported functions from Java, in a sandboxed, secure way, and be happy with it!

Why?

  • no rewrites

  • low maintenance

  • 1:1 behavior

A Chicory extension for Quarkus applications

Chicory is an open-source, 100% native Java WebAssembly (Wasm) runtime.

Its primary goal is to allow Java developers to run Wasm modules within the JVM, without relying on native libraries, JNI (Java Native Interface) usage, or unsafe code.

Unlike other Wasm runtimes that require platform-specific binaries, Chicory is pure Java.

It executes Wasm code within the JVM’s memory space, providing a "double sandbox" effect (Wasm isolation + JVM security). It also provides WASI (WebAssembly System Interface) support, that allows Wasm modules to interact with system resources safely. Finally, it includes both an interpreter and a runtime compiler for quick execution, in addition to a build-time compiler that converts Wasm into Java bytecode for optimal performance, see Chicory execution modes.

It is ideal for plugin systems, allowing users to write safe plugins in any language (Rust, C++, Go, etc.) that compiles to Wasm, and running them inside a Java app. It can be used for Serverless/Edge Computing cases, as it provides a mean to run lightweight logic on Java-based infrastructure without the overhead of application containers.

And for something that never gets old, i.e. cross-platform portability, your application remains "Write Once, Run Anywhere", without managing different .so or .dll files for different architectures.

You can learn more about Chicory, but from now on let’s focus on its Quarkus extension, here. :-)

Quarkus Chicory brings Chicory to Quarkus application developers, integrating its features into the Quarkus ecosystem and applications build-time and runtime peculiarities, for a natural developer experience. It just…​ works!

Features

  • Build-Time Code Generation

Generates Java bytecode from WebAssembly modules, thus replacing the Chicory Maven plugin.

  • Dependency Management

Automatically handles version alignment between Quarkus and Chicory’s ASM dependencies.

  • Multi WASM Module Support

Configure and manage multiple WebAssembly modules.

  • Dynamic Loading

Manage runtime-loaded WASM modules.

  • Intelligent Execution Mode Selection

Configures the MachineFactory and WasmModule instances based on the environment. MachineFactory can leverage build-time generated bytecode for optimal performance in production/native, and use runtime or interpreter mode during development or test. Similarly, WasmModule instances can be initialized with WASM metadata rather than pure WASM payload, again for better performance and memory footprint, by leveraging the build-time code generation process.

  • Live Reload in Development

Static modules are automatically watched and reloaded. Think of everyone’s favorite quarkus:dev, but with Rust and Go modules

  • Native Image Compatibility

  • WASM/WASI support

With such potential in our hands, we chose the popular Google CEL, a Go library/API adopted by several Go operators, and decided to integrate it in our application.

Application setup and configuration

Creating the application is straightforward using the Quarkus Maven plugin. Note that Quarkus 3.x requires Java 17 or higher (we used Java 21 for this example):

mvn io.quarkus.platform:quarkus-maven-plugin:create \
    -DprojectGroupId=io.quarkiverse.chicory.demo \
    -DprojectArtifactId=quarkus-cel-k8s-validator \
    -Dextensions='rest'

Next, we add the Quarkus Chicory extension to our pom.xml:

    <properties>
        <dylibso.version>1.6.1</dylibso.version>
        <quarkus-chicory.version>0.0.1</quarkus-chicory.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>${quarkus.platform.group-id}</groupId>
                <artifactId>${quarkus.platform.artifact-id}</artifactId>
                <version>${quarkus.platform.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>com.dylibso.chicory</groupId>
                <artifactId>wasi</artifactId>
                <version>${dylibso.version}</version>
            </dependency>
            <dependency>
                <groupId>io.quarkiverse.chicory</groupId>
                <artifactId>quarkus-chicory</artifactId>
                <version>${quarkus-chicory.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-rest</artifactId>
        </dependency>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-rest-jackson</artifactId>
        </dependency>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-arc</artifactId>
        </dependency>
        <dependency>
            <groupId>io.quarkiverse.chicory</groupId>
            <artifactId>quarkus-chicory</artifactId>
        </dependency>
        <dependency>
            <groupId>com.dylibso.chicory</groupId>
            <artifactId>wasi</artifactId>
        </dependency>
        <!-- more dependencies here... -->
    </dependencies>

Finally, the WASM module configuration goes in application.properties:

quarkus.chicory.modules.go-cel.name=io.quarkiverse.chicory.demo.GoCelModule
quarkus.chicory.modules.go-cel.wasm-file=src/main/resources/wasm/go-cel.wasm

This tells Quarkus Chicory to generate a GoCelModule class at build time from the specified WASM file. The extension will automatically generate Java bytecode from the WebAssembly module, configure the appropriate MachineFactory based on the runtime environment, and - in development mode - watch the WASM file for changes to trigger live reload.

One last step, now. We will use the WasmModuleInterface annotation and configure the annotation processor, for Chicory to generate a Java class containing methods mapping 1:1 to Go exported functions.

First, let’s create a K8sCel Java class where to place our annotation:

package io.quarkiverse.chicory.demo;

import com.dylibso.chicory.annotations.WasmModuleInterface;

@WasmModuleInterface(WasmResource.absoluteFile)
public class K8sCel {

    private K8sCel() {}

}

At build-time, the Chicory annotation processor will discover such annotation and generate a K8sCel_ModuleExports class, which provides the exported methods:

package io.quarkiverse.chicory.demo;

import com.dylibso.chicory.runtime.ExportFunction;
import com.dylibso.chicory.runtime.Instance;
import com.dylibso.chicory.runtime.Memory;

public class K8sCel_ModuleExports {
    private final ExportFunction field__start;
    private final ExportFunction field_malloc;
    private final ExportFunction field_free;
    private final ExportFunction field_evalPolicy;
    private final Memory field_memory;

    public K8sCel_ModuleExports(Instance instance) {
        this.field__start = instance.exports().function("_start");
        this.field_malloc = instance.exports().function("malloc");
        this.field_free = instance.exports().function("free");
        this.field_evalPolicy = instance.exports().function("evalPolicy");
        this.field_memory = instance.exports().memory("memory");
    }

    public void _start() {
        this.field__start.apply(new long[0]);
    }

    public int malloc(int arg0) {
        long result = this.field_malloc.apply(new long[]{(long)arg0})[0];
        return (int)result;
    }

    public void free(int arg0) {
        this.field_free.apply(new long[]{(long)arg0});
    }

    public int evalPolicy(int arg0, int arg1, int arg2, int arg3) {
        long result = this.field_evalPolicy.apply(new long[]{(long)arg0, (long)arg1, (long)arg2, (long)arg3})[0];
        return (int)result;
    }

    public Memory memory() {
        return this.field_memory;
    }
}

And that’s enough!

The WasmQuarkusContext API

The example application follows a clean architecture pattern with separated concerns:

  • K8sCelValidatorService - Manages WASM integration and business logic

  • K8sCelValidatorResource - Provides REST API endpoint

The Quarkus Chicory extension provides the WasmQuarkusContext API for injection, to access configured WASM modules. Here’s how we use it in our service:

@ApplicationScoped
public class K8sCelValidatorService {

    @Inject
    @Named("go-cel")
    WasmQuarkusContext wasmQuarkusContext;

    Instance instance;
    K8sCel_ModuleExports exports;

    @PostConstruct
    public void init() throws IOException {
        WasmModule wasmModule = wasmQuarkusContext.getWasmModule();
        if (wasmModule == null) {
            throw new IllegalStateException("Wasm module " + wasmQuarkusContext.getName() + " not found!");
        }

        // Create WASI support for stdout/stderr
        WasiOptions options = WasiOptions.builder()
                .withStdout(new ByteArrayOutputStream())
                .withStderr(new ByteArrayOutputStream())
                .build();
        WasiPreview1 wasi = WasiPreview1.builder()
                .withOptions(options)
                .build();
        Store store = new Store().addFunction(wasi.toHostFunctions());

        instance = Instance.builder(wasmModule) (1)
                .withMachineFactory(wasmQuarkusContext.getMachineFactory())
                .withImportValues(store.toImportValues())
                // Don't auto-run _start(), we'll call it manually
                .withStart(false)
                .build();

        // Get exported functions BEFORE calling _start
        exports = new K8sCel_ModuleExports(instance);   (2)

        // Initialize Go runtime by calling _start()
        // This is required to perform initialization, i.e. to run main(), which indeed should exit with 0,
        // so we catch the expected WasiExitException accordingly.
        try {
            exports.start();    (3)
        } catch (com.dylibso.chicory.wasi.WasiExitException e) {
            // Expected - Go main() exits after completing
            if (e.exitCode() != 0) {
                throw new RuntimeException("Go runtime initialization failed with exit code: " + e.exitCode());
            }
            // Exit code 0 is success - runtime is now initialized and exported functions are ready
        }
    }

    public ValidationResult validate(final String resourceJson, final String celPolicy) {

        byte[] policyBytes = celPolicy.getBytes(StandardCharsets.UTF_8);
        byte[] inputBytes = resourceJson.getBytes(StandardCharsets.UTF_8);

        // Allocate memory for policy string in WASM
        int policyPtr = exports.malloc(policyBytes.length);
        if (policyPtr == 0) {
            throw new IllegalStateException("Failed to allocate memory for policy");
        }

        // Allocate memory for input JSON in WASM
        int inputPtr = exports.malloc(inputBytes.length);
        if (inputPtr == 0) {
            throw new IllegalStateException("Failed to allocate memory for input");
        }

        try {
            // Write policy and input to WASM memory
            exports.memory().write(policyPtr, policyBytes);
            exports.memory().write(inputPtr, inputBytes);

            // Call evalPolicy(policyPtr, policyLen, inputPtr, inputLen)
            int returnCode = exports.evalPolicy(policyPtr, policyBytes.length, inputPtr, inputBytes.length);

            // Interpret result
            if (returnCode == 11) {
                return new ValidationResult(VALIDATION_RESULT_ALLOWED, "Policy ALLOWS the request", celPolicy);
            } else if (returnCode == 0) {
                return new ValidationResult(VALIDATION_RESULT_DENIED, "Policy DENIES the request", celPolicy);
            } else {
                // Negative values are errors
                String errorMsg = switch (returnCode) {
                    case -1 -> "JSON parse error";
                    case -2 -> "CEL environment creation error";
                    case -3 -> "CEL compilation error";
                    case -4 -> "CEL program creation error";
                    case -5 -> "CEL runtime error";
                    default -> "Unknown error: " + returnCode;
                };
                return new ValidationResult(VALIDATION_RESULT_ERROR, "CEL evaluation failed: " + errorMsg, celPolicy);
            }
        } finally {
            // Free allocated memory in WASM
            exports.free(policyPtr);
            exports.free(inputPtr);
        }
    }

    public static final String VALIDATION_RESULT_ALLOWED = "allowed";
    public static final String VALIDATION_RESULT_DENIED = "denied";
    public static final String VALIDATION_RESULT_ERROR = "error";

    public record ValidationResult(String status, String message, String policy) {}
}
1 The injected WasmQuarkusContext bean configures Instance.Builder to use MachineFactory and WasmModule instances, which are created dynamically, based on the application configuration and execution environment.
2 Once instance is built, export is initialized with a K8sCel_ModuleExports instance, providing exported functions which are called later in the validate() method.
3 The exported "_start" function is called, to initialize the Go runtime. This executes the Go program main() function. As it’s empty in our implementation, it will exit immediately, so we catch WasiExitExcpetion to check for a 0 (no errors) exit code.

A note about multi-user and thread safety

WasmQuarkusContext instances are injected as @ApplicationScoped beans. This means that a unique application instance can be used by several clients (or user requests) and threads. That being said, the API implementation is stateless, i.e. getMachineFactory() and getWasmModule() always return new instances.

The way such instances are dealt with, and how their lifecycle is orchestrated, is something that pertains to the application domain. For example, the above implementation doesn’t take concurrency into account. If multiple threads are going to consume the same Memory instance, a thread-safe implementation would be required in order to avoid corrupting the shared WasmModule linear memory.

Back to our application code, the REST resource is then a simple delegation layer:

@Path("/k8s")
public class K8sCelValidatorResource {

    @Inject
    K8sCelValidatorService validatorService;

    @POST
    @Path("/validate")
    public Response validate(@RestForm String resourceJson, @RestForm String celPolicy) {
        ValidationResult result = validatorService.validate(resourceJson, celPolicy);

        Response.Status status = switch (result.status()) {
            case "allowed" -> Response.Status.OK;
            case "denied" -> Response.Status.FORBIDDEN;
            default -> Response.Status.BAD_REQUEST;
        };

        return Response.status(status).entity(result).build();
    }
}

The WasmQuarkusContext API provides two key methods:

  • getWasmModule(): returns the parsed WebAssembly module

  • getMachineFactory(): returns the appropriate MachineFactory based on environment (interpreter for dev, build-time compilation for production/native)

The @Named qualifier matches the module name from application.properties. The extension handles all the complexity of WASM module lifecycle, allowing us to focus on the business logic.

How it works: Go → Wasm → Java bytecode

The heart of our application is the Go CEL implementation, compiled to WebAssembly. The Go code implements three key exported functions:

//go:wasmexport evalPolicy
func evalPolicy(policyPtr, policyLen, inputPtr, inputLen uint32) int32 {
    // Convert pointers to Go types
    policy := unsafe.String((*byte)(unsafe.Pointer(uintptr(policyPtr))), policyLen)
    inputJSON := unsafe.Slice((*byte)(unsafe.Pointer(uintptr(inputPtr))), inputLen)

    // Parse the JSON input
    var input map[string]any
    if err := json.Unmarshal(inputJSON, &input); err != nil {
        return -1  // JSON parse error
    }

    // Create CEL environment
    env, err := cel.NewEnv(
        cel.Declarations(
            decls.NewVar("object", decls.NewMapType(decls.String, decls.Dyn)),
        ),
    )
    if err != nil {
        return -2  // CEL environment creation error
    }

    // Compile and evaluate the CEL expression
    ast, iss := env.Compile(policy)
    if iss.Err() != nil {
        return -3  // Compilation error
    }

    prg, err := env.Program(ast)
    if err != nil {
        return -4  // Program creation error
    }

    out, _, err := prg.Eval(map[string]any{"object": input})
    if err != nil {
        return -5  // CEL runtime error
    }

    // Return 1 for allow, 0 for deny
    if b, ok := out.Value().(bool); ok && b {
        return 1
    }
    return 0
}

This Go code is compiled to WASM using the WASI target:

GOOS=wasip1 GOARCH=wasm go build -o go-cel.wasm main.go

In our service implementation:

  1. Java allocates WASM memory for the policy string and input resource manifest (JSON), and writes the data to WASM memory

  2. Java calls evalPolicy() with pointers to arguments and their size

  3. Go code reads from its memory space, and evaluates the CEL expression using the Google CEL library

  4. Go returns an integer result code (1=allow, 0=deny, negative=error)

  5. Java interprets the result and performs clean up

This demonstrates the power of WebAssembly: we can use the mature, battle-tested Google CEL-Go library from Java without reimplementing CEL from scratch.

The WASM boundary provides a clean and safe interface between the two languages.

In production, we can write CEL policies that validate Kubernetes resources just like Go operators do:

// Require production label
has(object.metadata.labels.env) && object.metadata.labels.env == "production"

// Deny privileged containers
!(has(object.spec.containers) && object.spec.containers.exists(c,
  has(c.securityContext) && c.securityContext.privileged == true))

// Require resource limits
has(object.spec.containers) && object.spec.containers.all(c,
  has(c.resources) && has(c.resources.limits))

Conclusion

By combining Quarkus Chicory with Google CEL-Go compiled to WebAssembly, we’ve created a Kubernetes-style CEL policy engine that runs entirely in Java.

This approach offers several benefits:

  • Reuse existing Go libraries: No need to reimplement CEL in Java, 1:1 mapping with original Go code

  • Type safety and performance: Quarkus Chicory generates Java bytecode from WASM modules

  • Production ready: The same CEL library used by Go operators, now available in Java

  • Developer experience: Live reload, build-time code generation, and native image support

  • Ecosystem compatibility: Works seamlessly with Java-based Kubernetes operators

This demonstrates that WebAssembly is not just a browser technology, but rather a powerful tool for cross-language interoperability in cloud-native applications, and showcases how to integrate this workflow easily in Quarkus applications, thanks to Quarkus Chicory.

For Java developers building Kubernetes operators, this approach opens up a large part of the Go ecosystem without leaving the JVM.

You can find the complete working example at: https://github.com/fabiobrz/quarkus-cel-k8s-validator