Skip to content

Commit af9d71b

Browse files
committed
feat: Add Shutdown action
- New method on Server to call shutdown - Uses same action service as the delete action for tracking
1 parent c588985 commit af9d71b

File tree

5 files changed

+158
-2
lines changed

5 files changed

+158
-2
lines changed

src/main/java/dev/tomr/hcloud/listener/ServerChangeListener.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ public void propertyChange(PropertyChangeEvent evt) {
2222
if (evt.getPropertyName().equals("delete")) {
2323
logger.warn("Server delete has been called. Instructing Hetzner to delete");
2424
HetznerCloud.getInstance().getServiceManager().getServerService().deleteServerFromHetzner(server);
25+
} else if (evt.getPropertyName().equals("shutdown")) {
26+
logger.info("Server shutdown has been called. Instructing Hetzner to shut the server down");
27+
HetznerCloud.getInstance().getServiceManager().getServerService().shutdownServer(server);
2528
} else {
2629
logger.info("Server changed: " + evt.getPropertyName());
2730
logger.info("Server: " + evt.getOldValue() + " -> " + evt.getNewValue());

src/main/java/dev/tomr/hcloud/resources/server/Server.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,22 @@ private void setupPropertyChangeListener() {
9797
propertyChangeSupport.addPropertyChangeListener(HetznerCloud.getInstance().getListenerManager().getServerChangeListener());
9898
}
9999

100+
// The following methods are for calling Actions on the server
101+
100102
/**
101103
* Deletes a Server from Hetzner. Note, this is immediate and destructive. Ensure you want to delete the server before calling.
102104
*/
103105
public void delete() {
104106
propertyChangeSupport.firePropertyChange("delete", null, null);
105107
}
106108

109+
/**
110+
* 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'
111+
*/
112+
public void shutdown() {
113+
propertyChangeSupport.firePropertyChange("shutdown", null, null);
114+
}
115+
107116

108117
// 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
109118

src/main/java/dev/tomr/hcloud/service/server/ServerService.java

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
import java.util.concurrent.ConcurrentHashMap;
2424
import java.util.concurrent.TimeUnit;
2525
import java.util.concurrent.atomic.AtomicReference;
26-
import java.util.function.Function;
2726

2827
import static dev.tomr.hcloud.http.RequestVerb.*;
2928

@@ -127,6 +126,33 @@ public void deleteServerFromHetzner(Server server) {
127126
}
128127
}
129128

129+
public void shutdownServer(Server server) {
130+
List<String> hostAndKey = HetznerCloud.getInstance().getHttpDetails();
131+
String httpUrl = String.format("%sservers/%d/actions/poweroff", hostAndKey.get(0), server.getId());
132+
AtomicReference<String> exceptionMsg = new AtomicReference<>();
133+
try {
134+
Action action = client.sendHttpRequest(ActionWrapper.class, httpUrl, POST, hostAndKey.get(1), "").getAction();
135+
CompletableFuture<Action> completedActionFuture = serviceManager.getActionService().waitForActionToComplete(action).thenApplyAsync((completedAction) -> {
136+
if (completedAction == null) {
137+
throw new NullPointerException();
138+
}
139+
logger.info("Server shutdown at {}", completedAction.getFinished());
140+
return completedAction;
141+
}).exceptionally((e) -> {
142+
logger.error("Server shutdown failed");
143+
logger.error(e.getMessage());
144+
exceptionMsg.set(e.getMessage());
145+
return null;
146+
});
147+
if (completedActionFuture.get() == null) {
148+
throw new RuntimeException(exceptionMsg.get());
149+
}
150+
} catch (Exception e) {
151+
logger.error("Failed to shutdown the Server");
152+
throw new RuntimeException(e);
153+
}
154+
}
155+
130156
private void updateAllRemoteServers() {
131157
Map<Date, Server> newServerMap = new HashMap<>();
132158
List<String> hostAndKey = HetznerCloud.getInstance().getHttpDetails();

src/test/java/dev/tomr/hcloud/resources/server/ServerTest.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,5 +222,31 @@ void callingDeleteSendsAnEventToTheServerChangeListener() {
222222

223223
}
224224

225+
@Test
226+
@DisplayName("calling shutdown sends an event to the ServerChangeListener")
227+
void callingShutdownSendsAnEventToTheServerChangeListener() {
228+
try (MockedStatic<HetznerCloud> hetznerCloud = mockStatic(HetznerCloud.class)) {
229+
HetznerCloud hetznerCloudMock = mock(HetznerCloud.class);
230+
ServerChangeListener scl = new ServerChangeListener();
231+
ServerChangeListener serverChangeListener = spy(scl);
232+
ListenerManager listenerManager = mock(ListenerManager.class);
233+
ServiceManager serviceManager = mock(ServiceManager.class);
234+
ServerService serverService = mock(ServerService.class);
235+
ArgumentCaptor<PropertyChangeEvent> captor = ArgumentCaptor.forClass(PropertyChangeEvent.class);
236+
237+
hetznerCloud.when(HetznerCloud::getInstance).thenReturn(hetznerCloudMock);
238+
when(hetznerCloudMock.getListenerManager()).thenReturn(listenerManager);
239+
when(hetznerCloudMock.getServiceManager()).thenReturn(serviceManager);
240+
when(listenerManager.getServerChangeListener()).thenReturn(serverChangeListener);
241+
when(serviceManager.getServerService()).thenReturn(serverService);
242+
243+
Server server = new Server();
244+
server.shutdown();
245+
verify(serverChangeListener, times(1)).propertyChange(captor.capture());
246+
assertEquals("shutdown", captor.getValue().getPropertyName());
247+
}
248+
249+
}
250+
225251

226252
}

src/test/java/dev/tomr/hcloud/service/server/ServerServiceTest.java

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,7 +610,7 @@ void whenActionServiceThrowsDeleteServerAlsoThrows() throws IOException, Interru
610610
}
611611

612612
@Test
613-
@DisplayName("When Action returned from Hetzner is Null, server service throws a null pointer exception")
613+
@DisplayName("When Delete Action returned from Hetzner is Null, server service throws a null pointer exception")
614614
void whenActionReturnedFromHetznerIsNullServerServiceThrowsANullPointer() throws IOException, InterruptedException, IllegalAccessException {
615615
HetznerCloud hetznerCloud = mock(HetznerCloud.class);
616616
HetznerCloudHttpClient hetznerCloudHttpClient = mock(HetznerCloudHttpClient.class);
@@ -669,4 +669,96 @@ void deleteServerFromHetznerHandlesException() throws IOException, InterruptedEx
669669
}
670670
}
671671

672+
@Test
673+
@DisplayName("Shutdown Server calls Hetzner and tracks the action")
674+
void shutdownServerCallsHetznerAndTracksTheAction() throws IOException, InterruptedException, IllegalAccessException {
675+
HetznerCloud hetznerCloud = mock(HetznerCloud.class);
676+
HetznerCloudHttpClient hetznerCloudHttpClient = mock(HetznerCloudHttpClient.class);
677+
ListenerManager listenerManager = mock(ListenerManager.class);
678+
ServiceManager serviceManager = mock(ServiceManager.class);
679+
ActionService actionService = mock(ActionService.class);
680+
681+
try (MockedStatic<HetznerCloud> hetznerCloudMockedStatic = mockStatic(HetznerCloud.class);
682+
MockedStatic<HetznerCloudHttpClient> hetznerCloudHttpClientMockedStatic = mockStatic(HetznerCloudHttpClient.class)) {
683+
684+
Action action = new Action();
685+
action.setFinished(Date.from(Instant.now()).toString());
686+
687+
hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient);
688+
hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud);
689+
when(hetznerCloud.getListenerManager()).thenReturn(listenerManager);
690+
when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234"));
691+
when(serviceManager.getActionService()).thenReturn(actionService);
692+
when(actionService.waitForActionToComplete(any(Action.class))).thenReturn(CompletableFuture.completedFuture(action));
693+
694+
when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString(), anyString())).thenReturn(new ActionWrapper(action));
695+
696+
ServerService serverService = new ServerService(serviceManager);
697+
serverService.shutdownServer(new Server());
698+
699+
verify(hetznerCloudHttpClient, times(1)).sendHttpRequest(any(), anyString(), eq(RequestVerb.POST), eq("key1234"), eq(""));
700+
verify(actionService, times(1)).waitForActionToComplete(any(Action.class));
701+
}
702+
}
703+
704+
@Test
705+
@DisplayName("When httpclient throws, then shutdown Server also throws a Runtime exception")
706+
void shutdownServerHandlesException() throws IOException, InterruptedException, IllegalAccessException {
707+
HetznerCloud hetznerCloud = mock(HetznerCloud.class);
708+
HetznerCloudHttpClient hetznerCloudHttpClient = mock(HetznerCloudHttpClient.class);
709+
ListenerManager listenerManager = mock(ListenerManager.class);
710+
711+
try (MockedStatic<HetznerCloud> hetznerCloudMockedStatic = mockStatic(HetznerCloud.class);
712+
MockedStatic<HetznerCloudHttpClient> hetznerCloudHttpClientMockedStatic = mockStatic(HetznerCloudHttpClient.class)) {
713+
714+
hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient);
715+
hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud);
716+
when(hetznerCloud.getListenerManager()).thenReturn(listenerManager);
717+
when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234"));
718+
719+
when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString(), anyString())).thenThrow(new IOException());
720+
721+
ServerService serverService = new ServerService();
722+
723+
RuntimeException runtimeException = assertThrows(RuntimeException.class, () -> serverService.shutdownServer(new Server()));
724+
725+
verify(hetznerCloudHttpClient, times(1)).sendHttpRequest(any(), anyString(), eq(RequestVerb.POST), eq("key1234"), eq(""));
726+
727+
assertTrue(runtimeException.getMessage().contains("IOException"));
728+
}
729+
}
730+
731+
@Test
732+
@DisplayName("When Shutdown Action returned from Hetzner is Null, server service throws a null pointer exception")
733+
void whenShutdownActionReturnedFromHetznerIsNullServerServiceThrowsANullPointer() throws IOException, InterruptedException, IllegalAccessException {
734+
HetznerCloud hetznerCloud = mock(HetznerCloud.class);
735+
HetznerCloudHttpClient hetznerCloudHttpClient = mock(HetznerCloudHttpClient.class);
736+
ListenerManager listenerManager = mock(ListenerManager.class);
737+
ServiceManager serviceManager = mock(ServiceManager.class);
738+
ActionService actionService = mock(ActionService.class);
739+
740+
try (MockedStatic<HetznerCloud> hetznerCloudMockedStatic = mockStatic(HetznerCloud.class);
741+
MockedStatic<HetznerCloudHttpClient> hetznerCloudHttpClientMockedStatic = mockStatic(HetznerCloudHttpClient.class)) {
742+
743+
hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient);
744+
hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud);
745+
when(hetznerCloud.getListenerManager()).thenReturn(listenerManager);
746+
when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234"));
747+
when(serviceManager.getActionService()).thenReturn(actionService);
748+
749+
when(actionService.waitForActionToComplete(any(Action.class))).thenReturn(CompletableFuture.completedFuture(null));
750+
751+
when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString(), anyString())).thenReturn(new ActionWrapper(new Action()));
752+
753+
ServerService serverService = new ServerService(serviceManager);
754+
755+
RuntimeException runtimeException = assertThrows(RuntimeException.class, () -> serverService.shutdownServer(new Server()));
756+
757+
verify(hetznerCloudHttpClient, times(1)).sendHttpRequest(any(), anyString(), eq(RequestVerb.POST), eq("key1234"), eq(""));
758+
verify(actionService, times(1)).waitForActionToComplete(any(Action.class));
759+
760+
assertTrue(runtimeException.getMessage().contains("NullPointerException"));
761+
}
762+
}
763+
672764
}

0 commit comments

Comments
 (0)