From 2983242ddebf1529a9f4c6d9ea4e107fca49991f Mon Sep 17 00:00:00 2001 From: RouxAntoine Date: Mon, 24 Jul 2023 08:23:07 +0200 Subject: [PATCH] feature: backend list nodes by name, regex --- .sdkmanrc | 2 +- pom.xml | 45 ++++++- .../java/tk/antoine/roux/domain/Resource.java | 4 + .../antoine/roux/domain/ResourceLister.java | 15 +++ .../tk/antoine/roux/domain/model/Node.java | 38 ++++++ .../roux/domain/usecases/ByLabelCriteria.java | 4 + .../roux/domain/usecases/ByNameCriteria.java | 4 + .../roux/domain/usecases/ByRegexCriteria.java | 6 + .../antoine/roux/domain/usecases/Command.java | 14 +++ .../roux/domain/usecases/CriteriaCommand.java | 4 + .../usecases/InvalidCriteriaException.java | 9 ++ .../roux/domain/usecases/Parameter.java | 4 + .../antoine/roux/domain/usecases/Queries.java | 14 +++ .../usecases/operation/GetNodesUseCase.java | 31 +++++ .../domain/usecases/operation/UseCase.java | 9 ++ .../infrastructure/BackofficeProperties.java | 13 ++ .../infrastructure/SpringConfiguration.java | 9 ++ .../roux/infrastructure/in/Criteria.java | 42 +++++++ .../infrastructure/in/NodeController.java | 50 ++++++++ .../infrastructure/in/WebConfiguration.java | 16 +++ .../out/KubernetesClientConfiguration.java | 51 ++++++++ .../roux/infrastructure/out/NodeLister.java | 87 ++++++++++++++ src/test/java/tk/antoine/roux/MainTest.java | 14 +++ .../roux/SpringBootTestWithProfile.java | 21 ++++ .../roux/infrastructure/in/CriteriaTest.java | 111 ++++++++++++++++++ .../infrastructure/in/NodeControllerTest.java | 109 +++++++++++++++++ .../infrastructure/out/NodeListerTest.java | 88 ++++++++++++++ 27 files changed, 812 insertions(+), 2 deletions(-) create mode 100644 src/main/java/tk/antoine/roux/domain/Resource.java create mode 100644 src/main/java/tk/antoine/roux/domain/ResourceLister.java create mode 100644 src/main/java/tk/antoine/roux/domain/model/Node.java create mode 100644 src/main/java/tk/antoine/roux/domain/usecases/ByLabelCriteria.java create mode 100644 src/main/java/tk/antoine/roux/domain/usecases/ByNameCriteria.java create mode 100644 src/main/java/tk/antoine/roux/domain/usecases/ByRegexCriteria.java create mode 100644 src/main/java/tk/antoine/roux/domain/usecases/Command.java create mode 100644 src/main/java/tk/antoine/roux/domain/usecases/CriteriaCommand.java create mode 100644 src/main/java/tk/antoine/roux/domain/usecases/InvalidCriteriaException.java create mode 100644 src/main/java/tk/antoine/roux/domain/usecases/Parameter.java create mode 100644 src/main/java/tk/antoine/roux/domain/usecases/Queries.java create mode 100644 src/main/java/tk/antoine/roux/domain/usecases/operation/GetNodesUseCase.java create mode 100644 src/main/java/tk/antoine/roux/domain/usecases/operation/UseCase.java create mode 100644 src/main/java/tk/antoine/roux/infrastructure/BackofficeProperties.java create mode 100644 src/main/java/tk/antoine/roux/infrastructure/SpringConfiguration.java create mode 100644 src/main/java/tk/antoine/roux/infrastructure/in/Criteria.java create mode 100644 src/main/java/tk/antoine/roux/infrastructure/in/NodeController.java create mode 100644 src/main/java/tk/antoine/roux/infrastructure/in/WebConfiguration.java create mode 100644 src/main/java/tk/antoine/roux/infrastructure/out/KubernetesClientConfiguration.java create mode 100644 src/main/java/tk/antoine/roux/infrastructure/out/NodeLister.java create mode 100644 src/test/java/tk/antoine/roux/MainTest.java create mode 100644 src/test/java/tk/antoine/roux/SpringBootTestWithProfile.java create mode 100644 src/test/java/tk/antoine/roux/infrastructure/in/CriteriaTest.java create mode 100644 src/test/java/tk/antoine/roux/infrastructure/in/NodeControllerTest.java create mode 100644 src/test/java/tk/antoine/roux/infrastructure/out/NodeListerTest.java 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.source} + ${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()); + } +}