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..065ee2d --- /dev/null +++ b/portainer-automation/src/main/java/com/hithomelabs/portainer/config/PortainerClientConfig.java @@ -0,0 +1,42 @@ +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.apache.hc.core5.ssl.SSLContexts; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +import com.hithomelabs.common.portainer.client.PortainerApiClient; + +import javax.net.ssl.SSLContext; + +@Configuration +public class PortainerClientConfig { + + @Bean + public RestTemplate portainerRestTemplate() throws Exception { + SSLContext sslContext = SSLContexts.custom() + .loadTrustMaterial((chain, authType) -> true) + .build(); + SSLConnectionSocketFactory sslSocketFactory = + new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE); + HttpClientConnectionManager cm = PoolingHttpClientConnectionManagerBuilder.create() + .setSSLSocketFactory(sslSocketFactory) + .build(); + return new RestTemplate(new HttpComponentsClientHttpRequestFactory( + HttpClients.custom().setConnectionManager(cm).build())); + } + + @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..aedf306 --- /dev/null +++ b/portainer-automation/src/main/java/com/hithomelabs/portainer/service/DeployService.java @@ -0,0 +1,23 @@ +package com.hithomelabs.portainer.service; + +import org.springframework.stereotype.Service; + +import com.hithomelabs.common.portainer.client.PortainerApiClient; +import com.hithomelabs.portainer.config.PortainerAutomationProperties; + +@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) { + portainerApiClient.redeployGitStack(stackId, props.getEndpointId(), true); + } +} diff --git a/portainer-automation/src/main/resources/application.properties b/portainer-automation/src/main/resources/application.properties index 4d360de..680422a 100644 --- a/portainer-automation/src/main/resources/application.properties +++ b/portainer-automation/src/main/resources/application.properties @@ -1 +1,4 @@ server.port=8081 +portainer.base-url=https://192.168.0.100:9442 +portainer.endpoint-id=2 +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")); + } +}