diff --git a/common/src/main/java/com/hithomelabs/common/portainer/client/PortainerApiClient.java b/common/src/main/java/com/hithomelabs/common/portainer/client/PortainerApiClient.java index aaaa3f5..d62a97d 100644 --- a/common/src/main/java/com/hithomelabs/common/portainer/client/PortainerApiClient.java +++ b/common/src/main/java/com/hithomelabs/common/portainer/client/PortainerApiClient.java @@ -4,6 +4,7 @@ import com.hithomelabs.common.portainer.client.exception.PortainerAuthentication import com.hithomelabs.common.portainer.client.exception.PortainerConnectionException; import com.hithomelabs.common.portainer.client.exception.PortainerDeploymentException; import com.hithomelabs.common.portainer.client.exception.PortainerResourceNotFoundException; +import com.hithomelabs.common.portainer.model.EnvVariable; import com.hithomelabs.common.portainer.model.PortainerAuthRequest; import com.hithomelabs.common.portainer.model.PortainerAuthResponse; import com.hithomelabs.common.portainer.model.PortainerRedeployRequest; @@ -195,11 +196,50 @@ public class PortainerApiClient { } } + /** + * Fetch a single stack by its ID. + */ + public PortainerStack getStack(Long stackId) { + if (stackId == null) { + throw new PortainerDeploymentException("Stack ID must not be null"); + } + HttpEntity entity = buildAuthEntity(); + try { + ResponseEntity response = restTemplate.exchange( + baseUrl + API_STACKS + "/{stackId}", + HttpMethod.GET, + entity, + PortainerStack.class, + stackId); + if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) { + throw new PortainerResourceNotFoundException( + "Stack with ID " + stackId + " not found"); + } + return response.getBody(); + } catch (HttpClientErrorException e) { + if (e.getStatusCode() == HttpStatus.UNAUTHORIZED) { + throw new PortainerAuthenticationException( + "Not authenticated. Please authenticate before fetching stack.", e); + } + if (e.getStatusCode() == HttpStatus.NOT_FOUND) { + throw new PortainerResourceNotFoundException( + "Stack with ID " + stackId + " not found", e); + } + throw new PortainerDeploymentException( + "Failed to fetch stack " + stackId + ": " + e.getStatusCode(), e); + } catch (ResourceAccessException e) { + throw new PortainerConnectionException( + "Unable to connect to Portainer at " + baseUrl + ": " + e.getMessage(), e); + } + } + /** * Redeploy a git-based stack. * Sends a PUT to {@code /api/stacks/{stackId}/git/redeploy?endpointId={endpointId}}. + * + * @param env optional list of environment variables to pass for compose variable substitution */ - public void redeployGitStack(Long stackId, Long endpointId, boolean pullImage) { + public void redeployGitStack(Long stackId, Long endpointId, boolean pullImage, List env) { if (stackId == null) { throw new PortainerDeploymentException("Stack ID must not be null"); } @@ -207,7 +247,7 @@ public class PortainerApiClient { throw new PortainerDeploymentException("Endpoint ID must not be null"); } - PortainerRedeployRequest body = new PortainerRedeployRequest(pullImage, false); + PortainerRedeployRequest body = new PortainerRedeployRequest(pullImage, false, env); HttpEntity entity = new HttpEntity<>(body, buildAuthHeaders()); try { diff --git a/common/src/main/java/com/hithomelabs/common/portainer/model/EnvVariable.java b/common/src/main/java/com/hithomelabs/common/portainer/model/EnvVariable.java new file mode 100644 index 0000000..8fc6aff --- /dev/null +++ b/common/src/main/java/com/hithomelabs/common/portainer/model/EnvVariable.java @@ -0,0 +1,24 @@ +package com.hithomelabs.common.portainer.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class EnvVariable { + + @JsonProperty("name") + private String name; + + @JsonProperty("value") + private String value; + + public EnvVariable() {} + + public EnvVariable(String name, String value) { + this.name = name; + this.value = value; + } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getValue() { return value; } + public void setValue(String value) { this.value = value; } +} diff --git a/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerRedeployRequest.java b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerRedeployRequest.java index 945d63b..3fcbe43 100644 --- a/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerRedeployRequest.java +++ b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerRedeployRequest.java @@ -2,6 +2,8 @@ package com.hithomelabs.common.portainer.model; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + public class PortainerRedeployRequest { @JsonProperty("PullImage") @@ -10,6 +12,9 @@ public class PortainerRedeployRequest { @JsonProperty("Prune") private boolean prune; + @JsonProperty("Env") + private List env; + public PortainerRedeployRequest() {} public PortainerRedeployRequest(boolean pullImage, boolean prune) { @@ -17,6 +22,12 @@ public class PortainerRedeployRequest { this.prune = prune; } + public PortainerRedeployRequest(boolean pullImage, boolean prune, List env) { + this.pullImage = pullImage; + this.prune = prune; + this.env = env; + } + public boolean isPullImage() { return pullImage; } @@ -32,4 +43,12 @@ public class PortainerRedeployRequest { public void setPrune(boolean prune) { this.prune = prune; } + + public List getEnv() { + return env; + } + + public void setEnv(List env) { + this.env = env; + } } diff --git a/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerStack.java b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerStack.java index 5669e5d..fed0c6f 100644 --- a/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerStack.java +++ b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerStack.java @@ -2,6 +2,9 @@ package com.hithomelabs.common.portainer.model; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; + public class PortainerStack { @JsonProperty("Id") @@ -17,7 +20,10 @@ public class PortainerStack { private int status; @JsonProperty("ResourceControl") - private String resourceControl; + private Map resourceControl; + + @JsonProperty("Env") + private List env; public PortainerStack() {} @@ -53,11 +59,19 @@ public class PortainerStack { this.status = status; } - public String getResourceControl() { + public Map getResourceControl() { return resourceControl; } - public void setResourceControl(String resourceControl) { + public void setResourceControl(Map resourceControl) { this.resourceControl = resourceControl; } + + public List getEnv() { + return env; + } + + public void setEnv(List env) { + this.env = env; + } } diff --git a/common/src/test/java/com/hithomelabs/common/portainer/client/PortainerApiClientTest.java b/common/src/test/java/com/hithomelabs/common/portainer/client/PortainerApiClientTest.java index 1e7da28..3e02776 100644 --- a/common/src/test/java/com/hithomelabs/common/portainer/client/PortainerApiClientTest.java +++ b/common/src/test/java/com/hithomelabs/common/portainer/client/PortainerApiClientTest.java @@ -2,11 +2,17 @@ package com.hithomelabs.common.portainer.client; import com.hithomelabs.common.portainer.client.exception.PortainerAuthenticationException; import com.hithomelabs.common.portainer.client.exception.PortainerConnectionException; +import com.hithomelabs.common.portainer.client.exception.PortainerDeploymentException; +import com.hithomelabs.common.portainer.client.exception.PortainerResourceNotFoundException; +import com.hithomelabs.common.portainer.model.EnvVariable; import com.hithomelabs.common.portainer.model.PortainerAuthRequest; import com.hithomelabs.common.portainer.model.PortainerAuthResponse; +import com.hithomelabs.common.portainer.model.PortainerRedeployRequest; +import com.hithomelabs.common.portainer.model.PortainerStack; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.*; @@ -15,6 +21,7 @@ import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestTemplate; import java.nio.charset.StandardCharsets; +import java.util.List; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -220,4 +227,279 @@ class PortainerApiClientTest { // Should not throw; the client is created with an empty base URL assertDoesNotThrow(() -> clientNullUrl.authenticateWithApiKey("key")); } + + // ----------------------------------------------------------- + // getStack + // ----------------------------------------------------------- + + @Test + void getStack_success_returnsPortainerStack() { + PortainerStack expected = new PortainerStack(); + expected.setId(1L); + expected.setName("test-stack"); + ResponseEntity responseEntity = new ResponseEntity<>(expected, HttpStatus.OK); + + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(PortainerStack.class), + anyLong())) + .thenReturn(responseEntity); + + PortainerStack result = client.getStack(1L); + assertNotNull(result); + assertEquals(1L, result.getId()); + assertEquals("test-stack", result.getName()); + } + + @Test + void getStack_nullId_throwsException() { + assertThrows(PortainerDeploymentException.class, + () -> client.getStack(null)); + verifyNoInteractions(restTemplate); + } + + @Test + void getStack_http404_throwsResourceNotFoundException() { + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(PortainerStack.class), + anyLong())) + .thenThrow(HttpClientErrorException.create( + HttpStatus.NOT_FOUND, + "Not Found", + HttpHeaders.EMPTY, + "Stack not found".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8)); + + assertThrows(PortainerResourceNotFoundException.class, + () -> client.getStack(1L)); + } + + @Test + void getStack_http401_throwsAuthenticationException() { + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(PortainerStack.class), + anyLong())) + .thenThrow(HttpClientErrorException.create( + HttpStatus.UNAUTHORIZED, + "Unauthorized", + HttpHeaders.EMPTY, + "Not authenticated".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8)); + + assertThrows(PortainerAuthenticationException.class, + () -> client.getStack(1L)); + } + + @Test + void getStack_connectionError_throwsConnectionException() { + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(PortainerStack.class), + anyLong())) + .thenThrow(new ResourceAccessException("Connection refused")); + + PortainerConnectionException ex = assertThrows(PortainerConnectionException.class, + () -> client.getStack(1L)); + assertTrue(ex.getMessage().contains(BASE_URL)); + } + + @Test + void getStack_nonOkStatus_throwsResourceNotFoundException() { + ResponseEntity responseEntity = new ResponseEntity<>(null, HttpStatus.NO_CONTENT); + + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(PortainerStack.class), + anyLong())) + .thenReturn(responseEntity); + + assertThrows(PortainerResourceNotFoundException.class, + () -> client.getStack(1L)); + } + + // ----------------------------------------------------------- + // redeployGitStack + // ----------------------------------------------------------- + + @Test + void redeployGitStack_success_returnsNormally() { + ResponseEntity responseEntity = new ResponseEntity<>(HttpStatus.OK); + + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.PUT), + any(HttpEntity.class), + eq(Void.class), + anyLong(), + anyLong())) + .thenReturn(responseEntity); + + assertDoesNotThrow(() -> client.redeployGitStack(1L, 2L, true, null)); + } + + @Test + void redeployGitStack_withEnvList_containsEnvInRequestBody() { + List envList = List.of(new EnvVariable("DB_HOST", "postgres")); + ResponseEntity responseEntity = new ResponseEntity<>(HttpStatus.OK); + + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.PUT), + any(HttpEntity.class), + eq(Void.class), + anyLong(), + anyLong())) + .thenReturn(responseEntity); + + client.redeployGitStack(1L, 2L, true, envList); + + // Capture the request body to verify Env is included + ArgumentCaptor> captor = ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate).exchange( + anyString(), + eq(HttpMethod.PUT), + captor.capture(), + eq(Void.class), + anyLong(), + anyLong()); + + PortainerRedeployRequest capturedBody = captor.getValue().getBody(); + assertNotNull(capturedBody); + assertTrue(capturedBody.isPullImage()); + assertFalse(capturedBody.isPrune()); + assertNotNull(capturedBody.getEnv()); + assertEquals(1, capturedBody.getEnv().size()); + assertEquals("DB_HOST", capturedBody.getEnv().get(0).getName()); + assertEquals("postgres", capturedBody.getEnv().get(0).getValue()); + } + + @Test + void redeployGitStack_withNullEnv_requestBodyHasNullEnv() { + ResponseEntity responseEntity = new ResponseEntity<>(HttpStatus.OK); + + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.PUT), + any(HttpEntity.class), + eq(Void.class), + anyLong(), + anyLong())) + .thenReturn(responseEntity); + + client.redeployGitStack(1L, 2L, false, null); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate).exchange( + anyString(), + eq(HttpMethod.PUT), + captor.capture(), + eq(Void.class), + anyLong(), + anyLong()); + + PortainerRedeployRequest capturedBody = captor.getValue().getBody(); + assertNotNull(capturedBody); + assertFalse(capturedBody.isPullImage()); + assertFalse(capturedBody.isPrune()); + assertNull(capturedBody.getEnv()); + } + + @Test + void redeployGitStack_nullId_throwsException() { + assertThrows(PortainerDeploymentException.class, + () -> client.redeployGitStack(null, 2L, true, null)); + verifyNoInteractions(restTemplate); + } + + @Test + void redeployGitStack_nullEndpointId_throwsException() { + assertThrows(PortainerDeploymentException.class, + () -> client.redeployGitStack(1L, null, true, null)); + verifyNoInteractions(restTemplate); + } + + @Test + void redeployGitStack_http404_throwsResourceNotFoundException() { + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.PUT), + any(HttpEntity.class), + eq(Void.class), + anyLong(), + anyLong())) + .thenThrow(HttpClientErrorException.create( + HttpStatus.NOT_FOUND, + "Not Found", + HttpHeaders.EMPTY, + "Stack not found".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8)); + + assertThrows(PortainerResourceNotFoundException.class, + () -> client.redeployGitStack(1L, 2L, true, null)); + } + + @Test + void redeployGitStack_http401_throwsAuthenticationException() { + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.PUT), + any(HttpEntity.class), + eq(Void.class), + anyLong(), + anyLong())) + .thenThrow(HttpClientErrorException.create( + HttpStatus.UNAUTHORIZED, + "Unauthorized", + HttpHeaders.EMPTY, + "Not authenticated".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8)); + + assertThrows(PortainerAuthenticationException.class, + () -> client.redeployGitStack(1L, 2L, true, null)); + } + + @Test + void redeployGitStack_connectionError_throwsConnectionException() { + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.PUT), + any(HttpEntity.class), + eq(Void.class), + anyLong(), + anyLong())) + .thenThrow(new ResourceAccessException("Connection refused")); + + PortainerConnectionException ex = assertThrows(PortainerConnectionException.class, + () -> client.redeployGitStack(1L, 2L, true, null)); + assertTrue(ex.getMessage().contains(BASE_URL)); + } + + @Test + void redeployGitStack_nonOkStatus_throwsDeploymentException() { + ResponseEntity responseEntity = new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.PUT), + any(HttpEntity.class), + eq(Void.class), + anyLong(), + anyLong())) + .thenReturn(responseEntity); + + assertThrows(PortainerDeploymentException.class, + () -> client.redeployGitStack(1L, 2L, true, null)); + } } diff --git a/common/src/test/java/com/hithomelabs/common/portainer/model/EnvVariableTest.java b/common/src/test/java/com/hithomelabs/common/portainer/model/EnvVariableTest.java new file mode 100644 index 0000000..0ccd01b --- /dev/null +++ b/common/src/test/java/com/hithomelabs/common/portainer/model/EnvVariableTest.java @@ -0,0 +1,66 @@ +package com.hithomelabs.common.portainer.model; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class EnvVariableTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void serialize() throws JsonProcessingException { + EnvVariable env = new EnvVariable("FOO", "bar"); + String json = mapper.writeValueAsString(env); + assertEquals("{\"name\":\"FOO\",\"value\":\"bar\"}", json); + } + + @Test + void deserialize() throws JsonProcessingException { + String json = "{\"name\":\"FOO\",\"value\":\"bar\"}"; + EnvVariable env = mapper.readValue(json, EnvVariable.class); + assertEquals("FOO", env.getName()); + assertEquals("bar", env.getValue()); + } + + @Test + void nullName() throws JsonProcessingException { + EnvVariable env = new EnvVariable(null, "value"); + String json = mapper.writeValueAsString(env); + assertEquals("{\"name\":null,\"value\":\"value\"}", json); + } + + @Test + void nullValue() throws JsonProcessingException { + EnvVariable env = new EnvVariable("name", null); + String json = mapper.writeValueAsString(env); + assertEquals("{\"name\":\"name\",\"value\":null}", json); + } + + @Test + void roundTripWithNulls() throws JsonProcessingException { + EnvVariable env = new EnvVariable(null, null); + String json = mapper.writeValueAsString(env); + EnvVariable deserialized = mapper.readValue(json, EnvVariable.class); + assertNull(deserialized.getName()); + assertNull(deserialized.getValue()); + } + + @Test + void constructorAndGetters() { + EnvVariable env = new EnvVariable("KEY", "val"); + assertEquals("KEY", env.getName()); + assertEquals("val", env.getValue()); + } + + @Test + void setters() { + EnvVariable env = new EnvVariable(); + env.setName("A"); + env.setValue("B"); + assertEquals("A", env.getName()); + assertEquals("B", env.getValue()); + } +} diff --git a/common/src/test/java/com/hithomelabs/common/portainer/model/ModelSerializationTest.java b/common/src/test/java/com/hithomelabs/common/portainer/model/ModelSerializationTest.java new file mode 100644 index 0000000..1dda01f --- /dev/null +++ b/common/src/test/java/com/hithomelabs/common/portainer/model/ModelSerializationTest.java @@ -0,0 +1,121 @@ +package com.hithomelabs.common.portainer.model; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Full round-trip contract test for Portainer model serialization. + *

+ * Takes a realistic Portainer API JSON response, deserializes into + * {@link PortainerStack}, extracts the env list, creates a + * {@link PortainerRedeployRequest} with it, serializes back to JSON, + * and asserts the correct structure and values. + */ +class ModelSerializationTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void fullEnvRoundTrip() throws JsonProcessingException { + // Step 1: Realistic Portainer API JSON response with Env array and ResourceControl + String portainerJson = """ + { + "Id": 42, + "Name": "my-app-stack", + "EndpointId": 2, + "Status": 1, + "Env": [ + {"name":"DB_HOST","value":"postgres.internal"}, + {"name":"DB_PORT","value":"5432"}, + {"name":"REDIS_URL","value":"redis://cache:6379"} + ], + "ResourceControl": { + "Id": 1, + "Type": 2, + "Owner": "admin" + } + } + """; + + // Step 2: Deserialize into PortainerStack + PortainerStack stack = mapper.readValue(portainerJson, PortainerStack.class); + assertNotNull(stack, "Deserialized stack must not be null"); + assertEquals(42L, stack.getId().longValue()); + assertEquals("my-app-stack", stack.getName()); + assertNotNull(stack.getResourceControl()); + assertEquals(1, stack.getResourceControl().get("Id")); + + // Step 3: Extract the env list + List envList = stack.getEnv(); + assertNotNull(envList, "Env list must not be null"); + assertEquals(3, envList.size()); + assertEquals("DB_HOST", envList.get(0).getName()); + assertEquals("postgres.internal", envList.get(0).getValue()); + assertEquals("DB_PORT", envList.get(1).getName()); + assertEquals("5432", envList.get(1).getValue()); + assertEquals("REDIS_URL", envList.get(2).getName()); + assertEquals("redis://cache:6379", envList.get(2).getValue()); + + // Step 4: Create a PortainerRedeployRequest with that env list (3-arg constructor) + PortainerRedeployRequest redeployRequest = new PortainerRedeployRequest(true, false, envList); + + // Step 5: Serialize it back to JSON + String requestJson = mapper.writeValueAsString(redeployRequest); + + // Step 6: Assert JSON contains correct key/value pairs + // Assert top-level fields + assertTrue(requestJson.contains("\"PullImage\":true"), + "JSON must contain PullImage:true"); + assertTrue(requestJson.contains("\"Prune\":false"), + "JSON must contain Prune:false"); + + // Assert the Env array is present + assertTrue(requestJson.contains("\"Env\""), + "JSON must contain Env field"); + + // Assert correct name/value casing (JsonProperty annotations) + assertTrue(requestJson.contains("\"name\":\"DB_HOST\""), + "JSON must contain name:DB_HOST"); + assertTrue(requestJson.contains("\"value\":\"postgres.internal\""), + "JSON must contain value:postgres.internal"); + assertTrue(requestJson.contains("\"name\":\"DB_PORT\""), + "JSON must contain name:DB_PORT"); + assertTrue(requestJson.contains("\"value\":\"5432\""), + "JSON must contain value:5432"); + assertTrue(requestJson.contains("\"name\":\"REDIS_URL\""), + "JSON must contain name:REDIS_URL"); + assertTrue(requestJson.contains("\"value\":\"redis://cache:6379\""), + "JSON must contain value:redis://cache:6379"); + } + + @Test + void redeployRequestWithNullEnv() throws JsonProcessingException { + // Verify that a PortainerRedeployRequest with null env serializes without NPE + PortainerRedeployRequest request = new PortainerRedeployRequest(false, true, null); + String json = mapper.writeValueAsString(request); + assertTrue(json.contains("\"PullImage\":false")); + assertTrue(json.contains("\"Prune\":true")); + // Env may be serialized as null or omitted + } + + @Test + void redeployRequestRoundTrip() throws JsonProcessingException { + // Full round-trip: serialize PortainerRedeployRequest and deserialize back + List envList = List.of(new EnvVariable("KEY", "VALUE")); + PortainerRedeployRequest original = new PortainerRedeployRequest(true, true, envList); + String json = mapper.writeValueAsString(original); + PortainerRedeployRequest deserialized = mapper.readValue(json, PortainerRedeployRequest.class); + + assertTrue(deserialized.isPullImage()); + assertTrue(deserialized.isPrune()); + assertNotNull(deserialized.getEnv()); + assertEquals(1, deserialized.getEnv().size()); + assertEquals("KEY", deserialized.getEnv().get(0).getName()); + assertEquals("VALUE", deserialized.getEnv().get(0).getValue()); + } +} diff --git a/common/src/test/java/com/hithomelabs/common/portainer/model/PortainerStackTest.java b/common/src/test/java/com/hithomelabs/common/portainer/model/PortainerStackTest.java new file mode 100644 index 0000000..d91c9af --- /dev/null +++ b/common/src/test/java/com/hithomelabs/common/portainer/model/PortainerStackTest.java @@ -0,0 +1,101 @@ +package com.hithomelabs.common.portainer.model; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class PortainerStackTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void deserializeWithEnv() throws JsonProcessingException { + String json = """ + { + "Id": 1, + "Name": "test-stack", + "Env": [{"name":"DB_HOST","value":"postgres"}] + } + """; + PortainerStack stack = mapper.readValue(json, PortainerStack.class); + assertEquals(1L, stack.getId()); + assertEquals("test-stack", stack.getName()); + assertNotNull(stack.getEnv()); + assertEquals(1, stack.getEnv().size()); + assertEquals("DB_HOST", stack.getEnv().get(0).getName()); + assertEquals("postgres", stack.getEnv().get(0).getValue()); + } + + @Test + void deserializeWithResourceControl() throws JsonProcessingException { + String json = """ + { + "Id": 1, + "ResourceControl": {"Id":1,"Type":2} + } + """; + PortainerStack stack = mapper.readValue(json, PortainerStack.class); + assertNotNull(stack.getResourceControl()); + assertEquals(1, stack.getResourceControl().get("Id")); + assertEquals(2, stack.getResourceControl().get("Type")); + } + + @Test + void deserializeWithNullResourceControl() throws JsonProcessingException { + String json = """ + { + "Id": 1, + "ResourceControl": null + } + """; + PortainerStack stack = mapper.readValue(json, PortainerStack.class); + assertNull(stack.getResourceControl()); + } + + @Test + void deserializeWithoutEnv() throws JsonProcessingException { + String json = """ + { + "Id": 1, + "Name": "test-stack" + } + """; + PortainerStack stack = mapper.readValue(json, PortainerStack.class); + // When no "Env" field is present in JSON, the field should remain null + assertNull(stack.getEnv()); + } + + @Test + void deserializeWithMultipleEnvVars() throws JsonProcessingException { + String json = """ + { + "Id": 2, + "Env": [ + {"name":"DB_HOST","value":"postgres"}, + {"name":"DB_PORT","value":"5432"}, + {"name":"REDIS_URL","value":"redis://cache:6379"} + ] + } + """; + PortainerStack stack = mapper.readValue(json, PortainerStack.class); + assertNotNull(stack.getEnv()); + assertEquals(3, stack.getEnv().size()); + assertEquals("DB_PORT", stack.getEnv().get(1).getName()); + assertEquals("redis://cache:6379", stack.getEnv().get(2).getValue()); + } + + @Test + void deserializeWithEmptyEnv() throws JsonProcessingException { + String json = """ + { + "Id": 3, + "Env": [] + } + """; + PortainerStack stack = mapper.readValue(json, PortainerStack.class); + assertNotNull(stack.getEnv()); + assertTrue(stack.getEnv().isEmpty()); + } +} diff --git a/portainer-automation/build.gradle b/portainer-automation/build.gradle index bbe3892..0cfaa3e 100644 --- a/portainer-automation/build.gradle +++ b/portainer-automation/build.gradle @@ -5,6 +5,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.apache.httpcomponents.client5:httpclient5' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/portainer-automation/src/main/java/com/hithomelabs/portainer/PortainerAutomationApplication.java b/portainer-automation/src/main/java/com/hithomelabs/portainer/PortainerAutomationApplication.java index a848ca2..d190259 100644 --- a/portainer-automation/src/main/java/com/hithomelabs/portainer/PortainerAutomationApplication.java +++ b/portainer-automation/src/main/java/com/hithomelabs/portainer/PortainerAutomationApplication.java @@ -2,8 +2,12 @@ package com.hithomelabs.portainer; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; -@SpringBootApplication +import com.hithomelabs.portainer.config.PortainerAutomationProperties; + +@SpringBootApplication(scanBasePackages = {"com.hithomelabs.portainer", "com.hithomelabs.common"}) +@EnableConfigurationProperties(PortainerAutomationProperties.class) public class PortainerAutomationApplication { public static void main(String[] args) { diff --git a/portainer-automation/src/main/java/com/hithomelabs/portainer/config/PortainerAutomationProperties.java b/portainer-automation/src/main/java/com/hithomelabs/portainer/config/PortainerAutomationProperties.java new file mode 100644 index 0000000..8628909 --- /dev/null +++ b/portainer-automation/src/main/java/com/hithomelabs/portainer/config/PortainerAutomationProperties.java @@ -0,0 +1,55 @@ +package com.hithomelabs.portainer.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "portainer") +public class PortainerAutomationProperties { + private String baseUrl; + private String apiKey; + private Long endpointId; + private Service service = new Service(); + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public Long getEndpointId() { + return endpointId; + } + + public void setEndpointId(Long endpointId) { + this.endpointId = endpointId; + } + + public Service getService() { + return service; + } + + public void setService(Service service) { + this.service = service; + } + + public static class Service { + private String apiKey; + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + } +} diff --git a/portainer-automation/src/main/java/com/hithomelabs/portainer/config/PortainerClientConfig.java b/portainer-automation/src/main/java/com/hithomelabs/portainer/config/PortainerClientConfig.java new file mode 100644 index 0000000..a1eb364 --- /dev/null +++ b/portainer-automation/src/main/java/com/hithomelabs/portainer/config/PortainerClientConfig.java @@ -0,0 +1,66 @@ +package com.hithomelabs.portainer.config; + +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +import com.hithomelabs.common.portainer.client.PortainerApiClient; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.security.cert.X509Certificate; + +@Configuration +public class PortainerClientConfig { + + /** + * Trust-all SSL RestTemplate for the "local" profile. + * Used when connecting via Cloudflare Tunnel (self-signed certs). + */ + @Profile("local") + @Bean(name = "portainerRestTemplate") + public RestTemplate portainerRestTemplateLocal() throws Exception { + TrustManager[] trustAllCerts = new TrustManager[]{ + new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } + public void checkClientTrusted(X509Certificate[] certs, String authType) {} + public void checkServerTrusted(X509Certificate[] certs, String authType) {} + } + }; + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); + SSLConnectionSocketFactory sslSocketFactory = + new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE); + HttpClientConnectionManager cm = PoolingHttpClientConnectionManagerBuilder.create() + .setSSLSocketFactory(sslSocketFactory) + .build(); + return new RestTemplate(new HttpComponentsClientHttpRequestFactory( + HttpClients.custom().setConnectionManager(cm).build())); + } + + /** + * Standard validating SSL RestTemplate for non-local profiles + * (CI, test, prod — internal Docker network). + */ + @Profile("!local") + @Bean(name = "portainerRestTemplate") + public RestTemplate portainerRestTemplate() { + return new RestTemplate(); + } + + @Bean + public PortainerApiClient portainerApiClient(PortainerAutomationProperties props, + RestTemplate portainerRestTemplate) { + PortainerApiClient client = new PortainerApiClient(portainerRestTemplate, props.getBaseUrl()); + client.authenticateWithApiKey(props.getApiKey()); + return client; + } +} diff --git a/portainer-automation/src/main/java/com/hithomelabs/portainer/controller/DeployController.java b/portainer-automation/src/main/java/com/hithomelabs/portainer/controller/DeployController.java new file mode 100644 index 0000000..afd1e34 --- /dev/null +++ b/portainer-automation/src/main/java/com/hithomelabs/portainer/controller/DeployController.java @@ -0,0 +1,41 @@ +package com.hithomelabs.portainer.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.hithomelabs.portainer.config.PortainerAutomationProperties; +import com.hithomelabs.portainer.service.DeployService; + +@RestController +@RequestMapping("/api/deploy") +public class DeployController { + + private final DeployService deployService; + private final PortainerAutomationProperties props; + + public DeployController(DeployService deployService, PortainerAutomationProperties props) { + this.deployService = deployService; + this.props = props; + } + + @PostMapping("/{stackId}") + public ResponseEntity deploy(@PathVariable Long stackId, + @RequestHeader(value = "X-API-Key", required = false) String apiKey) { + String expectedApiKey = props.getService().getApiKey(); + if (expectedApiKey == null || !expectedApiKey.equals(apiKey)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid API key"); + } + try { + deployService.redeploy(stackId); + return ResponseEntity.ok("Deployment initiated for stack " + stackId); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Deployment failed: " + e.getMessage()); + } + } +} diff --git a/portainer-automation/src/main/java/com/hithomelabs/portainer/service/DeployService.java b/portainer-automation/src/main/java/com/hithomelabs/portainer/service/DeployService.java new file mode 100644 index 0000000..33bde32 --- /dev/null +++ b/portainer-automation/src/main/java/com/hithomelabs/portainer/service/DeployService.java @@ -0,0 +1,29 @@ +package com.hithomelabs.portainer.service; + +import org.springframework.stereotype.Service; + +import com.hithomelabs.common.portainer.client.PortainerApiClient; +import com.hithomelabs.common.portainer.model.EnvVariable; +import com.hithomelabs.common.portainer.model.PortainerStack; +import com.hithomelabs.portainer.config.PortainerAutomationProperties; + +import java.util.List; + +@Service +public class DeployService { + + private final PortainerApiClient portainerApiClient; + private final PortainerAutomationProperties props; + + public DeployService(PortainerApiClient portainerApiClient, + PortainerAutomationProperties props) { + this.portainerApiClient = portainerApiClient; + this.props = props; + } + + public void redeploy(Long stackId) { + PortainerStack stack = portainerApiClient.getStack(stackId); + List env = stack.getEnv() != null ? stack.getEnv() : List.of(); + portainerApiClient.redeployGitStack(stackId, props.getEndpointId(), true, env); + } +} diff --git a/portainer-automation/src/main/resources/application-local.properties b/portainer-automation/src/main/resources/application-local.properties new file mode 100644 index 0000000..a8db0cf --- /dev/null +++ b/portainer-automation/src/main/resources/application-local.properties @@ -0,0 +1,4 @@ +portainer.base-url=https://devdocker.hithomelabs.com +portainer.api-key=${PORTAINER_API_KEY:dev-test-key} +portainer.endpoint-id=2 +portainer.service.api-key=dev-test-key diff --git a/portainer-automation/src/main/resources/application.properties b/portainer-automation/src/main/resources/application.properties index 4d360de..682aa5c 100644 --- a/portainer-automation/src/main/resources/application.properties +++ b/portainer-automation/src/main/resources/application.properties @@ -1 +1,5 @@ server.port=8081 +portainer.base-url=https://192.168.0.100:9442 +portainer.api-key=${PORTAINER_API_KEY:} +portainer.endpoint-id=1 +portainer.service.api-key=${PORTAINER_SERVICE_API_KEY:change-me} diff --git a/portainer-automation/src/test/java/com/hithomelabs/portainer/controller/DeployControllerTest.java b/portainer-automation/src/test/java/com/hithomelabs/portainer/controller/DeployControllerTest.java new file mode 100644 index 0000000..e31c025 --- /dev/null +++ b/portainer-automation/src/test/java/com/hithomelabs/portainer/controller/DeployControllerTest.java @@ -0,0 +1,73 @@ +package com.hithomelabs.portainer.controller; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.hithomelabs.portainer.config.PortainerAutomationProperties; +import com.hithomelabs.portainer.service.DeployService; + +@WebMvcTest(DeployController.class) +class DeployControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private DeployService deployService; + + @MockitoBean + private PortainerAutomationProperties props; + + @MockitoBean + private PortainerAutomationProperties.Service service; + + @BeforeEach + void setUp() { + when(props.getService()).thenReturn(service); + when(service.getApiKey()).thenReturn("valid-key"); + } + + @Test + void deployWithValidKeyReturnsOk() throws Exception { + mockMvc.perform(post("/api/deploy/1") + .header("X-API-Key", "valid-key")) + .andExpect(status().isOk()) + .andExpect(content().string("Deployment initiated for stack 1")); + } + + @Test + void deployWithInvalidKeyReturnsUnauthorized() throws Exception { + mockMvc.perform(post("/api/deploy/1") + .header("X-API-Key", "wrong-key")) + .andExpect(status().isUnauthorized()) + .andExpect(content().string("Invalid API key")); + } + + @Test + void deployWithMissingKeyReturnsUnauthorized() throws Exception { + mockMvc.perform(post("/api/deploy/1")) + .andExpect(status().isUnauthorized()) + .andExpect(content().string("Invalid API key")); + } + + @Test + void deployWhenServiceFailsReturnsServerError() throws Exception { + doThrow(new RuntimeException("Portainer error")) + .when(deployService).redeploy(anyLong()); + mockMvc.perform(post("/api/deploy/1") + .header("X-API-Key", "valid-key")) + .andExpect(status().isInternalServerError()) + .andExpect(content().string("Deployment failed: Portainer error")); + } +} diff --git a/portainer-automation/src/test/java/com/hithomelabs/portainer/service/DeployServiceTest.java b/portainer-automation/src/test/java/com/hithomelabs/portainer/service/DeployServiceTest.java new file mode 100644 index 0000000..3fcd0c1 --- /dev/null +++ b/portainer-automation/src/test/java/com/hithomelabs/portainer/service/DeployServiceTest.java @@ -0,0 +1,97 @@ +package com.hithomelabs.portainer.service; + +import com.hithomelabs.common.portainer.client.PortainerApiClient; +import com.hithomelabs.common.portainer.client.exception.PortainerConnectionException; +import com.hithomelabs.common.portainer.client.exception.PortainerResourceNotFoundException; +import com.hithomelabs.common.portainer.model.EnvVariable; +import com.hithomelabs.common.portainer.model.PortainerStack; +import com.hithomelabs.portainer.config.PortainerAutomationProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DeployServiceTest { + + @Mock + private PortainerApiClient portainerApiClient; + + @Mock + private PortainerAutomationProperties props; + + private DeployService deployService; + + private static final Long STACK_ID = 1L; + private static final Long ENDPOINT_ID = 2L; + + @BeforeEach + void setUp() { + when(props.getEndpointId()).thenReturn(ENDPOINT_ID); + deployService = new DeployService(portainerApiClient, props); + } + + @Test + void redeploy_withEnv_callsRedeployGitStackWithSameEnv() { + List envList = List.of(new EnvVariable("DB_HOST", "postgres")); + PortainerStack stack = new PortainerStack(); + stack.setEnv(envList); + + when(portainerApiClient.getStack(STACK_ID)).thenReturn(stack); + + deployService.redeploy(STACK_ID); + + verify(portainerApiClient).getStack(STACK_ID); + verify(portainerApiClient).redeployGitStack(STACK_ID, ENDPOINT_ID, true, envList); + } + + @Test + void redeploy_withNullEnv_callsRedeployGitStackWithNull() { + PortainerStack stack = new PortainerStack(); + stack.setEnv(null); + + when(portainerApiClient.getStack(STACK_ID)).thenReturn(stack); + + deployService.redeploy(STACK_ID); + + verify(portainerApiClient).getStack(STACK_ID); + // When getEnv() returns null, an empty list is passed to redeployGitStack + verify(portainerApiClient).redeployGitStack(STACK_ID, ENDPOINT_ID, true, List.of()); + } + + @Test + void redeploy_whenGetStackThrowsResourceNotFound_propagatesException() { + when(portainerApiClient.getStack(STACK_ID)) + .thenThrow(new PortainerResourceNotFoundException("Stack not found")); + + assertThrows(PortainerResourceNotFoundException.class, + () -> deployService.redeploy(STACK_ID)); + } + + @Test + void redeploy_whenGetStackThrowsConnectionError_propagatesException() { + when(portainerApiClient.getStack(STACK_ID)) + .thenThrow(new PortainerConnectionException("Connection refused")); + + assertThrows(PortainerConnectionException.class, + () -> deployService.redeploy(STACK_ID)); + } + + @Test + void redeploy_whenRedeployThrows_propagatesException() { + PortainerStack stack = new PortainerStack(); + stack.setEnv(List.of(new EnvVariable("A", "B"))); + when(portainerApiClient.getStack(STACK_ID)).thenReturn(stack); + doThrow(new PortainerConnectionException("Connection refused")) + .when(portainerApiClient).redeployGitStack(STACK_ID, ENDPOINT_ID, true, stack.getEnv()); + + assertThrows(PortainerConnectionException.class, + () -> deployService.redeploy(STACK_ID)); + } +}