Skip to content

Commit fa876af

Browse files
ARTEMIS-6046 Kubernetes LockManager implementation
This commit introduces a Kubernetes-based distributed lock implementation using a generic HTTP REST client abstracted from KubernetesLoginModule. Key changes: - Extracted reusable Kubernetes HTTP client to artemis-commons - Implemented KubernetesLockManager using Kubernetes Lease API - Implemented KubeMutableLong using Kubernetes ConfigMap for distributed counters - Moved PemSupport and extracted KeyStoreSupport to artemis-commons to avoid circular dependencies - Added AbstractDistributedLockManager base class with parameter validation - Added tests using LockCoordinatorTest against real Kubernetes (via Minikube) and FakeMinikube MockServer - Added user manual documentation with RBAC configuration examples - Added smoke test configurations for Kubernetes-based dual-mirror setup Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent e901f71 commit fa876af

58 files changed

Lines changed: 3679 additions & 404 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

artemis-bom/pom.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,11 @@
191191
<artifactId>artemis-lockmanager-ri</artifactId>
192192
<version>${project.version}</version>
193193
</dependency>
194+
<dependency>
195+
<groupId>org.apache.artemis</groupId>
196+
<artifactId>artemis-kube-lock</artifactId>
197+
<version>${project.version}</version>
198+
</dependency>
194199
<dependency>
195200
<groupId>org.apache.artemis</groupId>
196201
<artifactId>artemis-ra</artifactId>
@@ -393,6 +398,11 @@
393398
<artifactId>artemis-lockmanager-ri</artifactId>
394399
<version>${project.version}</version>
395400
</dependency>
401+
<dependency>
402+
<groupId>org.apache.activemq</groupId>
403+
<artifactId>artemis-kube-lock</artifactId>
404+
<version>${project.version}</version>
405+
</dependency>
396406
<dependency>
397407
<groupId>org.apache.activemq</groupId>
398408
<artifactId>artemis-ra</artifactId>

artemis-commons/pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@
8383
<groupId>io.netty</groupId>
8484
<artifactId>netty-transport</artifactId>
8585
</dependency>
86+
<dependency>
87+
<groupId>de.dentrassi.crypto</groupId>
88+
<artifactId>pem-keystore</artifactId>
89+
</dependency>
8690
<dependency>
8791
<groupId>commons-beanutils</groupId>
8892
<artifactId>commons-beanutils</artifactId>

artemis-commons/src/main/java/org/apache/activemq/artemis/utils/FileUtil.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,15 @@ public static boolean findReplace(File file, String find, String replace) throws
131131
}
132132
}
133133

134+
public static boolean append(File file, String append) throws Exception {
135+
if (!file.exists()) {
136+
return false;
137+
}
138+
139+
Files.writeString(file.toPath(), append, java.nio.file.StandardOpenOption.APPEND);
140+
return true;
141+
}
142+
134143
public static String readFile(InputStream inputStream) throws Exception {
135144
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
136145
String fileOutput = bufferedReader.lines().collect(Collectors.joining(System.lineSeparator()));
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.activemq.artemis.utils.kubernetes;
18+
19+
/**
20+
* Base exception for Kubernetes API errors
21+
*/
22+
public class KubernetesApiException extends Exception {
23+
24+
private final int statusCode;
25+
private final String responseBody;
26+
27+
protected KubernetesApiException(String errorMessage) {
28+
super(errorMessage);
29+
this.statusCode = -1;
30+
this.responseBody = "InternalError:" + errorMessage;
31+
}
32+
33+
protected KubernetesApiException(String errorMessage, Exception e) {
34+
super(errorMessage, e);
35+
this.statusCode = -1;
36+
this.responseBody = "InternalError:" + errorMessage;
37+
}
38+
39+
public KubernetesApiException(int statusCode, String responseBody) {
40+
super("HTTP " + statusCode + ": " + responseBody);
41+
this.statusCode = statusCode;
42+
this.responseBody = responseBody;
43+
}
44+
45+
public int getStatusCode() {
46+
return statusCode;
47+
}
48+
49+
public String getResponseBody() {
50+
return responseBody;
51+
}
52+
}
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.activemq.artemis.utils.kubernetes;
18+
19+
import javax.net.ssl.SSLContext;
20+
import javax.net.ssl.TrustManagerFactory;
21+
import java.io.File;
22+
import java.io.IOException;
23+
import java.io.StringReader;
24+
import java.net.URI;
25+
import java.net.http.HttpClient;
26+
import java.net.http.HttpRequest;
27+
import java.net.http.HttpResponse;
28+
import java.nio.file.Path;
29+
import java.security.KeyStore;
30+
import java.security.SecureRandom;
31+
import java.util.Map;
32+
import java.util.Scanner;
33+
import java.util.concurrent.ConcurrentHashMap;
34+
35+
import org.apache.activemq.artemis.json.JsonObject;
36+
import org.apache.activemq.artemis.utils.JsonLoader;
37+
import org.apache.activemq.artemis.utils.ssl.KeyStoreSupport;
38+
import org.slf4j.Logger;
39+
import org.slf4j.LoggerFactory;
40+
41+
public class KubernetesClient {
42+
43+
private static final Logger logger = LoggerFactory.getLogger(KubernetesClient.class);
44+
45+
public static final String KUBERNETES_HOST = "KUBERNETES_SERVICE_HOST";
46+
public static final String KUBERNETES_PORT = "KUBERNETES_SERVICE_PORT";
47+
public static final String KUBERNETES_TOKEN_PATH = "KUBERNETES_TOKEN_PATH";
48+
public static final String KUBERNETES_CA_PATH = "KUBERNETES_CA_PATH";
49+
public static final String KUBERNETES_API_URI = "KUBERNETES_API_URI";
50+
51+
private static final String DEFAULT_KUBERNETES_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token";
52+
private static final String DEFAULT_KUBERNETES_CA_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt";
53+
54+
private static String authToken;
55+
56+
private static volatile HttpClient _httpClient;
57+
private static volatile Map<String, String> params;
58+
private static URI apiUri;
59+
60+
private KubernetesClient() {
61+
}
62+
63+
public static HttpClient getHttpClient() throws KubernetesApiException {
64+
HttpClient result = _httpClient;
65+
if (result != null) {
66+
return result;
67+
}
68+
synchronized (KubernetesClient.class) {
69+
if (_httpClient == null) {
70+
try {
71+
_httpClient = HttpClient.newBuilder().sslContext(buildSSLContext()).build();
72+
} catch (Exception e) {
73+
logger.error("Unable to build a valid SSLContext or HttpClient", e);
74+
}
75+
}
76+
if (authToken == null) {
77+
String tokenPath = getParam(KUBERNETES_TOKEN_PATH, DEFAULT_KUBERNETES_TOKEN_PATH);
78+
try {
79+
logger.debug("Loading client authentication token from {}", tokenPath);
80+
authToken = readFile(tokenPath);
81+
logger.debug("Loaded client authentication token from {}", tokenPath);
82+
} catch (IOException e) {
83+
logger.error("Cannot retrieve Service Account Authentication Token from " + tokenPath, e);
84+
throw new KubernetesInternalException("cannot retrieve token");
85+
}
86+
}
87+
88+
{
89+
String apiURIParameter = getParam(KUBERNETES_API_URI);
90+
if (apiURIParameter != null) {
91+
apiUri = URI.create(apiURIParameter);
92+
}
93+
}
94+
95+
if (apiUri == null) {
96+
String host = getParam(KUBERNETES_HOST);
97+
String port = getParam(KUBERNETES_PORT);
98+
apiUri = URI.create("https://" + host + ":" + port);
99+
}
100+
}
101+
return _httpClient;
102+
}
103+
104+
// for tests
105+
public static void setParam(String name, String value) {
106+
if (params == null) {
107+
synchronized (KubernetesClient.class) {
108+
if (params == null) {
109+
params = new ConcurrentHashMap<>();
110+
}
111+
}
112+
}
113+
params.put(name, value);
114+
}
115+
116+
// for tests
117+
public static void clear(boolean clearParams) {
118+
if (clearParams) {
119+
if (params != null) {
120+
params = null;
121+
}
122+
}
123+
_httpClient = null;
124+
authToken = null;
125+
apiUri = null;
126+
}
127+
128+
public static String getParam(String name, String defaultValue) {
129+
String value = null;
130+
if (params != null) {
131+
value = params.get(name);
132+
}
133+
if (value == null) {
134+
value = System.getProperty(name);
135+
}
136+
if (value == null) {
137+
value = System.getenv(name);
138+
}
139+
if (value == null) {
140+
value = defaultValue;
141+
}
142+
return value;
143+
}
144+
145+
public static String getParam(String name) {
146+
return getParam(name, null);
147+
}
148+
149+
public static JsonObject get(String path) throws KubernetesApiException {
150+
HttpClient theClient = getHttpClient();
151+
HttpRequest request = HttpRequest.newBuilder()
152+
.uri(apiUri.resolve(path))
153+
.header("Authorization", "Bearer " + authToken)
154+
.header("Accept", "application/json; charset=utf-8")
155+
.GET()
156+
.build();
157+
158+
HttpResponse<String> response = doSend(theClient, request);
159+
160+
if (response.statusCode() == 404) {
161+
return null;
162+
}
163+
164+
if (response.statusCode() < 200 || response.statusCode() >= 300) {
165+
throw new KubernetesApiException(response.statusCode(), response.body());
166+
}
167+
168+
return JsonLoader.readObject(new StringReader(response.body()));
169+
}
170+
171+
private static HttpResponse<String> doSend(HttpClient theClient,
172+
HttpRequest request) throws KubernetesApiException {
173+
HttpResponse<String> response;
174+
try {
175+
response = theClient.send(request, HttpResponse.BodyHandlers.ofString());
176+
} catch (InterruptedException | IOException e) {
177+
throw new KubernetesInternalException(e.getMessage(), e);
178+
}
179+
return response;
180+
}
181+
182+
public static JsonObject put(String path, String jsonBody) throws KubernetesApiException {
183+
HttpClient theClient = getHttpClient();
184+
HttpRequest request = HttpRequest.newBuilder()
185+
.uri(apiUri.resolve(path))
186+
.header("Authorization", "Bearer " + authToken)
187+
.header("Content-Type", "application/json")
188+
.header("Accept", "application/json; charset=utf-8")
189+
.PUT(HttpRequest.BodyPublishers.ofString(jsonBody))
190+
.build();
191+
192+
HttpResponse<String> response = doSend(theClient, request);
193+
194+
if (response.statusCode() == 409) {
195+
throw new KubernetesConflictException(response.body());
196+
}
197+
198+
if (response.statusCode() < 200 || response.statusCode() >= 300) {
199+
throw new KubernetesApiException(response.statusCode(), response.body());
200+
}
201+
202+
return JsonLoader.readObject(new StringReader(response.body()));
203+
}
204+
205+
public static void delete(String path) throws KubernetesApiException {
206+
HttpClient theClient = getHttpClient();
207+
HttpRequest request = HttpRequest.newBuilder()
208+
.uri(apiUri.resolve(path))
209+
.header("Authorization", "Bearer " + authToken)
210+
.header("Accept", "application/json")
211+
.DELETE()
212+
.build();
213+
214+
HttpResponse<String> response = doSend(theClient, request);
215+
216+
if (response.statusCode() == 409) {
217+
throw new KubernetesConflictException(response.body());
218+
}
219+
220+
if (response.statusCode() != 404 && (response.statusCode() < 200 || response.statusCode() >= 300)) {
221+
throw new KubernetesApiException(response.statusCode(), response.body());
222+
}
223+
}
224+
225+
public static JsonObject post(String path, String jsonBody) throws KubernetesApiException {
226+
HttpClient theClient = getHttpClient();
227+
HttpRequest request = HttpRequest.newBuilder()
228+
.uri(apiUri.resolve(path))
229+
.header("Authorization", "Bearer " + authToken)
230+
.header("Accept", "application/json; charset=utf-8")
231+
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
232+
.build();
233+
234+
HttpResponse<String> response = doSend(theClient, request);
235+
236+
if (response.statusCode() == 409) {
237+
throw new KubernetesConflictException(response.body());
238+
}
239+
240+
if (response.statusCode() < 200 || response.statusCode() >= 300) {
241+
throw new KubernetesApiException(response.statusCode(), response.body());
242+
}
243+
244+
return JsonLoader.readObject(new StringReader(response.body()));
245+
}
246+
247+
private static String readFile(String path) throws IOException {
248+
try (Scanner scanner = new Scanner(Path.of(path))) {
249+
StringBuilder buffer = new StringBuilder();
250+
while (scanner.hasNextLine()) {
251+
String line = scanner.nextLine();
252+
if (!line.isBlank() && !line.startsWith("#")) {
253+
buffer.append(line);
254+
}
255+
}
256+
return buffer.toString();
257+
}
258+
}
259+
260+
private static SSLContext buildSSLContext() throws Exception {
261+
SSLContext ctx = SSLContext.getInstance("TLS");
262+
String caPath = getParam(KUBERNETES_CA_PATH, DEFAULT_KUBERNETES_CA_PATH);
263+
File certFile = new File(caPath);
264+
if (!certFile.exists()) {
265+
throw new KubernetesInternalException("no certFile available");
266+
}
267+
KeyStore trustStore = KeyStoreSupport.loadKeystore(null, KeyStoreSupport.PEMCA, caPath, null);
268+
TrustManagerFactory tmFactory = TrustManagerFactory
269+
.getInstance(TrustManagerFactory.getDefaultAlgorithm());
270+
tmFactory.init(trustStore);
271+
272+
ctx.init(null, tmFactory.getTrustManagers(), new SecureRandom());
273+
return ctx;
274+
}
275+
}

0 commit comments

Comments
 (0)