From c588985199e3ad353c66b360bf8535f3b2e2db59 Mon Sep 17 00:00:00 2001 From: Tom Roman Date: Wed, 1 Jan 2025 17:29:21 +0000 Subject: [PATCH 1/5] fix: Shutdown executor after it's not needed --- src/main/java/dev/tomr/hcloud/service/action/ActionService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/dev/tomr/hcloud/service/action/ActionService.java b/src/main/java/dev/tomr/hcloud/service/action/ActionService.java index 5026250..7cc5131 100644 --- a/src/main/java/dev/tomr/hcloud/service/action/ActionService.java +++ b/src/main/java/dev/tomr/hcloud/service/action/ActionService.java @@ -49,6 +49,7 @@ public CompletableFuture waitForActionToComplete(Action action) { futures.forEach((f) -> { f.cancel(true); }); + scheduler.shutdownNow(); return completedAction.get(); }); } From af9d71bfbcd80ca0dfb8307a07361c92ab162573 Mon Sep 17 00:00:00 2001 From: Tom Roman Date: Wed, 1 Jan 2025 17:29:59 +0000 Subject: [PATCH 2/5] feat: Add Shutdown action - New method on Server to call shutdown - Uses same action service as the delete action for tracking --- .../hcloud/listener/ServerChangeListener.java | 3 + .../tomr/hcloud/resources/server/Server.java | 9 ++ .../hcloud/service/server/ServerService.java | 28 +++++- .../hcloud/resources/server/ServerTest.java | 26 +++++ .../service/server/ServerServiceTest.java | 94 ++++++++++++++++++- 5 files changed, 158 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 e490819..7793581 100644 --- a/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java +++ b/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java @@ -22,6 +22,9 @@ public void propertyChange(PropertyChangeEvent evt) { if (evt.getPropertyName().equals("delete")) { logger.warn("Server delete has been called. Instructing Hetzner to delete"); HetznerCloud.getInstance().getServiceManager().getServerService().deleteServerFromHetzner(server); + } else if (evt.getPropertyName().equals("shutdown")) { + logger.info("Server shutdown has been called. Instructing Hetzner to shut the server down"); + HetznerCloud.getInstance().getServiceManager().getServerService().shutdownServer(server); } else { 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 dd18eb4..0c8d17f 100644 --- a/src/main/java/dev/tomr/hcloud/resources/server/Server.java +++ b/src/main/java/dev/tomr/hcloud/resources/server/Server.java @@ -97,6 +97,8 @@ private void setupPropertyChangeListener() { propertyChangeSupport.addPropertyChangeListener(HetznerCloud.getInstance().getListenerManager().getServerChangeListener()); } + // The following methods are for calling Actions on the server + /** * Deletes a Server from Hetzner. Note, this is immediate and destructive. Ensure you want to delete the server before calling. */ @@ -104,6 +106,13 @@ public void delete() { propertyChangeSupport.firePropertyChange("delete", null, null); } + /** + * Sends a Shutdown signal to the server, which will instruct the OS to shut it down. Note that if you **must** ensure the server is completely offline, you should also call .powerOff() to ensure the 'plug is pulled' + */ + public void shutdown() { + propertyChangeSupport.firePropertyChange("shutdown", 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/server/ServerService.java b/src/main/java/dev/tomr/hcloud/service/server/ServerService.java index 4b0ef32..03200f6 100644 --- a/src/main/java/dev/tomr/hcloud/service/server/ServerService.java +++ b/src/main/java/dev/tomr/hcloud/service/server/ServerService.java @@ -23,7 +23,6 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Function; import static dev.tomr.hcloud.http.RequestVerb.*; @@ -127,6 +126,33 @@ public void deleteServerFromHetzner(Server server) { } } + public void shutdownServer(Server server) { + List hostAndKey = HetznerCloud.getInstance().getHttpDetails(); + String httpUrl = String.format("%sservers/%d/actions/poweroff", hostAndKey.get(0), server.getId()); + AtomicReference exceptionMsg = new AtomicReference<>(); + try { + Action action = client.sendHttpRequest(ActionWrapper.class, httpUrl, POST, hostAndKey.get(1), "").getAction(); + CompletableFuture completedActionFuture = serviceManager.getActionService().waitForActionToComplete(action).thenApplyAsync((completedAction) -> { + if (completedAction == null) { + throw new NullPointerException(); + } + logger.info("Server shutdown at {}", completedAction.getFinished()); + return completedAction; + }).exceptionally((e) -> { + logger.error("Server shutdown failed"); + logger.error(e.getMessage()); + exceptionMsg.set(e.getMessage()); + return null; + }); + if (completedActionFuture.get() == null) { + throw new RuntimeException(exceptionMsg.get()); + } + } catch (Exception e) { + logger.error("Failed to shutdown the Server"); + throw new RuntimeException(e); + } + } + 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 92fd40a..fd9c778 100644 --- a/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java +++ b/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java @@ -222,5 +222,31 @@ void callingDeleteSendsAnEventToTheServerChangeListener() { } + @Test + @DisplayName("calling shutdown sends an event to the ServerChangeListener") + void callingShutdownSendsAnEventToTheServerChangeListener() { + 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.shutdown(); + verify(serverChangeListener, times(1)).propertyChange(captor.capture()); + assertEquals("shutdown", 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 61bc00d..6cfcc60 100644 --- a/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java +++ b/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java @@ -610,7 +610,7 @@ void whenActionServiceThrowsDeleteServerAlsoThrows() throws IOException, Interru } @Test - @DisplayName("When Action returned from Hetzner is Null, server service throws a null pointer exception") + @DisplayName("When Delete Action returned from Hetzner is Null, server service throws a null pointer exception") void whenActionReturnedFromHetznerIsNullServerServiceThrowsANullPointer() throws IOException, InterruptedException, IllegalAccessException { HetznerCloud hetznerCloud = mock(HetznerCloud.class); HetznerCloudHttpClient hetznerCloudHttpClient = mock(HetznerCloudHttpClient.class); @@ -669,4 +669,96 @@ void deleteServerFromHetznerHandlesException() throws IOException, InterruptedEx } } + @Test + @DisplayName("Shutdown Server calls Hetzner and tracks the action") + void shutdownServerCallsHetznerAndTracksTheAction() 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::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.shutdownServer(new Server()); + + verify(hetznerCloudHttpClient, times(1)).sendHttpRequest(any(), anyString(), eq(RequestVerb.POST), eq("key1234"), eq("")); + verify(actionService, times(1)).waitForActionToComplete(any(Action.class)); + } + } + + @Test + @DisplayName("When httpclient throws, then shutdown Server also throws a Runtime exception") + void shutdownServerHandlesException() throws IOException, InterruptedException, IllegalAccessException { + HetznerCloud hetznerCloud = mock(HetznerCloud.class); + HetznerCloudHttpClient hetznerCloudHttpClient = mock(HetznerCloudHttpClient.class); + ListenerManager listenerManager = mock(ListenerManager.class); + + try (MockedStatic hetznerCloudMockedStatic = mockStatic(HetznerCloud.class); + MockedStatic hetznerCloudHttpClientMockedStatic = mockStatic(HetznerCloudHttpClient.class)) { + + hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); + hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud); + when(hetznerCloud.getListenerManager()).thenReturn(listenerManager); + when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234")); + + when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString(), anyString())).thenThrow(new IOException()); + + ServerService serverService = new ServerService(); + + RuntimeException runtimeException = assertThrows(RuntimeException.class, () -> serverService.shutdownServer(new Server())); + + verify(hetznerCloudHttpClient, times(1)).sendHttpRequest(any(), anyString(), eq(RequestVerb.POST), eq("key1234"), eq("")); + + assertTrue(runtimeException.getMessage().contains("IOException")); + } + } + + @Test + @DisplayName("When Shutdown Action returned from Hetzner is Null, server service throws a null pointer exception") + void whenShutdownActionReturnedFromHetznerIsNullServerServiceThrowsANullPointer() 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)) { + + hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient); + 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(null)); + + when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString(), anyString())).thenReturn(new ActionWrapper(new Action())); + + ServerService serverService = new ServerService(serviceManager); + + RuntimeException runtimeException = assertThrows(RuntimeException.class, () -> serverService.shutdownServer(new Server())); + + verify(hetznerCloudHttpClient, times(1)).sendHttpRequest(any(), anyString(), eq(RequestVerb.POST), eq("key1234"), eq("")); + verify(actionService, times(1)).waitForActionToComplete(any(Action.class)); + + assertTrue(runtimeException.getMessage().contains("NullPointerException")); + } + } + } \ No newline at end of file From b07587b55baed52258acb85b92044264a82cf4db Mon Sep 17 00:00:00 2001 From: Tom Roman Date: Thu, 2 Jan 2025 18:37:13 +0000 Subject: [PATCH 3/5] feat: Add Poweroff action - Moves ServerChangeListener to use a Switch - Moves action sending + tracking logic to a shared method - Adds powerOff method on Server --- .../hcloud/listener/ServerChangeListener.java | 40 ++++++++++++++----- .../tomr/hcloud/resources/server/Server.java | 9 ++++- .../tomr/hcloud/service/action/Action.java | 12 ++++++ .../hcloud/service/server/ServerService.java | 18 +++++++-- .../hcloud/resources/server/ServerTest.java | 26 ++++++++++++ .../hcloud/service/action/ActionEnumTest.java | 21 ++++++++++ .../service/server/ServerServiceTest.java | 37 +++++++++++++++-- 7 files changed, 144 insertions(+), 19 deletions(-) create mode 100644 src/main/java/dev/tomr/hcloud/service/action/Action.java create mode 100644 src/test/java/dev/tomr/hcloud/service/action/ActionEnumTest.java diff --git a/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java b/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java index 7793581..1ac2543 100644 --- a/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java +++ b/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java @@ -19,16 +19,36 @@ public class ServerChangeListener implements PropertyChangeListener { @Override public void propertyChange(PropertyChangeEvent evt) { Server server = (Server) evt.getSource(); - if (evt.getPropertyName().equals("delete")) { - logger.warn("Server delete has been called. Instructing Hetzner to delete"); - HetznerCloud.getInstance().getServiceManager().getServerService().deleteServerFromHetzner(server); - } else if (evt.getPropertyName().equals("shutdown")) { - logger.info("Server shutdown has been called. Instructing Hetzner to shut the server down"); - HetznerCloud.getInstance().getServiceManager().getServerService().shutdownServer(server); - } else { - logger.info("Server changed: " + evt.getPropertyName()); - logger.info("Server: " + evt.getOldValue() + " -> " + evt.getNewValue()); - HetznerCloud.getInstance().getServiceManager().getServerService().serverNameOrLabelUpdate(evt.getPropertyName(), evt.getNewValue(), server); + String propertyName = evt.getPropertyName(); + + switch (propertyName) { + case "delete" -> { + logger.warn("Server delete has been called. Instructing Hetzner to delete"); + HetznerCloud.getInstance().getServiceManager().getServerService().deleteServerFromHetzner(server); + } + case "shutdown" -> { + logger.info("Server shutdown has been called. Instructing Hetzner to shut the server down"); + HetznerCloud.getInstance().getServiceManager().getServerService().shutdownServer(server); + } + case "poweroff" -> { + logger.info("Server poweroff has been called. Instructing Hetzner to power down the server"); + logger.warn("This is a potentially destructive action!"); + HetznerCloud.getInstance().getServiceManager().getServerService().powerOffServer(server); + } + default -> { + logger.info("Server changed: {}", evt.getPropertyName()); + logger.info("Server: {} -> {}", evt.getOldValue(), evt.getNewValue()); + HetznerCloud.getInstance().getServiceManager().getServerService().serverNameOrLabelUpdate(evt.getPropertyName(), evt.getNewValue(), server); + } } +// if (evt.getPropertyName().equals("delete")) { +// +// } else if (evt.getPropertyName().equals("shutdown")) { +// +// } else if (evt.getPropertyName().equals("poweroff")) { +// +// } else { +// +// } } } 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 0c8d17f..34c6566 100644 --- a/src/main/java/dev/tomr/hcloud/resources/server/Server.java +++ b/src/main/java/dev/tomr/hcloud/resources/server/Server.java @@ -107,12 +107,19 @@ public void delete() { } /** - * Sends a Shutdown signal to the server, which will instruct the OS to shut it down. Note that if you **must** ensure the server is completely offline, you should also call .powerOff() to ensure the 'plug is pulled' + * Sends a Shutdown signal to the server, which will instruct the OS to shut it down. Note that if you must> ensure the server is completely offline, you should also call .powerOff() to ensure the 'plug is pulled' */ public void shutdown() { propertyChangeSupport.firePropertyChange("shutdown", null, null); } + /** + * Sends a command to Power off the server. This is essentially 'pulling the plug' and could be destructive if programs are still running on the server. Only use if absolutely necessary + */ + public void powerOff() { + propertyChangeSupport.firePropertyChange("poweroff", 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 new file mode 100644 index 0000000..a9214fc --- /dev/null +++ b/src/main/java/dev/tomr/hcloud/service/action/Action.java @@ -0,0 +1,12 @@ +package dev.tomr.hcloud.service.action; + +public enum Action { + SHUTDOWN("shutdown"), + POWEROFF("poweroff"); + + public final String path; + + Action(String path) { + this.path = 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 03200f6..9a36a2c 100644 --- a/src/main/java/dev/tomr/hcloud/service/server/ServerService.java +++ b/src/main/java/dev/tomr/hcloud/service/server/ServerService.java @@ -25,6 +25,8 @@ import java.util.concurrent.atomic.AtomicReference; import static dev.tomr.hcloud.http.RequestVerb.*; +import static dev.tomr.hcloud.service.action.Action.POWEROFF; +import static dev.tomr.hcloud.service.action.Action.SHUTDOWN; public class ServerService { protected static final Logger logger = LogManager.getLogger(); @@ -127,8 +129,16 @@ public void deleteServerFromHetzner(Server server) { } public void shutdownServer(Server server) { + sendServerAction(server, SHUTDOWN); + } + + public void powerOffServer(Server server) { + sendServerAction(server, POWEROFF); + } + + private void sendServerAction(Server server, dev.tomr.hcloud.service.action.Action givenAction) { List hostAndKey = HetznerCloud.getInstance().getHttpDetails(); - String httpUrl = String.format("%sservers/%d/actions/poweroff", hostAndKey.get(0), server.getId()); + 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(); @@ -136,10 +146,10 @@ public void shutdownServer(Server server) { if (completedAction == null) { throw new NullPointerException(); } - logger.info("Server shutdown at {}", completedAction.getFinished()); + logger.info("Server {} at {}", givenAction.toString(), completedAction.getFinished()); return completedAction; }).exceptionally((e) -> { - logger.error("Server shutdown failed"); + logger.error("Server {} failed", givenAction.toString()); logger.error(e.getMessage()); exceptionMsg.set(e.getMessage()); return null; @@ -148,7 +158,7 @@ public void shutdownServer(Server server) { throw new RuntimeException(exceptionMsg.get()); } } catch (Exception e) { - logger.error("Failed to shutdown the Server"); + logger.error("Failed to {} the Server", givenAction.toString()); throw new RuntimeException(e); } } 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 fd9c778..f94cb45 100644 --- a/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java +++ b/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java @@ -248,5 +248,31 @@ void callingShutdownSendsAnEventToTheServerChangeListener() { } + @Test + @DisplayName("calling poweroff sends an event to the ServerChangeListener") + void callingPoweroffSendsAnEventToTheServerChangeListener() { + 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.powerOff(); + verify(serverChangeListener, times(1)).propertyChange(captor.capture()); + assertEquals("poweroff", captor.getValue().getPropertyName()); + } + + } + } \ No newline at end of file diff --git a/src/test/java/dev/tomr/hcloud/service/action/ActionEnumTest.java b/src/test/java/dev/tomr/hcloud/service/action/ActionEnumTest.java new file mode 100644 index 0000000..240a2ae --- /dev/null +++ b/src/test/java/dev/tomr/hcloud/service/action/ActionEnumTest.java @@ -0,0 +1,21 @@ +package dev.tomr.hcloud.service.action; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ActionEnumTest { + + @Test + @DisplayName("SHUTDOWN enum returns 'shutdown' for the path") + void shutdown() { + assertEquals("shutdown", Action.SHUTDOWN.path); + } + + @Test + @DisplayName("POWEROFF enum returns 'poweroff' for the path") + void poweroff() { + assertEquals("poweroff", Action.POWEROFF.path); + } +} 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 6cfcc60..cf514f2 100644 --- a/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java +++ b/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java @@ -12,10 +12,7 @@ import dev.tomr.hcloud.resources.server.Server; import dev.tomr.hcloud.service.ServiceManager; import dev.tomr.hcloud.service.action.ActionService; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -761,4 +758,36 @@ void whenShutdownActionReturnedFromHetznerIsNullServerServiceThrowsANullPointer( } } + @Test + @DisplayName("Poweroff Server calls Hetzner and tracks the action") + void powerOffServerCallsHetznerAndTracksTheAction() 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::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.powerOffServer(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 920c7eb5e1701187591ae09ae731d24bb99277d6 Mon Sep 17 00:00:00 2001 From: Tom Roman Date: Thu, 2 Jan 2025 18:42:55 +0000 Subject: [PATCH 4/5] feat: Add PowerOn action for server --- .../hcloud/listener/ServerChangeListener.java | 13 +++----- .../tomr/hcloud/resources/server/Server.java | 7 +++++ .../tomr/hcloud/service/action/Action.java | 3 +- .../hcloud/service/server/ServerService.java | 7 +++-- .../hcloud/resources/server/ServerTest.java | 24 ++++++++++++++ .../service/server/ServerServiceTest.java | 31 +++++++++++++++++++ 6 files changed, 73 insertions(+), 12 deletions(-) diff --git a/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java b/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java index 1ac2543..783cdaf 100644 --- a/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java +++ b/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java @@ -35,20 +35,15 @@ public void propertyChange(PropertyChangeEvent evt) { logger.warn("This is a potentially destructive action!"); HetznerCloud.getInstance().getServiceManager().getServerService().powerOffServer(server); } + case "poweron" -> { + logger.info("Server power on has been called. Instructing Hetzner to power up the server"); + HetznerCloud.getInstance().getServiceManager().getServerService().powerOnServer(server); + } default -> { logger.info("Server changed: {}", evt.getPropertyName()); logger.info("Server: {} -> {}", evt.getOldValue(), evt.getNewValue()); HetznerCloud.getInstance().getServiceManager().getServerService().serverNameOrLabelUpdate(evt.getPropertyName(), evt.getNewValue(), server); } } -// if (evt.getPropertyName().equals("delete")) { -// -// } else if (evt.getPropertyName().equals("shutdown")) { -// -// } else if (evt.getPropertyName().equals("poweroff")) { -// -// } else { -// -// } } } 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 34c6566..b5b4b7d 100644 --- a/src/main/java/dev/tomr/hcloud/resources/server/Server.java +++ b/src/main/java/dev/tomr/hcloud/resources/server/Server.java @@ -120,6 +120,13 @@ public void powerOff() { propertyChangeSupport.firePropertyChange("poweroff", null, null); } + /** + * Starts the Server by turning it's power on + */ + public void powerOn() { + propertyChangeSupport.firePropertyChange("poweron", 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 a9214fc..18ed1a2 100644 --- a/src/main/java/dev/tomr/hcloud/service/action/Action.java +++ b/src/main/java/dev/tomr/hcloud/service/action/Action.java @@ -2,7 +2,8 @@ public enum Action { SHUTDOWN("shutdown"), - POWEROFF("poweroff"); + POWEROFF("poweroff"), + POWERON("poweron"); 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 9a36a2c..47f3f0a 100644 --- a/src/main/java/dev/tomr/hcloud/service/server/ServerService.java +++ b/src/main/java/dev/tomr/hcloud/service/server/ServerService.java @@ -25,8 +25,7 @@ import java.util.concurrent.atomic.AtomicReference; import static dev.tomr.hcloud.http.RequestVerb.*; -import static dev.tomr.hcloud.service.action.Action.POWEROFF; -import static dev.tomr.hcloud.service.action.Action.SHUTDOWN; +import static dev.tomr.hcloud.service.action.Action.*; public class ServerService { protected static final Logger logger = LogManager.getLogger(); @@ -136,6 +135,10 @@ public void powerOffServer(Server server) { sendServerAction(server, POWEROFF); } + public void powerOnServer(Server server) { + sendServerAction(server, POWERON); + } + private void sendServerAction(Server server, dev.tomr.hcloud.service.action.Action givenAction) { 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 f94cb45..89dad2a 100644 --- a/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java +++ b/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java @@ -274,5 +274,29 @@ void callingPoweroffSendsAnEventToTheServerChangeListener() { } + @Test + @DisplayName("calling poweron sends an event to the ServerChangeListener") + void callingPowerOnSendsAnEventToTheServerChangeListener() { + 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.powerOn(); + verify(serverChangeListener, times(1)).propertyChange(captor.capture()); + assertEquals("poweron", 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 cf514f2..bcfe6c6 100644 --- a/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java +++ b/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java @@ -790,4 +790,35 @@ void powerOffServerCallsHetznerAndTracksTheAction() throws IOException, Interrup } } + @Test + @DisplayName("PowerOn Server calls Hetzner and tracks the action") + void powerOnServerCallsHetznerAndTracksTheAction() 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::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.powerOnServer(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 e8861faddbca02f6328cc1d2592eb762b931fc3c Mon Sep 17 00:00:00 2001 From: Tom Roman Date: Thu, 2 Jan 2025 18:53:47 +0000 Subject: [PATCH 5/5] feat: Add Reboot & Reset action to server --- .../hcloud/listener/ServerChangeListener.java | 9 +++ .../tomr/hcloud/resources/server/Server.java | 18 +++++- .../tomr/hcloud/service/action/Action.java | 4 +- .../hcloud/service/server/ServerService.java | 8 +++ .../hcloud/resources/server/ServerTest.java | 53 +++++++++++++-- .../hcloud/service/action/ActionEnumTest.java | 12 ++++ .../service/server/ServerServiceTest.java | 64 +++++++++++++++++++ 7 files changed, 161 insertions(+), 7 deletions(-) diff --git a/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java b/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java index 783cdaf..af85a78 100644 --- a/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java +++ b/src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java @@ -39,6 +39,15 @@ public void propertyChange(PropertyChangeEvent evt) { logger.info("Server power on has been called. Instructing Hetzner to power up the server"); HetznerCloud.getInstance().getServiceManager().getServerService().powerOnServer(server); } + case "reboot" -> { + logger.info("Server reboot has been called. Instructing Hetzner to reboot the server"); + HetznerCloud.getInstance().getServiceManager().getServerService().rebootServer(server); + } + case "reset" -> { + logger.info("Server reset has been called. Instructing Hetzner to reset the server"); + logger.warn("This is a potentially destructive action!"); + HetznerCloud.getInstance().getServiceManager().getServerService().resetServer(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 b5b4b7d..725c481 100644 --- a/src/main/java/dev/tomr/hcloud/resources/server/Server.java +++ b/src/main/java/dev/tomr/hcloud/resources/server/Server.java @@ -107,7 +107,8 @@ public void delete() { } /** - * Sends a Shutdown signal to the server, which will instruct the OS to shut it down. Note that if you must> ensure the server is completely offline, you should also call .powerOff() to ensure the 'plug is pulled' + * Sends a Shutdown request to the server by sending an ACPI request, which will instruct the OS to shut it down. Note that if you must> ensure the server is completely offline, you should also call .powerOff() to ensure the 'plug is pulled'. + * The server OS must support ACPI */ public void shutdown() { propertyChangeSupport.firePropertyChange("shutdown", null, null); @@ -121,12 +122,25 @@ public void powerOff() { } /** - * Starts the Server by turning it's power on + * Starts the Server by turning its power on */ public void powerOn() { propertyChangeSupport.firePropertyChange("poweron", null, null); } + /** + * Sends a reboot request to the server by sending an ACPI request. The server OS must support ACPI + */ + public void reboot() { + propertyChangeSupport.firePropertyChange("reboot", null, null); + } + + /** + * Cuts power to the server and starts it again. Forcefully stops the server without giving the OS time to shut down. Should only be used if reboot does not work. + */ + public void reset() { + propertyChangeSupport.firePropertyChange("reset", 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 18ed1a2..18a757b 100644 --- a/src/main/java/dev/tomr/hcloud/service/action/Action.java +++ b/src/main/java/dev/tomr/hcloud/service/action/Action.java @@ -3,7 +3,9 @@ public enum Action { SHUTDOWN("shutdown"), POWEROFF("poweroff"), - POWERON("poweron"); + POWERON("poweron"), + REBOOT("reboot"), + RESET("reset"),; 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 47f3f0a..5fc097f 100644 --- a/src/main/java/dev/tomr/hcloud/service/server/ServerService.java +++ b/src/main/java/dev/tomr/hcloud/service/server/ServerService.java @@ -139,6 +139,14 @@ public void powerOnServer(Server server) { sendServerAction(server, POWERON); } + public void rebootServer(Server server) { + sendServerAction(server, REBOOT); + } + + public void resetServer(Server server) { + sendServerAction(server, RESET); + } + private void sendServerAction(Server server, dev.tomr.hcloud.service.action.Action givenAction) { 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 89dad2a..a8ff57e 100644 --- a/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java +++ b/src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java @@ -193,7 +193,6 @@ void callingSetNameSendsAnEventToTheServerChangeListener() { assertNull(captor.getValue().getOldValue()); assertEquals("test", captor.getValue().getNewValue()); } - } @Test @@ -219,7 +218,6 @@ void callingDeleteSendsAnEventToTheServerChangeListener() { verify(serverChangeListener, times(1)).propertyChange(captor.capture()); assertEquals("delete", captor.getValue().getPropertyName()); } - } @Test @@ -245,7 +243,6 @@ void callingShutdownSendsAnEventToTheServerChangeListener() { verify(serverChangeListener, times(1)).propertyChange(captor.capture()); assertEquals("shutdown", captor.getValue().getPropertyName()); } - } @Test @@ -271,7 +268,6 @@ void callingPoweroffSendsAnEventToTheServerChangeListener() { verify(serverChangeListener, times(1)).propertyChange(captor.capture()); assertEquals("poweroff", captor.getValue().getPropertyName()); } - } @Test @@ -297,6 +293,55 @@ void callingPowerOnSendsAnEventToTheServerChangeListener() { verify(serverChangeListener, times(1)).propertyChange(captor.capture()); assertEquals("poweron", captor.getValue().getPropertyName()); } + } + @Test + @DisplayName("calling reboot sends an event to the ServerChangeListener") + void callingRebootSendsAnEventToTheServerChangeListener() { + 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.reboot(); + verify(serverChangeListener, times(1)).propertyChange(captor.capture()); + assertEquals("reboot", captor.getValue().getPropertyName()); + } } + + @Test + @DisplayName("calling reset sends an event to the ServerChangeListener") + void callingResetSendsAnEventToTheServerChangeListener() { + 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.reset(); + verify(serverChangeListener, times(1)).propertyChange(captor.capture()); + assertEquals("reset", captor.getValue().getPropertyName()); + } + } + } \ No newline at end of file diff --git a/src/test/java/dev/tomr/hcloud/service/action/ActionEnumTest.java b/src/test/java/dev/tomr/hcloud/service/action/ActionEnumTest.java index 240a2ae..34af472 100644 --- a/src/test/java/dev/tomr/hcloud/service/action/ActionEnumTest.java +++ b/src/test/java/dev/tomr/hcloud/service/action/ActionEnumTest.java @@ -18,4 +18,16 @@ void shutdown() { void poweroff() { assertEquals("poweroff", Action.POWEROFF.path); } + + @Test + @DisplayName("POWERON enum returns 'poweron' for the path") + void poweron() { + assertEquals("poweron", Action.POWERON.path); + } + + @Test + @DisplayName("REBOOT enum returns 'reboot' for the path") + void reboot() { + assertEquals("reboot", Action.REBOOT.path); + } } 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 bcfe6c6..05189f7 100644 --- a/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java +++ b/src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java @@ -821,4 +821,68 @@ void powerOnServerCallsHetznerAndTracksTheAction() throws IOException, Interrupt verify(actionService, times(1)).waitForActionToComplete(any(Action.class)); } } + + @Test + @DisplayName("Reboot Server calls Hetzner and tracks the action") + void RebootServerCallsHetznerAndTracksTheAction() 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::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.rebootServer(new Server()); + + verify(hetznerCloudHttpClient, times(1)).sendHttpRequest(any(), anyString(), eq(RequestVerb.POST), eq("key1234"), eq("")); + verify(actionService, times(1)).waitForActionToComplete(any(Action.class)); + } + } + + @Test + @DisplayName("Reset Server calls Hetzner and tracks the action") + void ResetServerCallsHetznerAndTracksTheAction() 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::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.resetServer(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