test #130
@ -8,4 +8,6 @@ dependencies {
|
|||||||
annotationProcessor 'org.projectlombok:lombok'
|
annotationProcessor 'org.projectlombok:lombok'
|
||||||
|
|
||||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||||
|
testImplementation 'org.mockito:mockito-core'
|
||||||
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,17 +5,29 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
|
||||||
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class RestTemplateConfig {
|
public class RestTemplateConfig {
|
||||||
|
|
||||||
|
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
|
||||||
|
private static final Duration READ_TIMEOUT = Duration.ofSeconds(30);
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public RestTemplate restTemplate() {
|
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();
|
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);
|
restTemplate.getMessageConverters().add(0, converter);
|
||||||
return restTemplate;
|
return restTemplate;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<PortainerAuthRequest> entity = new HttpEntity<>(request, headers);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ResponseEntity<PortainerAuthResponse> 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<PortainerStack> listStacks() {
|
||||||
|
HttpEntity<Void> entity = buildAuthEntity();
|
||||||
|
|
||||||
|
try {
|
||||||
|
ResponseEntity<List<PortainerStack>> 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<PortainerStack> findStackByName(String name) {
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PortainerStack> 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<PortainerRedeployRequest> entity = new HttpEntity<>(body, buildAuthHeaders());
|
||||||
|
|
||||||
|
try {
|
||||||
|
ResponseEntity<Void> 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<PortainerRedeployRequest> entity = new HttpEntity<>(body, buildAuthHeaders());
|
||||||
|
|
||||||
|
try {
|
||||||
|
ResponseEntity<Void> 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 <T> HttpEntity<T> buildAuthEntity() {
|
||||||
|
return new HttpEntity<>(buildAuthHeaders());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
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() {}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.hithomelabs.common.portainer.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
public class PortainerAuthResponse {
|
||||||
|
|
||||||
|
@JsonProperty("jwt")
|
||||||
|
private String jwt;
|
||||||
|
|
||||||
|
public PortainerAuthResponse() {}
|
||||||
|
|
||||||
|
public String getJwt() {
|
||||||
|
return jwt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setJwt(String jwt) {
|
||||||
|
this.jwt = jwt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
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() {}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
@JsonProperty("Status")
|
||||||
|
private int status;
|
||||||
|
|
||||||
|
@JsonProperty("ResourceControl")
|
||||||
|
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 int getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(int status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getResourceControl() {
|
||||||
|
return resourceControl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setResourceControl(String resourceControl) {
|
||||||
|
this.resourceControl = resourceControl;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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("^(?<registry>[^/]+)/(?<repository>[^:]+):(?<tag>.+)$");
|
||||||
|
|
||||||
|
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 '<registry>/<repository>:<tag>', 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<PortainerAuthResponse> 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<PortainerAuthResponse> 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<PortainerAuthResponse> 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user