This is an automated email from the ASF dual-hosted git repository. apupier pushed a commit to branch revert-26677-feature/CAMEL-24742-wasm-test-infra in repository https://gitbox.apache.org/repos/asf/camel.git
commit 61371eda6e59fab6bf150cd2e21a722b2c2a31cc Author: Aurélien Pupier <[email protected]> AuthorDate: Mon Sep 28 09:55:54 2026 +0200 Revert "CAMEL-24742: camel-opa - compile the WASM test fixtures instead of co…" This reverts commit 097ee856bbdab4451aef158e13044858b7839e66. --- .../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 0 -> 57234 bytes components/camel-opa/src/test/resources/authz.wasm | Bin 0 -> 140391 bytes .../src/test/resources/roles-bundle.tar.gz | Bin 0 -> 56415 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, 313 insertions(+), 628 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 deleted file mode 100644 index 660a09ac04c2..000000000000 --- a/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaWasmConfigurationTest.java +++ /dev/null @@ -1,52 +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 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 new file mode 100644 index 000000000000..06d4e8ea49f7 --- /dev/null +++ b/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaWasmEvaluatorTest.java @@ -0,0 +1,205 @@ +/* + * 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 deleted file mode 100644 index 45d7cf211232..000000000000 --- a/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaWasmIT.java +++ /dev/null @@ -1,298 +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.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 new file mode 100644 index 000000000000..e4661972652d --- /dev/null +++ b/components/camel-opa/src/test/java/org/apache/camel/component/opa/OpaWasmModeValidationTest.java @@ -0,0 +1,80 @@ +/* + * 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 new file mode 100644 index 000000000000..ee0ce62e21c4 --- /dev/null +++ b/components/camel-opa/src/test/resources/README-wasm-fixtures.md @@ -0,0 +1,28 @@ +# 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 new file mode 100644 index 000000000000..1174e3398077 Binary files /dev/null and b/components/camel-opa/src/test/resources/authz-bundle.tar.gz differ diff --git a/components/camel-opa/src/test/resources/authz.wasm b/components/camel-opa/src/test/resources/authz.wasm new file mode 100644 index 000000000000..44d056602484 Binary files /dev/null and b/components/camel-opa/src/test/resources/authz.wasm 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 new file mode 100644 index 000000000000..75ac2992c094 Binary files /dev/null and b/components/camel-opa/src/test/resources/roles-bundle.tar.gz differ diff --git a/test-infra/camel-test-infra-opa/pom.xml b/test-infra/camel-test-infra-opa/pom.xml index 5a9c628cae14..8cb5cbab748f 100644 --- a/test-infra/camel-test-infra-opa/pom.xml +++ b/test-infra/camel-test-infra-opa/pom.xml @@ -38,34 +38,4 @@ </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 deleted file mode 100644 index 703edffabf11..000000000000 --- a/test-infra/camel-test-infra-opa/src/main/java/org/apache/camel/test/infra/opa/services/OpaWasmBundleBuilder.java +++ /dev/null @@ -1,142 +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.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 deleted file mode 100644 index 8fb94f3964be..000000000000 --- a/test-infra/camel-test-infra-opa/src/test/java/org/apache/camel/test/infra/opa/OpaWasmBundleBuilderIT.java +++ /dev/null @@ -1,106 +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.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()); - } -}
