Hithomelabs/CFTunnels#87: Add PortainerApiClient, DTOs, ImageReference, and exception classes

This commit is contained in:
hitanshu310 2026-07-05 16:54:23 +05:30
parent 9fb4c4fb14
commit e515532d99
11 changed files with 513 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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