diff --git a/compose/sample-apps.yml b/compose/sample-apps.yml index 6166e2489..c646bad87 100644 --- a/compose/sample-apps.yml +++ b/compose/sample-apps.yml @@ -113,6 +113,7 @@ services: CRYOSTAT_AGENT_HARVESTER_MAX_FILES: 3 CRYOSTAT_AGENT_HARVESTER_EXIT_MAX_AGE_MS: 60000 CRYOSTAT_AGENT_HARVESTER_EXIT_MAX_SIZE_B: 153600 # "$(echo 1024*150 | bc)" + CRYOSTAT_AGENT_API_WRITES_ENABLED: "true" restart: always healthcheck: test: curl --fail http://localhost:10010 || exit 1 diff --git a/src/main/java/io/cryostat/JsonRequestFilter.java b/src/main/java/io/cryostat/JsonRequestFilter.java index a63ee9d67..3f541a803 100644 --- a/src/main/java/io/cryostat/JsonRequestFilter.java +++ b/src/main/java/io/cryostat/JsonRequestFilter.java @@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.inject.Inject; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.container.ContainerRequestFilter; import jakarta.ws.rs.core.MediaType; @@ -36,7 +37,7 @@ public class JsonRequestFilter implements ContainerRequestFilter { static final Set allowedPaths = Set.of("/api/v2.2/discovery", "/api/beta/matchexpressions"); - private final ObjectMapper objectMapper = new ObjectMapper(); + @Inject ObjectMapper objectMapper; @Override public void filter(ContainerRequestContext requestContext) throws IOException { diff --git a/src/main/java/io/cryostat/credentials/CredentialsFinder.java b/src/main/java/io/cryostat/credentials/CredentialsFinder.java new file mode 100644 index 000000000..b773d15b3 --- /dev/null +++ b/src/main/java/io/cryostat/credentials/CredentialsFinder.java @@ -0,0 +1,55 @@ +/* + * 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.credentials; + +import java.net.URI; +import java.util.Optional; + +import io.cryostat.expressions.MatchExpressionEvaluator; +import io.cryostat.targets.Target; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; +import org.projectnessie.cel.tools.ScriptException; + +@ApplicationScoped +public class CredentialsFinder { + + @Inject MatchExpressionEvaluator expressionEvaluator; + @Inject Logger logger; + + public Optional getCredentialsForTarget(Target target) { + return Credential.listAll().stream() + .filter( + c -> { + try { + return expressionEvaluator.applies(c.matchExpression, target); + } catch (ScriptException e) { + logger.error(e); + return false; + } + }) + .findFirst(); + } + + public Optional getCredentialsForConnectUrl(URI connectUrl) { + return Target.find("connectUrl", connectUrl) + .firstResultOptional() + .map(this::getCredentialsForTarget) + .orElse(Optional.empty()); + } +} diff --git a/src/main/java/io/cryostat/discovery/ContainerDiscovery.java b/src/main/java/io/cryostat/discovery/ContainerDiscovery.java index 1ce795ff2..48dffc944 100644 --- a/src/main/java/io/cryostat/discovery/ContainerDiscovery.java +++ b/src/main/java/io/cryostat/discovery/ContainerDiscovery.java @@ -251,16 +251,14 @@ private void doContainerListRequest(Consumer> successHandler item.body(), new TypeReference>() {})); } catch (JsonProcessingException e) { - logger.error("Json processing error"); + logger.error("Json processing error", e); } }, failure -> { - logger.error( - String.format("%s API request failed", getRealm()), - failure); + logger.errorv(failure, "{0} API request failed", getRealm()); }); } catch (JsonProcessingException e) { - logger.error("Json processing error"); + logger.error("Json processing error", e); } } @@ -279,13 +277,12 @@ private CompletableFuture doContainerInspectRequest(ContainerS result.complete( mapper.readValue(item.body(), ContainerDetails.class)); } catch (JsonProcessingException e) { - logger.error("Json processing error"); + logger.error("Json processing error", e); result.completeExceptionally(e); } }, failure -> { - logger.error( - String.format("%s API request failed", getRealm()), failure); + logger.errorv(failure, "{0} API request failed", getRealm()); result.completeExceptionally(failure); }); return result; @@ -322,7 +319,7 @@ public void handleContainerEvent(ContainerSpec desc, EventKind evtKind) { .Hostname; } catch (InterruptedException | TimeoutException | ExecutionException e) { containers.remove(desc); - logger.warn(String.format("Invalid %s target observed", getRealm()), e); + logger.warnv(e, "Invalid {0} target observed", getRealm()); return; } } @@ -331,7 +328,7 @@ public void handleContainerEvent(ContainerSpec desc, EventKind evtKind) { connectUrl = URI.create(serviceUrl.toString()); } catch (MalformedURLException | URISyntaxException e) { containers.remove(desc); - logger.warn(String.format("Invalid %s target observed", getRealm()), e); + logger.warnv(e, "Invalid {0} target observed", getRealm()); return; } diff --git a/src/main/java/io/cryostat/recordings/RecordingHelper.java b/src/main/java/io/cryostat/recordings/RecordingHelper.java index 07f219cce..a883b49ac 100644 --- a/src/main/java/io/cryostat/recordings/RecordingHelper.java +++ b/src/main/java/io/cryostat/recordings/RecordingHelper.java @@ -223,7 +223,7 @@ public ActiveRecording startRecording( IRecordingDescriptor desc = connection .getService() - .start(recordingOptions, enableEvents(target, eventTemplate)); + .start(recordingOptions, eventTemplate.getName(), eventTemplate.getType()); Map labels = metadata.labels(); labels.put("template.name", eventTemplate.getName()); diff --git a/src/main/java/io/cryostat/recordings/Recordings.java b/src/main/java/io/cryostat/recordings/Recordings.java index 0bcea5e3c..9e359a59d 100644 --- a/src/main/java/io/cryostat/recordings/Recordings.java +++ b/src/main/java/io/cryostat/recordings/Recordings.java @@ -577,7 +577,7 @@ public Response createRecording( @RestForm Optional replace, // restart param is deprecated, only 'replace' should be used and takes priority if both // are provided - @RestForm Optional restart, + @Deprecated @RestForm Optional restart, @RestForm Optional duration, @RestForm Optional toDisk, @RestForm Optional maxAge, diff --git a/src/main/java/io/cryostat/rules/RuleService.java b/src/main/java/io/cryostat/rules/RuleService.java index bf726e608..7db738c77 100644 --- a/src/main/java/io/cryostat/rules/RuleService.java +++ b/src/main/java/io/cryostat/rules/RuleService.java @@ -249,10 +249,11 @@ private void scheduleArchival(Rule rule, Target target, ActiveRecording recordin try { quartz.scheduleJob(jobDetail, trigger); } catch (SchedulerException e) { - logger.infov( + logger.errorv( + e, "Failed to schedule archival job for rule {0} in target {1}", - rule.name, target.alias); - logger.error(e); + rule.name, + target.alias); } jobs.add(jobDetail.getKey()); } diff --git a/src/main/java/io/cryostat/targets/AgentConnectionFactory.java b/src/main/java/io/cryostat/targets/AgentApiException.java similarity index 58% rename from src/main/java/io/cryostat/targets/AgentConnectionFactory.java rename to src/main/java/io/cryostat/targets/AgentApiException.java index 6b87538b7..b67bfdb02 100644 --- a/src/main/java/io/cryostat/targets/AgentConnectionFactory.java +++ b/src/main/java/io/cryostat/targets/AgentApiException.java @@ -15,22 +15,10 @@ */ package io.cryostat.targets; -import java.net.URI; +import jakarta.ws.rs.WebApplicationException; -import io.cryostat.core.sys.Clock; - -import io.vertx.mutiny.core.Vertx; -import io.vertx.mutiny.ext.web.client.WebClient; -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; - -@ApplicationScoped -class AgentConnectionFactory { - - @Inject Vertx vertx; - @Inject Clock clock; - - AgentConnection createConnection(URI agentUri) { - return new AgentConnection(agentUri, WebClient.create(vertx), clock); +public class AgentApiException extends WebApplicationException { + public AgentApiException(int statusCode) { + super(String.format("Unexpected HTTP response code %d", statusCode)); } } diff --git a/src/main/java/io/cryostat/targets/AgentClient.java b/src/main/java/io/cryostat/targets/AgentClient.java new file mode 100644 index 000000000..2da1fc5cb --- /dev/null +++ b/src/main/java/io/cryostat/targets/AgentClient.java @@ -0,0 +1,566 @@ +/* + * 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.targets; + +import java.net.URI; +import java.time.Duration; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.openjdk.jmc.common.unit.IConstrainedMap; +import org.openjdk.jmc.common.unit.IConstraint; +import org.openjdk.jmc.common.unit.IMutableConstrainedMap; +import org.openjdk.jmc.common.unit.IOptionDescriptor; +import org.openjdk.jmc.common.unit.QuantityConversionException; +import org.openjdk.jmc.common.unit.SimpleConstrainedMap; +import org.openjdk.jmc.flightrecorder.configuration.events.EventOptionID; +import org.openjdk.jmc.flightrecorder.configuration.events.IEventTypeID; +import org.openjdk.jmc.flightrecorder.configuration.internal.EventTypeIDV2; +import org.openjdk.jmc.rjmx.services.jfr.IEventTypeInfo; +import org.openjdk.jmc.rjmx.services.jfr.IRecordingDescriptor; + +import io.cryostat.core.net.MBeanMetrics; +import io.cryostat.core.serialization.SerializableRecordingDescriptor; +import io.cryostat.credentials.Credential; +import io.cryostat.credentials.CredentialsFinder; +import io.cryostat.targets.AgentJFRService.StartRecordingRequest; +import io.cryostat.util.HttpStatusCodeIdentifier; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.unchecked.Unchecked; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.authentication.UsernamePasswordCredentials; +import io.vertx.mutiny.core.buffer.Buffer; +import io.vertx.mutiny.ext.web.client.HttpRequest; +import io.vertx.mutiny.ext.web.client.HttpResponse; +import io.vertx.mutiny.ext.web.client.WebClient; +import io.vertx.mutiny.ext.web.codec.BodyCodec; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.ForbiddenException; +import jdk.jfr.RecordingState; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.hc.client5.http.auth.InvalidCredentialsException; +import org.jboss.logging.Logger; + +public class AgentClient { + + public static final String NULL_CREDENTIALS = "No credentials found for agent"; + + private final Target target; + private final WebClient webClient; + private final Duration httpTimeout; + private final ObjectMapper mapper; + private final CredentialsFinder credentialsFinder; + private final Logger logger = Logger.getLogger(getClass()); + + private AgentClient( + Target target, + WebClient webClient, + ObjectMapper mapper, + Duration httpTimeout, + CredentialsFinder credentialsFinder) { + this.target = target; + this.webClient = webClient; + this.mapper = mapper; + this.httpTimeout = httpTimeout; + this.credentialsFinder = credentialsFinder; + } + + Target getTarget() { + return target; + } + + URI getUri() { + return getTarget().connectUrl; + } + + Uni ping() { + return invoke(HttpMethod.GET, "/", BodyCodec.none()) + .map(HttpResponse::statusCode) + .map(HttpStatusCodeIdentifier::isSuccessCode); + } + + Uni mbeanMetrics() { + return invoke(HttpMethod.GET, "/mbean-metrics/", BodyCodec.string()) + .map(HttpResponse::body) + .map(Unchecked.function(s -> mapper.readValue(s, MBeanMetrics.class))); + } + + Uni startRecording(StartRecordingRequest req) { + try { + return invoke( + HttpMethod.POST, + "/recordings/", + Buffer.buffer(mapper.writeValueAsBytes(req)), + BodyCodec.string()) + .map( + Unchecked.function( + resp -> { + int statusCode = resp.statusCode(); + if (HttpStatusCodeIdentifier.isSuccessCode(statusCode)) { + String body = resp.body(); + return mapper.readValue( + body, + SerializableRecordingDescriptor.class) + .toJmcForm(); + } else if (statusCode == 403) { + logger.errorv( + "startRecording for {0} failed: HTTP 403", + getUri()); + throw new ForbiddenException( + new UnsupportedOperationException( + "startRecording")); + } else { + logger.errorv( + "startRecording for {0} failed: HTTP {1}", + getUri(), statusCode); + throw new AgentApiException(statusCode); + } + })); + } catch (JsonProcessingException e) { + logger.error("startRecording request failed", e); + return Uni.createFrom().failure(e); + } + } + + Uni startSnapshot() { + try { + return invoke( + HttpMethod.POST, + "/recordings/", + Buffer.buffer( + mapper.writeValueAsBytes( + new StartRecordingRequest( + "snapshot", "", "", 0, 0, 0))), + BodyCodec.string()) + .map( + Unchecked.function( + resp -> { + int statusCode = resp.statusCode(); + if (HttpStatusCodeIdentifier.isSuccessCode(statusCode)) { + String body = resp.body(); + return mapper.readValue( + body, + SerializableRecordingDescriptor.class) + .toJmcForm(); + } else if (statusCode == 403) { + throw new ForbiddenException( + new UnsupportedOperationException( + "startSnapshot")); + } else { + throw new AgentApiException(statusCode); + } + })); + } catch (JsonProcessingException e) { + logger.error(e); + return Uni.createFrom().failure(e); + } + } + + Uni updateRecordingOptions(long id, IConstrainedMap newSettings) { + Map settings = new HashMap<>(newSettings.keySet().size()); + for (String key : newSettings.keySet()) { + Object value = newSettings.get(key); + if (value == null) { + continue; + } + if (value instanceof String && StringUtils.isBlank((String) value)) { + continue; + } + settings.put(key, value); + } + + try { + return invoke( + HttpMethod.PATCH, + String.format("/recordings/%d", id), + Buffer.buffer(mapper.writeValueAsBytes(settings)), + BodyCodec.none()) + .map( + resp -> { + int statusCode = resp.statusCode(); + if (HttpStatusCodeIdentifier.isSuccessCode(statusCode)) { + return 0; + } else if (statusCode == 403) { + throw new ForbiddenException( + new UnsupportedOperationException( + "updateRecordingOptions")); + } else { + throw new AgentApiException(statusCode); + } + }); + } catch (JsonProcessingException e) { + logger.error(e); + return Uni.createFrom().failure(e); + } + } + + Uni openStream(long id) { + return invoke(HttpMethod.GET, "/recordings/" + id, BodyCodec.buffer()) + .map( + resp -> { + int statusCode = resp.statusCode(); + if (HttpStatusCodeIdentifier.isSuccessCode(statusCode)) { + return resp.body(); + } else if (statusCode == 403) { + throw new ForbiddenException( + new UnsupportedOperationException("openStream")); + } else { + throw new AgentApiException(statusCode); + } + }); + } + + Uni stopRecording(long id) { + // FIXME this is a terrible hack, the interfaces here should not require only an + // IConstrainedMap with IOptionDescriptors but allow us to pass other and more simply + // serializable data to the Agent, such as this recording state entry + IConstrainedMap map = + new IConstrainedMap() { + @Override + public Set keySet() { + return Set.of("state"); + } + + @Override + public Object get(String key) { + return RecordingState.STOPPED.name(); + } + + @Override + public IConstraint getConstraint(String key) { + throw new UnsupportedOperationException(); + } + + @Override + public String getPersistableString(String key) { + throw new UnsupportedOperationException(); + } + + @Override + public IMutableConstrainedMap emptyWithSameConstraints() { + throw new UnsupportedOperationException(); + } + + @Override + public IMutableConstrainedMap mutableCopy() { + throw new UnsupportedOperationException(); + } + }; + return updateRecordingOptions(id, map); + } + + Uni deleteRecording(long id) { + return invoke( + HttpMethod.DELETE, + String.format("/recordings/%d", id), + Buffer.buffer(), + BodyCodec.none()) + .map( + resp -> { + int statusCode = resp.statusCode(); + if (HttpStatusCodeIdentifier.isSuccessCode(statusCode)) { + return 0; + } else if (statusCode == 403) { + throw new ForbiddenException( + new UnsupportedOperationException("deleteRecording")); + } else { + throw new AgentApiException(statusCode); + } + }); + } + + Uni> activeRecordings() { + return invoke(HttpMethod.GET, "/recordings/", BodyCodec.string()) + .map(HttpResponse::body) + .map( + s -> { + try { + return mapper.readValue( + s, + new TypeReference< + List>() {}); + } catch (JsonProcessingException e) { + logger.error(e); + return List.of(); + } + }) + .map(arr -> arr.stream().map(SerializableRecordingDescriptor::toJmcForm).toList()); + } + + Uni> eventTypes() { + return invoke(HttpMethod.GET, "/event-types/", BodyCodec.jsonArray()) + .map(HttpResponse::body) + .map(arr -> arr.stream().map(o -> new AgentEventTypeInfo((JsonObject) o)).toList()); + } + + Uni> eventSettings() { + return invoke(HttpMethod.GET, "/event-settings/", BodyCodec.jsonArray()) + .map(HttpResponse::body) + .map( + arr -> { + return arr.stream() + .map( + o -> { + JsonObject json = (JsonObject) o; + String eventName = json.getString("name"); + JsonArray jsonSettings = + json.getJsonArray("settings"); + Map settings = new HashMap<>(); + jsonSettings.forEach( + s -> { + JsonObject j = (JsonObject) s; + settings.put( + j.getString("name"), + j.getString("defaultValue")); + }); + return Pair.of(eventName, settings); + }) + .toList(); + }) + .map( + list -> { + SimpleConstrainedMap result = + new SimpleConstrainedMap(null); + list.forEach( + item -> { + item.getRight() + .forEach( + (key, val) -> { + try { + result.put( + new EventOptionID( + new EventTypeIDV2( + item + .getLeft()), + key), + null, + val); + } catch ( + QuantityConversionException + qce) { + logger.warn( + "Event settings exception", + qce); + } + }); + }); + return result; + }); + } + + Uni> eventTemplates() { + return invoke(HttpMethod.GET, "/event-templates/", BodyCodec.jsonArray()) + .map(HttpResponse::body) + .map(arr -> arr.stream().map(Object::toString).toList()); + } + + private Uni> invoke(HttpMethod mtd, String path, BodyCodec codec) { + return invoke(mtd, path, null, codec); + } + + private Uni> invoke( + HttpMethod mtd, String path, Buffer payload, BodyCodec codec) { + logger.infov("{0} {1} {2}", mtd, getUri(), path); + HttpRequest req = + webClient + .request(mtd, getUri().getPort(), getUri().getHost(), path) + .ssl("https".equals(getUri().getScheme())) + .timeout(httpTimeout.toMillis()) + .followRedirects(true) + .as(codec); + try { + Credential credential = + credentialsFinder.getCredentialsForConnectUrl(getUri()).orElse(null); + if (credential == null || credential.username == null || credential.password == null) { + throw new InvalidCredentialsException(NULL_CREDENTIALS + " " + getUri()); + } + req = + req.authentication( + new UsernamePasswordCredentials( + credential.username, credential.password)); + } catch (InvalidCredentialsException e) { + logger.error("Authentication exception", e); + throw new IllegalStateException(e); + } + + Uni> uni; + if (payload != null) { + uni = req.sendBuffer(payload); + } else { + uni = req.send(); + } + return uni; + } + + @ApplicationScoped + public static class Factory { + + @Inject ObjectMapper mapper; + @Inject WebClient webClient; + @Inject CredentialsFinder credentialsFinder; + @Inject Logger logger; + + public AgentClient create(Target target) { + return new AgentClient( + target, webClient, mapper, Duration.ofSeconds(10), credentialsFinder); + } + } + + private static class AgentEventTypeInfo implements IEventTypeInfo { + + final JsonObject json; + + AgentEventTypeInfo(JsonObject json) { + this.json = json; + } + + @Override + public String getDescription() { + return json.getString("description"); + } + + @Override + public IEventTypeID getEventTypeID() { + return new EventTypeIDV2(json.getString("name")); + } + + @Override + public String[] getHierarchicalCategory() { + return ((List) + json.getJsonArray("categories").getList().stream() + .map(Object::toString) + .toList()) + .toArray(new String[0]); + } + + @Override + public String getName() { + return json.getString("name"); + } + + static V capture(T t) { + // TODO clean up this generics hack + return (V) t; + } + + @Override + public Map> getOptionDescriptors() { + Map> result = new HashMap<>(); + JsonArray settings = json.getJsonArray("settings"); + settings.forEach( + setting -> { + String name = ((JsonObject) setting).getString("name"); + String defaultValue = ((JsonObject) setting).getString("defaultValue"); + result.put( + name, + capture( + new IOptionDescriptor() { + @Override + public String getName() { + return name; + } + + @Override + public String getDescription() { + return ""; + } + + @Override + public IConstraint getConstraint() { + return new IConstraint() { + + @Override + public IConstraint combine( + IConstraint other) { + // TODO Auto-generated method stub + throw new UnsupportedOperationException( + "Unimplemented method 'combine'"); + } + + @Override + public boolean validate(String value) + throws QuantityConversionException { + // TODO Auto-generated method stub + throw new UnsupportedOperationException( + "Unimplemented method 'validate'"); + } + + @Override + public String persistableString(String value) + throws QuantityConversionException { + // TODO Auto-generated method stub + throw new UnsupportedOperationException( + "Unimplemented method" + + " 'persistableString'"); + } + + @Override + public String parsePersisted( + String persistedValue) + throws QuantityConversionException { + // TODO Auto-generated method stub + throw new UnsupportedOperationException( + "Unimplemented method" + + " 'parsePersisted'"); + } + + @Override + public String interactiveFormat(String value) + throws QuantityConversionException { + // TODO Auto-generated method stub + throw new UnsupportedOperationException( + "Unimplemented method" + + " 'interactiveFormat'"); + } + + @Override + public String parseInteractive( + String interactiveValue) + throws QuantityConversionException { + // TODO Auto-generated method stub + throw new UnsupportedOperationException( + "Unimplemented method" + + " 'parseInteractive'"); + } + }; + } + + @Override + public String getDefault() { + return defaultValue; + } + })); + }); + return result; + } + + @Override + public IOptionDescriptor getOptionInfo(String s) { + return getOptionDescriptors().get(s); + } + } +} diff --git a/src/main/java/io/cryostat/targets/AgentConnection.java b/src/main/java/io/cryostat/targets/AgentConnection.java index 44218a4fe..adbf86a3f 100644 --- a/src/main/java/io/cryostat/targets/AgentConnection.java +++ b/src/main/java/io/cryostat/targets/AgentConnection.java @@ -17,49 +17,38 @@ import java.io.IOException; import java.net.URI; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.time.Duration; +import java.util.Set; import javax.management.InstanceNotFoundException; import javax.management.IntrospectionException; import javax.management.ReflectionException; import javax.management.remote.JMXServiceURL; -import org.openjdk.jmc.common.unit.IConstrainedMap; -import org.openjdk.jmc.flightrecorder.configuration.events.EventOptionID; import org.openjdk.jmc.rjmx.ConnectionException; import org.openjdk.jmc.rjmx.IConnectionHandle; import org.openjdk.jmc.rjmx.ServiceNotAvailableException; -import io.cryostat.core.FlightRecorderException; import io.cryostat.core.JvmIdentifier; import io.cryostat.core.net.CryostatFlightRecorderService; import io.cryostat.core.net.IDException; import io.cryostat.core.net.JFRConnection; import io.cryostat.core.net.MBeanMetrics; -import io.cryostat.core.net.MemoryMetrics; -import io.cryostat.core.net.OperatingSystemMetrics; -import io.cryostat.core.net.RuntimeMetrics; -import io.cryostat.core.net.ThreadMetrics; import io.cryostat.core.sys.Clock; -import io.cryostat.core.templates.Template; +import io.cryostat.core.templates.RemoteTemplateService; import io.cryostat.core.templates.TemplateService; -import io.cryostat.core.templates.TemplateType; -import io.vertx.mutiny.ext.web.client.WebClient; -import org.jsoup.nodes.Document; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; class AgentConnection implements JFRConnection { - private final URI agentUri; - private final WebClient webClient; - private final Clock clock; + private final AgentClient client; + private final Logger logger = Logger.getLogger(getClass()); - AgentConnection(URI agentUri, WebClient webClient, Clock clock) { - this.agentUri = agentUri; - this.webClient = webClient; - this.clock = clock; + AgentConnection(AgentClient client) { + this.client = client; } @Override @@ -67,88 +56,72 @@ public void close() throws Exception {} @Override public void connect() throws ConnectionException { - // TODO test connection by pinging agent callback + if (!client.ping().await().atMost(Duration.ofSeconds(10))) { + throw new ConnectionException("Connection failed"); + } } @Override public void disconnect() {} + public URI getUri() { + return client.getUri(); + } + @Override - public long getApproximateServerTime(Clock arg0) { + public long getApproximateServerTime(Clock clock) { return clock.now().toEpochMilli(); } @Override public IConnectionHandle getHandle() throws ConnectionException, IOException { - // TODO Auto-generated method stub - return null; + throw new UnsupportedOperationException(); } @Override public String getHost() { - return agentUri.getHost(); + return getUri().getHost(); } - @Override - public JMXServiceURL getJMXURL() throws IOException { - // TODO Auto-generated method stub - return null; + public static boolean isAgentConnection(URI uri) { + return Set.of("http", "https", "cryostat-agent").contains(uri.getScheme()); } @Override - public String getJvmId() throws IDException, IOException { - // this should have already been populated when the agent published itself to the Discovery - // API. If not, then this will fail, but we were in a bad state to begin with. - return Target.getTargetByConnectUrl(agentUri).jvmId; + public JMXServiceURL getJMXURL() throws IOException { + if (!isAgentConnection(getUri())) { + throw new UnsupportedOperationException(); + } + return new JMXServiceURL(getUri().toString()); } @Override public JvmIdentifier getJvmIdentifier() throws IDException, IOException { - // try { - // return JvmIdentifier.from(getMBeanMetrics().getRuntime()); - // } catch (IntrospectionException | InstanceNotFoundException | ReflectionException e) { - // throw new IDException(e); - // } - throw new UnsupportedOperationException("Unimplemented method 'getJvmIdentifier'"); + try { + return JvmIdentifier.from(getMBeanMetrics().getRuntime()); + } catch (IntrospectionException | InstanceNotFoundException | ReflectionException e) { + throw new IDException(e); + } } @Override public int getPort() { - return agentUri.getPort(); + return getUri().getPort(); } @Override public CryostatFlightRecorderService getService() throws ConnectionException, IOException, ServiceNotAvailableException { - return new AgentJFRService(webClient); + return new AgentJFRService(client, getTemplateService()); } @Override public TemplateService getTemplateService() { - return new TemplateService() { - - @Override - public Optional> getEvents( - String name, TemplateType type) throws FlightRecorderException { - return Optional.empty(); - } - - @Override - public List