Skip to content

Commit

Permalink
Implement KubernetesActuatorTemplate
Browse files Browse the repository at this point in the history
  • Loading branch information
dturanski committed Jan 12, 2022
1 parent dc1f2be commit d16ea64
Show file tree
Hide file tree
Showing 10 changed files with 564 additions and 138 deletions.
17 changes: 17 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<java-semver.version>0.9.0</java-semver.version>
<maven.compiler.plugin.version>3.8.0</maven.compiler.plugin.version>
<powermock.version>2.0.2</powermock.version>
<okhttp.version>3.14.9</okhttp.version>
</properties>


Expand All @@ -40,6 +41,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<dependency>
<groupId>io.fabric8</groupId>
<artifactId>kubernetes-client</artifactId>
Expand Down Expand Up @@ -78,6 +83,18 @@
<version>2.23.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>${okhttp.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<version>${okhttp.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

<profiles>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright 2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.springframework.cloud.deployer.spi.kubernetes;

import org.springframework.cloud.deployer.spi.app.AbstractActuatorTemplate;
import org.springframework.cloud.deployer.spi.app.AppDeployer;
import org.springframework.cloud.deployer.spi.app.AppInstanceStatus;
import org.springframework.web.client.RestTemplate;

/**
* @author David Turanski
*/

public class KubernetesActuatorTemplate extends AbstractActuatorTemplate {

public KubernetesActuatorTemplate(RestTemplate restTemplate, AppDeployer appDeployer) {
super(restTemplate, appDeployer);
}

protected String actuatorUrlForInstance(AppInstanceStatus appInstanceStatus) {
return String.format("http://%s:%d/actuator", appInstanceStatus.getAttributes().get("pod.ip"),
Integer.valueOf(appInstanceStatus.getAttributes().get("actuator.port")));
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2015-2019 the original author or authors.
* Copyright 2015-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -20,7 +20,9 @@
import java.util.List;
import java.util.Map;

import io.fabric8.kubernetes.api.model.Container;
import io.fabric8.kubernetes.api.model.ContainerStatus;
import io.fabric8.kubernetes.api.model.IntOrString;
import io.fabric8.kubernetes.api.model.Pod;
import io.fabric8.kubernetes.api.model.Service;
import io.fabric8.kubernetes.api.model.ServicePort;
Expand Down Expand Up @@ -127,6 +129,7 @@ public Map<String, String> getAttributes() {
result.put("pod.name", pod.getMetadata().getName());
result.put("pod.startTime", pod.getStatus().getStartTime());
result.put("pod.ip", pod.getStatus().getPodIP());
result.put("actuator.port", determineActuatorPort(pod));
result.put("host.ip", pod.getStatus().getHostIP());
result.put("phase", pod.getStatus().getPhase());
result.put(AbstractKubernetesDeployer.SPRING_APP_KEY.replace('-', '.'),
Expand Down Expand Up @@ -175,6 +178,16 @@ public Map<String, String> getAttributes() {
}
return result;
}

private String determineActuatorPort(Pod pod) {
return pod.getSpec().getContainers().stream().filter( (Container container) ->
container.getLivenessProbe() != null &&
container.getLivenessProbe().getHttpGet() != null &&
container.getLivenessProbe().getHttpGet().getPath().startsWith("/actuator"))
.findFirst()
.map(container -> container.getLivenessProbe().getHttpGet().getPort().getStrVal())
.orElse(new IntOrString("unknown").getStrVal());
}
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@
import org.springframework.boot.autoconfigure.AutoConfigureOrder;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.deployer.spi.app.ActuatorOperations;
import org.springframework.cloud.deployer.spi.app.AppDeployer;
import org.springframework.cloud.deployer.spi.task.TaskLauncher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.web.client.RestTemplate;

/**
* Spring Bean configuration for the {@link KubernetesAppDeployer}.
Expand Down Expand Up @@ -72,4 +74,15 @@ public ContainerFactory containerFactory() {
return new DefaultContainerFactory(deployerProperties);
}

@Bean
ActuatorOperations actuatorSupport(RestTemplate actuatorRestTemplate, AppDeployer appDeployer) {
return new KubernetesActuatorTemplate(actuatorRestTemplate, appDeployer);
}

@Bean
RestTemplate actuatorRestTemplate() {
//TODO: Configure security
return new RestTemplate();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*
* Copyright 2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.springframework.cloud.deployer.spi.kubernetes;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import okhttp3.mockwebserver.Dispatcher;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import org.springframework.cloud.deployer.spi.app.ActuatorOperations;
import org.springframework.cloud.deployer.spi.app.AppDeployer;
import org.springframework.cloud.deployer.spi.app.AppInstanceStatus;
import org.springframework.cloud.deployer.spi.app.AppStatus;
import org.springframework.cloud.deployer.spi.app.DeploymentState;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpStatus;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

public class KubernetesActuatorTemplateTests {
private static MockWebServer mockActuator;

private final AppDeployer appDeployer = mock(AppDeployer.class);

private final ActuatorOperations actuatorOperations = new KubernetesActuatorTemplate(new RestTemplate(), appDeployer);

private AppInstanceStatus appInstanceStatus;

@BeforeAll
static void setupMockServer() throws IOException {
mockActuator = new MockWebServer();
mockActuator.start();
mockActuator.setDispatcher(new Dispatcher() {
@Override
public MockResponse dispatch(RecordedRequest recordedRequest) throws InterruptedException {
switch (recordedRequest.getPath()) {
case "/actuator/info":
return new MockResponse().setBody(resourceAsString("actuator-info.json"))
.addHeader("Content-Type", "application/json").setResponseCode(200);
case "/actuator/health":
return new MockResponse().setBody("\"status\":\"UP\"}")
.addHeader("Content-Type", "application/json").setResponseCode(200);
case "/actuator/bindings":
return new MockResponse().setBody(resourceAsString("actuator-bindings.json"))
.addHeader("Content-Type", "application/json").setResponseCode(200);
case "/actuator/bindings/input":
if (recordedRequest.getMethod().equals("GET")) {
return new MockResponse().setBody(resourceAsString("actuator-binding-input.json"))
.addHeader("Content-Type", "application/json")
.setResponseCode(200);
}
else if (recordedRequest.getMethod().equals("POST")) {
if (!StringUtils.hasText(recordedRequest.getBody().toString())) {
return new MockResponse().setResponseCode(HttpStatus.BAD_REQUEST.value());
}
else {
return new MockResponse().setBody(recordedRequest.getBody())
.addHeader("Content-Type", "application/json").setResponseCode(200);
}
}
else {
return new MockResponse().setResponseCode(HttpStatus.BAD_REQUEST.value());
}
default:
return new MockResponse().setResponseCode(HttpStatus.NOT_FOUND.value());
}
}
});
}

@AfterAll
static void tearDown() throws IOException {
mockActuator.shutdown();
}

@BeforeEach
void setUp() {
appInstanceStatus = mock(AppInstanceStatus.class);
Map<String, String> attributes = new HashMap<>();
attributes.put("pod.ip", "127.0.0.1");
attributes.put("actuator.port", String.valueOf(mockActuator.getPort()));
attributes.put("guid", "test-application-0");
when(appInstanceStatus.getAttributes()).thenReturn(attributes);
when(appInstanceStatus.getState()).thenReturn(DeploymentState.deployed);
AppStatus appStatus = AppStatus.of("test-application-id")
.with(appInstanceStatus)
.build();
when(appDeployer.status(anyString())).thenReturn(appStatus);
}

@Test
void actuatorInfo() {
Map<String, Object> info = actuatorOperations
.getFromActuator("test-application-id", "test-application-0", "/info", Map.class);

assertThat(((Map<?, ?>) (info.get("app"))).get("name")).isEqualTo("log-sink-rabbit");
}

@Test
void actuatorBindings() {
List<?> bindings = actuatorOperations
.getFromActuator("test-application-id", "test-application-0", "/bindings", List.class);

assertThat(((Map<?, ?>) (bindings.get(0))).get("bindingName")).isEqualTo("input");
}

@Test
void actuatorBindingInput() {
Map<String, Object> binding = actuatorOperations
.getFromActuator("test-application-id", "test-application-0", "/bindings/input", Map.class);
assertThat(binding.get("bindingName")).isEqualTo("input");
}

@Test
void actuatorPostBindingInput() {
Map<String, Object> state = actuatorOperations
.postToActuator("test-application-id", "test-application-0", "/bindings/input",
Collections.singletonMap("state", "STOPPED"), Map.class);
assertThat(state.get("state")).isEqualTo("STOPPED");
}

@Test
void noInstanceDeployed() {
when(appInstanceStatus.getState()).thenReturn(DeploymentState.failed);
assertThatThrownBy(() -> {
actuatorOperations
.getFromActuator("test-application-id", "test-application-0", "/info", Map.class);

}).isInstanceOf(IllegalStateException.class).hasMessageContaining("not deployed");
}

private static String resourceAsString(String path) {
try {
return StreamUtils.copyToString(new ClassPathResource(path).getInputStream(), StandardCharsets.UTF_8);
}
catch (IOException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
}
Loading

0 comments on commit d16ea64

Please sign in to comment.