diff --git a/common/src/main/java/com/hithomelabs/common/config/RestTemplateConfig.java b/common/src/main/java/com/hithomelabs/common/config/RestTemplateConfig.java index 298d3fe..dcc2836 100644 --- a/common/src/main/java/com/hithomelabs/common/config/RestTemplateConfig.java +++ b/common/src/main/java/com/hithomelabs/common/config/RestTemplateConfig.java @@ -5,17 +5,29 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.web.client.RestTemplate; +import java.time.Duration; + @Configuration public class RestTemplateConfig { + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10); + private static final Duration READ_TIMEOUT = Duration.ofSeconds(30); + @Bean public RestTemplate restTemplate() { - RestTemplate restTemplate = new RestTemplate(); + HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(); + factory.setConnectTimeout(Math.toIntExact(CONNECT_TIMEOUT.toMillis())); + factory.setReadTimeout(Math.toIntExact(READ_TIMEOUT.toMillis())); + + RestTemplate restTemplate = new RestTemplate(factory); MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); - converter.setObjectMapper(new ObjectMapper().enable(SerializationFeature.WRAP_ROOT_VALUE).setSerializationInclusion(JsonInclude.Include.NON_NULL)); + converter.setObjectMapper(new ObjectMapper() + .enable(SerializationFeature.WRAP_ROOT_VALUE) + .setSerializationInclusion(JsonInclude.Include.NON_NULL)); restTemplate.getMessageConverters().add(0, converter); return restTemplate; } 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 new file mode 100644 index 0000000..aaaa3f5 --- /dev/null +++ b/common/src/main/java/com/hithomelabs/common/portainer/client/PortainerApiClient.java @@ -0,0 +1,262 @@ +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.PortainerAuthRequest; +import com.hithomelabs.common.portainer.model.PortainerAuthResponse; +import com.hithomelabs.common.portainer.model.PortainerRedeployRequest; +import com.hithomelabs.common.portainer.model.PortainerStack; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +public class PortainerApiClient { + + private static final String API_AUTH = "/api/auth"; + private static final String API_STACKS = "/api/stacks"; + + private final RestTemplate restTemplate; + private final String baseUrl; + private String jwtToken; + private String apiKey; + + public PortainerApiClient(RestTemplate restTemplate, String baseUrl) { + this.restTemplate = restTemplate; + // Strip trailing slash if present + this.baseUrl = baseUrl != null ? baseUrl.replaceAll("/+$", "") : ""; + } + + // ----------------------------------------------------------- + // Authentication + // ----------------------------------------------------------- + + /** + * Authenticate using a Portainer API key (recommended). + * The API key is sent as a Bearer token in the Authorization header + * for all subsequent requests. + */ + public void authenticateWithApiKey(String apiKey) { + if (apiKey == null || apiKey.isBlank()) { + throw new PortainerAuthenticationException("API key must not be null or blank"); + } + this.apiKey = apiKey; + // No JWT token needed when using API key auth + this.jwtToken = null; + } + + /** + * Authenticate with username/password credentials (fallback). + * Sends a POST to {@code /api/auth} and stores the returned JWT. + */ + public PortainerAuthResponse authenticateWithPassword(String username, String password) { + if (username == null || username.isBlank()) { + throw new PortainerAuthenticationException("Username must not be null or blank"); + } + if (password == null || password.isBlank()) { + throw new PortainerAuthenticationException("Password must not be null or blank"); + } + + PortainerAuthRequest request = new PortainerAuthRequest(username, password); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(request, headers); + + try { + ResponseEntity response = restTemplate.exchange( + baseUrl + API_AUTH, + HttpMethod.POST, + entity, + PortainerAuthResponse.class); + + if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) { + throw new PortainerAuthenticationException( + "Authentication failed with status: " + response.getStatusCode()); + } + + this.jwtToken = response.getBody().getJwt(); + // Clear any previously set API key + this.apiKey = null; + return response.getBody(); + } catch (HttpClientErrorException e) { + throw new PortainerAuthenticationException( + "Authentication failed: " + e.getStatusCode() + " - " + e.getResponseBodyAsString(), e); + } catch (ResourceAccessException e) { + throw new PortainerConnectionException( + "Unable to connect to Portainer at " + baseUrl + ": " + e.getMessage(), e); + } + } + + // ----------------------------------------------------------- + // Stack operations + // ----------------------------------------------------------- + + /** + * List all stacks managed by Portainer. + */ + public List listStacks() { + HttpEntity entity = buildAuthEntity(); + + try { + ResponseEntity> response = restTemplate.exchange( + baseUrl + API_STACKS, + HttpMethod.GET, + entity, + new ParameterizedTypeReference<>() {}); + + if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) { + return Collections.emptyList(); + } + return response.getBody(); + } catch (HttpClientErrorException e) { + if (e.getStatusCode() == HttpStatus.UNAUTHORIZED) { + throw new PortainerAuthenticationException( + "Not authenticated. Please authenticate before listing stacks.", e); + } + throw new PortainerDeploymentException( + "Failed to list stacks: " + e.getStatusCode() + " - " + e.getResponseBodyAsString(), e); + } catch (ResourceAccessException e) { + throw new PortainerConnectionException( + "Unable to connect to Portainer at " + baseUrl + ": " + e.getMessage(), e); + } + } + + /** + * Find a stack by its exact name. + */ + public Optional findStackByName(String name) { + if (name == null || name.isBlank()) { + return Optional.empty(); + } + + List stacks = listStacks(); + return stacks.stream() + .filter(stack -> name.equals(stack.getName())) + .findFirst(); + } + + /** + * Redeploy a standard (non-git) stack. + * Sends a PUT to {@code /api/stacks/{stackId}?endpointId={endpointId}}. + */ + public void redeployStack(Long stackId, Long endpointId, boolean pullImage) { + if (stackId == null) { + throw new PortainerDeploymentException("Stack ID must not be null"); + } + if (endpointId == null) { + throw new PortainerDeploymentException("Endpoint ID must not be null"); + } + + PortainerRedeployRequest body = new PortainerRedeployRequest(pullImage, true); + HttpEntity entity = new HttpEntity<>(body, buildAuthHeaders()); + + try { + ResponseEntity response = restTemplate.exchange( + baseUrl + API_STACKS + "/{stackId}?endpointId={endpointId}", + HttpMethod.PUT, + entity, + Void.class, + stackId, + endpointId); + + if (!response.getStatusCode().is2xxSuccessful()) { + throw new PortainerDeploymentException( + "Redeploy failed with status: " + response.getStatusCode()); + } + } catch (HttpClientErrorException e) { + if (e.getStatusCode() == HttpStatus.UNAUTHORIZED) { + throw new PortainerAuthenticationException( + "Not authenticated. Please authenticate before redeploying.", e); + } + if (e.getStatusCode() == HttpStatus.NOT_FOUND) { + throw new PortainerResourceNotFoundException( + "Stack with ID " + stackId + " not found on endpoint " + endpointId, e); + } + throw new PortainerDeploymentException( + "Failed to redeploy stack " + stackId + ": " + e.getStatusCode() + + " - " + e.getResponseBodyAsString(), 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}}. + */ + public void redeployGitStack(Long stackId, Long endpointId, boolean pullImage) { + if (stackId == null) { + throw new PortainerDeploymentException("Stack ID must not be null"); + } + if (endpointId == null) { + throw new PortainerDeploymentException("Endpoint ID must not be null"); + } + + PortainerRedeployRequest body = new PortainerRedeployRequest(pullImage, false); + HttpEntity entity = new HttpEntity<>(body, buildAuthHeaders()); + + try { + ResponseEntity response = restTemplate.exchange( + baseUrl + API_STACKS + "/{stackId}/git/redeploy?endpointId={endpointId}", + HttpMethod.PUT, + entity, + Void.class, + stackId, + endpointId); + + if (!response.getStatusCode().is2xxSuccessful()) { + throw new PortainerDeploymentException( + "Git redeploy failed with status: " + response.getStatusCode()); + } + } catch (HttpClientErrorException e) { + if (e.getStatusCode() == HttpStatus.UNAUTHORIZED) { + throw new PortainerAuthenticationException( + "Not authenticated. Please authenticate before redeploying.", e); + } + if (e.getStatusCode() == HttpStatus.NOT_FOUND) { + throw new PortainerResourceNotFoundException( + "Git stack with ID " + stackId + " not found on endpoint " + endpointId, e); + } + throw new PortainerDeploymentException( + "Failed to git-redeploy stack " + stackId + ": " + e.getStatusCode() + + " - " + e.getResponseBodyAsString(), e); + } catch (ResourceAccessException e) { + throw new PortainerConnectionException( + "Unable to connect to Portainer at " + baseUrl + ": " + e.getMessage(), e); + } + } + + // ----------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------- + + private HttpHeaders buildAuthHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + if (apiKey != null && !apiKey.isBlank()) { + headers.set("X-API-Key", apiKey); + } else if (jwtToken != null && !jwtToken.isBlank()) { + headers.setBearerAuth(jwtToken); + } + return headers; + } + + private HttpEntity buildAuthEntity() { + return new HttpEntity<>(buildAuthHeaders()); + } +} diff --git a/common/src/main/java/com/hithomelabs/common/portainer/client/exception/PortainerAuthenticationException.java b/common/src/main/java/com/hithomelabs/common/portainer/client/exception/PortainerAuthenticationException.java new file mode 100644 index 0000000..2f0a286 --- /dev/null +++ b/common/src/main/java/com/hithomelabs/common/portainer/client/exception/PortainerAuthenticationException.java @@ -0,0 +1,12 @@ +package com.hithomelabs.common.portainer.client.exception; + +public class PortainerAuthenticationException extends RuntimeException { + + public PortainerAuthenticationException(String message) { + super(message); + } + + public PortainerAuthenticationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/common/src/main/java/com/hithomelabs/common/portainer/client/exception/PortainerConnectionException.java b/common/src/main/java/com/hithomelabs/common/portainer/client/exception/PortainerConnectionException.java new file mode 100644 index 0000000..f59468e --- /dev/null +++ b/common/src/main/java/com/hithomelabs/common/portainer/client/exception/PortainerConnectionException.java @@ -0,0 +1,12 @@ +package com.hithomelabs.common.portainer.client.exception; + +public class PortainerConnectionException extends RuntimeException { + + public PortainerConnectionException(String message) { + super(message); + } + + public PortainerConnectionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/common/src/main/java/com/hithomelabs/common/portainer/client/exception/PortainerDeploymentException.java b/common/src/main/java/com/hithomelabs/common/portainer/client/exception/PortainerDeploymentException.java new file mode 100644 index 0000000..fefaaa1 --- /dev/null +++ b/common/src/main/java/com/hithomelabs/common/portainer/client/exception/PortainerDeploymentException.java @@ -0,0 +1,12 @@ +package com.hithomelabs.common.portainer.client.exception; + +public class PortainerDeploymentException extends RuntimeException { + + public PortainerDeploymentException(String message) { + super(message); + } + + public PortainerDeploymentException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/common/src/main/java/com/hithomelabs/common/portainer/client/exception/PortainerResourceNotFoundException.java b/common/src/main/java/com/hithomelabs/common/portainer/client/exception/PortainerResourceNotFoundException.java new file mode 100644 index 0000000..b90c7da --- /dev/null +++ b/common/src/main/java/com/hithomelabs/common/portainer/client/exception/PortainerResourceNotFoundException.java @@ -0,0 +1,12 @@ +package com.hithomelabs.common.portainer.client.exception; + +public class PortainerResourceNotFoundException extends RuntimeException { + + public PortainerResourceNotFoundException(String message) { + super(message); + } + + public PortainerResourceNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthRequest.java b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthRequest.java new file mode 100644 index 0000000..42de8aa --- /dev/null +++ b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthRequest.java @@ -0,0 +1,29 @@ +package com.hithomelabs.common.portainer.model; + +public class PortainerAuthRequest { + private String username; + private String password; + + public PortainerAuthRequest() {} + + public PortainerAuthRequest(String username, String password) { + this.username = username; + this.password = password; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} diff --git a/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthResponse.java b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthResponse.java new file mode 100644 index 0000000..ce351a8 --- /dev/null +++ b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthResponse.java @@ -0,0 +1,15 @@ +package com.hithomelabs.common.portainer.model; + +public class PortainerAuthResponse { + private String jwt; + + public PortainerAuthResponse() {} + + public String getJwt() { + return jwt; + } + + public void setJwt(String jwt) { + this.jwt = jwt; + } +} 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 new file mode 100644 index 0000000..d7e0cfb --- /dev/null +++ b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerRedeployRequest.java @@ -0,0 +1,29 @@ +package com.hithomelabs.common.portainer.model; + +public class PortainerRedeployRequest { + private boolean pullImage; + private boolean prune; + + public PortainerRedeployRequest() {} + + public PortainerRedeployRequest(boolean pullImage, boolean prune) { + this.pullImage = pullImage; + this.prune = prune; + } + + public boolean isPullImage() { + return pullImage; + } + + public void setPullImage(boolean pullImage) { + this.pullImage = pullImage; + } + + public boolean isPrune() { + return prune; + } + + public void setPrune(boolean prune) { + this.prune = prune; + } +} 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 new file mode 100644 index 0000000..6e6d107 --- /dev/null +++ b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerStack.java @@ -0,0 +1,51 @@ +package com.hithomelabs.common.portainer.model; + +public class PortainerStack { + private Long id; + private String name; + private Long endpointId; + private String status; + private String resourceControl; + + public PortainerStack() {} + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Long getEndpointId() { + return endpointId; + } + + public void setEndpointId(Long endpointId) { + this.endpointId = endpointId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getResourceControl() { + return resourceControl; + } + + public void setResourceControl(String resourceControl) { + this.resourceControl = resourceControl; + } +} diff --git a/common/src/main/java/com/hithomelabs/common/registry/model/ImageReference.java b/common/src/main/java/com/hithomelabs/common/registry/model/ImageReference.java new file mode 100644 index 0000000..18129d3 --- /dev/null +++ b/common/src/main/java/com/hithomelabs/common/registry/model/ImageReference.java @@ -0,0 +1,65 @@ +package com.hithomelabs.common.registry.model; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class ImageReference { + + private static final Pattern IMAGE_PATTERN = + Pattern.compile("^(?[^/]+)/(?[^:]+):(?.+)$"); + + private final String registry; + private final String repository; + private final String tag; + private final String fullImage; + + private ImageReference(String registry, String repository, String tag, String fullImage) { + this.registry = registry; + this.repository = repository; + this.tag = tag; + this.fullImage = fullImage; + } + + /** + * Parses a container image reference string of the form: + * {@code registry/repository:tag} + * e.g. {@code 192.168.0.100:8928/hithomelabs/cftunnels:1.2.3} + * + * @param image the full image reference string + * @return a parsed {@code ImageReference} + * @throws IllegalArgumentException if the string cannot be parsed + */ + public static ImageReference parse(String image) { + if (image == null || image.isBlank()) { + throw new IllegalArgumentException("Image reference must not be null or blank"); + } + + Matcher matcher = IMAGE_PATTERN.matcher(image); + if (!matcher.matches()) { + throw new IllegalArgumentException( + "Invalid image reference format. Expected '/:', got: " + image); + } + + String registry = matcher.group("registry"); + String repository = matcher.group("repository"); + String tag = matcher.group("tag"); + + return new ImageReference(registry, repository, tag, image); + } + + public String getRegistry() { + return registry; + } + + public String getRepository() { + return repository; + } + + public String getTag() { + return tag; + } + + public String getFullImage() { + return fullImage; + } +}