Skip to content

Commit

Permalink
feat(config): add config properties for callback URL components (#524)
Browse files Browse the repository at this point in the history
* feat(config): add config properties for callback on Kubernetes

* Use CEL expression for modifying host name candidates

* Clarify README
  • Loading branch information
ebaron authored Nov 4, 2024
1 parent 9ca90a3 commit 5667fe3
Show file tree
Hide file tree
Showing 3 changed files with 230 additions and 0 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,10 @@ and how it advertises itself to a Cryostat server instance. Properties that requ
- [ ] `cryostat.agent.harvester.max-size-b` [`long`]: the JFR `maxsize` setting, specified in bytes, to apply to periodic uploads during the application lifecycle. Defaults to `0`, which means `unlimited`.
- [ ] `cryostat.agent.smart-trigger.definitions` [`String[]`]: a comma-separated list of Smart Trigger definitions to load at startup. Defaults to the empty string: no Smart Triggers.
- [ ] `cryostat.agent.smart-trigger.evaluation.period-ms` [`long`]: the length of time between Smart Trigger evaluations. Default `1000`.
- [ ] `cryostat.agent.callback.scheme` [`String`]: An override for the scheme portion of the `cryostat.agent.callback` URL (e.g. `https`).
- [ ] `cryostat.agent.callback.host-name` [`String[]`]: An override for the host portion of the `cryostat.agent.callback` URL. Supports multiple possible host names. The first host name to resolve when paired with `cryostat.agent.callback.domain-name` will be selected. Supports an optional CEL expression that can be used to transform a provided host name. Only the host name is subject to the provided CEL expression, the domain name is not included. Syntax: `<host name>` or `<host name>[cel-expression]`, the latter evaluates the following CEL expression and uses the result as a host name candidate: `'<host name>'.cel-expression`. Example: `host1, hostx[replace("x"\\, "2")]`, the agent will try to resolve host1, followed by host2.
- [ ] `cryostat.agent.callback.domain-name` [`String`]: An override for the domain portion of the `cryostat.agent.callback` URL. This will be appended to a resolvable host name to form the callback URL.
- [ ] `cryostat.agent.callback.port` [`int`]: An override for the port portion of the `cryostat.agent.callback` URL.
- [ ] `rht.insights.java.opt-out` [`boolean`]: for the Red Hat build of Cryostat, set this to true to disable data collection for Red Hat Insights. Defaults to `false`. Red Hat Insights data collection is always disabled for community builds of Cryostat.
- [ ] `rht.insights.java.debug` [`boolean`]: for the Red Hat build of Cryostat, set this to true to enable debug logging for the Red Hat Insights Java Agent. Defaults to `false`. Red Hat Insights data collection is always disabled for community builds of Cryostat.

Expand Down
113 changes: 113 additions & 0 deletions src/main/java/io/cryostat/agent/ConfigModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import java.io.IOException;
import java.net.InetAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLClassLoader;
import java.net.UnknownHostException;
Expand Down Expand Up @@ -49,8 +50,13 @@

import dagger.Module;
import dagger.Provides;
import org.apache.http.client.utils.URIBuilder;
import org.eclipse.microprofile.config.Config;
import org.eclipse.microprofile.config.ConfigProvider;
import org.projectnessie.cel.extension.StringsLib;
import org.projectnessie.cel.tools.Script;
import org.projectnessie.cel.tools.ScriptException;
import org.projectnessie.cel.tools.ScriptHost;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand Down Expand Up @@ -208,9 +214,20 @@ public abstract class ConfigModule {
public static final String CRYOSTAT_AGENT_SMART_TRIGGER_EVALUATION_PERIOD_MS =
"cryostat.agent.smart-trigger.evaluation.period-ms";

public static final String CRYOSTAT_AGENT_CALLBACK_SCHEME = "cryostat.agent.callback.scheme";
public static final String CRYOSTAT_AGENT_CALLBACK_HOST_NAME =
"cryostat.agent.callback.host-name";
public static final String CRYOSTAT_AGENT_CALLBACK_DOMAIN_NAME =
"cryostat.agent.callback.domain-name";
public static final String CRYOSTAT_AGENT_CALLBACK_PORT = "cryostat.agent.callback.port";

public static final String CRYOSTAT_AGENT_API_WRITES_ENABLED =
"cryostat.agent.api.writes-enabled";

private static final String HOST_SCRIPT_PATTERN_STRING =
"(?<host>[A-Za-z0-9-.]+)(?:\\[(?<script>.+)\\])?";
private static final Pattern HOST_SCRIPT_PATTERN = Pattern.compile(HOST_SCRIPT_PATTERN_STRING);

@Provides
@Singleton
public static Config provideConfig() {
Expand Down Expand Up @@ -251,6 +268,10 @@ public static URI provideCryostatAgentBaseUri(Config config) {
@Singleton
@Named(CRYOSTAT_AGENT_CALLBACK)
public static URI provideCryostatAgentCallback(Config config) {
Optional<URI> callback = buildCallbackFromComponents(config);
if (callback.isPresent()) {
return callback.get();
}
return config.getValue(CRYOSTAT_AGENT_CALLBACK, URI.class);
}

Expand Down Expand Up @@ -968,4 +989,96 @@ public void clear() {
Arrays.fill(this.buf, (byte) 0);
}
}

private static Optional<URI> buildCallbackFromComponents(Config config) {
Optional<String> scheme =
config.getOptionalValue(CRYOSTAT_AGENT_CALLBACK_SCHEME, String.class);
Optional<String[]> hostNames =
config.getOptionalValue(CRYOSTAT_AGENT_CALLBACK_HOST_NAME, String[].class);
Optional<String> domainName =
config.getOptionalValue(CRYOSTAT_AGENT_CALLBACK_DOMAIN_NAME, String.class);
Optional<Integer> port =
config.getOptionalValue(CRYOSTAT_AGENT_CALLBACK_PORT, Integer.class);
if (scheme.isPresent()
&& hostNames.isPresent()
&& domainName.isPresent()
&& port.isPresent()) {

// Try resolving the each provided host name in order as a DNS name
Optional<String> resolvedHost =
computeHostNames(hostNames.get()).stream()
.sequential()
.map(name -> name + "." + domainName.get())
.filter(host -> tryResolveHostname(host))
.findFirst();

// If none of the above resolved, then throw an error
if (resolvedHost.isEmpty()) {
throw new RuntimeException(
"Failed to resolve hostname, consider disabling hostname verification in"
+ " Cryostat for the agent callback");
}

try {
URI result =
new URIBuilder()
.setScheme(scheme.get())
.setHost(resolvedHost.get())
.setPort(port.get())
.build();
log.debug("Using {} for callback URL", result.toASCIIString());
return Optional.of(result);
} catch (URISyntaxException e) {
throw new IllegalArgumentException(e);
}
}
return Optional.empty();
}

private static List<String> computeHostNames(String[] hostNames) {
ScriptHost scriptHost = ScriptHost.newBuilder().build();
List<String> result = new ArrayList<>(hostNames.length);
for (String hostName : hostNames) {
Matcher m = HOST_SCRIPT_PATTERN.matcher(hostName);
if (!m.matches()) {
throw new RuntimeException(
"Invalid hostname argument encountered: "
+ hostName
+ ". Expected format: \"hostname\" or \"hostname[cel-script]\".");
}
if (m.group("script") == null) {
result.add(m.group("host"));
} else {
result.add(evaluateHostnameScript(scriptHost, m.group("script"), m.group("host")));
}
}

return result;
}

private static String evaluateHostnameScript(
ScriptHost scriptHost, String scriptText, String hostname) {
try {
Script script =
scriptHost
.buildScript("\"" + hostname + "\"." + scriptText)
.withLibraries(new StringsLib())
.build();
Map<String, Object> args = new HashMap<>();
return script.execute(String.class, args);
} catch (ScriptException e) {
throw new RuntimeException("Failed to execute provided CEL script", e);
}
}

private static boolean tryResolveHostname(String hostname) {
try {
log.debug("Attempting to resolve {}", hostname);
InetAddress addr = InetAddress.getByName(hostname);
log.debug("Resolved {} to {}", hostname, addr.getHostAddress());
return true;
} catch (UnknownHostException ignored) {
return false;
}
}
}
113 changes: 113 additions & 0 deletions src/test/java/io/cryostat/agent/ConfigModuleTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* Copyright The Cryostat Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.cryostat.agent;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.when;

import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.util.Optional;

import org.eclipse.microprofile.config.Config;
import org.junit.jupiter.api.AfterEach;
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.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
public class ConfigModuleTest {

@Mock Config config;
@Mock InetAddress addr;
MockedStatic<InetAddress> addrMock;

@BeforeEach
void setupEach() throws Exception {
addrMock = Mockito.mockStatic(InetAddress.class);
}

@AfterEach
void teardownEach() {
addrMock.close();
}

@Test
void testCallback() throws Exception {
when(config.getValue(ConfigModule.CRYOSTAT_AGENT_CALLBACK, URI.class))
.thenReturn(new URI("https://callback.example.com:9977"));

URI result = ConfigModule.provideCryostatAgentCallback(config);
assertEquals("https://callback.example.com:9977", result.toASCIIString());
}

@Test
void testCallbackComponentsHostname() throws Exception {
setupCallbackComponents();

when(addr.getHostAddress()).thenReturn("10.2.3.4");
addrMock.when(() -> InetAddress.getByName("foo.headless.svc.example.com")).thenReturn(addr);

URI result = ConfigModule.provideCryostatAgentCallback(config);
assertEquals("https://foo.headless.svc.example.com:9977", result.toASCIIString());
}

@Test
void testCallbackComponentsDashedIP() throws Exception {
setupCallbackComponents();

when(addr.getHostAddress()).thenReturn("10.2.3.4");
addrMock.when(() -> InetAddress.getByName("foo.headless.svc.example.com"))
.thenThrow(new UnknownHostException("TEST"));
addrMock.when(() -> InetAddress.getByName("10-2-3-4.headless.svc.example.com"))
.thenReturn(addr);

URI result = ConfigModule.provideCryostatAgentCallback(config);
assertEquals("https://10-2-3-4.headless.svc.example.com:9977", result.toASCIIString());
}

@Test
void testCallbackComponentsNoMatch() throws Exception {
setupCallbackComponents();

addrMock.when(() -> InetAddress.getByName("foo.headless.svc.example.com"))
.thenThrow(new UnknownHostException("TEST"));
addrMock.when(() -> InetAddress.getByName("10-2-3-4.headless.svc.example.com"))
.thenThrow(new UnknownHostException("TEST"));

assertThrows(
RuntimeException.class, () -> ConfigModule.provideCryostatAgentCallback(config));
}

private void setupCallbackComponents() {
when(config.getOptionalValue(
ConfigModule.CRYOSTAT_AGENT_CALLBACK_DOMAIN_NAME, String.class))
.thenReturn(Optional.of("headless.svc.example.com"));
when(config.getOptionalValue(
ConfigModule.CRYOSTAT_AGENT_CALLBACK_HOST_NAME, String[].class))
.thenReturn(Optional.of(new String[] {"foo", "10.2.3.4[replace(\".\", \"-\")]"}));
when(config.getOptionalValue(ConfigModule.CRYOSTAT_AGENT_CALLBACK_PORT, Integer.class))
.thenReturn(Optional.of(9977));
when(config.getOptionalValue(ConfigModule.CRYOSTAT_AGENT_CALLBACK_SCHEME, String.class))
.thenReturn(Optional.of("https"));
}
}

0 comments on commit 5667fe3

Please sign in to comment.