88: Build portainer-automation service #131

Merged
hitanshu merged 10 commits from Dave/CFTunnels:ISSUE-88 into test 2026-07-06 05:14:36 +00:00
8 changed files with 243 additions and 1 deletions

View File

@ -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'

View File

@ -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) {

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -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<String> 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());
}
}
}

View File

@ -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);
}
}

View File

@ -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

View File

@ -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"));
}
}