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/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..56299c2 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(); + 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..17827ae 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.api-key=${PORTAINER_API_KEY:} +portainer.endpoint-id=1 portainer.service.api-key=change-me