diff --git a/.sdkmanrc b/.sdkmanrc
index 5ecad56..fc49ff1 100644
--- a/.sdkmanrc
+++ b/.sdkmanrc
@@ -1,3 +1,3 @@
# Enable auto-env through the sdkman_auto_env config
# Add key=value pairs of SDKs to use below
-java=21.ea.31-open
+java=20.0.1-tem
diff --git a/pom.xml b/pom.xml
index 3df4c5a..3d35ca5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -15,12 +15,14 @@
1.0-SNAPSHOT
- 21
+ 20
UTF-8
${java.version}
${java.version}
1.0.0-alpha-4
+ 1.0.0-alpha-3
+ 15.0.
@@ -30,6 +32,16 @@
vavr
${vavr.version}
+
+ io.vavr
+ vavr-jackson
+ ${vavr-jackson.version}
+
+
+ io.kubernetes
+ client-java
+ ${kubernetes-client.version}1
+
@@ -38,10 +50,27 @@
io.vavr
vavr
+
+ io.vavr
+ vavr-jackson
+
+
+ io.kubernetes
+ client-java
+
org.springframework.boot
spring-boot-starter-web
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
org.springframework.boot
spring-boot-devtools
@@ -49,4 +78,18 @@
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+ ${maven.compiler.target}
+ --enable-preview
+
+
+
+
+
diff --git a/src/main/java/tk/antoine/roux/domain/Resource.java b/src/main/java/tk/antoine/roux/domain/Resource.java
new file mode 100644
index 0000000..0160d6f
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/domain/Resource.java
@@ -0,0 +1,4 @@
+package tk.antoine.roux.domain;
+
+public interface Resource {
+}
diff --git a/src/main/java/tk/antoine/roux/domain/ResourceLister.java b/src/main/java/tk/antoine/roux/domain/ResourceLister.java
new file mode 100644
index 0000000..bbbc7c8
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/domain/ResourceLister.java
@@ -0,0 +1,15 @@
+package tk.antoine.roux.domain;
+
+import io.vavr.collection.List;
+import tk.antoine.roux.domain.model.Node;
+
+import java.util.regex.Pattern;
+
+public interface ResourceLister {
+
+ List listAll();
+
+ List listAllByName(String namePattern);
+
+ List listAllByRegex(Pattern namePattern);
+}
diff --git a/src/main/java/tk/antoine/roux/domain/model/Node.java b/src/main/java/tk/antoine/roux/domain/model/Node.java
new file mode 100644
index 0000000..39abbde
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/domain/model/Node.java
@@ -0,0 +1,38 @@
+package tk.antoine.roux.domain.model;
+
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility;
+import tk.antoine.roux.domain.Resource;
+
+@JsonAutoDetect(creatorVisibility = Visibility.ANY, fieldVisibility = Visibility.ANY)
+public final class Node implements Resource {
+ private static final String NOT_DEFINED_NAME = "#NotDefined";
+ private final String name;
+
+ private Node(String name) {
+ this.name = name;
+ }
+
+ public String name() {
+ return name;
+ }
+
+ public static NodeBuilder builder() {
+ return new NodeBuilder();
+ }
+
+ public static class NodeBuilder {
+ private String name = NOT_DEFINED_NAME;
+
+ public NodeBuilder withName(String name) {
+ this.name = name;
+ return this;
+ }
+
+ public Node build() {
+ return new Node(name);
+ }
+
+ }
+
+}
diff --git a/src/main/java/tk/antoine/roux/domain/usecases/ByLabelCriteria.java b/src/main/java/tk/antoine/roux/domain/usecases/ByLabelCriteria.java
new file mode 100644
index 0000000..0aeea93
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/domain/usecases/ByLabelCriteria.java
@@ -0,0 +1,4 @@
+package tk.antoine.roux.domain.usecases;
+
+public record ByLabelCriteria() implements CriteriaCommand {
+}
diff --git a/src/main/java/tk/antoine/roux/domain/usecases/ByNameCriteria.java b/src/main/java/tk/antoine/roux/domain/usecases/ByNameCriteria.java
new file mode 100644
index 0000000..5a67fe9
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/domain/usecases/ByNameCriteria.java
@@ -0,0 +1,4 @@
+package tk.antoine.roux.domain.usecases;
+
+public record ByNameCriteria(String value) implements CriteriaCommand {
+}
diff --git a/src/main/java/tk/antoine/roux/domain/usecases/ByRegexCriteria.java b/src/main/java/tk/antoine/roux/domain/usecases/ByRegexCriteria.java
new file mode 100644
index 0000000..3529b1f
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/domain/usecases/ByRegexCriteria.java
@@ -0,0 +1,6 @@
+package tk.antoine.roux.domain.usecases;
+
+import java.util.regex.Pattern;
+
+public record ByRegexCriteria(Pattern regex) implements CriteriaCommand {
+}
diff --git a/src/main/java/tk/antoine/roux/domain/usecases/Command.java b/src/main/java/tk/antoine/roux/domain/usecases/Command.java
new file mode 100644
index 0000000..e31f9a9
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/domain/usecases/Command.java
@@ -0,0 +1,14 @@
+package tk.antoine.roux.domain.usecases;
+
+import tk.antoine.roux.domain.usecases.Command.EmptyCommand;
+import tk.antoine.roux.domain.usecases.operation.UseCase;
+
+public sealed interface Command extends Parameter permits EmptyCommand, CriteriaCommand {
+
+ /**
+ * Default {@link UseCase} {@link Command} without parameter
+ */
+ record EmptyCommand() implements Command {
+ }
+
+}
diff --git a/src/main/java/tk/antoine/roux/domain/usecases/CriteriaCommand.java b/src/main/java/tk/antoine/roux/domain/usecases/CriteriaCommand.java
new file mode 100644
index 0000000..94e766c
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/domain/usecases/CriteriaCommand.java
@@ -0,0 +1,4 @@
+package tk.antoine.roux.domain.usecases;
+
+public sealed interface CriteriaCommand extends Command permits ByLabelCriteria, ByNameCriteria, ByRegexCriteria {
+}
diff --git a/src/main/java/tk/antoine/roux/domain/usecases/InvalidCriteriaException.java b/src/main/java/tk/antoine/roux/domain/usecases/InvalidCriteriaException.java
new file mode 100644
index 0000000..8a9f5e1
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/domain/usecases/InvalidCriteriaException.java
@@ -0,0 +1,9 @@
+package tk.antoine.roux.domain.usecases;
+
+import tk.antoine.roux.infrastructure.in.Criteria;
+
+public class InvalidCriteriaException extends Exception {
+ public InvalidCriteriaException(String reason, Criteria criteria) {
+ super("Invalid criteria because " + reason + ", received criteria " + criteria);
+ }
+}
diff --git a/src/main/java/tk/antoine/roux/domain/usecases/Parameter.java b/src/main/java/tk/antoine/roux/domain/usecases/Parameter.java
new file mode 100644
index 0000000..efb0b74
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/domain/usecases/Parameter.java
@@ -0,0 +1,4 @@
+package tk.antoine.roux.domain.usecases;
+
+public sealed interface Parameter permits Command, Queries {
+}
diff --git a/src/main/java/tk/antoine/roux/domain/usecases/Queries.java b/src/main/java/tk/antoine/roux/domain/usecases/Queries.java
new file mode 100644
index 0000000..054c03f
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/domain/usecases/Queries.java
@@ -0,0 +1,14 @@
+package tk.antoine.roux.domain.usecases;
+
+import tk.antoine.roux.domain.usecases.Queries.EmptyQueries;
+import tk.antoine.roux.domain.usecases.operation.UseCase;
+
+public sealed interface Queries extends Parameter permits EmptyQueries {
+
+ /**
+ * Default {@link UseCase} {@link Queries} without parameter
+ */
+ record EmptyQueries() implements Queries {
+ }
+
+}
diff --git a/src/main/java/tk/antoine/roux/domain/usecases/operation/GetNodesUseCase.java b/src/main/java/tk/antoine/roux/domain/usecases/operation/GetNodesUseCase.java
new file mode 100644
index 0000000..f1f7b1c
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/domain/usecases/operation/GetNodesUseCase.java
@@ -0,0 +1,31 @@
+package tk.antoine.roux.domain.usecases.operation;
+
+import io.vavr.collection.List;
+import org.springframework.stereotype.Service;
+import tk.antoine.roux.domain.model.Node;
+import tk.antoine.roux.domain.usecases.ByLabelCriteria;
+import tk.antoine.roux.domain.usecases.ByNameCriteria;
+import tk.antoine.roux.domain.usecases.ByRegexCriteria;
+import tk.antoine.roux.domain.usecases.Command;
+import tk.antoine.roux.infrastructure.out.NodeLister;
+
+@Service
+public final class GetNodesUseCase implements UseCase, Command> {
+
+ private final NodeLister nodeLister;
+
+ public GetNodesUseCase(NodeLister nodeLister) {
+ this.nodeLister = nodeLister;
+ }
+
+ @Override
+ public List invoke(Command command) {
+ return switch (command) {
+ case Command.EmptyCommand emptyCommand -> nodeLister.listAll();
+ case ByNameCriteria nameCriteria -> nodeLister.listAllByName(nameCriteria.value());
+ case ByRegexCriteria regexCriteria -> nodeLister.listAllByRegex(regexCriteria.regex());
+ case ByLabelCriteria byLabelCriteria -> nodeLister.listAll();
+ };
+ }
+
+}
diff --git a/src/main/java/tk/antoine/roux/domain/usecases/operation/UseCase.java b/src/main/java/tk/antoine/roux/domain/usecases/operation/UseCase.java
new file mode 100644
index 0000000..27c3132
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/domain/usecases/operation/UseCase.java
@@ -0,0 +1,9 @@
+package tk.antoine.roux.domain.usecases.operation;
+
+import tk.antoine.roux.domain.usecases.Parameter;
+
+public sealed interface UseCase permits GetNodesUseCase {
+
+ T invoke(C command);
+
+}
diff --git a/src/main/java/tk/antoine/roux/infrastructure/BackofficeProperties.java b/src/main/java/tk/antoine/roux/infrastructure/BackofficeProperties.java
new file mode 100644
index 0000000..346f272
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/infrastructure/BackofficeProperties.java
@@ -0,0 +1,13 @@
+package tk.antoine.roux.infrastructure;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties("backoffice.configuration")
+public record BackofficeProperties(String apiPrefix, Kubernetes kubernetes) {
+
+ public record Kubernetes(Api api) {
+ }
+
+ public record Api(String kubeconfig) {
+ }
+}
diff --git a/src/main/java/tk/antoine/roux/infrastructure/SpringConfiguration.java b/src/main/java/tk/antoine/roux/infrastructure/SpringConfiguration.java
new file mode 100644
index 0000000..08fab82
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/infrastructure/SpringConfiguration.java
@@ -0,0 +1,9 @@
+package tk.antoine.roux.infrastructure;
+
+import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@ConfigurationPropertiesScan
+public class SpringConfiguration {
+}
diff --git a/src/main/java/tk/antoine/roux/infrastructure/in/Criteria.java b/src/main/java/tk/antoine/roux/infrastructure/in/Criteria.java
new file mode 100644
index 0000000..cec5c2b
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/infrastructure/in/Criteria.java
@@ -0,0 +1,42 @@
+package tk.antoine.roux.infrastructure.in;
+
+import io.vavr.CheckedFunction1;
+import io.vavr.control.Either;
+import tk.antoine.roux.domain.usecases.ByLabelCriteria;
+import tk.antoine.roux.domain.usecases.ByNameCriteria;
+import tk.antoine.roux.domain.usecases.ByRegexCriteria;
+import tk.antoine.roux.domain.usecases.CriteriaCommand;
+import tk.antoine.roux.domain.usecases.InvalidCriteriaException;
+
+import java.util.regex.Pattern;
+
+public record Criteria(String rawCriteria) {
+
+ // https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names
+ private static final String RESOURCE_NAME = "^[a-z0-9][a-z0-9-.]{0,252}[a-z0-9]?$";
+ private static final String LABEL_RESOURCE = "^[a-z0-9][a-z0-9-./]{0,252}[a-z0-9]?=[a-z0-9][a-z0-9-.]{0,252}[a-z0-9]?$";
+
+ public Either toCommand() {
+ String trimmedRawCriteria = rawCriteria.trim();
+ final Either resultingCriteria;
+
+ if (trimmedRawCriteria.matches(RESOURCE_NAME)) {
+ resultingCriteria = Either.right(new ByNameCriteria(trimmedRawCriteria));
+ } else if (trimmedRawCriteria.matches(LABEL_RESOURCE)) {
+ resultingCriteria = Either.right(new ByLabelCriteria());
+ } else if (isAValidRegex(trimmedRawCriteria)) {
+ resultingCriteria = Either.right(new ByRegexCriteria(Pattern.compile(trimmedRawCriteria)));
+ } else {
+ resultingCriteria = Either.left(new InvalidCriteriaException("not a word", this));
+ }
+ return resultingCriteria;
+ }
+
+ private boolean isAValidRegex(String value) {
+ return CheckedFunction1.liftTry(Pattern::compile)
+ .apply(value)
+ .map(pattern -> true)
+ .getOrElse(false);
+ }
+
+}
diff --git a/src/main/java/tk/antoine/roux/infrastructure/in/NodeController.java b/src/main/java/tk/antoine/roux/infrastructure/in/NodeController.java
new file mode 100644
index 0000000..7fa99b1
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/infrastructure/in/NodeController.java
@@ -0,0 +1,50 @@
+package tk.antoine.roux.infrastructure.in;
+
+import io.vavr.control.Either;
+import io.vavr.control.Option;
+import org.jetbrains.annotations.NotNull;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ProblemDetail;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import tk.antoine.roux.domain.usecases.Command;
+import tk.antoine.roux.domain.usecases.Command.EmptyCommand;
+import tk.antoine.roux.domain.usecases.operation.GetNodesUseCase;
+
+import java.util.Optional;
+
+@RestController
+@RequestMapping("${backoffice.configuration.api-prefix}/v1")
+public class NodeController {
+
+ private static final EmptyCommand EMPTY_COMMAND = new EmptyCommand();
+ private static final String LIST_NODES_ERROR_MESSAGE = "List nodes failed";
+ private final GetNodesUseCase getNodesUseCase;
+
+ public NodeController(GetNodesUseCase getNodesUseCase) {
+ this.getNodesUseCase = getNodesUseCase;
+ }
+
+ @GetMapping("/nodes")
+ ResponseEntity> listNode(@RequestParam(required = false, value = "criteria") Optional optionalCriteria) {
+ return Option.ofOptional(optionalCriteria)
+ .map(criteria -> criteria.toCommand().map(Command.class::cast))
+ .toEither(EMPTY_COMMAND)
+ .getOrElseGet(Either::right)
+ .map(getNodesUseCase::invoke)
+ .mapLeft(NodeController::exceptionToProblemDetail)
+ .fold(problemDetail -> ResponseEntity.of(problemDetail).build(), ResponseEntity::ok);
+ }
+
+ @NotNull
+ private static ProblemDetail exceptionToProblemDetail(Exception e) {
+ ProblemDetail problemDetail = ProblemDetail
+ .forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage());
+ problemDetail.setTitle(LIST_NODES_ERROR_MESSAGE);
+ return problemDetail;
+ }
+
+}
diff --git a/src/main/java/tk/antoine/roux/infrastructure/in/WebConfiguration.java b/src/main/java/tk/antoine/roux/infrastructure/in/WebConfiguration.java
new file mode 100644
index 0000000..6308e8b
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/infrastructure/in/WebConfiguration.java
@@ -0,0 +1,16 @@
+package tk.antoine.roux.infrastructure.in;
+
+import com.fasterxml.jackson.databind.Module;
+import io.vavr.jackson.datatype.VavrModule;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class WebConfiguration {
+
+ @Bean
+ public Module vavrModule() {
+ return new VavrModule();
+ }
+
+}
diff --git a/src/main/java/tk/antoine/roux/infrastructure/out/KubernetesClientConfiguration.java b/src/main/java/tk/antoine/roux/infrastructure/out/KubernetesClientConfiguration.java
new file mode 100644
index 0000000..7b189af
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/infrastructure/out/KubernetesClientConfiguration.java
@@ -0,0 +1,51 @@
+package tk.antoine.roux.infrastructure.out;
+
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.apis.CoreV1Api;
+import io.kubernetes.client.util.ClientBuilder;
+import io.kubernetes.client.util.KubeConfig;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import tk.antoine.roux.infrastructure.BackofficeProperties;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+
+@Configuration(proxyBeanMethods = false)
+public class KubernetesClientConfiguration {
+
+ @Bean
+ @Profile("!dev")
+ public ApiClient prodClient() throws IOException {
+ return ClientBuilder.cluster().build();
+ }
+
+ @Bean
+ @Profile("dev")
+ public ApiClient devClient(BackofficeProperties backofficeProperties) throws IOException {
+ String kubeConfigPath = backofficeProperties.kubernetes().api().kubeconfig();
+ File kubeConfigFile = new File(kubeConfigPath);
+ try (BufferedReader kubeConfigReader =
+ new BufferedReader(
+ new InputStreamReader(
+ new FileInputStream(kubeConfigFile), StandardCharsets.UTF_8))) {
+
+ KubeConfig kubeConfig = KubeConfig.loadKubeConfig(kubeConfigReader);
+ kubeConfig.setFile(kubeConfigFile);
+
+ return ClientBuilder.kubeconfig(kubeConfig).setPingInterval(Duration.ofSeconds(2)).build();
+ }
+ }
+
+ @Bean
+ public CoreV1Api getCoreApi(ApiClient apiClient) {
+ return new CoreV1Api(apiClient);
+ }
+
+}
diff --git a/src/main/java/tk/antoine/roux/infrastructure/out/NodeLister.java b/src/main/java/tk/antoine/roux/infrastructure/out/NodeLister.java
new file mode 100644
index 0000000..ce4c457
--- /dev/null
+++ b/src/main/java/tk/antoine/roux/infrastructure/out/NodeLister.java
@@ -0,0 +1,87 @@
+package tk.antoine.roux.infrastructure.out;
+
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.openapi.apis.CoreV1Api;
+import io.kubernetes.client.openapi.models.V1Node;
+import io.kubernetes.client.openapi.models.V1NodeList;
+import io.kubernetes.client.openapi.models.V1ObjectMeta;
+import io.vavr.collection.List;
+import io.vavr.control.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import tk.antoine.roux.domain.model.Node;
+import tk.antoine.roux.domain.model.Node.NodeBuilder;
+import tk.antoine.roux.domain.ResourceLister;
+
+import java.lang.invoke.MethodHandles;
+import java.util.regex.Pattern;
+
+@Service
+public class NodeLister implements ResourceLister {
+
+ private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+ private final CoreV1Api coreV1Api;
+
+ public NodeLister(CoreV1Api coreV1Api) {
+ this.coreV1Api = coreV1Api;
+ }
+
+ @Override
+ public List listAll() {
+ try {
+ V1NodeList v1NodeList = coreV1Api.listNode(
+ null, null, null, null, null, null,
+ null, null, null, null
+ );
+ return List.ofAll(v1NodeList.getItems())
+ .map(NodeLister::buildNodeFromV1Node);
+ } catch (ApiException exception) {
+ log.warn("Node listing failed", exception);
+ return List.empty();
+ }
+ }
+
+ @Override
+ public List listAllByName(String namePrefix) {
+ try {
+ V1NodeList v1NodeList = coreV1Api.listNode(
+ null, null, null, null, null, null,
+ null, null, null, null
+ );
+ return List.ofAll(v1NodeList.getItems())
+ .map(NodeLister::buildNodeFromV1Node)
+ .filter(node -> node.name().startsWith(namePrefix));
+ } catch (ApiException exception) {
+ log.warn("Node listing failed", exception);
+ return List.empty();
+ }
+ }
+
+ @Override
+ public List listAllByRegex(Pattern regex) {
+ try {
+ V1NodeList v1NodeList = coreV1Api.listNode(
+ null, null, null, null, null, null,
+ null, null, null, null
+ );
+ return List.ofAll(v1NodeList.getItems())
+ .map(NodeLister::buildNodeFromV1Node)
+ .filter(node -> regex.matcher(node.name()).find());
+ } catch (ApiException exception) {
+ log.warn("Node listing failed", exception);
+ return List.empty();
+ }
+ }
+
+ private static Node buildNodeFromV1Node(V1Node v1Node) {
+ NodeBuilder nodeBuilder = Node.builder();
+
+ Option.of(v1Node.getMetadata())
+ .map(V1ObjectMeta::getName)
+ .map(nodeBuilder::withName);
+
+ return nodeBuilder.build();
+ }
+}
diff --git a/src/test/java/tk/antoine/roux/MainTest.java b/src/test/java/tk/antoine/roux/MainTest.java
new file mode 100644
index 0000000..b2edc18
--- /dev/null
+++ b/src/test/java/tk/antoine/roux/MainTest.java
@@ -0,0 +1,14 @@
+package tk.antoine.roux;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@SpringBootTestWithProfile(activeProfiles = "dev")
+class MainTest {
+
+ @Test
+ void testApplicationContextLoading() {
+ assertTrue(true);
+ }
+}
diff --git a/src/test/java/tk/antoine/roux/SpringBootTestWithProfile.java b/src/test/java/tk/antoine/roux/SpringBootTestWithProfile.java
new file mode 100644
index 0000000..c039cb8
--- /dev/null
+++ b/src/test/java/tk/antoine/roux/SpringBootTestWithProfile.java
@@ -0,0 +1,21 @@
+package tk.antoine.roux;
+
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.core.annotation.AliasFor;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@SpringBootTest
+@ActiveProfiles
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface SpringBootTestWithProfile {
+
+ @AliasFor(annotation = ActiveProfiles.class, attribute = "profiles")
+ String[] activeProfiles() default {};
+}
+
diff --git a/src/test/java/tk/antoine/roux/infrastructure/in/CriteriaTest.java b/src/test/java/tk/antoine/roux/infrastructure/in/CriteriaTest.java
new file mode 100644
index 0000000..d6836ad
--- /dev/null
+++ b/src/test/java/tk/antoine/roux/infrastructure/in/CriteriaTest.java
@@ -0,0 +1,111 @@
+package tk.antoine.roux.infrastructure.in;
+
+import io.vavr.control.Either;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import tk.antoine.roux.domain.usecases.ByLabelCriteria;
+import tk.antoine.roux.domain.usecases.ByNameCriteria;
+import tk.antoine.roux.domain.usecases.ByRegexCriteria;
+import tk.antoine.roux.domain.usecases.CriteriaCommand;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class CriteriaTest {
+
+ @Test
+ @DisplayName("match a literal name")
+ void caseOne() {
+ // Given
+ Criteria worker = new Criteria("worker");
+
+ // When
+ Either command = worker.toCommand();
+
+ // Then
+ assertTrue(command.isRight());
+ assertInstanceOf(ByNameCriteria.class, command.get());
+ }
+
+ @Test
+ @DisplayName("match literal name with dash")
+ void caseTwo() {
+ // Given
+ Criteria worker = new Criteria("worker-2");
+
+ // When
+ Either command = worker.toCommand();
+
+ // Then
+ assertTrue(command.isRight());
+ assertInstanceOf(ByNameCriteria.class, command.get());
+ }
+
+ @Test
+ @DisplayName("match regex")
+ void caseThree() {
+ // Given
+ Criteria worker = new Criteria("worker*");
+
+ // When
+ Either command = worker.toCommand();
+
+ // Then
+ assertTrue(command.isRight());
+ assertInstanceOf(ByRegexCriteria.class, command.get());
+ }
+
+ @Test
+ @DisplayName("match label")
+ void caseFour() {
+ // Given
+ Criteria worker = new Criteria("node=worker");
+
+ // When
+ Either command = worker.toCommand();
+
+ // Then
+ assertTrue(command.isRight());
+ assertInstanceOf(ByLabelCriteria.class, command.get());
+ }
+
+ @Test
+ @DisplayName("match label with slash and dot")
+ void caseFive() {
+ // Given
+ Criteria worker = new Criteria("kubernetes.io/hostname=worker-2");
+
+ // When
+ Either command = worker.toCommand();
+
+ // Then
+ assertTrue(command.isRight());
+ assertInstanceOf(ByLabelCriteria.class, command.get());
+ }
+
+ @Test
+ @DisplayName("match regex with wildcard")
+ void caseSix() {
+ // Given
+ Criteria worker = new Criteria("worker-*");
+
+ // When
+ Either command = worker.toCommand();
+
+ // Then
+ assertTrue(command.isRight());
+ assertInstanceOf(ByRegexCriteria.class, command.get());
+ }
+
+ @Test
+ @DisplayName("don't match")
+ void errorCase() {
+ // Given
+ Criteria worker = new Criteria("worker [ltl");
+
+ // When
+ Either command = worker.toCommand();
+
+ // Then
+ assertTrue(command.isLeft());
+ }
+}
diff --git a/src/test/java/tk/antoine/roux/infrastructure/in/NodeControllerTest.java b/src/test/java/tk/antoine/roux/infrastructure/in/NodeControllerTest.java
new file mode 100644
index 0000000..1820e1c
--- /dev/null
+++ b/src/test/java/tk/antoine/roux/infrastructure/in/NodeControllerTest.java
@@ -0,0 +1,109 @@
+package tk.antoine.roux.infrastructure.in;
+
+import io.vavr.collection.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+import tk.antoine.roux.domain.model.Node;
+import tk.antoine.roux.domain.usecases.Command;
+import tk.antoine.roux.domain.usecases.Command.EmptyCommand;
+import tk.antoine.roux.domain.usecases.CriteriaCommand;
+import tk.antoine.roux.domain.usecases.operation.GetNodesUseCase;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isA;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(NodeController.class)
+@Import(WebConfiguration.class)
+class NodeControllerTest {
+
+ @Autowired
+ MockMvc mockMvc;
+
+ @Value("${backoffice.configuration.api-prefix}")
+ private String backendPrefix;
+
+ @MockBean
+ private GetNodesUseCase getNodesUseCase;
+
+ private static final EmptyCommand EMPTY_COMMAND = new EmptyCommand();
+
+ @Test
+ void listNode() throws Exception {
+ // Given
+ MockHttpServletRequestBuilder request = get(backendPrefix + "/v1/nodes");
+ Mockito.when(getNodesUseCase.invoke(any(Command.class)))
+ .thenReturn(List.of(Node.builder().build(), Node.builder().build(), Node.builder().build()));
+
+ // When
+ ResultActions requestResult = mockMvc.perform(request);
+
+ // Then
+ requestResult
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$").isArray())
+ .andExpect(jsonPath("$[0]").exists())
+ .andExpect(jsonPath("$[1]").exists())
+ .andExpect(jsonPath("$[2]").exists())
+ ;
+ Mockito.verify(getNodesUseCase).invoke(eq(EMPTY_COMMAND));
+ }
+
+ @Test
+ void listNodeWithCriteria() throws Exception {
+ // Given
+ MockHttpServletRequestBuilder request = get(backendPrefix + "/v1/nodes")
+ .param("criteria", "worker");
+
+ Mockito.when(getNodesUseCase.invoke(any(Command.class)))
+ .thenReturn(List.of(Node.builder().build(), Node.builder().build(), Node.builder().build()));
+
+ // When
+ ResultActions requestResult = mockMvc.perform(request);
+
+ // Then
+ requestResult
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$").isArray());
+
+ Mockito.verify(getNodesUseCase).invoke(isA(CriteriaCommand.class));
+ }
+
+
+ @Test
+ @DisplayName("Call nodes endpoint with criteria causing bad request")
+ void listNodeWithInvalidCriteria() throws Exception {
+ // Given
+ MockHttpServletRequestBuilder request = get(backendPrefix + "/v1/nodes")
+ .param("criteria", "worker [ltl");
+
+ Mockito.when(getNodesUseCase.invoke(any(Command.class)))
+ .thenReturn(List.of(Node.builder().build(), Node.builder().build(), Node.builder().build()));
+
+ // When
+ ResultActions requestResult = mockMvc.perform(request);
+
+ // Then
+ requestResult
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$").isMap())
+ .andExpect(jsonPath("$.title").exists())
+ .andExpect(jsonPath("$.detail").exists())
+ .andExpect(jsonPath("$.status").value(400))
+ ;
+
+ Mockito.verifyNoInteractions(getNodesUseCase);
+ }
+}
diff --git a/src/test/java/tk/antoine/roux/infrastructure/out/NodeListerTest.java b/src/test/java/tk/antoine/roux/infrastructure/out/NodeListerTest.java
new file mode 100644
index 0000000..7086223
--- /dev/null
+++ b/src/test/java/tk/antoine/roux/infrastructure/out/NodeListerTest.java
@@ -0,0 +1,88 @@
+package tk.antoine.roux.infrastructure.out;
+
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.openapi.apis.CoreV1Api;
+import io.kubernetes.client.openapi.models.V1Node;
+import io.kubernetes.client.openapi.models.V1NodeList;
+import io.kubernetes.client.openapi.models.V1ObjectMeta;
+import io.vavr.collection.List;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import tk.antoine.roux.domain.model.Node;
+
+import java.util.regex.Pattern;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class NodeListerTest {
+
+ @Mock
+ private CoreV1Api coreV1Api;
+
+ @InjectMocks
+ NodeLister nodeLister;
+
+ @Test
+ void listAll() throws ApiException {
+ // Given
+ when(coreV1Api.listNode(any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(new V1NodeList());
+
+ // When
+ List nodes = nodeLister.listAll();
+
+ // Then
+ Assertions.assertEquals(0, nodes.size());
+ verify(coreV1Api).listNode(any(), any(), any(), any(), any(), any(), any(), any(), any(), any());
+ }
+
+ @Test
+ void listAllByName() throws ApiException {
+ // Given
+ V1Node node1 = new V1Node().metadata(new V1ObjectMeta().name("worker-2"));
+ V1Node node2 = new V1Node().metadata(new V1ObjectMeta().name("worker-5"));
+ V1Node node3 = new V1Node().metadata(new V1ObjectMeta().name("worker-3"));
+
+ V1NodeList nodeList = new V1NodeList()
+ .addItemsItem(node1)
+ .addItemsItem(node2)
+ .addItemsItem(node3);
+
+ when(coreV1Api.listNode(any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(nodeList);
+
+ // When
+ List nodes = nodeLister.listAllByName("worker-2");
+
+ // Then
+ Assertions.assertEquals(1, nodes.size());
+ verify(coreV1Api).listNode(any(), any(), any(), any(), any(), any(), any(), any(), any(), any());
+ }
+
+ @Test
+ void listAllByRegex() throws ApiException {
+ // Given
+ V1Node node1 = new V1Node().metadata(new V1ObjectMeta().name("worker-2"));
+ V1Node node2 = new V1Node().metadata(new V1ObjectMeta().name("worker-5"));
+ V1Node node3 = new V1Node().metadata(new V1ObjectMeta().name("worker-3"));
+
+ V1NodeList nodeList = new V1NodeList()
+ .addItemsItem(node1)
+ .addItemsItem(node2)
+ .addItemsItem(node3);
+
+ when(coreV1Api.listNode(any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(nodeList);
+
+ // When
+ List nodes = nodeLister.listAllByRegex(Pattern.compile("worker-[25]"));
+
+ // Then
+ Assertions.assertEquals(2, nodes.size());
+ verify(coreV1Api).listNode(any(), any(), any(), any(), any(), any(), any(), any(), any(), any());
+ }
+}