diff --git a/kubernetes/k8s-admission-controller/Dockerfile b/kubernetes/k8s-admission-controller/Dockerfile new file mode 100644 index 0000000000..f8939ee7c8 --- /dev/null +++ b/kubernetes/k8s-admission-controller/Dockerfile @@ -0,0 +1,11 @@ +FROM adoptopenjdk:11-jre-hotspot as builder +ARG JAR_FILE=target/*.jar +COPY ${JAR_FILE} application.jar +RUN java -Djarmode=layertools -jar application.jar extract + +FROM adoptopenjdk:11-jre-hotspot +COPY --from=builder dependencies/ ./ +COPY --from=builder snapshot-dependencies/ ./ +COPY --from=builder spring-boot-loader/ ./ +COPY --from=builder application/ ./ +ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"] \ No newline at end of file diff --git a/kubernetes/k8s-admission-controller/pom.xml b/kubernetes/k8s-admission-controller/pom.xml new file mode 100644 index 0000000000..fbee9ceba6 --- /dev/null +++ b/kubernetes/k8s-admission-controller/pom.xml @@ -0,0 +1,86 @@ + + + 4.0.0 + + + com.baeldung + parent-boot-2 + 0.0.1-SNAPSHOT + ./../../parent-boot-2 + + + k8s-admission-controller + k8s-admission-controller + Demo project for Spring Boot + + + + org.springframework.boot + spring-boot-starter-webflux + + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + io.projectreactor + reactor-test + test + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-devtools + runtime + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + + + + + org.projectlombok + lombok + + + com.baeldung.kubernetes.admission.Application + + true + + + + + + + diff --git a/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/Application.java b/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/Application.java new file mode 100644 index 0000000000..1260ec0af9 --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/Application.java @@ -0,0 +1,17 @@ +package com.baeldung.kubernetes.admission; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +import com.baeldung.kubernetes.admission.config.AdmissionControllerProperties; + +@SpringBootApplication +@EnableConfigurationProperties(AdmissionControllerProperties.class) +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + +} diff --git a/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/config/AdmissionControllerProperties.java b/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/config/AdmissionControllerProperties.java new file mode 100644 index 0000000000..2352068ba1 --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/config/AdmissionControllerProperties.java @@ -0,0 +1,22 @@ +/** + * + */ +package com.baeldung.kubernetes.admission.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import lombok.Data; + +/** + * @author lighthouse.psevestre + * + */ +@ConfigurationProperties(prefix = "admission-controller") +@Data +public class AdmissionControllerProperties { + + private boolean disabled; + private String annotation = "com.baeldung/wait-for-it"; + private String waitForItImage = "willwill/wait-for-it"; + +} diff --git a/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/controller/AdmissionReviewController.java b/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/controller/AdmissionReviewController.java new file mode 100644 index 0000000000..a73aa3d89b --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/controller/AdmissionReviewController.java @@ -0,0 +1,30 @@ +/** + * + */ +package com.baeldung.kubernetes.admission.controller; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import com.baeldung.kubernetes.admission.dto.AdmissionReviewResponse; +import com.baeldung.kubernetes.admission.service.AdmissionService; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Mono; + +/** + * + */ +@RestController +@RequiredArgsConstructor +public class AdmissionReviewController { + + private final AdmissionService admissionService; + + @PostMapping(path = "/mutate") + public Mono processAdmissionReviewRequest(@RequestBody Mono request) { + return request.map((body) -> admissionService.processAdmission(body)); + } +} diff --git a/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/dto/AdmissionReviewData.java b/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/dto/AdmissionReviewData.java new file mode 100644 index 0000000000..6d590ff408 --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/dto/AdmissionReviewData.java @@ -0,0 +1,31 @@ +/** + * + */ +package com.baeldung.kubernetes.admission.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +import lombok.Builder; +import lombok.Data; + +/** + * Result sent to the API server after reviewing and, possibly + * modifying the incoming request + */ +@Builder +@Data +public class AdmissionReviewData { + + final String uid; + final boolean allowed; + + @JsonInclude(Include.NON_NULL) + final String patchType; + + @JsonInclude(Include.NON_NULL) + final String patch; + + @JsonInclude(Include.NON_NULL) + final AdmissionStatus status; +} diff --git a/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/dto/AdmissionReviewException.java b/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/dto/AdmissionReviewException.java new file mode 100644 index 0000000000..a788eca461 --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/dto/AdmissionReviewException.java @@ -0,0 +1,28 @@ +package com.baeldung.kubernetes.admission.dto; + +/** + * Exceção utilizada para reportar erros de validação no manifesto recebido para admissão + * @author lighthouse.psevestre + * + */ +public class AdmissionReviewException extends RuntimeException { + + private static final long serialVersionUID = 1L; + private final int code; + + public AdmissionReviewException(int code, String message) { + super(message); + this.code = code; + } + + public AdmissionReviewException(String message) { + super(message); + this.code = 400; + + } + + public int getCode() { + return code; + } + +} diff --git a/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/dto/AdmissionReviewResponse.java b/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/dto/AdmissionReviewResponse.java new file mode 100644 index 0000000000..1780f52540 --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/dto/AdmissionReviewResponse.java @@ -0,0 +1,25 @@ +/** + * + */ +package com.baeldung.kubernetes.admission.dto; + +import lombok.Builder; +import lombok.Builder.Default; +import lombok.Data; + +/** + * Response "envelope" sent back to the API Server + */ +@Builder +@Data +public class AdmissionReviewResponse { + + @Default + final String apiVersion = "admission.k8s.io/v1"; + + @Default + final String kind = "AdmissionReview"; + + final AdmissionReviewData response; + +} diff --git a/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/dto/AdmissionStatus.java b/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/dto/AdmissionStatus.java new file mode 100644 index 0000000000..ccab7ac958 --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/dto/AdmissionStatus.java @@ -0,0 +1,13 @@ +package com.baeldung.kubernetes.admission.dto; + +import lombok.Builder; +import lombok.Data; + +@Builder +@Data +public class AdmissionStatus { + + int code; + String message; + +} diff --git a/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/service/AdmissionService.java b/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/service/AdmissionService.java new file mode 100644 index 0000000000..814bafbae7 --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/main/java/com/baeldung/kubernetes/admission/service/AdmissionService.java @@ -0,0 +1,218 @@ +/** + * + */ +package com.baeldung.kubernetes.admission.service; + +import java.util.Base64; +import java.util.UUID; + +import org.springframework.stereotype.Component; + +import com.baeldung.kubernetes.admission.config.AdmissionControllerProperties; +import com.baeldung.kubernetes.admission.dto.AdmissionReviewData; +import com.baeldung.kubernetes.admission.dto.AdmissionReviewException; +import com.baeldung.kubernetes.admission.dto.AdmissionReviewResponse; +import com.baeldung.kubernetes.admission.dto.AdmissionStatus; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Process an incoming admission request and add the "wait-for-it" init container + * if there's an appropriate annotation + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class AdmissionService { + + private final AdmissionControllerProperties admissionControllerProperties; + private final ObjectMapper om; + + public AdmissionReviewResponse processAdmission(ObjectNode body) { + + String uid = body.path("request") + .required("uid") + .asText(); + + log.info("[I42] processAdmission: uid={}",uid); + if ( log.isDebugEnabled()) { + log.debug("processAdmission: body={}", body.toPrettyString()); + } + + // Get request annotations + JsonNode annotations = body.path("request") + .path("object") + .path("metadata") + .path("annotations"); + log.info("processAdmision: annotations={}", annotations.toString()); + + AdmissionReviewData data; + try { + if (admissionControllerProperties.isDisabled()) { + log.info("[I58] 'disabled' option in effect. No changes to current request will be made"); + data = createSimpleAllowedReview(body); + } else if (annotations.isMissingNode()) { + log.info("[I68] No annotations found in request. No changes will be made"); + data = createSimpleAllowedReview(body); + } else { + data = processAnnotations(body, annotations); + } + + log.info("[I65] Review result: isAllowed=" + data.isAllowed()); + log.info("[I64] AdmissionReviewData= {}", data); + + return AdmissionReviewResponse.builder() + .apiVersion(body.required("apiVersion").asText()) + .kind(body.required("kind").asText()) + .response(data) + .build(); + } catch (AdmissionReviewException ex) { + log.error("[E72] Error processing AdmissionRequest: code={}, message={}", ex.getCode(), ex.getMessage()); + data = createRejectedAdmissionReview(body, ex.getCode(), ex.getMessage()); + + return AdmissionReviewResponse.builder() + .apiVersion(body.required("apiVersion").asText()) + .kind(body.required("kind").asText()) + .response(data) + .build(); + } catch (Exception ex) { + log.error("[E72] Unable to process AdmissionRequest: " + ex.getMessage(), ex); + data = createRejectedAdmissionReview(body, 500, ex.getMessage()); + return AdmissionReviewResponse.builder() + .apiVersion(body.required("apiVersion").asText()) + .kind(body.required("kind").asText()) + .response(data) + .build(); + } + } + + /** + * @param body + * @return + */ + protected AdmissionReviewData createSimpleAllowedReview(ObjectNode body) { + AdmissionReviewData data; + String requestId = body.path("request") + .required("uid") + .asText(); + + data = AdmissionReviewData.builder() + .allowed(true) + .uid(requestId) + .build(); + + return data; + + } + + /** + * @param body + * @return + */ + protected AdmissionReviewData createRejectedAdmissionReview(ObjectNode body, int code, String message) { + AdmissionReviewData data; + String requestId = body.path("request") + .required("uid") + .asText(); + + AdmissionStatus status = AdmissionStatus.builder() + .code(code) + .message(message) + .build(); + + data = AdmissionReviewData.builder() + .allowed(false) + .uid(requestId) + .status(status) + .build(); + + return data; + + } + + /** + * Processa anotações incluídas no deployment + * @param annotations + * @return + */ + protected AdmissionReviewData processAnnotations(ObjectNode body, JsonNode annotations) { + + if (annotations.path(admissionControllerProperties.getAnnotation()) + .isMissingNode()) { + log.info("[I78] processAnnotations: Annotation {} not found in deployment deployment.", admissionControllerProperties.getAnnotation()); + return createSimpleAllowedReview(body); + } + else { + log.info("[I163] annotation found: {}", annotations.path(admissionControllerProperties.getAnnotation())); + } + + // Get wait-for-it arguments from the annotation + String waitForArgs = annotations.path(admissionControllerProperties.getAnnotation()) + .asText(); + + log.info("[I169] waitForArgs={}", waitForArgs); + // Create a PATCH object + String patch = injectInitContainer(body, waitForArgs); + + return AdmissionReviewData.builder() + .allowed(true) + .uid(body.path("request") + .required("uid") + .asText()) + .patch(Base64.getEncoder() + .encodeToString(patch.getBytes())) + .patchType("JSONPatch") + .build(); + + } + + /** + * Creates the JSONPatch to be included in the admission response + * @param body + * @param waitForArgs + * @return JSONPatch string + */ + protected String injectInitContainer(ObjectNode body, String waitForArgs) { + + // Recover original init containers from the request + JsonNode originalSpec = body.path("request") + .path("object") + .path("spec") + .path("template") + .path("spec") + .require(); + + JsonNode maybeInitContainers = originalSpec.path("initContainers"); + ArrayNode initContainers = + maybeInitContainers.isMissingNode()? + om.createArrayNode():(ArrayNode) maybeInitContainers; + + // Create the patch array + ArrayNode patchArray = om.createArrayNode(); + ObjectNode addNode = patchArray.addObject(); + + addNode.put("op", "add"); + addNode.put("path", "/spec/template/spec/initContainers"); + ArrayNode values = addNode.putArray("value"); + + // Preserve original init containers + values.addAll(initContainers); + + // append the "wait-for-it" container + ObjectNode wfi = values.addObject(); + wfi.put("name", "wait-for-it-" + UUID.randomUUID()); // Create an unique name, JIC + wfi.put("image", admissionControllerProperties.getWaitForItImage()); + + ArrayNode args = wfi.putArray("args"); + for (String s : waitForArgs.split("\\s")) { + args.add(s); + } + + return patchArray.toString(); + } +} diff --git a/kubernetes/k8s-admission-controller/src/test/java/com/baeldung/kubernetes/admission/service/AdmissionServiceUnitTest.java b/kubernetes/k8s-admission-controller/src/test/java/com/baeldung/kubernetes/admission/service/AdmissionServiceUnitTest.java new file mode 100644 index 0000000000..544b48a9d4 --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/test/java/com/baeldung/kubernetes/admission/service/AdmissionServiceUnitTest.java @@ -0,0 +1,65 @@ +package com.baeldung.kubernetes.admission.service; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.Base64; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import com.baeldung.kubernetes.admission.config.AdmissionControllerProperties; +import com.baeldung.kubernetes.admission.dto.AdmissionReviewResponse; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +@SpringBootTest +@ActiveProfiles("test") +@EnableConfigurationProperties(AdmissionControllerProperties.class) +class AdmissionServiceUnitTest { + + @Autowired + private ObjectMapper mapper; + + @Autowired + private AdmissionService admissionService; + + @Test + void whenAnnotationPresent_thenAddContainer() throws Exception { + + InputStream is = this.getClass() + .getClassLoader() + .getResourceAsStream("test1.json"); + JsonNode body = mapper.readTree(is); + AdmissionReviewResponse response = admissionService.processAdmission((ObjectNode) body); + assertNotNull(response); + assertNotNull(response.getResponse()); + assertNotNull(response.getResponse()); + assertTrue(response.getResponse() + .isAllowed()); + + String jsonResponse = mapper.writeValueAsString(response); + System.out.println(jsonResponse); + + // Decode Patch data + String b64patch = response.getResponse() + .getPatch(); + assertNotNull(b64patch); + byte[] patch = Base64.getDecoder() + .decode(b64patch); + + JsonNode root = mapper.reader() + .readTree(new ByteArrayInputStream(patch)); + assertTrue(root instanceof ArrayNode); + + assertEquals(1, ((ArrayNode) root).size()); + + } + +} diff --git a/kubernetes/k8s-admission-controller/src/test/k8s/nginx.yaml b/kubernetes/k8s-admission-controller/src/test/k8s/nginx.yaml new file mode 100644 index 0000000000..0c601660d1 --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/test/k8s/nginx.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + labels: + app: nginx + annotations: + com.baeldung/wait-for-it: "www.google.com:80" +spec: + replicas: 1 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ports: + - containerPort: 80 diff --git a/kubernetes/k8s-admission-controller/src/test/resources/test1.json b/kubernetes/k8s-admission-controller/src/test/resources/test1.json new file mode 100644 index 0000000000..0ad42ae133 --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/test/resources/test1.json @@ -0,0 +1,84 @@ +{ + "kind": "AdmissionReview", + "apiVersion": "admission.k8s.io/v1", + "request": { + "uid": "c46a6607-129d-425b-af2f-c6f87a0756da", + "kind": { + "group": "apps", + "version": "v1", + "kind": "Deployment" + }, + "resource": { + "group": "apps", + "version": "v1", + "resource": "deployments" + }, + "requestKind": { + "group": "apps", + "version": "v1", + "kind": "Deployment" + }, + "requestResource": { + "group": "apps", + "version": "v1", + "resource": "deployments" + }, + "name": "test-deployment", + "namespace": "test-namespace", + "operation": "CREATE", + "object": { + "kind": "Deployment", + "apiVersion": "apps/v1", + "metadata": { + "name": "test-deployment", + "namespace": "test-namespace", + "annotations": { + "com.baeldung/wait-for-it": "www.google.com:80" + } + }, + "spec": { + "replicas": 1, + "selector": { + "matchLabels": { + "app": "test-app" + } + }, + "template": { + "metadata": { + "name": "test-app", + "creationTimestamp": null, + "labels": { + "app": "test-app" + } + }, + "spec": { + "containers": [ + { + "name": "app", + "image": "test-app-image:latest", + "ports": [ + { + "name": "http", + "containerPort": 8080, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "imagePullPolicy": "Always" + } + ] + } + } + }, + "status": {} + }, + "oldObject": null, + "dryRun": false, + "options": { + "kind": "CreateOptions", + "apiVersion": "meta.k8s.io/v1" + } + } +} \ No newline at end of file diff --git a/kubernetes/k8s-admission-controller/src/test/resources/test2.json b/kubernetes/k8s-admission-controller/src/test/resources/test2.json new file mode 100644 index 0000000000..5b8a6ca243 --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/test/resources/test2.json @@ -0,0 +1,85 @@ +{ + "kind": "AdmissionReview", + "apiVersion": "admission.k8s.io/v1", + "request": { + "uid": "c46a6607-129d-425b-af2f-c6f87a0756da", + "kind": { + "group": "apps", + "version": "v1", + "kind": "Deployment" + }, + "resource": { + "group": "apps", + "version": "v1", + "resource": "deployments" + }, + "requestKind": { + "group": "apps", + "version": "v1", + "kind": "Deployment" + }, + "requestResource": { + "group": "apps", + "version": "v1", + "resource": "deployments" + }, + "name": "test-deployment", + "namespace": "test-namespace", + "operation": "CREATE", + "object": { + "kind": "Deployment", + "apiVersion": "apps/v1", + "metadata": { + "name": "test-deployment", + "namespace": "test-namespace", + "annotations": { + "com.baeldung/wait-for-it": "www.google.com:80" + } + }, + "spec": { + "replicas": 1, + "selector": { + "matchLabels": { + "app": "test-app" + } + }, + "template": { + "metadata": { + "name": "test-app", + "creationTimestamp": null, + "labels": { + "app": "test-app" + } + }, + "spec": { + "initContainers": [], + "containers": [ + { + "name": "app", + "image": "test-app-image:latest", + "ports": [ + { + "name": "http", + "containerPort": 8080, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "imagePullPolicy": "Always" + } + ] + } + } + }, + "status": {} + }, + "oldObject": null, + "dryRun": false, + "options": { + "kind": "CreateOptions", + "apiVersion": "meta.k8s.io/v1" + } + } +} \ No newline at end of file diff --git a/kubernetes/k8s-admission-controller/src/test/resources/test3.json b/kubernetes/k8s-admission-controller/src/test/resources/test3.json new file mode 100644 index 0000000000..9cb4d516f9 --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/test/resources/test3.json @@ -0,0 +1,94 @@ +{ + "kind": "AdmissionReview", + "apiVersion": "admission.k8s.io/v1", + "request": { + "uid": "c46a6607-129d-425b-af2f-c6f87a0756da", + "kind": { + "group": "apps", + "version": "v1", + "kind": "Deployment" + }, + "resource": { + "group": "apps", + "version": "v1", + "resource": "deployments" + }, + "requestKind": { + "group": "apps", + "version": "v1", + "kind": "Deployment" + }, + "requestResource": { + "group": "apps", + "version": "v1", + "resource": "deployments" + }, + "name": "test-deployment", + "namespace": "test-namespace", + "operation": "CREATE", + "object": { + "kind": "Deployment", + "apiVersion": "apps/v1", + "metadata": { + "name": "test-deployment", + "namespace": "test-namespace", + "annotations": { + "com.baeldung/wait-for-it": "www.google.com:80" + } + }, + "spec": { + "replicas": 1, + "selector": { + "matchLabels": { + "app": "test-app" + } + }, + "template": { + "metadata": { + "name": "test-app", + "creationTimestamp": null, + "labels": { + "app": "test-app" + } + }, + "spec": { + "initContainers": [ + { + "name": "init1", + "image": "test-app-image:latest", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "imagePullPolicy": "Always" + } + ], + "containers": [ + { + "name": "app", + "image": "test-app-image:latest", + "ports": [ + { + "name": "http", + "containerPort": 8080, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "imagePullPolicy": "Always" + } + ] + } + } + }, + "status": {} + }, + "oldObject": null, + "dryRun": false, + "options": { + "kind": "CreateOptions", + "apiVersion": "meta.k8s.io/v1" + } + } +} \ No newline at end of file diff --git a/kubernetes/k8s-admission-controller/src/test/resources/test4.json b/kubernetes/k8s-admission-controller/src/test/resources/test4.json new file mode 100644 index 0000000000..1e3be90a93 --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/test/resources/test4.json @@ -0,0 +1,48 @@ +{ + "kind": "AdmissionReview", + "apiVersion": "admission.k8s.io/v1", + "request": { + "uid": "26beb334-739a-48d2-b04d-25f6e5e7c106", + "kind": { + "group": "apps", + "version": "v1", + "kind": "Deployment" + }, + "resource": {}, + "f:type": {} + }, + "f:template": { + "f:metadata": { + "f:labels": { + ".": {}, + "f:app": {} + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + } +} \ No newline at end of file diff --git a/kubernetes/k8s-admission-controller/src/test/terraform/.gitignore b/kubernetes/k8s-admission-controller/src/test/terraform/.gitignore new file mode 100644 index 0000000000..4c305a080a --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/test/terraform/.gitignore @@ -0,0 +1,3 @@ +.terraform +terraform.tfstate +terraform.tfstate.backup diff --git a/kubernetes/k8s-admission-controller/src/test/terraform/.terraform.lock.hcl b/kubernetes/k8s-admission-controller/src/test/terraform/.terraform.lock.hcl new file mode 100644 index 0000000000..e13c37b4ec --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/test/terraform/.terraform.lock.hcl @@ -0,0 +1,57 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/kubernetes" { + version = "2.3.2" + constraints = "2.3.2" + hashes = [ + "h1:D8HWX3vouTPI3Jicq43xOQyoYWtSsVua92cBVrJ3ZMs=", + "zh:10f71c170be13538374a4b9553fcb3d98a6036bcd1ca5901877773116c3f828e", + "zh:11d2230e531b7480317e988207a73cb67b332f225b0892304983b19b6014ebe0", + "zh:3317387a9a6cc27fd7536b8f3cad4b8a9285e9461f125c5a15d192cef3281856", + "zh:458a9858362900fbe97e00432ae8a5bef212a4dacf97a57ede7534c164730da4", + "zh:50ea297007d9fe53e5411577f87a4b13f3877ce732089b42f938430e6aadff0d", + "zh:56705c959e4cbea3b115782d04c62c68ac75128c5c44ee7aa4043df253ffbfe3", + "zh:7eb3722f7f036e224824470c3e0d941f1f268fcd5fa2f8203e0eee425d0e1484", + "zh:9f408a6df4d74089e6ce18f9206b06b8107ddb57e2bc9b958a6b7dc352c62980", + "zh:aadd25ccc3021040808feb2645779962f638766eb583f586806e59f24dde81bb", + "zh:b101c3456e4309b09aab129b0118561178c92cb4be5d96dec553189c3084dca1", + "zh:ec08478573b4953764099fbfd670fae81dc24b60e467fb3b023e6fab50b70a9e", + ] +} + +provider "registry.terraform.io/hashicorp/null" { + version = "3.1.0" + hashes = [ + "h1:SFT7X3zY18CLWjoH2GfQyapxsRv6GDKsy9cF1aRwncc=", + "zh:02a1675fd8de126a00460942aaae242e65ca3380b5bb192e8773ef3da9073fd2", + "zh:53e30545ff8926a8e30ad30648991ca8b93b6fa496272cd23b26763c8ee84515", + "zh:5f9200bf708913621d0f6514179d89700e9aa3097c77dac730e8ba6e5901d521", + "zh:9ebf4d9704faba06b3ec7242c773c0fbfe12d62db7d00356d4f55385fc69bfb2", + "zh:a6576c81adc70326e4e1c999c04ad9ca37113a6e925aefab4765e5a5198efa7e", + "zh:a8a42d13346347aff6c63a37cda9b2c6aa5cc384a55b2fe6d6adfa390e609c53", + "zh:c797744d08a5307d50210e0454f91ca4d1c7621c68740441cf4579390452321d", + "zh:cecb6a304046df34c11229f20a80b24b1603960b794d68361a67c5efe58e62b8", + "zh:e1371aa1e502000d9974cfaff5be4cfa02f47b17400005a16f14d2ef30dc2a70", + "zh:fc39cc1fe71234a0b0369d5c5c7f876c71b956d23d7d6f518289737a001ba69b", + "zh:fea4227271ebf7d9e2b61b89ce2328c7262acd9fd190e1fd6d15a591abfa848e", + ] +} + +provider "registry.terraform.io/hashicorp/tls" { + version = "3.1.0" + hashes = [ + "h1:ekOxs6MjdIElt8h9crEVaOwWbEqtfUUfArtA13Jkk6A=", + "zh:3d46616b41fea215566f4a957b6d3a1aa43f1f75c26776d72a98bdba79439db6", + "zh:623a203817a6dafa86f1b4141b645159e07ec418c82fe40acd4d2a27543cbaa2", + "zh:668217e78b210a6572e7b0ecb4134a6781cc4d738f4f5d09eb756085b082592e", + "zh:95354df03710691773c8f50a32e31fca25f124b7f3d6078265fdf3c4e1384dca", + "zh:9f97ab190380430d57392303e3f36f4f7835c74ea83276baa98d6b9a997c3698", + "zh:a16f0bab665f8d933e95ca055b9c8d5707f1a0dd8c8ecca6c13091f40dc1e99d", + "zh:be274d5008c24dc0d6540c19e22dbb31ee6bfdd0b2cddd4d97f3cd8a8d657841", + "zh:d5faa9dce0a5fc9d26b2463cea5be35f8586ab75030e7fa4d4920cd73ee26989", + "zh:e9b672210b7fb410780e7b429975adcc76dd557738ecc7c890ea18942eb321a5", + "zh:eb1f8368573d2370605d6dbf60f9aaa5b64e55741d96b5fb026dbfe91de67c0d", + "zh:fc1e12b713837b85daf6c3bb703d7795eaf1c5177aebae1afcf811dd7009f4b0", + ] +} diff --git a/kubernetes/k8s-admission-controller/src/test/terraform/component.auto.tfvars b/kubernetes/k8s-admission-controller/src/test/terraform/component.auto.tfvars new file mode 100644 index 0000000000..eb1cdaea53 --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/test/terraform/component.auto.tfvars @@ -0,0 +1,10 @@ +# +# Sample variable values. +# +namespace="default" +deployment_name="wait-for-it-admission-controller" +replicas=1 +image="psevestre/wait-for-it-admission-controller" +image_prefix="" +image_version="latest" +k8s_config_context="minikube" diff --git a/kubernetes/k8s-admission-controller/src/test/terraform/main.tf b/kubernetes/k8s-admission-controller/src/test/terraform/main.tf new file mode 100644 index 0000000000..a0717c4013 --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/test/terraform/main.tf @@ -0,0 +1,277 @@ + +locals { + prefix = var.image_prefix != "" ? "${var.image_prefix}/":"" + image = "${local.prefix}${var.image}:${var.image_version}" + cloud_sdk_image = "${local.prefix}frapsoft/openssl" + ns = data.kubernetes_namespace.ns.metadata[0].name + + # Spring SSL Configuration + webhook_config_json = jsonencode({ + server = { + port = 443 + ssl = { + "key-store" = "/shared-config/webhook.p12" + "key-store-type" = "PKCS12" + "key-alias" = "webhook" + "key-store-password" = "" + } + } + + admission-controller = { + disabled = false + image-prefix = "gcr.io/sandboxbv-01" + } + }) + +} + + +# Resource namespace +data "kubernetes_namespace" "ns" { + metadata { + name = var.namespace + } +} + +# TLS Key +resource "tls_private_key" "tls" { + algorithm = "RSA" +} + +# CSR +resource "tls_cert_request" "tls" { + key_algorithm = "RSA" + private_key_pem = tls_private_key.tls.private_key_pem + subject { + common_name = "${var.deployment_name}.${var.namespace}.svc" + } + + dns_names = [ + var.deployment_name, + "${var.deployment_name}.${var.namespace}", + "${var.deployment_name}.${var.namespace}.svc", + "${var.deployment_name}.${var.namespace}.svc.cluster.local" + ] + +} + +# HTTPS Certificate +resource "tls_self_signed_cert" "tls" { + key_algorithm = tls_private_key.tls.algorithm + private_key_pem = tls_private_key.tls.private_key_pem + + subject { + common_name = "${var.deployment_name}.${local.ns}" + } + + validity_period_hours = 24*365*20 + + dns_names = [ + var.deployment_name, + "${var.deployment_name}.${var.namespace}", + "${var.deployment_name}.${var.namespace}.svc", + "${var.deployment_name}.${var.namespace}.svc.cluster.local" + ] + + allowed_uses = [ + "key_encipherment", + "digital_signature", + "server_auth" + ] +} + +# Certificado +# Obs: Desativado pois o certificado fica preso no estado "Issued" +resource "kubernetes_certificate_signing_request" "tls" { + count = 0 + metadata { + name = "${var.deployment_name}.${var.namespace}" + } + + auto_approve = true + + spec { + usages = [ + "key encipherment", + "digital signature", + "server auth" + ] + + signer_name = "kubernetes.io/kubelet-serving" + + request = tls_cert_request.tls.cert_request_pem + } + +} + +# Secret to store TLS key/cert +resource "kubernetes_secret" "tls" { + metadata { + namespace = local.ns + name = var.deployment_name + } + + data = { + "webhook-key.pem" = tls_private_key.tls.private_key_pem + "webhook-cert.pem" = tls_self_signed_cert.tls.cert_pem + } + +} + +# Deployment +resource "kubernetes_deployment" "main" { + metadata { + name = var.deployment_name + namespace = local.ns + } + + spec { + replicas = var.replicas + selector { + match_labels = { + app = var.deployment_name + } + } + + template { + metadata { + labels = { + app = var.deployment_name + } + } + + spec { + container { + image = local.image + name = var.deployment_name + volume_mount { + mount_path = "/shared-config" + name = "shared-config" + } + + env { + name = "SPRING_APPLICATION_JSON" + value = local.webhook_config_json + } + + } + + init_container { + name = "setup-keystore" + image = local.cloud_sdk_image + + args = [ + "pkcs12", "-export", + "-in", "/secret/webhook-cert.pem", + "-inkey", "/secret/webhook-key.pem", + "-name", "webhook", + "-out", "/shared-config/webhook.p12", + "-passout", "pass:" + ] + + volume_mount { + mount_path = "/shared-config" + name = "shared-config" + } + + volume_mount { + mount_path = "/secret/webhook-cert.pem" + name = "webhook-secret" + sub_path = "webhook-cert.pem" + } + + volume_mount { + mount_path = "/secret/webhook-key.pem" + name = "webhook-secret" + sub_path = "webhook-key.pem" + } + + } + + volume { + name = "shared-config" + empty_dir {} + } + + volume { + name = "webhook-secret" + secret { + secret_name = kubernetes_secret.tls.metadata[0].name + items { + key = "webhook-cert.pem" + path = "webhook-cert.pem" + } + items { + key = "webhook-key.pem" + path = "webhook-key.pem" + } + } + } + + } + } + } +} + +# Service +resource "kubernetes_service" "svc" { + metadata { + name = var.deployment_name + namespace = local.ns + } + + spec { + selector = { + "app" = var.deployment_name + } + + port { + name = "https" + port = 443 + protocol = "TCP" + target_port = 443 + } + + type = "ClusterIP" + } +} + +# Admission Controller +resource "kubernetes_mutating_webhook_configuration" "waitforit" { + metadata { + name = var.deployment_name + } + + webhook { + name = var.admission_controller_name + admission_review_versions = [ "v1", "v1beta1" ] + + #failure_policy = "Ignore" # + + client_config { + + service { + name = kubernetes_service.svc.metadata[0].name + namespace = local.ns + path = "/mutate" + port = 443 + } + + # IMPORTANT: CA_BUNDLE must be Base64-encoded + ca_bundle = tls_self_signed_cert.tls.cert_pem + } + + rule { + api_groups = [ "*" ] + api_versions = [ "*" ] + operations = [ "CREATE", "UPDATE" ] + resources = [ "deployments", "statefulsets" ] + } + + side_effects = "None" # + } + + depends_on = [ + kubernetes_deployment.main + ] +} diff --git a/kubernetes/k8s-admission-controller/src/test/terraform/providers.tf b/kubernetes/k8s-admission-controller/src/test/terraform/providers.tf new file mode 100644 index 0000000000..eb095caa0e --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/test/terraform/providers.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + kubernetes = { + source = "hashicorp/kubernetes" + version = "2.3.2" + } + } +} + +# Use standard kubectl environment to get connection details +provider "kubernetes" { + config_context = var.k8s_config_context + config_path = var.k8s_config_path +} \ No newline at end of file diff --git a/kubernetes/k8s-admission-controller/src/test/terraform/variables.tf b/kubernetes/k8s-admission-controller/src/test/terraform/variables.tf new file mode 100644 index 0000000000..33ca735f0d --- /dev/null +++ b/kubernetes/k8s-admission-controller/src/test/terraform/variables.tf @@ -0,0 +1,50 @@ +variable "namespace" { + type = string + description = "Namespace where the Admission Controller will be deploymed" +} + +variable "deployment_name" { + type = string + description = "Admission Controller Deployment Name" +} + +variable "replicas" { + type = number + description = "Number of replicas used in the deployment" + default = 3 +} + +variable "image" { + type = string + description = "Admission Controller image name" +} + +variable "image_version" { + type = string + description = "Admission Controller image version name" + default = "latest" +} + +variable "image_prefix" { + type = string + description = "Image repository prefix" + default = "gcr.io/baeldung" +} + +variable "admission_controller_name" { + type = string + description = "Admission Controller name" + default = "wait-for-it.service.local" +} + +variable "k8s_config_context" { + type = string + description = "Name of the K8S config context" +} + +variable "k8s_config_path" { + type = string + description = "Location of the standard K8S configuration" + default = "~/.kube/config" + +} \ No newline at end of file diff --git a/kubernetes/pom.xml b/kubernetes/pom.xml index 72cb1577a4..57ae4fd596 100644 --- a/kubernetes/pom.xml +++ b/kubernetes/pom.xml @@ -1,6 +1,5 @@ - + + 4.0.0 kubernetes pom @@ -13,5 +12,6 @@ k8s-intro - + k8s-admission-controller + \ No newline at end of file