Skip to content

Commit 956e2a2

Browse files
committed
feat: Add supporting action service
- Checks and monitors an action completing - Needs an accompanying notification service, to let a program be told it has happened (not just logs) - Also needs tests
1 parent 1736f88 commit 956e2a2

File tree

8 files changed

+190
-5
lines changed

8 files changed

+190
-5
lines changed

src/main/java/dev/tomr/hcloud/HetznerCloud.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package dev.tomr.hcloud;
22

3+
import com.fasterxml.jackson.databind.MapperFeature;
34
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.fasterxml.jackson.databind.json.JsonMapper;
46
import dev.tomr.hcloud.listener.ListenerManager;
57
import dev.tomr.hcloud.resources.server.Server;
68
import dev.tomr.hcloud.service.ServiceManager;
@@ -15,7 +17,7 @@
1517
public class HetznerCloud {
1618
protected static final Logger logger = LogManager.getLogger();
1719

18-
private static final ObjectMapper objectMapper = new ObjectMapper();
20+
private static final ObjectMapper objectMapper = JsonMapper.builder().configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true).build();
1921
private static final String HETZNER_CLOUD_HOST = "https://api.hetzner.cloud/v1/";
2022

2123
private static HetznerCloud instance;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package dev.tomr.hcloud.http.model;
2+
3+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4+
import dev.tomr.hcloud.http.HetznerJsonObject;
5+
6+
@JsonIgnoreProperties(ignoreUnknown = true)
7+
public class ActionWrapper extends HetznerJsonObject {
8+
private Action action;
9+
10+
public ActionWrapper() {
11+
}
12+
13+
public ActionWrapper(Action action) {
14+
this.action = action;
15+
}
16+
17+
public Action getAction() {
18+
return action;
19+
}
20+
21+
public void setAction(Action action) {
22+
this.action = action;
23+
}
24+
}

src/main/java/dev/tomr/hcloud/service/ServiceManager.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package dev.tomr.hcloud.service;
22

3+
import dev.tomr.hcloud.service.action.ActionService;
34
import dev.tomr.hcloud.service.server.ServerService;
45

56
import java.util.concurrent.ExecutorService;
@@ -9,12 +10,14 @@ public class ServiceManager {
910
private static ServiceManager instance;
1011

1112
private final ServerService serverService;
13+
private final ActionService actionService;
1214

1315
private ExecutorService executor;
1416

1517
private ServiceManager() {
1618
instance = this;
1719
this.serverService = new ServerService(this);
20+
this.actionService = new ActionService();
1821
}
1922

2023
/**
@@ -36,6 +39,14 @@ public ServerService getServerService() {
3639
return serverService;
3740
}
3841

42+
/**
43+
* Get ActionService Instance
44+
* @return the {@code ActionService} instance
45+
*/
46+
public ActionService getActionService() {
47+
return actionService;
48+
}
49+
3950
/**
4051
* Get an Executor for threaded tasks
4152
* @return The Existing or a new {@code ExecutorService}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package dev.tomr.hcloud.service.action;
2+
3+
import dev.tomr.hcloud.HetznerCloud;
4+
import dev.tomr.hcloud.http.HetznerCloudHttpClient;
5+
import dev.tomr.hcloud.http.RequestVerb;
6+
import dev.tomr.hcloud.http.model.Action;
7+
import dev.tomr.hcloud.http.model.ActionWrapper;
8+
import org.apache.logging.log4j.LogManager;
9+
import org.apache.logging.log4j.Logger;
10+
11+
import java.util.ArrayList;
12+
import java.util.List;
13+
import java.util.concurrent.*;
14+
import java.util.concurrent.atomic.AtomicInteger;
15+
import java.util.concurrent.atomic.AtomicReference;
16+
17+
public class ActionService {
18+
protected static final Logger logger = LogManager.getLogger();
19+
20+
private final HetznerCloudHttpClient client = HetznerCloudHttpClient.getInstance();
21+
22+
public ActionService() {
23+
}
24+
25+
public CompletableFuture<Action> waitForActionToComplete(Action action) {
26+
return CompletableFuture.supplyAsync(() -> {
27+
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
28+
List<String> hostAndKey = HetznerCloud.getInstance().getHttpDetails();
29+
List<Future<Action>> futures = new ArrayList<>();
30+
AtomicReference<Action> completedAction = new AtomicReference<>();
31+
futures.add(scheduler.schedule(createCheckCallable(action, hostAndKey), 0, TimeUnit.MILLISECONDS));
32+
33+
try {
34+
for (int i = 0; i < futures.size(); i++) {
35+
Future<Action> future = futures.get(i);
36+
if (future.isDone()) {
37+
if (future.get() != null) {
38+
completedAction.set(future.get());
39+
} else {
40+
futures.add(scheduler.schedule(createCheckCallable(action, hostAndKey), 1000L * (i + 1), TimeUnit.MILLISECONDS));
41+
}
42+
} else {
43+
i--;
44+
}
45+
}
46+
} catch (Exception e) {
47+
throw new RuntimeException(e);
48+
}
49+
futures.forEach((f) -> {
50+
f.cancel(true);
51+
});
52+
return completedAction.get();
53+
});
54+
}
55+
56+
private Callable<Action> createCheckCallable(Action action, List<String> hostAndKey) {
57+
String url = String.format("%sactions/%d", hostAndKey.get(0), action.getId());
58+
59+
return () -> {
60+
try {
61+
Action newAction = client.sendHttpRequest(ActionWrapper.class, url, RequestVerb.GET, hostAndKey.get(1)).getAction();
62+
if (newAction.getError() != null) {
63+
throw new Exception(String.format("Error from Hetzner: %s, %s", newAction.getError().getMessage(), newAction.getError().getCode()));
64+
}else if (newAction.getProgress() == 100 && newAction.getFinished() != null) {
65+
return newAction;
66+
} else {
67+
return null;
68+
}
69+
} catch (Exception e) {
70+
throw new RuntimeException(e);
71+
}
72+
};
73+
}
74+
}

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import dev.tomr.hcloud.http.HetznerCloudHttpClient;
55
import dev.tomr.hcloud.http.converter.ServerConverterUtil;
66
import dev.tomr.hcloud.http.model.Action;
7+
import dev.tomr.hcloud.http.model.ActionWrapper;
78
import dev.tomr.hcloud.http.model.ServerDTO;
89
import dev.tomr.hcloud.http.model.ServerDTOList;
910
import dev.tomr.hcloud.resources.server.Server;
@@ -22,6 +23,7 @@
2223
import java.util.concurrent.ConcurrentHashMap;
2324
import java.util.concurrent.TimeUnit;
2425
import java.util.concurrent.atomic.AtomicReference;
26+
import java.util.function.Function;
2527

2628
import static dev.tomr.hcloud.http.RequestVerb.*;
2729

@@ -101,10 +103,25 @@ public void cancelServerNameOrLabelUpdate() {
101103
public void deleteServerFromHetzner(Server server) {
102104
List<String> hostAndKey = HetznerCloud.getInstance().getHttpDetails();
103105
String httpUrl = String.format("%sservers/%d", hostAndKey.get(0), server.getId());
106+
AtomicReference<String> exceptionMsg = new AtomicReference<>();
104107
try {
105-
Action action = client.sendHttpRequest(Action.class, httpUrl, DELETE, hostAndKey.get(1));
106-
107-
} catch (IOException | IllegalAccessException | InterruptedException e) {
108+
Action action = client.sendHttpRequest(ActionWrapper.class, httpUrl, DELETE, hostAndKey.get(1)).getAction();
109+
CompletableFuture<Action> completedActionFuture = serviceManager.getActionService().waitForActionToComplete(action).thenApplyAsync((completedAction) -> {
110+
if (action == null) {
111+
throw new NullPointerException();
112+
}
113+
logger.info("Server confirmed deleted at {}", completedAction.getFinished());
114+
return completedAction;
115+
}).exceptionally((e) -> {
116+
logger.error("Server delete failed");
117+
logger.error(e.getMessage());
118+
exceptionMsg.set(e.getMessage());
119+
return null;
120+
});
121+
if (completedActionFuture.get() == null) {
122+
throw new RuntimeException(exceptionMsg.get());
123+
}
124+
} catch (Exception e) {
108125
logger.error("Failed to delete the Server");
109126
throw new RuntimeException(e);
110127
}

src/test/java/dev/tomr/hcloud/service/ServiceManagerTest.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package dev.tomr.hcloud.service;
22

3+
import dev.tomr.hcloud.service.action.ActionService;
34
import dev.tomr.hcloud.service.server.ServerService;
45
import org.junit.jupiter.api.BeforeEach;
56
import org.junit.jupiter.api.DisplayName;
@@ -76,4 +77,12 @@ void callingCloseExecutorDoesNothingIfExecutorIsNull() {
7677
assertDoesNotThrow(serviceManager::closeExecutor);
7778
}
7879

80+
@Test
81+
@DisplayName("Calling getActionService returns the ActionService instance")
82+
void callingGetActionServiceReturnsTheActionServiceInstance() {
83+
ServiceManager serviceManager = ServiceManager.getInstance();
84+
assertInstanceOf(ActionService.class, serviceManager.getActionService());
85+
assertNotNull(serviceManager.getActionService());
86+
}
87+
7988
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package dev.tomr.hcloud.service.action;
2+
3+
public class ActionServiceTest {
4+
}

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

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import dev.tomr.hcloud.resources.common.*;
1111
import dev.tomr.hcloud.resources.server.Server;
1212
import dev.tomr.hcloud.service.ServiceManager;
13+
import dev.tomr.hcloud.service.action.ActionService;
1314
import org.junit.jupiter.api.AfterEach;
1415
import org.junit.jupiter.api.BeforeEach;
1516
import org.junit.jupiter.api.DisplayName;
@@ -25,6 +26,8 @@
2526
import java.util.Date;
2627
import java.util.List;
2728
import java.util.Map;
29+
import java.util.concurrent.CompletableFuture;
30+
import java.util.concurrent.TimeoutException;
2831

2932
import static org.junit.jupiter.api.Assertions.*;
3033
import static org.mockito.Mockito.*;
@@ -549,21 +552,62 @@ void deleteServerFromHetznerAndTracksTheAction() throws IOException, Interrupted
549552
HetznerCloud hetznerCloud = mock(HetznerCloud.class);
550553
HetznerCloudHttpClient hetznerCloudHttpClient = mock(HetznerCloudHttpClient.class);
551554
ListenerManager listenerManager = mock(ListenerManager.class);
555+
ServiceManager serviceManager = mock(ServiceManager.class);
556+
ActionService actionService = mock(ActionService.class);
552557

553558
try (MockedStatic<HetznerCloud> hetznerCloudMockedStatic = mockStatic(HetznerCloud.class);
554559
MockedStatic<HetznerCloudHttpClient> hetznerCloudHttpClientMockedStatic = mockStatic(HetznerCloudHttpClient.class)) {
555560

561+
Action action = new Action();
562+
action.setFinished(Date.from(Instant.now()).toString());
563+
556564
hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient);
557565
hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud);
558566
when(hetznerCloud.getListenerManager()).thenReturn(listenerManager);
559567
when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234"));
568+
when(serviceManager.getActionService()).thenReturn(actionService);
569+
when(actionService.waitForActionToComplete(any(Action.class))).thenReturn(CompletableFuture.completedFuture(action));
560570

561571
when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString())).thenReturn(new Action());
562572

563-
ServerService serverService = new ServerService();
573+
ServerService serverService = new ServerService(serviceManager);
564574
serverService.deleteServerFromHetzner(new Server());
565575

566576
verify(hetznerCloudHttpClient, times(1)).sendHttpRequest(any(), anyString(), eq(RequestVerb.DELETE), eq("key1234"));
577+
verify(actionService, times(1)).waitForActionToComplete(any(Action.class));
578+
}
579+
}
580+
581+
@Test
582+
@DisplayName("When actionService throws, then delete Server from Hetzner also throws a Runtime exception")
583+
void whenActionServiceThrowsDeleteServerAlsoThrows() throws IOException, InterruptedException, IllegalAccessException {
584+
HetznerCloud hetznerCloud = mock(HetznerCloud.class);
585+
HetznerCloudHttpClient hetznerCloudHttpClient = mock(HetznerCloudHttpClient.class);
586+
ListenerManager listenerManager = mock(ListenerManager.class);
587+
ServiceManager serviceManager = mock(ServiceManager.class);
588+
ActionService actionService = mock(ActionService.class);
589+
590+
try (MockedStatic<HetznerCloud> hetznerCloudMockedStatic = mockStatic(HetznerCloud.class);
591+
MockedStatic<HetznerCloudHttpClient> hetznerCloudHttpClientMockedStatic = mockStatic(HetznerCloudHttpClient.class)) {
592+
593+
hetznerCloudHttpClientMockedStatic.when(HetznerCloudHttpClient::getInstance).thenReturn(hetznerCloudHttpClient);
594+
hetznerCloudMockedStatic.when(HetznerCloud::getInstance).thenReturn(hetznerCloud);
595+
when(hetznerCloud.getListenerManager()).thenReturn(listenerManager);
596+
when(hetznerCloud.getHttpDetails()).thenReturn(List.of("http://host/", "key1234"));
597+
when(serviceManager.getActionService()).thenReturn(actionService);
598+
599+
when(actionService.waitForActionToComplete(any(Action.class))).thenReturn(CompletableFuture.failedFuture(new RuntimeException(new TimeoutException())));
600+
601+
when(hetznerCloudHttpClient.sendHttpRequest(any(), anyString(), any(RequestVerb.class), anyString())).thenReturn(new Action());
602+
603+
ServerService serverService = new ServerService(serviceManager);
604+
605+
RuntimeException runtimeException = assertThrows(RuntimeException.class, () -> serverService.deleteServerFromHetzner(new Server()));
606+
607+
verify(hetznerCloudHttpClient, times(1)).sendHttpRequest(any(), anyString(), eq(RequestVerb.DELETE), eq("key1234"));
608+
verify(actionService, times(1)).waitForActionToComplete(any(Action.class));
609+
610+
assertTrue(runtimeException.getMessage().contains("TimeoutException"));
567611
}
568612
}
569613

0 commit comments

Comments
 (0)