Skip to content

Commit bf5d93d

Browse files
committed
Add integration test for CRD validation
Tests that: 1. myriad invalid custom resources will not be accepted by k8s 2. all the in-* files used by our DerivedResourcesTest are valid, this is handy because it is a crucial test, but since it is mocked it is possible for us to test against different data than would be allowed in via the k8s APIs. Why: Our CRDs contain a lot of declared constraints. Required fields, types, length restrictions, patterns. We want some quick feedback that we have specified things correctly. Checking directly with a json schema validation library is a bit tricky since k8s uses an odd standard with their own extensions. Instead we test against minikube to get some real integration with a k8s API server. Note that when we assert which error messages were returned from the k8s APIs we are using contains to check something minimal showing that the resource was invalidated for the expected reason. The detailed error message is more likely to change with k8s version, so we don't do an exact match on the whole error. Signed-off-by: Robert Young <[email protected]>
1 parent 3e7673d commit bf5d93d

File tree

71 files changed

+1808
-31
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+1808
-31
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Copyright Kroxylicious Authors.
3+
*
4+
* Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
7+
package io.kroxylicious.kubernetes.operator;
8+
9+
import java.io.IOException;
10+
import java.io.InputStream;
11+
import java.nio.file.Files;
12+
import java.nio.file.Path;
13+
import java.util.stream.Stream;
14+
15+
import org.assertj.core.api.Assertions;
16+
import org.junit.jupiter.api.AfterAll;
17+
import org.junit.jupiter.api.BeforeAll;
18+
import org.junit.jupiter.api.condition.EnabledIf;
19+
import org.junit.jupiter.params.ParameterizedTest;
20+
import org.junit.jupiter.params.provider.Arguments;
21+
import org.junit.jupiter.params.provider.MethodSource;
22+
23+
import com.fasterxml.jackson.core.JsonProcessingException;
24+
import com.fasterxml.jackson.databind.ObjectMapper;
25+
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
26+
27+
import io.fabric8.kubernetes.api.model.HasMetadata;
28+
import io.fabric8.kubernetes.api.model.Namespace;
29+
import io.fabric8.kubernetes.api.model.NamespaceBuilder;
30+
import io.fabric8.kubernetes.api.model.Status;
31+
import io.fabric8.kubernetes.client.KubernetesClient;
32+
import io.fabric8.kubernetes.client.KubernetesClientException;
33+
import io.fabric8.kubernetes.client.dsl.NamespaceableResource;
34+
import io.fabric8.kubernetes.client.dsl.NonDeletingOperation;
35+
import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension;
36+
37+
import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxy;
38+
import io.kroxylicious.kubernetes.api.v1alpha1.KafkaProxyIngress;
39+
import io.kroxylicious.kubernetes.api.v1alpha1.KafkaService;
40+
import io.kroxylicious.kubernetes.api.v1alpha1.VirtualKafkaCluster;
41+
import io.kroxylicious.kubernetes.filter.api.v1alpha1.KafkaProtocolFilter;
42+
43+
import static org.assertj.core.api.Assertions.assertThat;
44+
45+
@EnabledIf(value = "io.kroxylicious.kubernetes.operator.OperatorTestUtils#isKubeClientAvailable", disabledReason = "no viable kube client available")
46+
class CustomResourceValidationIT {
47+
48+
public static final Namespace NAMESPACE = new NamespaceBuilder().withNewMetadata().withName("proxy-ns").endMetadata().build();
49+
public static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory());
50+
51+
@BeforeAll
52+
static void beforeAll() {
53+
KubernetesClient client = OperatorTestUtils.kubeClient();
54+
LocallyRunOperatorExtension.applyCrd(KafkaProtocolFilter.class, client);
55+
LocallyRunOperatorExtension.applyCrd(KafkaProxy.class, client);
56+
LocallyRunOperatorExtension.applyCrd(VirtualKafkaCluster.class, client);
57+
LocallyRunOperatorExtension.applyCrd(KafkaService.class, client);
58+
LocallyRunOperatorExtension.applyCrd(KafkaProxyIngress.class, client);
59+
client.namespaces().resource(NAMESPACE).createOr(NonDeletingOperation::update);
60+
}
61+
62+
@AfterAll
63+
static void afterAll() {
64+
try (KubernetesClient kubernetesClient = OperatorTestUtils.kubeClient()) {
65+
kubernetesClient.namespaces().resource(NAMESPACE).delete();
66+
kubernetesClient.resources(KafkaProtocolFilter.class).delete();
67+
kubernetesClient.resources(KafkaProxyIngress.class).delete();
68+
kubernetesClient.resources(KafkaProxy.class).delete();
69+
kubernetesClient.resources(VirtualKafkaCluster.class).delete();
70+
kubernetesClient.resources(KafkaService.class).delete();
71+
}
72+
}
73+
74+
public static Stream<Path> testDerivedResourceInputsValid() {
75+
return TestFiles.recursiveFilesInDirectoryForTest(DerivedResourcesTest.class, "in-*.yaml").stream();
76+
}
77+
78+
public static Stream<Arguments> testResourceInvalid() {
79+
return TestFiles.recursiveFilesInDirectoryForTest(CustomResourceValidationIT.class, "invalid-*.yaml").stream().map(p -> {
80+
try {
81+
return Arguments.argumentSet(p.toString(), YAML_MAPPER.readValue(p.toFile(), InvalidResource.class));
82+
}
83+
catch (IOException e) {
84+
throw new RuntimeException(e);
85+
}
86+
});
87+
}
88+
89+
@MethodSource
90+
@ParameterizedTest
91+
void testDerivedResourceInputsValid(Path validYaml) {
92+
try (InputStream is = Files.newInputStream(validYaml)) {
93+
NamespaceableResource<HasMetadata> resource = OperatorTestUtils.kubeClient().resource(is);
94+
Assertions.assertThatCode(resource::create).doesNotThrowAnyException();
95+
Assertions.assertThatCode(resource::delete).doesNotThrowAnyException();
96+
}
97+
catch (IOException e) {
98+
throw new RuntimeException(e);
99+
}
100+
}
101+
102+
record InvalidResource(String expectFailureMessageToContain, Object resource) {
103+
String resourceAsString() {
104+
try {
105+
return YAML_MAPPER.writeValueAsString(resource);
106+
}
107+
catch (JsonProcessingException e) {
108+
throw new RuntimeException(e);
109+
}
110+
}
111+
}
112+
113+
@MethodSource
114+
@ParameterizedTest
115+
void testResourceInvalid(InvalidResource invalidYaml) {
116+
NamespaceableResource<HasMetadata> resource = OperatorTestUtils.kubeClient().resource(invalidYaml.resourceAsString());
117+
try {
118+
Assertions.assertThatThrownBy(resource::create).isInstanceOfSatisfying(KubernetesClientException.class, e -> {
119+
Status status = e.getStatus();
120+
assertThat(status).isNotNull();
121+
assertThat(status.getCode()).isEqualTo(422);
122+
assertThat(status.getMessage()).contains(invalidYaml.expectFailureMessageToContain);
123+
});
124+
}
125+
finally {
126+
try {
127+
resource.delete();
128+
}
129+
catch (KubernetesClientException e) {
130+
// ignored, redundantly deleting in case the resource was accidentally valid
131+
}
132+
}
133+
}
134+
135+
}

kroxylicious-operator/src/test/java/io/kroxylicious/kubernetes/operator/DerivedResourcesTest.java

Lines changed: 8 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,8 @@
2222
import java.util.Map;
2323
import java.util.Set;
2424
import java.util.TreeSet;
25-
import java.util.regex.Pattern;
2625
import java.util.stream.Collectors;
2726
import java.util.stream.Stream;
28-
import java.util.stream.StreamSupport;
2927

3028
import org.assertj.core.api.Assertions;
3129
import org.junit.jupiter.api.DynamicContainer;
@@ -193,22 +191,9 @@ Stream<DynamicContainer> dependentResourcesShouldEqual() {
193191
return dependentResourcesShouldEqual(list);
194192
}
195193

196-
static List<Path> filesInDir(Path dir, Pattern pattern) {
197-
var result = new ArrayList<Path>();
198-
try (var expected = Files.newDirectoryStream(dir, path -> pattern.matcher(path.getFileName().toString()).matches())) {
199-
for (Path f : expected) {
200-
result.add(f);
201-
}
202-
}
203-
catch (IOException e) {
204-
throw new UncheckedIOException(e);
205-
}
206-
return result;
207-
}
208-
209194
Stream<DynamicContainer> dependentResourcesShouldEqual(List<DesiredFn<KafkaProxy, ?>> list) {
210-
var dir = Path.of("target", "test-classes", DerivedResourcesTest.class.getSimpleName());
211-
return filesInDir(dir, Pattern.compile(".*")).stream()
195+
List<Path> paths = TestFiles.subDirectoriesForTest(DerivedResourcesTest.class);
196+
return paths.stream()
212197
.map(testDir -> {
213198
String testCase = fileName(testDir);
214199
try {
@@ -235,17 +220,18 @@ private static List<DynamicTest> testsForDir(List<DesiredFn<KafkaProxy, ?>> depe
235220
Path testDir)
236221
throws IOException {
237222
try {
238-
var unusedFiles = childFilesMatching(testDir, "*");
223+
var unusedFiles = TestFiles.childFilesMatching(testDir, "*");
239224
String inFileName = "in-KafkaProxy.yaml";
240225
Path input = testDir.resolve(inFileName);
241226
KafkaProxy kafkaProxy = kafkaProxyFromFile(input);
242-
List<VirtualKafkaCluster> virtualKafkaClusters = resourcesFromFiles(childFilesMatching(testDir, "in-VirtualKafkaCluster-*"), VirtualKafkaCluster.class);
243-
List<KafkaService> kafkaServiceRefs = resourcesFromFiles(childFilesMatching(testDir, "in-KafkaService-*"), KafkaService.class);
227+
List<VirtualKafkaCluster> virtualKafkaClusters = resourcesFromFiles(TestFiles.childFilesMatching(testDir, "in-VirtualKafkaCluster-*"),
228+
VirtualKafkaCluster.class);
229+
List<KafkaService> kafkaServiceRefs = resourcesFromFiles(TestFiles.childFilesMatching(testDir, "in-KafkaService-*"), KafkaService.class);
244230
assertMinimalMetadata(kafkaProxy.getMetadata(), inFileName);
245-
List<KafkaProxyIngress> ingresses = kafkaProxyIngressesFromFiles(childFilesMatching(testDir, "in-KafkaProxyIngress-*"));
231+
List<KafkaProxyIngress> ingresses = kafkaProxyIngressesFromFiles(TestFiles.childFilesMatching(testDir, "in-KafkaProxyIngress-*"));
246232

247233
unusedFiles.remove(input);
248-
unusedFiles.removeAll(childFilesMatching(testDir, "in-*"));
234+
unusedFiles.removeAll(TestFiles.childFilesMatching(testDir, "in-*"));
249235

250236
Context<KafkaProxy> context;
251237
try {
@@ -323,15 +309,6 @@ private static <T> void assertSameYaml(T actualResource, T expected) throws Json
323309
}
324310
}
325311

326-
@NonNull
327-
private static HashSet<Path> childFilesMatching(
328-
Path testDir,
329-
String glob)
330-
throws IOException {
331-
return StreamSupport.stream(Files.newDirectoryStream(testDir, glob).spliterator(), false)
332-
.collect(Collectors.toCollection(HashSet::new));
333-
}
334-
335312
@NonNull
336313
private static Context<KafkaProxy> buildContext(Path testDir,
337314
List<VirtualKafkaCluster> virtualKafkaClusters,
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* Copyright Kroxylicious Authors.
3+
*
4+
* Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
7+
package io.kroxylicious.kubernetes.operator;
8+
9+
import java.io.IOException;
10+
import java.io.UncheckedIOException;
11+
import java.nio.file.FileSystems;
12+
import java.nio.file.FileVisitResult;
13+
import java.nio.file.Files;
14+
import java.nio.file.Path;
15+
import java.nio.file.PathMatcher;
16+
import java.nio.file.SimpleFileVisitor;
17+
import java.nio.file.attribute.BasicFileAttributes;
18+
import java.util.ArrayList;
19+
import java.util.HashSet;
20+
import java.util.List;
21+
import java.util.stream.Collectors;
22+
import java.util.stream.StreamSupport;
23+
24+
import io.kroxylicious.proxy.tag.VisibleForTesting;
25+
26+
import edu.umd.cs.findbugs.annotations.NonNull;
27+
28+
public class TestFiles {
29+
30+
@NonNull
31+
static HashSet<Path> childFilesMatching(
32+
Path testDir,
33+
String glob)
34+
throws IOException {
35+
return StreamSupport.stream(Files.newDirectoryStream(testDir, glob).spliterator(), false)
36+
.filter(Files::isRegularFile)
37+
.collect(Collectors.toCollection(HashSet::new));
38+
}
39+
40+
static List<Path> subDirectoriesForTest(Class<?> testClazz) {
41+
var dir = testDir(testClazz);
42+
return subDirectories(dir);
43+
}
44+
45+
private static @NonNull Path testDir(Class<?> testClazz) {
46+
return Path.of("target", "test-classes", testClazz.getSimpleName());
47+
}
48+
49+
@NonNull
50+
static List<Path> subDirectories(Path dir) {
51+
var directories = new ArrayList<Path>();
52+
try (var expected = Files.newDirectoryStream(dir, Files::isDirectory)) {
53+
for (Path f : expected) {
54+
directories.add(f);
55+
}
56+
}
57+
catch (IOException e) {
58+
throw new UncheckedIOException(e);
59+
}
60+
return directories;
61+
}
62+
63+
static List<Path> recursiveFilesInDirectoryForTest(Class<?> testClazz, String filenameGlob) {
64+
Path path = testDir(testClazz);
65+
return recursiveFilesInDirectory(filenameGlob, path);
66+
}
67+
68+
@VisibleForTesting
69+
static @NonNull List<Path> recursiveFilesInDirectory(String filenameGlob, Path path) {
70+
PathMatcher globMatcher = FileSystems.getDefault().getPathMatcher("glob:**/" + filenameGlob);
71+
List<Path> files = new ArrayList<>();
72+
try {
73+
Files.walkFileTree(path, new SimpleFileVisitor<>() {
74+
@Override
75+
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
76+
if (globMatcher.matches(file)) {
77+
files.add(file);
78+
}
79+
return FileVisitResult.CONTINUE;
80+
}
81+
});
82+
}
83+
catch (IOException e) {
84+
throw new RuntimeException(e);
85+
}
86+
return files;
87+
}
88+
}

0 commit comments

Comments
 (0)