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/src/main/java/com/hithomelabs/portainer/config/PortainerClientConfig.java b/portainer-automation/src/main/java/com/hithomelabs/portainer/config/PortainerClientConfig.java index 065ee2d..a1eb364 100644 --- a/portainer-automation/src/main/java/com/hithomelabs/portainer/config/PortainerClientConfig.java +++ b/portainer-automation/src/main/java/com/hithomelabs/portainer/config/PortainerClientConfig.java @@ -5,24 +5,38 @@ import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuil 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.apache.hc.core5.ssl.SSLContexts; 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 { - @Bean - public RestTemplate portainerRestTemplate() throws Exception { - SSLContext sslContext = SSLContexts.custom() - .loadTrustMaterial((chain, authType) -> true) - .build(); + /** + * 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() @@ -32,6 +46,16 @@ public class PortainerClientConfig { 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) { 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 index aedf306..33bde32 100644 --- a/portainer-automation/src/main/java/com/hithomelabs/portainer/service/DeployService.java +++ b/portainer-automation/src/main/java/com/hithomelabs/portainer/service/DeployService.java @@ -3,8 +3,12 @@ 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 { @@ -18,6 +22,8 @@ public class DeployService { } public void redeploy(Long stackId) { - portainerApiClient.redeployGitStack(stackId, props.getEndpointId(), true); + 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 680422a..682aa5c 100644 --- a/portainer-automation/src/main/resources/application.properties +++ b/portainer-automation/src/main/resources/application.properties @@ -1,4 +1,5 @@ server.port=8081 portainer.base-url=https://192.168.0.100:9442 -portainer.endpoint-id=2 -portainer.service.api-key=change-me +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/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)); + } +}