This is an automated email from the ASF dual-hosted git repository.
oscerd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 097ee856bbda CAMEL-24742: camel-opa - compile the WASM test fixtures
instead of committing them (#26677)
097ee856bbda is described below
commit 097ee856bbdab4451aef158e13044858b7839e66
Author: Andrea Cosentino <[email protected]>
AuthorDate: Fri Sep 25 11:05:45 2026 +0200
CAMEL-24742: camel-opa - compile the WASM test fixtures instead of
committing them (#26677)
* CAMEL-24742: camel-opa - compile the WASM fixtures instead of committing
them
authz.wasm was a compiled binary committed beside authz.rego with nothing
tying the two together, and they came apart: the module predated the second
decision rule in the Rego, so the WASM suite asserted that input was
undefined
while OpaIT asserted it returns deny reasons - about the same source file,
both
green. A suite whose purpose is "both engines decide the same way" was
comparing two different policies. A compiled binary also has no place in a
source release.
camel-test-infra-opa gains OpaWasmBundleBuilder, a one-shot container run of
opa build -t wasm using the image already pinned in container.properties, so
the compiler and the server the REST ITs talk to cannot disagree about their
OPA version - they did, as it happens: the fixtures were built with 1.9.0
while
the module pins 1.20.2.
OpaWasmIT compiles the very authz.rego OpaIT uploads to a real server, so
the
parity claim is tested rather than asserted. Deleting the mallory branch
from
that Rego now fails the WASM test; before this it changed nothing.
The ten tests that evaluate a policy move to ITs as a result. The two that
reject a configuration before any bundle is loaded stay unit tests, needing
neither an artifact nor a container.
One trap worth recording: opa build cannot write into the working directory
Testcontainers creates, because the image runs as a non-root user and the
directory is root-owned - so output goes to /tmp via -o. It is invisible to
a
manual docker run, where a bind-mounted host directory is writable, and the
failure surfaces only as "container did not start correctly" unless the
container's own stderr is attached to the exception, which the builder now
does.
Co-Authored-By: Claude Opus 5 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
* CAMEL-24742: camel-opa - restore what the move to an IT quietly dropped
Three assertions went missing converting the WASM tests, two spotted in
review
and one by diffing assertion counts per method against the deleted class:
- keepsTheEntrypointAcrossPooledReuse lost containsEntry("allow", true). The
type check alone passes for any rule returning an object, so the pair is
what
pins the entrypoint rather than its shape.
- keepsThePoolUsableAfterRepeatedEvaluationFailures lost @Timeout(60),
which is
the one test where a mishandled permit wedges template.request() and hangs
the run with no indication of where.
- acceptsTheBundleTarballOpaBuildActuallyEmits disappeared entirely. That is
the worst of the three: every bundle in the IT is a tarball, so the bare
.wasm branch of loadPolicy had silently lost all coverage. It is back as
acceptsABareModuleAsWellAsTheBundleTarball, extracting policy.wasm from
the
compiled bundle rather than committing one.
Also name the resource when getResourceAsStream returns null; the class
loads
four of them and an NPE inside readAllBytes says which of them none.
Co-Authored-By: Claude Opus 5 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
* CAMEL-24742: camel-opa - run the bundle-builder test under failsafe, not
surefire
OpaWasmBundleBuilderTest needs a container but ran under surefire, so a
plain
mvn install of camel-test-infra-opa failed for anyone without Docker. The
@DisabledIfSystemProperty(skipITs) guard only fires when that property is
passed explicitly, which is not the default path.
That is the same thing this PR exists to fix, made one module over: the
camel-opa WASM tests moved to ITs so a plain build needs no Docker, and
then a
Docker-dependent test arrived in test-infra as a unit test.
Renamed to OpaWasmBundleBuilderIT with the failsafe profile modelled on
camel-test-infra-jaeger. Verified both ways after a clean, since a stale
class
left in target by the rename masked it on the first attempt: mvn clean test
runs nothing and needs no Docker, mvn verify -Dopa-it-test runs all five.
Co-Authored-By: Claude Opus 5 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
* CAMEL-24742: use assertTrue for a boolean assertion in the bundle-builder
IT
Addresses review feedback on #26677.
reportsWhatFailedWhenTheRegoIsNotValid used assertEquals(true, ...) where
every other assertion in the file uses assertTrue. It was the only
assertEquals in the class, so the import goes with it.
Co-Authored-By: Claude Opus 5 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
* CAMEL-24742: move the wasm-mode validation tests to the IT that builds a
bundle
Addresses review feedback on #26677.
CAMEL-24743 merged OpaWasmModeValidationTest to main after this branch
was cut. Both of its tests start a route on classpath:authz.wasm, the
committed fixture this PR deletes, so the merged result failed:
FailedToStartRouteException: Failed to start route: route2 because:
java.io.FileNotFoundException: Cannot find resource: classpath:authz.wasm
Rebasing alone would only have moved that failure into the branch, which
it did: cherry-picking onto current main reproduced it locally, 2 tests,
2 errors.
registersTheCheckForTheRestRouteButNotTheWasmRoute and
acceptsFailOpenInWasmMode now live in OpaWasmIT, where compileBundles
produces a bundle that actually loads. Neither asserts anything about
wasm evaluation - one counts producer health checks, the other that a
failOpen route starts - so they sit with the other IT cases. The cost is
that they no longer run in a plain build; with the committed fixtures
gone there is no bundle to start a wasm route from without Docker.
OpaWasmIT had no createRouteBuilder of its own - it drives producers
through template.request with explicit URIs - so the health-check test
needed one adding for the rest/wasm pair it compares.
The relocated assertion still guards what it did before: disabling the
wasm-mode skip in OpaProducer reddens it alone, "Expected size: 1 but
was: 2". 79 unit tests and 12 ITs green.
Co-Authored-By: Claude Opus 5 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
---------
Signed-off-by: Andrea Cosentino <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
---
.../component/opa/OpaWasmConfigurationTest.java | 52 ++++
.../camel/component/opa/OpaWasmEvaluatorTest.java | 205 --------------
.../org/apache/camel/component/opa/OpaWasmIT.java | 298 +++++++++++++++++++++
.../component/opa/OpaWasmModeValidationTest.java | 80 ------
.../src/test/resources/README-wasm-fixtures.md | 28 --
.../src/test/resources/authz-bundle.tar.gz | Bin 57234 -> 0 bytes
components/camel-opa/src/test/resources/authz.wasm | Bin 140391 -> 0 bytes
.../src/test/resources/roles-bundle.tar.gz | Bin 56415 -> 0 bytes
test-infra/camel-test-infra-opa/pom.xml | 30 +++
.../infra/opa/services/OpaWasmBundleBuilder.java | 142 ++++++++++
.../test/infra/opa/OpaWasmBundleBuilderIT.java | 106 ++++++++
11 files changed, 628 insertions(+), 313 deletions(-)
diff --git
a/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaWasmConfigurationTest.java
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaWasmConfigurationTest.java
new file mode 100644
index 000000000000..660a09ac04c2
--- /dev/null
+++
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaWasmConfigurationTest.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.opa;
+
+import org.apache.camel.ResolveEndpointFailedException;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * The {@code wasm} checks that reject a configuration before any bundle is
loaded.
+ * <p/>
+ * These stay unit tests deliberately: they are the only two that never reach
{@code loadPolicy}, so they need no
+ * compiled artifact and no container. Everything that evaluates a policy
lives in {@link OpaWasmIT}, where the bundle
+ * is compiled from the Rego under test rather than committed beside it.
+ */
+public class OpaWasmConfigurationTest extends CamelTestSupport {
+
+ @Test
+ void requiresAPolicyBundle() {
+ // the check runs when the endpoint starts, so a misconfiguration
fails fast rather than once per message
+ assertThatThrownBy(() ->
template.request("opa:authz/allow?evaluationMode=wasm", e -> {
+ }))
+ .isInstanceOf(ResolveEndpointFailedException.class)
+ .hasMessageContaining("policyBundle is required");
+ }
+
+ @Test
+ void rejectsAPoolSizeBelowOne() {
+ // rejected before the bundle is resolved, so the location here is
never opened
+ assertThatThrownBy(() -> template.request(
+
"opa:authz/allow?evaluationMode=wasm&policyBundle=file:unused.wasm&poolSize=0",
e -> {
+ }))
+ .isInstanceOf(ResolveEndpointFailedException.class)
+ .hasMessageContaining("poolSize must be at least 1");
+ }
+}
diff --git
a/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaWasmEvaluatorTest.java
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaWasmEvaluatorTest.java
deleted file mode 100644
index 06d4e8ea49f7..000000000000
---
a/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaWasmEvaluatorTest.java
+++ /dev/null
@@ -1,205 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.camel.component.opa;
-
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.stream.Collectors;
-import java.util.stream.IntStream;
-
-import org.apache.camel.Exchange;
-import org.apache.camel.ResolveEndpointFailedException;
-import org.apache.camel.test.junit6.CamelTestSupport;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.Timeout;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-/**
- * In-process evaluation of a bundle produced by {@code opa build -t wasm}.
- * <p/>
- * The bundle here was compiled from the same {@code authz.rego} the REST
tests use, so the assertions double as a check
- * that a route sees the same decision whichever engine evaluated it.
- */
-public class OpaWasmEvaluatorTest extends CamelTestSupport {
-
- private static final String WASM =
"opa:authz/allow?evaluationMode=wasm&policyBundle=classpath:authz.wasm";
-
- @Test
- void allowsWhenThePolicyMatches() {
- Exchange out = template.request(WASM, e ->
e.getMessage().setHeader("user", "alice"));
-
- assertThat(out.getException()).isNull();
-
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(true);
- }
-
- @Test
- void deniesWhenThePolicyDoesNotMatch() {
- Exchange out = template.request(WASM, e ->
e.getMessage().setHeader("user", "mallory"));
-
- assertThat(out.getException()).isNull();
-
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(false);
- }
-
- @Test
- void readsAVerdictOutOfADecisionObject() {
- Exchange out = template.request(
-
"opa:authz/decision?evaluationMode=wasm&policyBundle=classpath:authz.wasm",
- e -> e.getMessage().setHeader("user", "alice"));
-
- assertThat(out.getException()).isNull();
-
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(true);
- assertThat(out.getMessage().getHeader(OpaConstants.DECISION,
Map.class)).containsEntry("allow", true);
- }
-
- @Test
- void keepsTheDenyReasonsJustLikeTheRestEngine() {
- Exchange out = template.request(
-
"opa:authz/decision?evaluationMode=wasm&policyBundle=classpath:authz.wasm",
- e -> e.getMessage().setHeader("user", "mallory"));
-
- assertThat(out.getException()).isNull();
-
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(false);
- assertThat(out.getMessage().getHeader(OpaConstants.DECISION,
Map.class))
- .containsEntry("reasons", List.of("not the owner"));
- }
-
- @Test
- void keepsTheEntrypointAcrossPooledReuse() {
- // Returning a borrowed OpaPolicy resets it, and a reset puts the
entrypoint back to 0 - so an instance
- // configured only where it was built answers the first exchange from
authz/decision and every later one
- // from whatever rule happens to be entrypoint 0 (here authz/allow, a
bare boolean). A single-instance
- // pool and more than one message is what makes that visible.
- String decision =
"opa:authz/decision?evaluationMode=wasm&policyBundle=classpath:authz.wasm&poolSize=1";
-
- for (int i = 0; i < 5; i++) {
- Exchange out = template.request(decision, e ->
e.getMessage().setHeader("user", "alice"));
-
- assertThat(out.getException()).as("exchange %d", i).isNull();
- assertThat(out.getMessage().getHeader(OpaConstants.DECISION))
- .as("message %d was still decided by authz/decision", i)
- .isInstanceOf(Map.class);
- assertThat(out.getMessage().getHeader(OpaConstants.DECISION,
Map.class)).containsEntry("allow", true);
- }
- }
-
- @Test
- void appliesTheDataDocumentPackedInTheBundle() {
- // roles.rego decides from data.admins, which opa build packs into the
bundle as data.json rather than
- // into the module. A reset clears the data as well as the entrypoint,
so it too has to be re-applied on
- // every borrow - without it the policy sees an empty data document
and denies everyone.
- String roles =
"opa:roles/allow?evaluationMode=wasm&policyBundle=classpath:roles-bundle.tar.gz&poolSize=1";
-
- for (int i = 0; i < 3; i++) {
- Exchange allowed = template.request(roles, e ->
e.getMessage().setHeader("user", "carol"));
- Exchange denied = template.request(roles, e ->
e.getMessage().setHeader("user", "alice"));
-
- assertThat(allowed.getException()).as("exchange %d", i).isNull();
-
assertThat(allowed.getMessage().getHeader(OpaConstants.DECISION_ALLOW))
- .as("data.admins was still visible on message %d", i)
- .isEqualTo(true);
-
assertThat(denied.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(false);
- }
- }
-
- @Test
- void failsClosedOnAnUndefinedDecisionJustLikeTheRestEngine() {
- // authz/strict_allow has no default, so for mallory the rule is
undefined. The WASM ABI returns an
- // empty array where the REST client raises an error; both must reach
the route the same way.
- Exchange out = template.request(
-
"opa:authz/strict_allow?evaluationMode=wasm&policyBundle=classpath:authz.wasm",
- e -> e.getMessage().setHeader("user", "mallory"));
-
-
assertThat(out.getException()).isInstanceOf(OpaPolicyEvaluationException.class);
-
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isNull();
- }
-
- @Test
- void acceptsTheBundleTarballOpaBuildActuallyEmits() {
- Exchange out = template.request(
-
"opa:authz/allow?evaluationMode=wasm&policyBundle=classpath:authz-bundle.tar.gz",
- e -> e.getMessage().setHeader("user", "alice"));
-
- assertThat(out.getException()).isNull();
-
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(true);
- }
-
- @Test
- void requiresAPolicyBundle() {
- // the check runs when the endpoint starts, so a misconfiguration
fails fast rather than once per message
- assertThatThrownBy(() ->
template.request("opa:authz/allow?evaluationMode=wasm", e -> {
- }))
- .isInstanceOf(ResolveEndpointFailedException.class)
- .hasMessageContaining("policyBundle is required");
- }
-
- @Test
- void rejectsAPoolSizeBelowOne() {
- // the pool rejects it as well, but as "maxSize must be positive" -
its own parameter rather than the
- // option that was set, which is what the operator has to go looking
for
- assertThatThrownBy(() -> template.request(
-
"opa:authz/allow?evaluationMode=wasm&policyBundle=classpath:authz.wasm&poolSize=0",
e -> {
- }))
- .isInstanceOf(ResolveEndpointFailedException.class)
- .hasMessageContaining("poolSize must be at least 1");
- }
-
- @Test
- @Timeout(60)
- void keepsThePoolUsableAfterRepeatedEvaluationFailures() {
- // a failed evaluation discards its instance. Mishandle the pool's
permit while doing so and a
- // single-instance pool either wedges on the next borrow or quietly
stops bounding anything - neither of
- // which a single failing exchange would show.
- String strict =
"opa:authz/strict_allow?evaluationMode=wasm&policyBundle=classpath:authz.wasm&poolSize=1";
-
- for (int i = 0; i < 5; i++) {
- Exchange failed = template.request(strict, e ->
e.getMessage().setHeader("user", "mallory"));
- assertThat(failed.getException()).as("failure %d",
i).isInstanceOf(OpaPolicyEvaluationException.class);
- }
-
- Exchange out = template.request(strict, e ->
e.getMessage().setHeader("user", "alice"));
-
- assertThat(out.getException()).isNull();
-
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(true);
- }
-
- @Test
- void evaluatesCorrectlyFromManyThreadsAtOnce() throws Exception {
- // OpaPolicy is not thread-safe; without pooling a concurrent route
would interleave input and data
- int threads = 16;
- ExecutorService pool = Executors.newFixedThreadPool(threads);
- try {
- var tasks = IntStream.range(0, threads * 8).mapToObj(i ->
(Callable<Boolean>) () -> {
- String user = i % 2 == 0 ? "alice" : "mallory";
- Exchange out = template.request(WASM, e ->
e.getMessage().setHeader("user", user));
- assertThat(out.getException()).isNull();
- return Boolean.valueOf("alice".equals(user))
-
.equals(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW));
- }).collect(Collectors.toList());
-
- for (var future : pool.invokeAll(tasks)) {
- assertThat(future.get()).as("verdict matched the user on every
thread").isTrue();
- }
- } finally {
- pool.shutdownNow();
- }
- }
-}
diff --git
a/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaWasmIT.java
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaWasmIT.java
new file mode 100644
index 000000000000..45d7cf211232
--- /dev/null
+++
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaWasmIT.java
@@ -0,0 +1,298 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.opa;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.health.HealthCheck;
+import org.apache.camel.health.HealthCheckHelper;
+import org.apache.camel.health.WritableHealthCheckRepository;
+import org.apache.camel.test.infra.opa.services.OpaWasmBundleBuilder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
+import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
+import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+/**
+ * In-process evaluation of a bundle compiled from the very {@code authz.rego}
that {@link OpaIT} uploads to a real OPA
+ * server.
+ * <p/>
+ * Sharing one policy between the two classes is the point rather than a
convenience: the component promises that a
+ * route sees the same decision whichever engine evaluated it, and that
promise is only tested if both engines are asked
+ * about the same rules. It also closes the way that promise was broken before
- a compiled bundle committed beside the
+ * Rego drifted from it, and the two suites asserted opposite things about
{@code authz/decision} while both stayed
+ * green (CAMEL-24741). Nothing is committed now; the bundle is built from the
policy under test.
+ */
+public class OpaWasmIT extends CamelTestSupport {
+
+ @TempDir
+ static Path bundles;
+
+ private static String authz;
+ private static String module;
+ private static String roles;
+
+ private static String resource(String name) throws Exception {
+ try (InputStream in = OpaWasmIT.class.getResourceAsStream(name)) {
+ if (in == null) {
+ throw new IllegalStateException("Test resource not found on
the classpath: " + name);
+ }
+ return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+
+ @BeforeAll
+ static void compileBundles() throws Exception {
+ byte[] authzBundle = OpaWasmBundleBuilder.build(
+ "authz.rego", resource("/authz.rego"),
+ "authz/allow", "authz/decision", "authz/strict_allow");
+ authz = write("authz-bundle.tar.gz", authzBundle);
+ module = extractModule(authzBundle);
+
+ // roles.rego decides from data.admins, which opa build packs beside
it as data.json
+ byte[] rolesBundle = OpaWasmBundleBuilder.build(
+ Map.of("roles.rego",
resource("/wasm-data/roles.rego").getBytes(StandardCharsets.UTF_8),
+ "data.json",
resource("/wasm-data/data.json").getBytes(StandardCharsets.UTF_8)),
+ "roles/allow");
+ roles = write("roles-bundle.tar.gz", rolesBundle);
+ }
+
+ /** The /policy.wasm inside a bundle, so the bare-module branch of
loadPolicy keeps its coverage. */
+ private static String extractModule(byte[] bundle) throws Exception {
+ try (TarArchiveInputStream tar
+ = new TarArchiveInputStream(new GzipCompressorInputStream(new
ByteArrayInputStream(bundle)))) {
+ TarArchiveEntry entry;
+ while ((entry = tar.getNextEntry()) != null) {
+ if (!entry.isDirectory() &&
entry.getName().endsWith("policy.wasm")) {
+ return write("authz.wasm", tar.readAllBytes());
+ }
+ }
+ }
+ throw new IllegalStateException("opa build emitted no policy.wasm");
+ }
+
+ private static String write(String name, byte[] bundle) throws Exception {
+ Path path = bundles.resolve(name);
+ Files.write(path, bundle);
+ return "file:" + path.toAbsolutePath();
+ }
+
+ private String wasm(String policyPath) {
+ return "opa:" + policyPath + "?evaluationMode=wasm&policyBundle=" +
authz;
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ // a rest-mode route registers a producer readiness check
(positive control), a wasm-mode route
+ // sharing the same policy path must not - the difference is
exactly what the health-check test
+ // asserts. These moved here from OpaWasmModeValidationTest
when the committed authz.wasm went
+ // away (CAMEL-24742): both routes have to start, so both need
a bundle that actually loads.
+
from("direct:rest").to("opa:authz/allow?serverUrl=http://opa-rest:8181");
+ from("direct:wasm").to(wasm("authz/allow"));
+ }
+ };
+ }
+
+ private List<HealthCheck> producerChecks() {
+ WritableHealthCheckRepository repository =
HealthCheckHelper.getHealthCheckRepository(
+ context, "producers", WritableHealthCheckRepository.class);
+ assertThat(repository).isNotNull();
+ // producer health checks are disabled globally by default, so enable
the repository to read them back
+ repository.setEnabled(true);
+ return repository.stream().toList();
+ }
+
+ /**
+ * In {@code wasm} mode the policy is evaluated in-process, so there is no
OPA server to probe and no producer
+ * health check is registered (CAMEL-24743).
+ */
+ @Test
+ void registersTheCheckForTheRestRouteButNotTheWasmRoute() {
+ List<HealthCheck> checks = producerChecks();
+ // exactly one check, and it is the rest route's - the wasm route
evaluates in-process with no server to probe
+ assertThat(checks).hasSize(1);
+ assertThat(checks.get(0).getId()).contains("opa-rest");
+ }
+
+ /**
+ * {@code failOpen} still governs an evaluation failure (a busy pool, a
bad bundle) in {@code wasm} mode, so it must
+ * not be rejected (CAMEL-24743).
+ */
+ @Test
+ void acceptsFailOpenInWasmMode() {
+ assertThatCode(() -> context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:failopen").to(wasm("authz/allow") +
"&failOpen=true");
+ }
+ })).doesNotThrowAnyException();
+ }
+
+ @Test
+ void allowsWhenThePolicyMatches() {
+ Exchange out = template.request(wasm("authz/allow"), e ->
e.getMessage().setHeader("user", "alice"));
+
+ assertThat(out.getException()).isNull();
+
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(true);
+ }
+
+ @Test
+ void acceptsABareModuleAsWellAsTheBundleTarball() {
+ // every other test here loads the tarball opa build emits, so without
this the other half of loadPolicy -
+ // a bare .wasm, which is what an operator extracting the module by
hand would have - goes untested
+ Exchange out = template.request(
+ "opa:authz/allow?evaluationMode=wasm&policyBundle=" + module,
+ e -> e.getMessage().setHeader("user", "alice"));
+
+ assertThat(out.getException()).isNull();
+
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(true);
+ }
+
+ @Test
+ void deniesWhenThePolicyDoesNotMatch() {
+ Exchange out = template.request(wasm("authz/allow"), e ->
e.getMessage().setHeader("user", "mallory"));
+
+ assertThat(out.getException()).isNull();
+
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(false);
+ }
+
+ @Test
+ void readsAVerdictOutOfADecisionObject() {
+ Exchange out = template.request(wasm("authz/decision"), e ->
e.getMessage().setHeader("user", "alice"));
+
+ assertThat(out.getException()).isNull();
+
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(true);
+ assertThat(out.getMessage().getHeader(OpaConstants.DECISION,
Map.class)).containsEntry("allow", true);
+ }
+
+ @Test
+ void keepsTheDenyReasonsJustLikeTheRestEngine() {
+ // OpaIT.keepsTheDenyReasonsFromADecisionObject asserts exactly this
against the server
+ Exchange out = template.request(wasm("authz/decision"), e ->
e.getMessage().setHeader("user", "mallory"));
+
+ assertThat(out.getException()).isNull();
+
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(false);
+ assertThat(out.getMessage().getHeader(OpaConstants.DECISION,
Map.class))
+ .containsEntry("reasons", List.of("not the owner"));
+ }
+
+ @Test
+ void failsClosedOnAnUndefinedDecisionJustLikeTheRestEngine() {
+ // authz/strict_allow has no default, so it is undefined for mallory.
The WASM ABI reports that as an empty
+ // result array where the REST client raises an error;
OpaIT.failsClosedOnAnUndefinedDecision is the twin
+ Exchange out = template.request(wasm("authz/strict_allow"), e ->
e.getMessage().setHeader("user", "mallory"));
+
+
assertThat(out.getException()).isInstanceOf(OpaPolicyEvaluationException.class);
+
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isNull();
+ }
+
+ @Test
+ void keepsTheEntrypointAcrossPooledReuse() {
+ // returning a borrowed instance resets it, putting the entrypoint
back to 0 - so an instance configured
+ // only where it was built answers the first exchange from
authz/decision and every later one from
+ // whatever rule is entrypoint 0. A single-instance pool and several
messages is what shows it
+ String decision = wasm("authz/decision") + "&poolSize=1";
+
+ for (int i = 0; i < 5; i++) {
+ Exchange out = template.request(decision, e ->
e.getMessage().setHeader("user", "alice"));
+
+ assertThat(out.getException()).as("exchange %d", i).isNull();
+ assertThat(out.getMessage().getHeader(OpaConstants.DECISION))
+ .as("message %d was still decided by authz/decision", i)
+ .isInstanceOf(Map.class);
+ // the type alone would pass for any rule returning an object; the
content is what pins the entrypoint
+ assertThat(out.getMessage().getHeader(OpaConstants.DECISION,
Map.class)).containsEntry("allow", true);
+ }
+ }
+
+ @Test
+ void appliesTheDataDocumentPackedInTheBundle() {
+ String policy = "opa:roles/allow?evaluationMode=wasm&policyBundle=" +
roles + "&poolSize=1";
+
+ for (int i = 0; i < 3; i++) {
+ Exchange allowed = template.request(policy, e ->
e.getMessage().setHeader("user", "carol"));
+ Exchange denied = template.request(policy, e ->
e.getMessage().setHeader("user", "alice"));
+
+ assertThat(allowed.getException()).as("exchange %d", i).isNull();
+
assertThat(allowed.getMessage().getHeader(OpaConstants.DECISION_ALLOW))
+ .as("data.admins was still visible on message %d", i)
+ .isEqualTo(true);
+
assertThat(denied.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(false);
+ }
+ }
+
+ @Test
+ @Timeout(60)
+ void keepsThePoolUsableAfterRepeatedEvaluationFailures() {
+ String strict = wasm("authz/strict_allow") + "&poolSize=1";
+
+ for (int i = 0; i < 5; i++) {
+ Exchange failed = template.request(strict, e ->
e.getMessage().setHeader("user", "mallory"));
+ assertThat(failed.getException()).as("failure %d",
i).isInstanceOf(OpaPolicyEvaluationException.class);
+ }
+
+ Exchange out = template.request(strict, e ->
e.getMessage().setHeader("user", "alice"));
+
+ assertThat(out.getException()).isNull();
+
assertThat(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW)).isEqualTo(true);
+ }
+
+ @Test
+ void evaluatesCorrectlyFromManyThreadsAtOnce() throws Exception {
+ // OpaPolicy is not thread-safe; without pooling a concurrent route
would interleave input and data
+ int threads = 16;
+ ExecutorService pool = Executors.newFixedThreadPool(threads);
+ try {
+ var tasks = IntStream.range(0, threads * 8).mapToObj(i ->
(Callable<Boolean>) () -> {
+ String user = i % 2 == 0 ? "alice" : "mallory";
+ Exchange out = template.request(wasm("authz/allow"), e ->
e.getMessage().setHeader("user", user));
+ assertThat(out.getException()).isNull();
+ return Boolean.valueOf("alice".equals(user))
+
.equals(out.getMessage().getHeader(OpaConstants.DECISION_ALLOW));
+ }).collect(Collectors.toList());
+
+ for (var future : pool.invokeAll(tasks)) {
+ assertThat(future.get()).as("verdict matched the user on every
thread").isTrue();
+ }
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+}
diff --git
a/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaWasmModeValidationTest.java
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaWasmModeValidationTest.java
deleted file mode 100644
index e4661972652d..000000000000
---
a/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaWasmModeValidationTest.java
+++ /dev/null
@@ -1,80 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.camel.component.opa;
-
-import java.util.List;
-
-import org.apache.camel.builder.RouteBuilder;
-import org.apache.camel.health.HealthCheck;
-import org.apache.camel.health.HealthCheckHelper;
-import org.apache.camel.health.WritableHealthCheckRepository;
-import org.apache.camel.test.junit6.CamelTestSupport;
-import org.junit.jupiter.api.Test;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatCode;
-
-/**
- * In {@code wasm} mode the policy is evaluated in-process, so there is no OPA
server to probe and no producer health
- * check is registered. {@code failOpen}, on the other hand, still governs an
evaluation failure (a busy pool, a bad
- * bundle) in {@code wasm} mode too, so it must not be rejected (CAMEL-24743).
- */
-public class OpaWasmModeValidationTest extends CamelTestSupport {
-
- @Override
- protected RouteBuilder createRouteBuilder() {
- return new RouteBuilder() {
- @Override
- public void configure() {
- // a rest-mode route registers a producer readiness check
(positive control), a wasm-mode route
- // sharing the same policy path must not - the difference is
exactly what this test asserts
-
from("direct:rest").to("opa:authz/allow?serverUrl=http://opa-rest:8181");
-
from("direct:wasm").to("opa:authz/allow?evaluationMode=wasm&policyBundle=classpath:authz.wasm");
- }
- };
- }
-
- private List<HealthCheck> producerChecks() {
- WritableHealthCheckRepository repository =
HealthCheckHelper.getHealthCheckRepository(
- context, "producers", WritableHealthCheckRepository.class);
- assertThat(repository).isNotNull();
- // producer health checks are disabled globally by default, so enable
the repository to read them back
- repository.setEnabled(true);
- return repository.stream().toList();
- }
-
- @Test
- void registersTheCheckForTheRestRouteButNotTheWasmRoute() {
- List<HealthCheck> checks = producerChecks();
- // exactly one check, and it is the rest route's - the wasm route
evaluates in-process with no server to probe
- assertThat(checks).hasSize(1);
- assertThat(checks.get(0).getId()).contains("opa-rest");
- }
-
- @Test
- void acceptsFailOpenInWasmMode() {
- // failOpen governs an evaluation failure (a busy pool, a bad bundle),
which happens in wasm too, so it is a
- // valid option here and starting the route must not throw
- assertThatCode(() -> context.addRoutes(new RouteBuilder() {
- @Override
- public void configure() {
- from("direct:failopen")
-
.to("opa:authz/allow?evaluationMode=wasm&policyBundle=classpath:authz.wasm&failOpen=true");
- }
- })).doesNotThrowAnyException();
- }
-}
diff --git a/components/camel-opa/src/test/resources/README-wasm-fixtures.md
b/components/camel-opa/src/test/resources/README-wasm-fixtures.md
deleted file mode 100644
index ee0ce62e21c4..000000000000
--- a/components/camel-opa/src/test/resources/README-wasm-fixtures.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# WASM test fixtures
-
-`authz.wasm`, `authz-bundle.tar.gz` and `roles-bundle.tar.gz` are **generated
artifacts**, compiled from the
-Rego sources next to them. They are committed only so the
`evaluationMode=wasm` tests can run before
-CAMEL-24742 adds a build step that compiles them, at which point all three
should be deleted.
-
-Regenerate after any change to the sources — nothing currently checks that
they agree, which is exactly why
-CAMEL-24742 exists. Use the pinned version below: a different OPA release can
emit a module built against a
-different WebAssembly ABI than `opa-java-wasm` supports.
-
-```sh
-OPA='docker run --rm -v "$PWD":/w:Z -w /w
mirror.gcr.io/openpolicyagent/opa:1.9.0-static'
-
-# authz.wasm + authz-bundle.tar.gz, from authz.rego
-eval $OPA build -t wasm -e authz/allow -e authz/decision -e authz/strict_allow
authz.rego
-tar xzf bundle.tar.gz --wildcards '*policy.wasm' && mv policy.wasm authz.wasm
-mv bundle.tar.gz authz-bundle.tar.gz
-
-# roles-bundle.tar.gz, from wasm-data/ (roles.rego plus the data.json that opa
build packs beside it)
-(cd wasm-data && eval $OPA build -t wasm -e roles/allow .)
-mv wasm-data/bundle.tar.gz roles-bundle.tar.gz
-```
-
-`-e` names the entrypoints. An entrypoint is fixed at build time and is not
the same thing as a data path,
-which is why `camel-opa` lets `entrypoint` be set separately from `policyPath`.
-
-`authz.rego` is shared with `OpaIT`, which uploads it to a real OPA server:
the two engines must decide the
-same way, so a rule added here should be exercised from both test classes.
diff --git a/components/camel-opa/src/test/resources/authz-bundle.tar.gz
b/components/camel-opa/src/test/resources/authz-bundle.tar.gz
deleted file mode 100644
index 1174e3398077..000000000000
Binary files a/components/camel-opa/src/test/resources/authz-bundle.tar.gz and
/dev/null differ
diff --git a/components/camel-opa/src/test/resources/authz.wasm
b/components/camel-opa/src/test/resources/authz.wasm
deleted file mode 100644
index 44d056602484..000000000000
Binary files a/components/camel-opa/src/test/resources/authz.wasm and /dev/null
differ
diff --git a/components/camel-opa/src/test/resources/roles-bundle.tar.gz
b/components/camel-opa/src/test/resources/roles-bundle.tar.gz
deleted file mode 100644
index 75ac2992c094..000000000000
Binary files a/components/camel-opa/src/test/resources/roles-bundle.tar.gz and
/dev/null differ
diff --git a/test-infra/camel-test-infra-opa/pom.xml
b/test-infra/camel-test-infra-opa/pom.xml
index 8cb5cbab748f..5a9c628cae14 100644
--- a/test-infra/camel-test-infra-opa/pom.xml
+++ b/test-infra/camel-test-infra-opa/pom.xml
@@ -38,4 +38,34 @@
</dependency>
</dependencies>
+ <profiles>
+ <profile>
+ <id>opa-it-test</id>
+ <activation>
+ <activeByDefault>false</activeByDefault>
+ <property>
+ <name>opa-it-test</name>
+ </property>
+ </activation>
+ <properties>
+ <skipITs>false</skipITs>
+ </properties>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-failsafe-plugin</artifactId>
+ <executions>
+ <execution>
+ <goals>
+ <goal>integration-test</goal>
+ <goal>verify</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ </profile>
+ </profiles>
</project>
diff --git
a/test-infra/camel-test-infra-opa/src/main/java/org/apache/camel/test/infra/opa/services/OpaWasmBundleBuilder.java
b/test-infra/camel-test-infra-opa/src/main/java/org/apache/camel/test/infra/opa/services/OpaWasmBundleBuilder.java
new file mode 100644
index 000000000000..703edffabf11
--- /dev/null
+++
b/test-infra/camel-test-infra-opa/src/main/java/org/apache/camel/test/infra/opa/services/OpaWasmBundleBuilder.java
@@ -0,0 +1,142 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.test.infra.opa.services;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.test.infra.common.LocalPropertyResolver;
+import org.apache.camel.test.infra.opa.common.OpaProperties;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy;
+import org.testcontainers.images.builder.Transferable;
+
+/**
+ * Compiles Rego into the WebAssembly bundle that {@code evaluationMode=wasm}
evaluates.
+ * <p/>
+ * This is the other half of what this module is for. The service next door
runs an OPA server so the REST tests can
+ * talk to it; in-process evaluation has no server to talk to, but the Rego
still has to be compiled, and
+ * {@code opa build -t wasm} needs the OPA binary. Rather than commit the
compiled artifact - which drifts silently
+ * against the {@code .rego} beside it, and has no business in a source
release - a test asks for a bundle and gets one
+ * built from the policy it is actually asserting on.
+ * <p/>
+ * The image is the one already pinned in {@code container.properties}, so the
compiler and the server the REST tests
+ * use never disagree about their OPA version.
+ */
+public final class OpaWasmBundleBuilder {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(OpaWasmBundleBuilder.class);
+ private static final String WORK_DIR = "/policy";
+ // the sources go to a directory the working-directory setting creates for
us, but the bundle cannot be
+ // written there: that directory ends up owned by root while the OPA image
runs as a non-root user, so
+ // "opa build" fails with "open bundle.tar.gz: permission denied". /tmp is
writable, so -o points there.
+ private static final String BUNDLE = "/tmp/bundle.tar.gz";
+
+ private OpaWasmBundleBuilder() {
+ }
+
+ /**
+ * Compiles a single Rego policy.
+ *
+ * @param fileName the name to give the policy inside the build, for
example {@code authz.rego}
+ * @param rego the policy source
+ * @param entrypoints the rules to expose, as {@code opa build -e} names
them - an entrypoint is fixed at build
+ * time and is not the same thing as a data path
+ * @return the {@code bundle.tar.gz} {@code opa build}
emitted, holding {@code /policy.wasm}
+ */
+ public static byte[] build(String fileName, String rego, String...
entrypoints) {
+ return build(Map.of(fileName, rego.getBytes(StandardCharsets.UTF_8)),
entrypoints);
+ }
+
+ /**
+ * Compiles a set of sources, so a policy that reads {@code data.*} can be
built together with the {@code data.json}
+ * that {@code opa build} packs beside it.
+ *
+ * @param sources file name to content, for example {@code
roles.rego} and {@code data.json}
+ * @param entrypoints the rules to expose
+ * @return the {@code bundle.tar.gz} {@code opa build} emitted
+ */
+ public static byte[] build(Map<String, byte[]> sources, String...
entrypoints) {
+ if (sources == null || sources.isEmpty()) {
+ throw new IllegalArgumentException("At least one source is
required to build a bundle");
+ }
+ if (entrypoints == null || entrypoints.length == 0) {
+ throw new IllegalArgumentException(
+ "At least one entrypoint is required; opa build -t wasm
emits nothing callable without one");
+ }
+
+ String image =
LocalPropertyResolver.getProperty(OpaLocalContainerInfraService.class,
OpaProperties.OPA_CONTAINER);
+ LOG.info("Compiling {} to WebAssembly with {}", sources.keySet(),
image);
+
+ Map<String, byte[]> ordered = new LinkedHashMap<>(sources);
+ GenericContainer<?> compiler = compiler(image, ordered, entrypoints);
+ try {
+ compiler.start();
+ // the container has exited by now: OneShotStartupCheckStrategy
waits for that rather than for a port,
+ // and the bundle is read back out of the stopped container's
filesystem
+ return compiler.copyFileFromContainer(BUNDLE,
InputStream::readAllBytes);
+ } catch (Exception e) {
+ // opa build reports what it disliked about the policy on stderr,
and losing that leaves a caller with
+ // "container did not start correctly", which says nothing about
their Rego
+ throw new IllegalStateException(
+ "Could not compile " + sources.keySet() + " to a
WebAssembly bundle. opa said: " + logsOf(compiler),
+ e);
+ } finally {
+ compiler.stop();
+ }
+ }
+
+ private static String logsOf(GenericContainer<?> container) {
+ try {
+ String logs = container.getLogs();
+ return logs == null || logs.isBlank() ? "(nothing)" : logs.strip();
+ } catch (Exception e) {
+ return "(logs unavailable: " + e.getMessage() + ")";
+ }
+ }
+
+ @SuppressWarnings("resource")
+ private static GenericContainer<?> compiler(String image, Map<String,
byte[]> sources, String[] entrypoints) {
+ GenericContainer<?> container = new GenericContainer<>(image) //
NOSONAR
+ .withWorkingDirectory(WORK_DIR)
+ .withStartupCheckStrategy(new OneShotStartupCheckStrategy());
+
+ for (Map.Entry<String, byte[]> source : sources.entrySet()) {
+ container.withCopyToContainer(
+ Transferable.of(source.getValue()), WORK_DIR + "/" +
source.getKey());
+ }
+ return container.withCommand(command(entrypoints));
+ }
+
+ private static String[] command(String[] entrypoints) {
+ // "opa build -t wasm -e <ep> ... ." - building the directory rather
than naming the files is what makes
+ // opa build pack a data.json sitting beside the policy into the bundle
+ List<String> command = new ArrayList<>(List.of("build", "-t", "wasm",
"-o", BUNDLE));
+ for (String entrypoint : entrypoints) {
+ command.add("-e");
+ command.add(entrypoint);
+ }
+ command.add(".");
+ return command.toArray(new String[0]);
+ }
+}
diff --git
a/test-infra/camel-test-infra-opa/src/test/java/org/apache/camel/test/infra/opa/OpaWasmBundleBuilderIT.java
b/test-infra/camel-test-infra-opa/src/test/java/org/apache/camel/test/infra/opa/OpaWasmBundleBuilderIT.java
new file mode 100644
index 000000000000..8fb94f3964be
--- /dev/null
+++
b/test-infra/camel-test-infra-opa/src/test/java/org/apache/camel/test/infra/opa/OpaWasmBundleBuilderIT.java
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.test.infra.opa;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.test.infra.opa.services.OpaWasmBundleBuilder;
+import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
+import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
+import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class OpaWasmBundleBuilderIT {
+ // an IT rather than a Test on purpose: this needs a container, and the
convention in this tree is that
+ // anything requiring Docker runs under failsafe so a plain build stays
green without it
+
+ private static final String REGO = """
+ package authz
+
+ default allow := false
+
+ allow if {
+ input.user == "alice"
+ }
+ """;
+
+ private List<String> entries(byte[] bundle) throws Exception {
+ List<String> names = new ArrayList<>();
+ try (TarArchiveInputStream tar
+ = new TarArchiveInputStream(new GzipCompressorInputStream(new
ByteArrayInputStream(bundle)))) {
+ TarArchiveEntry entry;
+ while ((entry = tar.getNextEntry()) != null) {
+ if (!entry.isDirectory()) {
+ names.add(entry.getName().replaceFirst("^/", ""));
+ }
+ }
+ }
+ return names;
+ }
+
+ @Test
+ void buildsABundleCarryingTheCompiledModule() throws Exception {
+ byte[] bundle = OpaWasmBundleBuilder.build("authz.rego", REGO,
"authz/allow");
+
+ assertTrue(entries(bundle).contains("policy.wasm"),
+ "opa build must emit /policy.wasm - that is the artifact the
component loads");
+ }
+
+ @Test
+ void packsADataDocumentSittingBesideThePolicy() throws Exception {
+ // building the directory rather than naming the file is what makes
opa build include data.json; a policy
+ // reading data.* is useless without it
+ byte[] bundle = OpaWasmBundleBuilder.build(
+ Map.of("roles.rego", REGO.getBytes(StandardCharsets.UTF_8),
+ "data.json",
"{\"admins\":[\"carol\"]}".getBytes(StandardCharsets.UTF_8)),
+ "authz/allow");
+
+ List<String> entries = entries(bundle);
+ assertTrue(entries.contains("policy.wasm"), entries.toString());
+ assertTrue(entries.contains("data.json"), entries.toString());
+ }
+
+ @Test
+ void refusesToBuildWithoutAnEntrypoint() {
+ // opa build -t wasm with no -e compiles happily and emits nothing
callable, which would surface much
+ // later as an empty result array rather than as a build failure
+ IllegalArgumentException e =
assertThrows(IllegalArgumentException.class,
+ () -> OpaWasmBundleBuilder.build("authz.rego", REGO));
+
+ assertTrue(e.getMessage().contains("entrypoint"), e.getMessage());
+ }
+
+ @Test
+ void refusesToBuildWithoutSources() {
+ assertThrows(IllegalArgumentException.class, () ->
OpaWasmBundleBuilder.build(Map.of(), "authz/allow"));
+ }
+
+ @Test
+ void reportsWhatFailedWhenTheRegoIsNotValid() {
+ IllegalStateException e = assertThrows(IllegalStateException.class,
+ () -> OpaWasmBundleBuilder.build("broken.rego", "this is not
rego at all", "authz/allow"));
+
+ assertTrue(e.getMessage().contains("broken.rego"), e.getMessage());
+ }
+}