From e515532d999ae9bbb4f3ebadafda964f4a3ee03f Mon Sep 17 00:00:00 2001 From: hitanshu310 Date: Sun, 5 Jul 2026 16:54:23 +0530 Subject: [PATCH 1/2] Hithomelabs/CFTunnels#87: Add PortainerApiClient, DTOs, ImageReference, and exception classes --- .../common/config/RestTemplateConfig.java | 16 +- .../portainer/client/PortainerApiClient.java | 262 ++++++++++++++++++ .../PortainerAuthenticationException.java | 12 + .../PortainerConnectionException.java | 12 + .../PortainerDeploymentException.java | 12 + .../PortainerResourceNotFoundException.java | 12 + .../portainer/model/PortainerAuthRequest.java | 29 ++ .../model/PortainerAuthResponse.java | 15 + .../model/PortainerRedeployRequest.java | 29 ++ .../portainer/model/PortainerStack.java | 51 ++++ .../common/registry/model/ImageReference.java | 65 +++++ 11 files changed, 513 insertions(+), 2 deletions(-) create mode 100644 common/src/main/java/com/hithomelabs/common/portainer/client/PortainerApiClient.java create mode 100644 common/src/main/java/com/hithomelabs/common/portainer/client/exception/PortainerAuthenticationException.java create mode 100644 common/src/main/java/com/hithomelabs/common/portainer/client/exception/PortainerConnectionException.java create mode 100644 common/src/main/java/com/hithomelabs/common/portainer/client/exception/PortainerDeploymentException.java create mode 100644 common/src/main/java/com/hithomelabs/common/portainer/client/exception/PortainerResourceNotFoundException.java create mode 100644 common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthRequest.java create mode 100644 common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthResponse.java create mode 100644 common/src/main/java/com/hithomelabs/common/portainer/model/PortainerRedeployRequest.java create mode 100644 common/src/main/java/com/hithomelabs/common/portainer/model/PortainerStack.java create mode 100644 common/src/main/java/com/hithomelabs/common/registry/model/ImageReference.java 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; + } +} -- 2.45.2 From 6fc44a6969645898db9c32814ff35c84651aadee Mon Sep 17 00:00:00 2001 From: hitanshu310 Date: Sun, 5 Jul 2026 17:50:56 +0530 Subject: [PATCH 2/2] Hithomelabs/CFTunnels#87: address PR review - add @JsonProperty, fix status type, add unit tests --- common/build.gradle | 2 + .../portainer/model/PortainerAuthRequest.java | 6 + .../model/PortainerAuthResponse.java | 4 + .../model/PortainerRedeployRequest.java | 6 + .../portainer/model/PortainerStack.java | 18 +- .../client/PortainerApiClientTest.java | 223 ++++++++++++++++++ .../registry/model/ImageReferenceTest.java | 107 +++++++++ 7 files changed, 363 insertions(+), 3 deletions(-) create mode 100644 common/src/test/java/com/hithomelabs/common/portainer/client/PortainerApiClientTest.java create mode 100644 common/src/test/java/com/hithomelabs/common/registry/model/ImageReferenceTest.java diff --git a/common/build.gradle b/common/build.gradle index 4af833f..05cec77 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -8,4 +8,6 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.mockito:mockito-core' + testImplementation 'org.springframework.boot:spring-boot-starter-test' } 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 index 42de8aa..7aea37c 100644 --- a/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthRequest.java +++ b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthRequest.java @@ -1,7 +1,13 @@ package com.hithomelabs.common.portainer.model; +import com.fasterxml.jackson.annotation.JsonProperty; + public class PortainerAuthRequest { + + @JsonProperty("Username") private String username; + + @JsonProperty("Password") private String password; public PortainerAuthRequest() {} 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 index ce351a8..544d3f8 100644 --- a/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthResponse.java +++ b/common/src/main/java/com/hithomelabs/common/portainer/model/PortainerAuthResponse.java @@ -1,6 +1,10 @@ package com.hithomelabs.common.portainer.model; +import com.fasterxml.jackson.annotation.JsonProperty; + public class PortainerAuthResponse { + + @JsonProperty("jwt") private String jwt; public PortainerAuthResponse() {} 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 d7e0cfb..945d63b 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 @@ -1,7 +1,13 @@ package com.hithomelabs.common.portainer.model; +import com.fasterxml.jackson.annotation.JsonProperty; + public class PortainerRedeployRequest { + + @JsonProperty("PullImage") private boolean pullImage; + + @JsonProperty("Prune") private boolean prune; public PortainerRedeployRequest() {} 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 6e6d107..5669e5d 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 @@ -1,10 +1,22 @@ package com.hithomelabs.common.portainer.model; +import com.fasterxml.jackson.annotation.JsonProperty; + public class PortainerStack { + + @JsonProperty("Id") private Long id; + + @JsonProperty("Name") private String name; + + @JsonProperty("EndpointId") private Long endpointId; - private String status; + + @JsonProperty("Status") + private int status; + + @JsonProperty("ResourceControl") private String resourceControl; public PortainerStack() {} @@ -33,11 +45,11 @@ public class PortainerStack { this.endpointId = endpointId; } - public String getStatus() { + public int getStatus() { return status; } - public void setStatus(String status) { + public void setStatus(int status) { this.status = status; } 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 new file mode 100644 index 0000000..1e7da28 --- /dev/null +++ b/common/src/test/java/com/hithomelabs/common/portainer/client/PortainerApiClientTest.java @@ -0,0 +1,223 @@ +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.model.PortainerAuthRequest; +import com.hithomelabs.common.portainer.model.PortainerAuthResponse; +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 org.springframework.http.*; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PortainerApiClientTest { + + @Mock + private RestTemplate restTemplate; + + private PortainerApiClient client; + + private static final String BASE_URL = "http://portainer.local:9000"; + + @BeforeEach + void setUp() { + client = new PortainerApiClient(restTemplate, BASE_URL); + } + + // -- authenticateWithPassword: success path -- + + @Test + void authenticateWithPassword_validCredentials_returnsAuthResponse() { + PortainerAuthResponse authResponse = new PortainerAuthResponse(); + authResponse.setJwt("test-jwt-token"); + + ResponseEntity responseEntity = new ResponseEntity<>( + authResponse, HttpStatus.OK); + + when(restTemplate.exchange( + eq(BASE_URL + "/api/auth"), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(PortainerAuthResponse.class))) + .thenReturn(responseEntity); + + PortainerAuthResponse result = client.authenticateWithPassword("admin", "password123"); + + assertNotNull(result); + assertEquals("test-jwt-token", result.getJwt()); + verify(restTemplate).exchange( + eq(BASE_URL + "/api/auth"), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(PortainerAuthResponse.class)); + } + + // -- authenticateWithPassword: input validation -- + + @Test + void authenticateWithPassword_nullUsername_throwsException() { + assertThrows(PortainerAuthenticationException.class, + () -> client.authenticateWithPassword(null, "password")); + verifyNoInteractions(restTemplate); + } + + @Test + void authenticateWithPassword_blankUsername_throwsException() { + assertThrows(PortainerAuthenticationException.class, + () -> client.authenticateWithPassword(" ", "password")); + verifyNoInteractions(restTemplate); + } + + @Test + void authenticateWithPassword_nullPassword_throwsException() { + assertThrows(PortainerAuthenticationException.class, + () -> client.authenticateWithPassword("admin", null)); + verifyNoInteractions(restTemplate); + } + + @Test + void authenticateWithPassword_blankPassword_throwsException() { + assertThrows(PortainerAuthenticationException.class, + () -> client.authenticateWithPassword("admin", " ")); + verifyNoInteractions(restTemplate); + } + + // -- authenticateWithPassword: HTTP error handling -- + + @Test + void authenticateWithPassword_http401_throwsAuthenticationException() { + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(PortainerAuthResponse.class))) + .thenThrow(HttpClientErrorException.create( + HttpStatus.UNAUTHORIZED, + "Unauthorized", + HttpHeaders.EMPTY, + "Bad credentials".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8)); + + PortainerAuthenticationException ex = assertThrows(PortainerAuthenticationException.class, + () -> client.authenticateWithPassword("admin", "wrong")); + assertTrue(ex.getMessage().contains("401")); + } + + @Test + void authenticateWithPassword_http403_throwsAuthenticationException() { + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(PortainerAuthResponse.class))) + .thenThrow(HttpClientErrorException.create( + HttpStatus.FORBIDDEN, + "Forbidden", + HttpHeaders.EMPTY, + "Forbidden".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8)); + + assertThrows(PortainerAuthenticationException.class, + () -> client.authenticateWithPassword("admin", "wrong")); + } + + @Test + void authenticateWithPassword_connectionError_throwsConnectionException() { + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(PortainerAuthResponse.class))) + .thenThrow(new ResourceAccessException("Connection refused")); + + PortainerConnectionException ex = assertThrows(PortainerConnectionException.class, + () -> client.authenticateWithPassword("admin", "password")); + assertTrue(ex.getMessage().contains(BASE_URL)); + assertTrue(ex.getMessage().contains("Connection refused")); + } + + @Test + void authenticateWithPassword_nonOkStatus_throwsAuthenticationException() { + PortainerAuthResponse authResponse = new PortainerAuthResponse(); + authResponse.setJwt("token"); + // Return a non-OK status with null body to trigger the status check path + ResponseEntity responseEntity = new ResponseEntity<>( + null, HttpStatus.INTERNAL_SERVER_ERROR); + + when(restTemplate.exchange( + anyString(), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(PortainerAuthResponse.class))) + .thenReturn(responseEntity); + + PortainerAuthenticationException ex = assertThrows(PortainerAuthenticationException.class, + () -> client.authenticateWithPassword("admin", "password")); + assertTrue(ex.getMessage().contains("500")); + } + + // -- authenticateWithApiKey: input validation -- + + @Test + void authenticateWithApiKey_nullKey_throwsException() { + assertThrows(PortainerAuthenticationException.class, + () -> client.authenticateWithApiKey(null)); + verifyNoInteractions(restTemplate); + } + + @Test + void authenticateWithApiKey_blankKey_throwsException() { + assertThrows(PortainerAuthenticationException.class, + () -> client.authenticateWithApiKey(" ")); + verifyNoInteractions(restTemplate); + } + + @Test + void authenticateWithApiKey_validKey_setsApiKey() { + client.authenticateWithApiKey("ptr_abc123def456"); + // No exception expected; we verify it doesn't throw + // We can test this by calling a subsequent method that uses the API key + assertDoesNotThrow(() -> client.authenticateWithApiKey("ptr_abc123def456")); + } + + // -- Constructor: trailing slash handling -- + + @Test + void constructor_withTrailingSlash_stripsIt() { + PortainerApiClient clientWithSlash = new PortainerApiClient(restTemplate, "http://portainer.local:9000/"); + // authenticateWithPassword should use the stripped URL + PortainerAuthResponse authResponse = new PortainerAuthResponse(); + authResponse.setJwt("token"); + ResponseEntity responseEntity = new ResponseEntity<>( + authResponse, HttpStatus.OK); + + when(restTemplate.exchange( + eq("http://portainer.local:9000/api/auth"), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(PortainerAuthResponse.class))) + .thenReturn(responseEntity); + + PortainerAuthResponse result = clientWithSlash.authenticateWithPassword("admin", "pass"); + assertNotNull(result); + assertEquals("token", result.getJwt()); + } + + @Test + void constructor_withNullBaseUrl_usesEmptyString() { + PortainerApiClient clientNullUrl = new PortainerApiClient(restTemplate, null); + // Should not throw; the client is created with an empty base URL + assertDoesNotThrow(() -> clientNullUrl.authenticateWithApiKey("key")); + } +} diff --git a/common/src/test/java/com/hithomelabs/common/registry/model/ImageReferenceTest.java b/common/src/test/java/com/hithomelabs/common/registry/model/ImageReferenceTest.java new file mode 100644 index 0000000..530c065 --- /dev/null +++ b/common/src/test/java/com/hithomelabs/common/registry/model/ImageReferenceTest.java @@ -0,0 +1,107 @@ +package com.hithomelabs.common.registry.model; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class ImageReferenceTest { + + @Test + void parse_withValidImage_returnsImageReference() { + ImageReference ref = ImageReference.parse("192.168.0.100:8928/hithomelabs/cftunnels:1.2.3"); + + assertNotNull(ref); + assertEquals("192.168.0.100:8928", ref.getRegistry()); + assertEquals("hithomelabs/cftunnels", ref.getRepository()); + assertEquals("1.2.3", ref.getTag()); + assertEquals("192.168.0.100:8928/hithomelabs/cftunnels:1.2.3", ref.getFullImage()); + } + + @Test + void parse_withRegistryPortAndMultiLevelRepository_returnsImageReference() { + ImageReference ref = ImageReference.parse("registry.example.com:5000/namespace/sub/image:latest"); + + assertNotNull(ref); + assertEquals("registry.example.com:5000", ref.getRegistry()); + assertEquals("namespace/sub/image", ref.getRepository()); + assertEquals("latest", ref.getTag()); + } + + @Test + void parse_withSimpleRegistryPath_returnsImageReference() { + ImageReference ref = ImageReference.parse("docker.io/library/nginx:1.25"); + + assertNotNull(ref); + assertEquals("docker.io", ref.getRegistry()); + assertEquals("library/nginx", ref.getRepository()); + assertEquals("1.25", ref.getTag()); + } + + @Test + void parse_withNumericTag_returnsImageReference() { + ImageReference ref = ImageReference.parse("myregistry.com/myapp:1"); + + assertNotNull(ref); + assertEquals("myregistry.com", ref.getRegistry()); + assertEquals("myapp", ref.getRepository()); + assertEquals("1", ref.getTag()); + } + + @Test + void parse_withTagContainingHyphenAndDot_returnsImageReference() { + ImageReference ref = ImageReference.parse("private.reg.io/team-app:3.2.1-rc1"); + + assertNotNull(ref); + assertEquals("private.reg.io", ref.getRegistry()); + assertEquals("team-app", ref.getRepository()); + assertEquals("3.2.1-rc1", ref.getTag()); + } + + @Test + void parse_withNullImage_throwsIllegalArgumentException() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ImageReference.parse(null)); + assertTrue(ex.getMessage().contains("null") || ex.getMessage().contains("blank")); + } + + @Test + void parse_withBlankImage_throwsIllegalArgumentException() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ImageReference.parse(" ")); + assertTrue(ex.getMessage().contains("null") || ex.getMessage().contains("blank")); + } + + @Test + void parse_withEmptyImage_throwsIllegalArgumentException() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ImageReference.parse("")); + assertTrue(ex.getMessage().contains("null") || ex.getMessage().contains("blank")); + } + + @Test + void parse_withMissingRegistry_throwsIllegalArgumentException() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ImageReference.parse("nginx:latest")); + assertTrue(ex.getMessage().contains("Invalid")); + } + + @Test + void parse_withMissingTag_throwsIllegalArgumentException() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ImageReference.parse("registry.com/repo")); + assertTrue(ex.getMessage().contains("Invalid")); + } + + @Test + void parse_withMissingColonSeparator_throwsIllegalArgumentException() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ImageReference.parse("registry.com/repo:")); + assertTrue(ex.getMessage().contains("Invalid")); + } + + @Test + void parse_withOnlyRegistry_throwsIllegalArgumentException() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ImageReference.parse("registry.com")); + assertTrue(ex.getMessage().contains("Invalid")); + } +} -- 2.45.2