From cd733ea6588fa6e9b8e8c17c4708161707a5b346 Mon Sep 17 00:00:00 2001 From: Tom Roman Date: Wed, 12 Feb 2025 21:46:59 +0000 Subject: [PATCH 1/3] feat(#6): Add Server to Placement Group Action --- .../hcloud/listener/ServerChangeListener.java | 4 ++ .../tomr/hcloud/resources/server/Server.java | 7 +++ .../tomr/hcloud/service/action/Action.java | 3 +- .../action/model/PlacementGroupBody.java | 19 ++++++++ .../hcloud/service/server/ServerService.java | 19 +++++--- .../hcloud/resources/server/ServerTest.java | 25 ++++++++++ .../action/model/PlacementGroupBodyTest.java | 21 +++++++++ .../service/server/ServerServiceTest.java | 46 +++++++++++++++++++ 8 files changed, 137 insertions(+), 7 deletions(-) create mode 100644 src/main/java/dev/tomr/hcloud/service/action/model/PlacementGroupBody.java create mode 100644 src/test/java/dev/tomr/hcloud/service/action/model/PlacementGroupBodyTest.java diff --git a/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java b/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java index af85a78..21e223a 100644 --- a/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java +++ b/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java @@ -48,6 +48,10 @@ public void propertyChange(PropertyChangeEvent evt) { logger.warn("This is a potentially destructive action!"); HetznerCloud.getInstance().getServiceManager().getServerService().resetServer(server); } + case "addToPlacementGroup" -> { + logger.info("Add Server to placement group has been called. Instructing Hetzner to add the server to the given placement group {}", evt.getNewValue()); + HetznerCloud.getInstance().getServiceManager().getServerService().addServerToPlacementGroup(server, (Integer) evt.getNewValue()); + } default -> { logger.info("Server changed: {}", evt.getPropertyName()); logger.info("Server: {} -> {}", evt.getOldValue(), evt.getNewValue()); diff --git a/src/main/java/dev/tomr/hcloud/resources/server/Server.java b/src/main/java/dev/tomr/hcloud/resources/server/Server.java index 725c481..191b2c0 100644 --- a/src/main/java/dev/tomr/hcloud/resources/server/Server.java +++ b/src/main/java/dev/tomr/hcloud/resources/server/Server.java @@ -142,6 +142,13 @@ public void reset() { propertyChangeSupport.firePropertyChange("reset", null, null); } + /** + * + */ + public void addToPlacementGroup(PlacementGroup placementGroup) { + propertyChangeSupport.firePropertyChange("addToPlacementGroup", null, placementGroup.getId()); + } + // These are the current setters that will send an API request (PUT /servers) when actions begin to be added, they will also likely be triggered by setters /** diff --git a/src/main/java/dev/tomr/hcloud/service/action/Action.java b/src/main/java/dev/tomr/hcloud/service/action/Action.java index 18a757b..c15cd06 100644 --- a/src/main/java/dev/tomr/hcloud/service/action/Action.java +++ b/src/main/java/dev/tomr/hcloud/service/action/Action.java @@ -5,7 +5,8 @@ public enum Action { POWEROFF("poweroff"), POWERON("poweron"), REBOOT("reboot"), - RESET("reset"),; + RESET("reset"), + ADD_PLACEMENT_GROUP("add_to_placement_group"); public final String path; diff --git a/src/main/java/dev/tomr/hcloud/service/action/model/PlacementGroupBody.java b/src/main/java/dev/tomr/hcloud/service/action/model/PlacementGroupBody.java new file mode 100644 index 0000000..a64b6b8 --- /dev/null +++ b/src/main/java/dev/tomr/hcloud/service/action/model/PlacementGroupBody.java @@ -0,0 +1,19 @@ +package dev.tomr.hcloud.service.action.model; + +import dev.tomr.hcloud.http.HetznerJsonObject; + +public class PlacementGroupBody extends HetznerJsonObject { + private Integer id; + + public PlacementGroupBody(Integer id) { + this.id = id; + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } +} diff --git a/src/main/java/dev/tomr/hcloud/service/server/ServerService.java b/src/main/java/dev/tomr/hcloud/service/server/ServerService.java index 5fc097f..4f56036 100644 --- a/src/main/java/dev/tomr/hcloud/service/server/ServerService.java +++ b/src/main/java/dev/tomr/hcloud/service/server/ServerService.java @@ -2,6 +2,7 @@ import dev.tomr.hcloud.HetznerCloud; import dev.tomr.hcloud.http.HetznerCloudHttpClient; +import dev.tomr.hcloud.http.HetznerJsonObject; import dev.tomr.hcloud.http.converter.ServerConverterUtil; import dev.tomr.hcloud.http.model.Action; import dev.tomr.hcloud.http.model.ActionWrapper; @@ -9,16 +10,14 @@ import dev.tomr.hcloud.http.model.ServerDTOList; import dev.tomr.hcloud.resources.server.Server; import dev.tomr.hcloud.service.ServiceManager; +import dev.tomr.hcloud.service.action.model.PlacementGroupBody; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.io.IOException; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; @@ -147,12 +146,16 @@ public void resetServer(Server server) { sendServerAction(server, RESET); } - private void sendServerAction(Server server, dev.tomr.hcloud.service.action.Action givenAction) { + public void addServerToPlacementGroup(Server server, Integer serverId) { + sendServerAction(server, ADD_PLACEMENT_GROUP, new PlacementGroupBody(serverId)); + } + + private void sendServerAction(Server server, dev.tomr.hcloud.service.action.Action givenAction, HetznerJsonObject body) { List hostAndKey = HetznerCloud.getInstance().getHttpDetails(); String httpUrl = String.format("%sservers/%d/actions/%s", hostAndKey.get(0), server.getId(), givenAction.path); AtomicReference exceptionMsg = new AtomicReference<>(); try { - Action action = client.sendHttpRequest(ActionWrapper.class, httpUrl, POST, hostAndKey.get(1), "").getAction(); + Action action = client.sendHttpRequest(ActionWrapper.class, httpUrl, POST, hostAndKey.get(1), body != null ? HetznerCloud.getObjectMapper().writeValueAsString(body) : "").getAction(); CompletableFuture completedActionFuture = serviceManager.getActionService().waitForActionToComplete(action).thenApplyAsync((completedAction) -> { if (completedAction == null) { throw new NullPointerException(); @@ -174,6 +177,10 @@ private void sendServerAction(Server server, dev.tomr.hcloud.service.action.Acti } } + private void sendServerAction(Server server, dev.tomr.hcloud.service.action.Action givenAction) { + sendServerAction(server, givenAction, null); + } + private void updateAllRemoteServers() { Map newServerMap = new HashMap<>(); List hostAndKey = HetznerCloud.getInstance().getHttpDetails(); diff --git a/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java b/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java index a8ff57e..9dd9f27 100644 --- a/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java +++ b/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java @@ -344,4 +344,29 @@ void callingResetSendsAnEventToTheServerChangeListener() { } } + @Test + @DisplayName("calling addServerToPlacementGroup sends an event to the ServerChangeListener") + void callingAddServerToPlacementGroupSendsAnEventToTheServerChangeListener() { + try (MockedStatic hetznerCloud = mockStatic(HetznerCloud.class)) { + HetznerCloud hetznerCloudMock = mock(HetznerCloud.class); + ServerChangeListener scl = new ServerChangeListener(); + ServerChangeListener serverChangeListener = spy(scl); + ListenerManager listenerManager = mock(ListenerManager.class); + ServiceManager serviceManager = mock(ServiceManager.class); + ServerService serverService = mock(ServerService.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(PropertyChangeEvent.class); + + hetznerCloud.when(HetznerCloud::getInstance).thenReturn(hetznerCloudMock); + when(hetznerCloudMock.getListenerManager()).thenReturn(listenerManager); + when(hetznerCloudMock.getServiceManager()).thenReturn(serviceManager); + when(listenerManager.getServerChangeListener()).thenReturn(serverChangeListener); + when(serviceManager.getServerService()).thenReturn(serverService); + + Server server = new Server(); + server.addToPlacementGroup(new PlacementGroup()); + verify(serverChangeListener, times(1)).propertyChange(captor.capture()); + assertEquals("addToPlacementGroup", captor.getValue().getPropertyName()); + } + } + } \ No newline at end of file diff --git a/src/test/java/dev/tomr/hcloud/service/action/model/PlacementGroupBodyTest.java b/src/test/java/dev/tomr/hcloud/service/action/model/PlacementGroupBodyTest.java new file mode 100644 index 0000000..d7023ad --- /dev/null +++ b/src/test/java/dev/tomr/hcloud/service/action/model/PlacementGroupBodyTest.java @@ -0,0 +1,21 @@ +package dev.tomr.hcloud.service.action.model; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class PlacementGroupBodyTest { + + @Test + void getId() { + PlacementGroupBody pl = new PlacementGroupBody(1); + assertEquals(1, pl.getId()); + } + + @Test + void setId() { + PlacementGroupBody pl = new PlacementGroupBody(1); + pl.setId(2); + assertEquals(2, pl.getId()); + } +} \ No newline at end of file diff --git a/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java b/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java index 38d8c22..c6e662d 100644 --- a/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java +++ b/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java @@ -1,5 +1,8 @@ package dev.tomr.hcloud.service.server; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; import dev.tomr.hcloud.HetznerCloud; import dev.tomr.hcloud.http.HetznerCloudHttpClient; import dev.tomr.hcloud.http.RequestVerb; @@ -12,6 +15,7 @@ import dev.tomr.hcloud.resources.server.Server; import dev.tomr.hcloud.service.ServiceManager; import dev.tomr.hcloud.service.action.ActionService; +import dev.tomr.hcloud.service.action.model.PlacementGroupBody; import org.junit.jupiter.api.*; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -587,6 +591,7 @@ void whenActionReturnedFromHetznerIsNullServerServiceThrowsANullPointer() throws hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + hetznerCloudMockedStatic.when(HetznerCloud::getObjectMapper).thenReturn(JsonMapper.builder().configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true).build()); when(hetznerCloud.getListenerManager()).thenReturn(listenerManager); when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); when(serviceManager.getActionService()).thenReturn(actionService); @@ -618,6 +623,7 @@ void deleteServerFromHetznerHandlesException() throws IOException, InterruptedEx hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + hetznerCloudMockedStatic.when(HetznerCloud::getObjectMapper).thenReturn(JsonMapper.builder().configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true).build()); when(hetznerCloud.getListenerManager()).thenReturn(listenerManager); when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); @@ -650,6 +656,7 @@ void shutdownServerCallsHetznerAndTracksTheAction() throws IOException, Interrup hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + hetznerCloudMockedStatic.when(HetznerCloud::getObjectMapper).thenReturn(JsonMapper.builder().configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true).build()); when(hetznerCloud.getListenerManager()).thenReturn(listenerManager); when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); when(serviceManager.getActionService()).thenReturn(actionService); @@ -677,6 +684,7 @@ void shutdownServerHandlesException() throws IOException, InterruptedException, hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + hetznerCloudMockedStatic.when(HetznerCloud::getObjectMapper).thenReturn(JsonMapper.builder().configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true).build()); when(hetznerCloud.getListenerManager()).thenReturn(listenerManager); when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); @@ -706,6 +714,7 @@ void whenShutdownActionReturnedFromHetznerIsNullServerServiceThrowsANullPointer( hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + hetznerCloudMockedStatic.when(HetznerCloud::getObjectMapper).thenReturn(JsonMapper.builder().configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true).build()); when(hetznerCloud.getListenerManager()).thenReturn(listenerManager); when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); when(serviceManager.getActionService()).thenReturn(actionService); @@ -742,6 +751,7 @@ void powerOffServerCallsHetznerAndTracksTheAction() throws IOException, Interrup hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + hetznerCloudMockedStatic.when(HetznerCloud::getObjectMapper).thenReturn(JsonMapper.builder().configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true).build()); when(hetznerCloud.getListenerManager()).thenReturn(listenerManager); when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); when(serviceManager.getActionService()).thenReturn(actionService); @@ -774,6 +784,7 @@ void powerOnServerCallsHetznerAndTracksTheAction() throws IOException, Interrupt hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + hetznerCloudMockedStatic.when(HetznerCloud::getObjectMapper).thenReturn(JsonMapper.builder().configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true).build()); when(hetznerCloud.getListenerManager()).thenReturn(listenerManager); when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); when(serviceManager.getActionService()).thenReturn(actionService); @@ -806,6 +817,7 @@ void RebootServerCallsHetznerAndTracksTheAction() throws IOException, Interrupte hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + hetznerCloudMockedStatic.when(HetznerCloud::getObjectMapper).thenReturn(JsonMapper.builder().configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true).build()); when(hetznerCloud.getListenerManager()).thenReturn(listenerManager); when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); when(serviceManager.getActionService()).thenReturn(actionService); @@ -837,6 +849,7 @@ void ResetServerCallsHetznerAndTracksTheAction() throws IOException, Interrupted action.setFinished(Date.from(Instant.now()).toString()); hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); + hetznerCloudMockedStatic.when(HetznerCloud::getObjectMapper).thenReturn(JsonMapper.builder().configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true).build()); hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); when(hetznerCloud.getListenerManager()).thenReturn(listenerManager); when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); @@ -852,4 +865,37 @@ void ResetServerCallsHetznerAndTracksTheAction() throws IOException, Interrupted verify(actionService, times(1)).waitForActionToComplete(any(Action.class)); } } + + @Test + @DisplayName("Add Server to Placement Group calls Hetzner and tracks the action") + void addServerToPlacementGroupCallsHetznerAndTracksTheAction() throws IOException, InterruptedException, IllegalAccessException { + HetznerCloud hetznerCloud = mock(HetznerCloud.class); + HetznerCloudHttpClient hetznerCloudHttpClient = mock(HetznerCloudHttpClient.class); + ListenerManager listenerManager = mock(ListenerManager.class); + ServiceManager serviceManager = mock(ServiceManager.class); + ActionService actionService = mock(ActionService.class); + + try (MockedStatic hetznerCloudMockedStatic = mockStatic(HetznerCloud.class); + MockedStatic hetznerCloudHttpClientMockedStatic = mockStatic(HetznerCloudHttpClient.class)) { + + Action action = new Action(); + action.setFinished(Date.from(Instant.now()).toString()); + + hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); + hetznerCloudMockedStatic.when(HetznerCloud::getObjectMapper).thenReturn(JsonMapper.builder().configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true).build()); + hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + when(hetznerCloud.getListenerManager()).thenReturn(listenerManager); + when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); + when(serviceManager.getActionService()).thenReturn(actionService); + when(actionService.waitForActionToComplete(any(Action.class))).thenReturn(CompletableFuture.completedFuture(action)); + + when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString(), anyString())).thenReturn(new ActionWrapper(action)); + + ServerService serverService = new ServerService(serviceManager); + serverService.addServerToPlacementGroup(new Server(), 1); + + verify(hetznerCloudHttpClient, times(1)).sendHttpRequest(any(), anyString(), eq(RequestVerb.POST), eq("key1234"), eq(new ObjectMapper().writeValueAsString(new PlacementGroupBody(1)))); + verify(actionService, times(1)).waitForActionToComplete(any(Action.class)); + } + } } \ No newline at end of file From 8bf2a538c1a7868de6a0a7a8d5ecf246c5851f05 Mon Sep 17 00:00:00 2001 From: Tom Roman Date: Thu, 13 Feb 2025 21:03:55 +0000 Subject: [PATCH 2/3] feat(#22): Remove Server from Placement Group Action --- .../hcloud/listener/ServerChangeListener.java | 4 +++ .../tomr/hcloud/resources/server/Server.java | 4 +++ .../tomr/hcloud/service/action/Action.java | 3 +- .../hcloud/service/server/ServerService.java | 4 +++ .../listener/ServerChangeListenerTest.java | 1 - .../hcloud/resources/server/ServerTest.java | 25 ++++++++++++++ .../service/server/ServerServiceTest.java | 33 +++++++++++++++++++ 7 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java b/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java index 21e223a..ae24a34 100644 --- a/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java +++ b/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java @@ -52,6 +52,10 @@ public void propertyChange(PropertyChangeEvent evt) { logger.info("Add Server to placement group has been called. Instructing Hetzner to add the server to the given placement group {}", evt.getNewValue()); HetznerCloud.getInstance().getServiceManager().getServerService().addServerToPlacementGroup(server, (Integer) evt.getNewValue()); } + case "removeFromPlacementGroup" -> { + logger.info("Remove Server from placement group has been called. Instructing Hetzner to remove the server from a placement group"); + HetznerCloud.getInstance().getServiceManager().getServerService().removeServerFromPlacementGroup(server); + } default -> { logger.info("Server changed: {}", evt.getPropertyName()); logger.info("Server: {} -> {}", evt.getOldValue(), evt.getNewValue()); diff --git a/src/main/java/dev/tomr/hcloud/resources/server/Server.java b/src/main/java/dev/tomr/hcloud/resources/server/Server.java index 191b2c0..ea8e19e 100644 --- a/src/main/java/dev/tomr/hcloud/resources/server/Server.java +++ b/src/main/java/dev/tomr/hcloud/resources/server/Server.java @@ -149,6 +149,10 @@ public void addToPlacementGroup(PlacementGroup placementGroup) { propertyChangeSupport.firePropertyChange("addToPlacementGroup", null, placementGroup.getId()); } + public void removeFromPlacementGroup() { + propertyChangeSupport.firePropertyChange("removeFromPlacementGroup", null, null); + } + // These are the current setters that will send an API request (PUT /servers) when actions begin to be added, they will also likely be triggered by setters /** diff --git a/src/main/java/dev/tomr/hcloud/service/action/Action.java b/src/main/java/dev/tomr/hcloud/service/action/Action.java index c15cd06..116db42 100644 --- a/src/main/java/dev/tomr/hcloud/service/action/Action.java +++ b/src/main/java/dev/tomr/hcloud/service/action/Action.java @@ -6,7 +6,8 @@ public enum Action { POWERON("poweron"), REBOOT("reboot"), RESET("reset"), - ADD_PLACEMENT_GROUP("add_to_placement_group"); + ADD_PLACEMENT_GROUP("add_to_placement_group"), + REMOVE_PLACEMENT_GROUP("remove_from_placement_group"); public final String path; diff --git a/src/main/java/dev/tomr/hcloud/service/server/ServerService.java b/src/main/java/dev/tomr/hcloud/service/server/ServerService.java index 4f56036..c79045f 100644 --- a/src/main/java/dev/tomr/hcloud/service/server/ServerService.java +++ b/src/main/java/dev/tomr/hcloud/service/server/ServerService.java @@ -150,6 +150,10 @@ public void addServerToPlacementGroup(Server server, Integer serverId) { sendServerAction(server, ADD_PLACEMENT_GROUP, new PlacementGroupBody(serverId)); } + public void removeServerFromPlacementGroup(Server server) { + sendServerAction(server, REMOVE_PLACEMENT_GROUP); + } + private void sendServerAction(Server server, dev.tomr.hcloud.service.action.Action givenAction, HetznerJsonObject body) { List hostAndKey = HetznerCloud.getInstance().getHttpDetails(); String httpUrl = String.format("%sservers/%d/actions/%s", hostAndKey.get(0), server.getId(), givenAction.path); diff --git a/src/test/java/dev/tomr/hcloud/listener/ServerChangeListenerTest.java b/src/test/java/dev/tomr/hcloud/listener/ServerChangeListenerTest.java index 9c021da..473a12b 100644 --- a/src/test/java/dev/tomr/hcloud/listener/ServerChangeListenerTest.java +++ b/src/test/java/dev/tomr/hcloud/listener/ServerChangeListenerTest.java @@ -7,7 +7,6 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; -import org.mockito.Spy; import java.beans.PropertyChangeEvent; import java.util.Map; diff --git a/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java b/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java index 9dd9f27..4f987c0 100644 --- a/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java +++ b/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java @@ -369,4 +369,29 @@ void callingAddServerToPlacementGroupSendsAnEventToTheServerChangeListener() { } } + @Test + @DisplayName("calling removeServerFromPlacementGroup sends an event to the ServerChangeListener") + void callingRemoveServerFromPlacementGroupSendsAnEventToTheServerChangeListener() { + try (MockedStatic hetznerCloud = mockStatic(HetznerCloud.class)) { + HetznerCloud hetznerCloudMock = mock(HetznerCloud.class); + ServerChangeListener scl = new ServerChangeListener(); + ServerChangeListener serverChangeListener = spy(scl); + ListenerManager listenerManager = mock(ListenerManager.class); + ServiceManager serviceManager = mock(ServiceManager.class); + ServerService serverService = mock(ServerService.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(PropertyChangeEvent.class); + + hetznerCloud.when(HetznerCloud::getInstance).thenReturn(hetznerCloudMock); + when(hetznerCloudMock.getListenerManager()).thenReturn(listenerManager); + when(hetznerCloudMock.getServiceManager()).thenReturn(serviceManager); + when(listenerManager.getServerChangeListener()).thenReturn(serverChangeListener); + when(serviceManager.getServerService()).thenReturn(serverService); + + Server server = new Server(); + server.removeFromPlacementGroup(); + verify(serverChangeListener, times(1)).propertyChange(captor.capture()); + assertEquals("removeFromPlacementGroup", captor.getValue().getPropertyName()); + } + } + } \ No newline at end of file diff --git a/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java b/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java index c6e662d..7ced4c8 100644 --- a/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java +++ b/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java @@ -898,4 +898,37 @@ void addServerToPlacementGroupCallsHetznerAndTracksTheAction() throws IOExceptio verify(actionService, times(1)).waitForActionToComplete(any(Action.class)); } } + + @Test + @DisplayName("Remove Server from a Placement Group calls Hetzner and tracks the action") + void removeServerFromPlacementGroupCallsHetznerAndTracksTheAction() throws IOException, InterruptedException, IllegalAccessException { + HetznerCloud hetznerCloud = mock(HetznerCloud.class); + HetznerCloudHttpClient hetznerCloudHttpClient = mock(HetznerCloudHttpClient.class); + ListenerManager listenerManager = mock(ListenerManager.class); + ServiceManager serviceManager = mock(ServiceManager.class); + ActionService actionService = mock(ActionService.class); + + try (MockedStatic hetznerCloudMockedStatic = mockStatic(HetznerCloud.class); + MockedStatic hetznerCloudHttpClientMockedStatic = mockStatic(HetznerCloudHttpClient.class)) { + + Action action = new Action(); + action.setFinished(Date.from(Instant.now()).toString()); + + hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); + hetznerCloudMockedStatic.when(HetznerCloud::getObjectMapper).thenReturn(JsonMapper.builder().configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true).build()); + hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + when(hetznerCloud.getListenerManager()).thenReturn(listenerManager); + when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); + when(serviceManager.getActionService()).thenReturn(actionService); + when(actionService.waitForActionToComplete(any(Action.class))).thenReturn(CompletableFuture.completedFuture(action)); + + when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString(), anyString())).thenReturn(new ActionWrapper(action)); + + ServerService serverService = new ServerService(serviceManager); + serverService.removeServerFromPlacementGroup(new Server()); + + verify(hetznerCloudHttpClient, times(1)).sendHttpRequest(any(), anyString(), eq(RequestVerb.POST), eq("key1234"), eq("")); + verify(actionService, times(1)).waitForActionToComplete(any(Action.class)); + } + } } \ No newline at end of file From d4c12008eeef96c9d5d2936c18667efad84661a1 Mon Sep 17 00:00:00 2001 From: Tom Roman Date: Thu, 13 Feb 2025 21:19:55 +0000 Subject: [PATCH 3/3] feat(#12): Add Server Protection Action --- .../hcloud/listener/ServerChangeListener.java | 5 +++ .../hcloud/resources/common/Protection.java | 8 +++++ .../tomr/hcloud/resources/server/Server.java | 14 +++++++- .../tomr/hcloud/service/action/Action.java | 3 +- .../action/model/ChangeProtectionBody.java | 29 +++++++++++++++ .../hcloud/service/server/ServerService.java | 6 ++++ .../hcloud/resources/server/ServerTest.java | 25 +++++++++++++ .../model/ChangeProtectionBodyTest.java | 35 +++++++++++++++++++ .../service/server/ServerServiceTest.java | 34 ++++++++++++++++++ 9 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 src/main/java/dev/tomr/hcloud/service/action/model/ChangeProtectionBody.java create mode 100644 src/test/java/dev/tomr/hcloud/service/action/model/ChangeProtectionBodyTest.java diff --git a/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java b/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java index ae24a34..b434b31 100644 --- a/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java +++ b/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java @@ -1,6 +1,7 @@ package dev.tomr.hcloud.listener; import dev.tomr.hcloud.HetznerCloud; +import dev.tomr.hcloud.resources.common.Protection; import dev.tomr.hcloud.resources.server.Server; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -56,6 +57,10 @@ public void propertyChange(PropertyChangeEvent evt) { logger.info("Remove Server from placement group has been called. Instructing Hetzner to remove the server from a placement group"); HetznerCloud.getInstance().getServiceManager().getServerService().removeServerFromPlacementGroup(server); } + case "changeServerProtection" -> { + logger.info("Change Server protection has been called. Instructin Hetzner to update the server protection to {}", evt.getNewValue().toString()); + HetznerCloud.getInstance().getServiceManager().getServerService().changeServerProtection(server, (Protection) evt.getNewValue()); + } default -> { logger.info("Server changed: {}", evt.getPropertyName()); logger.info("Server: {} -> {}", evt.getOldValue(), evt.getNewValue()); diff --git a/src/main/java/dev/tomr/hcloud/resources/common/Protection.java b/src/main/java/dev/tomr/hcloud/resources/common/Protection.java index 54ae08d..50cf525 100644 --- a/src/main/java/dev/tomr/hcloud/resources/common/Protection.java +++ b/src/main/java/dev/tomr/hcloud/resources/common/Protection.java @@ -29,4 +29,12 @@ public boolean isRebuild() { public void setRebuild(boolean rebuild) { this.rebuild = rebuild; } + + @Override + public String toString() { + return "Protection{" + + "delete=" + delete + + ", rebuild=" + rebuild + + '}'; + } } diff --git a/src/main/java/dev/tomr/hcloud/resources/server/Server.java b/src/main/java/dev/tomr/hcloud/resources/server/Server.java index ea8e19e..660784c 100644 --- a/src/main/java/dev/tomr/hcloud/resources/server/Server.java +++ b/src/main/java/dev/tomr/hcloud/resources/server/Server.java @@ -143,16 +143,28 @@ public void reset() { } /** - * + * Adds the server to a given placement group + * @param placementGroup The Placement group to add the server to */ public void addToPlacementGroup(PlacementGroup placementGroup) { propertyChangeSupport.firePropertyChange("addToPlacementGroup", null, placementGroup.getId()); } + /** + * Removes the server from any placement groups it may be in + */ public void removeFromPlacementGroup() { propertyChangeSupport.firePropertyChange("removeFromPlacementGroup", null, null); } + /** + * Updates the Protection of a Server + * @param protection The protection values to be applied + */ + public void changeServerProtection(Protection protection) { + propertyChangeSupport.firePropertyChange("changeServerProtection", null, protection); + } + // These are the current setters that will send an API request (PUT /servers) when actions begin to be added, they will also likely be triggered by setters /** diff --git a/src/main/java/dev/tomr/hcloud/service/action/Action.java b/src/main/java/dev/tomr/hcloud/service/action/Action.java index 116db42..99ddee4 100644 --- a/src/main/java/dev/tomr/hcloud/service/action/Action.java +++ b/src/main/java/dev/tomr/hcloud/service/action/Action.java @@ -7,7 +7,8 @@ public enum Action { REBOOT("reboot"), RESET("reset"), ADD_PLACEMENT_GROUP("add_to_placement_group"), - REMOVE_PLACEMENT_GROUP("remove_from_placement_group"); + REMOVE_PLACEMENT_GROUP("remove_from_placement_group"), + CHANGE_SERVER_PROTECTION("change_protection"); public final String path; diff --git a/src/main/java/dev/tomr/hcloud/service/action/model/ChangeProtectionBody.java b/src/main/java/dev/tomr/hcloud/service/action/model/ChangeProtectionBody.java new file mode 100644 index 0000000..ff19356 --- /dev/null +++ b/src/main/java/dev/tomr/hcloud/service/action/model/ChangeProtectionBody.java @@ -0,0 +1,29 @@ +package dev.tomr.hcloud.service.action.model; + +import dev.tomr.hcloud.http.HetznerJsonObject; + +public class ChangeProtectionBody extends HetznerJsonObject { + private boolean delete; + private boolean rebuild; + + public ChangeProtectionBody(boolean delete, boolean rebuild) { + this.delete = delete; + this.rebuild = rebuild; + } + + public boolean isDelete() { + return delete; + } + + public void setDelete(boolean delete) { + this.delete = delete; + } + + public boolean isRebuild() { + return rebuild; + } + + public void setRebuild(boolean rebuild) { + this.rebuild = rebuild; + } +} diff --git a/src/main/java/dev/tomr/hcloud/service/server/ServerService.java b/src/main/java/dev/tomr/hcloud/service/server/ServerService.java index c79045f..55af3b6 100644 --- a/src/main/java/dev/tomr/hcloud/service/server/ServerService.java +++ b/src/main/java/dev/tomr/hcloud/service/server/ServerService.java @@ -8,8 +8,10 @@ import dev.tomr.hcloud.http.model.ActionWrapper; import dev.tomr.hcloud.http.model.ServerDTO; import dev.tomr.hcloud.http.model.ServerDTOList; +import dev.tomr.hcloud.resources.common.Protection; import dev.tomr.hcloud.resources.server.Server; import dev.tomr.hcloud.service.ServiceManager; +import dev.tomr.hcloud.service.action.model.ChangeProtectionBody; import dev.tomr.hcloud.service.action.model.PlacementGroupBody; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -154,6 +156,10 @@ public void removeServerFromPlacementGroup(Server server) { sendServerAction(server, REMOVE_PLACEMENT_GROUP); } + public void changeServerProtection(Server server, Protection protection) { + sendServerAction(server, CHANGE_SERVER_PROTECTION, new ChangeProtectionBody(protection.isDelete(), protection.isRebuild())); + } + private void sendServerAction(Server server, dev.tomr.hcloud.service.action.Action givenAction, HetznerJsonObject body) { List hostAndKey = HetznerCloud.getInstance().getHttpDetails(); String httpUrl = String.format("%sservers/%d/actions/%s", hostAndKey.get(0), server.getId(), givenAction.path); diff --git a/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java b/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java index 4f987c0..fbeba91 100644 --- a/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java +++ b/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java @@ -394,4 +394,29 @@ void callingRemoveServerFromPlacementGroupSendsAnEventToTheServerChangeListener( } } + @Test + @DisplayName("calling changeServerProtection sends an event to the ServerChangeListener") + void callingChangeServerProtectionSendsAnEventToTheServerChangeListener() { + try (MockedStatic hetznerCloud = mockStatic(HetznerCloud.class)) { + HetznerCloud hetznerCloudMock = mock(HetznerCloud.class); + ServerChangeListener scl = new ServerChangeListener(); + ServerChangeListener serverChangeListener = spy(scl); + ListenerManager listenerManager = mock(ListenerManager.class); + ServiceManager serviceManager = mock(ServiceManager.class); + ServerService serverService = mock(ServerService.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(PropertyChangeEvent.class); + + hetznerCloud.when(HetznerCloud::getInstance).thenReturn(hetznerCloudMock); + when(hetznerCloudMock.getListenerManager()).thenReturn(listenerManager); + when(hetznerCloudMock.getServiceManager()).thenReturn(serviceManager); + when(listenerManager.getServerChangeListener()).thenReturn(serverChangeListener); + when(serviceManager.getServerService()).thenReturn(serverService); + + Server server = new Server(); + server.changeServerProtection(new Protection(false, false)); + verify(serverChangeListener, times(1)).propertyChange(captor.capture()); + assertEquals("changeServerProtection", captor.getValue().getPropertyName()); + } + } + } \ No newline at end of file diff --git a/src/test/java/dev/tomr/hcloud/service/action/model/ChangeProtectionBodyTest.java b/src/test/java/dev/tomr/hcloud/service/action/model/ChangeProtectionBodyTest.java new file mode 100644 index 0000000..0d941ec --- /dev/null +++ b/src/test/java/dev/tomr/hcloud/service/action/model/ChangeProtectionBodyTest.java @@ -0,0 +1,35 @@ +package dev.tomr.hcloud.service.action.model; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ChangeProtectionBodyTest { + + @Test + void isDelete() { + ChangeProtectionBody changeProtectionBody = new ChangeProtectionBody(true, true); + assertTrue(changeProtectionBody.isDelete()); + } + + @Test + void isRebuild() { + ChangeProtectionBody changeProtectionBody = new ChangeProtectionBody(true, true); + assertTrue(changeProtectionBody.isRebuild()); + } + + @Test + void setDelete() { + ChangeProtectionBody changeProtectionBody = new ChangeProtectionBody(true, true); + changeProtectionBody.setDelete(false); + assertFalse(changeProtectionBody.isDelete()); + } + + @Test + void setRebuild() { + ChangeProtectionBody changeProtectionBody = new ChangeProtectionBody(true, true); + changeProtectionBody.setRebuild(false); + assertFalse(changeProtectionBody.isRebuild()); + } +} diff --git a/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java b/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java index 7ced4c8..7e245d2 100644 --- a/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java +++ b/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java @@ -15,6 +15,7 @@ import dev.tomr.hcloud.resources.server.Server; import dev.tomr.hcloud.service.ServiceManager; import dev.tomr.hcloud.service.action.ActionService; +import dev.tomr.hcloud.service.action.model.ChangeProtectionBody; import dev.tomr.hcloud.service.action.model.PlacementGroupBody; import org.junit.jupiter.api.*; import org.mockito.MockedStatic; @@ -931,4 +932,37 @@ void removeServerFromPlacementGroupCallsHetznerAndTracksTheAction() throws IOExc verify(actionService, times(1)).waitForActionToComplete(any(Action.class)); } } + + @Test + @DisplayName("Change Server Protection calls Hetzner and tracks the action") + void changeServerProtectionCallsHetznerAndTracksTheAction() throws IOException, InterruptedException, IllegalAccessException { + HetznerCloud hetznerCloud = mock(HetznerCloud.class); + HetznerCloudHttpClient hetznerCloudHttpClient = mock(HetznerCloudHttpClient.class); + ListenerManager listenerManager = mock(ListenerManager.class); + ServiceManager serviceManager = mock(ServiceManager.class); + ActionService actionService = mock(ActionService.class); + + try (MockedStatic hetznerCloudMockedStatic = mockStatic(HetznerCloud.class); + MockedStatic hetznerCloudHttpClientMockedStatic = mockStatic(HetznerCloudHttpClient.class)) { + + Action action = new Action(); + action.setFinished(Date.from(Instant.now()).toString()); + + hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); + hetznerCloudMockedStatic.when(HetznerCloud::getObjectMapper).thenReturn(JsonMapper.builder().configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true).build()); + hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + when(hetznerCloud.getListenerManager()).thenReturn(listenerManager); + when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); + when(serviceManager.getActionService()).thenReturn(actionService); + when(actionService.waitForActionToComplete(any(Action.class))).thenReturn(CompletableFuture.completedFuture(action)); + + when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString(), anyString())).thenReturn(new ActionWrapper(action)); + + ServerService serverService = new ServerService(serviceManager); + serverService.changeServerProtection(new Server(), new Protection(true, false)); + + verify(hetznerCloudHttpClient, times(1)).sendHttpRequest(any(), anyString(), eq(RequestVerb.POST), eq("key1234"), eq(new ObjectMapper().writeValueAsString(new ChangeProtectionBody(true, false)))); + verify(actionService, times(1)).waitForActionToComplete(any(Action.class)); + } + } } \ No newline at end of file