Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(config): add config properties for callback URL components #524

Merged
merged 3 commits into from
Nov 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"));
}
}
Loading