Skip to content

Commit

Permalink
feat(api): enable dynamic JFR stop, delete (#176)
Browse files Browse the repository at this point in the history
* end paths with / so as to not match by prefix

* refactor: extract methods

* add utility for extracting ID from path

* add handling for stopping recording

* add handling for closing (deleting) recording

* only allow GET requests if write-operations are not enabled

* extract HTTP response body length constants
  • Loading branch information
andrewazores committed Aug 14, 2023
1 parent 8039907 commit 82002e8
Show file tree
Hide file tree
Showing 6 changed files with 149 additions and 46 deletions.
12 changes: 8 additions & 4 deletions src/main/java/io/cryostat/agent/WebServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,8 @@ private HttpHandler wrap(HttpHandler handler) {
handler.handle(x);
} catch (Exception e) {
log.error("Unhandled exception", e);
x.sendResponseHeaders(HttpStatus.SC_INTERNAL_SERVER_ERROR, 0);
x.sendResponseHeaders(
HttpStatus.SC_INTERNAL_SERVER_ERROR, RemoteContext.BODY_LENGTH_NONE);
x.close();
}
};
Expand All @@ -184,15 +185,18 @@ public void handle(HttpExchange exchange) throws IOException {
case "POST":
synchronized (WebServer.this.credentials) {
executor.execute(registration.get()::tryRegister);
exchange.sendResponseHeaders(HttpStatus.SC_NO_CONTENT, -1);
exchange.sendResponseHeaders(
HttpStatus.SC_NO_CONTENT, RemoteContext.BODY_LENGTH_NONE);
}
break;
case "GET":
exchange.sendResponseHeaders(HttpStatus.SC_NO_CONTENT, -1);
exchange.sendResponseHeaders(
HttpStatus.SC_NO_CONTENT, RemoteContext.BODY_LENGTH_NONE);
break;
default:
log.warn("Unknown request method {}", mtd);
exchange.sendResponseHeaders(HttpStatus.SC_METHOD_NOT_ALLOWED, -1);
exchange.sendResponseHeaders(
HttpStatus.SC_METHOD_NOT_ALLOWED, RemoteContext.BODY_LENGTH_NONE);
break;
}
} finally {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class EventTemplatesContext implements RemoteContext {

@Override
public String path() {
return "/event-templates";
return "/event-templates/";
}

@Override
Expand All @@ -53,7 +53,7 @@ public void handle(HttpExchange exchange) throws IOException {
switch (mtd) {
case "GET":
try {
exchange.sendResponseHeaders(HttpStatus.SC_OK, 0);
exchange.sendResponseHeaders(HttpStatus.SC_OK, BODY_LENGTH_UNKNOWN);
try (OutputStream response = exchange.getResponseBody()) {
FlightRecorderMXBean bean =
ManagementFactory.getPlatformMXBean(FlightRecorderMXBean.class);
Expand All @@ -69,7 +69,8 @@ public void handle(HttpExchange exchange) throws IOException {
break;
default:
log.warn("Unknown request method {}", mtd);
exchange.sendResponseHeaders(HttpStatus.SC_METHOD_NOT_ALLOWED, -1);
exchange.sendResponseHeaders(
HttpStatus.SC_METHOD_NOT_ALLOWED, BODY_LENGTH_NONE);
break;
}
} finally {
Expand Down
10 changes: 6 additions & 4 deletions src/main/java/io/cryostat/agent/remote/EventTypesContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class EventTypesContext implements RemoteContext {

@Override
public String path() {
return "/event-types";
return "/event-types/";
}

@Override
Expand All @@ -59,17 +59,19 @@ public void handle(HttpExchange exchange) throws IOException {
events.addAll(getEventTypes());
} catch (Exception e) {
log.error("events serialization failure", e);
exchange.sendResponseHeaders(HttpStatus.SC_INTERNAL_SERVER_ERROR, 0);
exchange.sendResponseHeaders(
HttpStatus.SC_INTERNAL_SERVER_ERROR, BODY_LENGTH_NONE);
break;
}
exchange.sendResponseHeaders(HttpStatus.SC_OK, 0);
exchange.sendResponseHeaders(HttpStatus.SC_OK, BODY_LENGTH_UNKNOWN);
try (OutputStream response = exchange.getResponseBody()) {
mapper.writeValue(response, events);
}
break;
default:
log.warn("Unknown request method {}", mtd);
exchange.sendResponseHeaders(HttpStatus.SC_METHOD_NOT_ALLOWED, -1);
exchange.sendResponseHeaders(
HttpStatus.SC_METHOD_NOT_ALLOWED, BODY_LENGTH_NONE);
break;
}
} finally {
Expand Down
7 changes: 4 additions & 3 deletions src/main/java/io/cryostat/agent/remote/MBeanContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ class MBeanContext implements RemoteContext {

@Override
public String path() {
return "/mbean-metrics";
return "/mbean-metrics/";
}

@Override
Expand All @@ -70,7 +70,7 @@ public void handle(HttpExchange exchange) throws IOException {
case "GET":
try {
MBeanMetrics metrics = getMBeanMetrics();
exchange.sendResponseHeaders(HttpStatus.SC_OK, 0);
exchange.sendResponseHeaders(HttpStatus.SC_OK, BODY_LENGTH_UNKNOWN);
try (OutputStream response = exchange.getResponseBody()) {
mapper.writeValue(response, metrics);
}
Expand All @@ -80,7 +80,8 @@ public void handle(HttpExchange exchange) throws IOException {
break;
default:
log.warn("Unknown request method {}", mtd);
exchange.sendResponseHeaders(HttpStatus.SC_METHOD_NOT_ALLOWED, -1);
exchange.sendResponseHeaders(
HttpStatus.SC_METHOD_NOT_ALLOWED, BODY_LENGTH_NONE);
break;
}
} finally {
Expand Down
155 changes: 123 additions & 32 deletions src/main/java/io/cryostat/agent/remote/RecordingsContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import javax.inject.Inject;
Expand Down Expand Up @@ -50,12 +53,17 @@
import com.sun.net.httpserver.HttpExchange;
import io.smallrye.config.SmallRyeConfig;
import jdk.jfr.FlightRecorder;
import jdk.jfr.Recording;
import org.apache.http.HttpStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

class RecordingsContext implements RemoteContext {

private static final String PATH = "/recordings";
private static final Pattern PATH_ID_PATTERN =
Pattern.compile("^" + PATH + "/(\\d+)$", Pattern.MULTILINE);

private final Logger log = LoggerFactory.getLogger(getClass());
private final SmallRyeConfig config;
private final ObjectMapper mapper;
Expand All @@ -76,7 +84,7 @@ class RecordingsContext implements RemoteContext {

@Override
public String path() {
return "/recordings";
return PATH;
}

@Override
Expand All @@ -86,60 +94,143 @@ public void handle(HttpExchange exchange) throws IOException {
if (!ensureMethodAccepted(exchange)) {
return;
}
int id = Integer.MIN_VALUE;
switch (mtd) {
case "GET":
try (OutputStream response = exchange.getResponseBody()) {
List<SerializableRecordingDescriptor> recordings = getRecordings();
exchange.sendResponseHeaders(HttpStatus.SC_OK, 0);
mapper.writeValue(response, recordings);
} catch (Exception e) {
log.error("recordings serialization failure", e);
id = extractId(exchange);
if (id == Integer.MIN_VALUE) {
handleGetList(exchange);
} else {
exchange.sendResponseHeaders(
HttpStatus.SC_NOT_IMPLEMENTED, BODY_LENGTH_NONE);
}
break;
case "POST":
try (InputStream body = exchange.getRequestBody()) {
StartRecordingRequest req =
mapper.readValue(body, StartRecordingRequest.class);
if (!req.isValid()) {
exchange.sendResponseHeaders(HttpStatus.SC_BAD_REQUEST, -1);
return;
}
SerializableRecordingDescriptor recording = startRecording(req);
exchange.sendResponseHeaders(HttpStatus.SC_CREATED, 0);
try (OutputStream response = exchange.getResponseBody()) {
mapper.writeValue(response, recording);
}
} catch (QuantityConversionException
| ServiceNotAvailableException
| FlightRecorderException
| org.openjdk.jmc.rjmx.services.jfr.FlightRecorderException
| InvalidEventTemplateException
| InvalidXmlException
| IOException e) {
log.error("Failed to start recording", e);
exchange.sendResponseHeaders(HttpStatus.SC_INTERNAL_SERVER_ERROR, -1);
handleStart(exchange);
break;
case "PATCH":
id = extractId(exchange);
if (id >= 0) {
handleStop(exchange, id);
} else {
exchange.sendResponseHeaders(HttpStatus.SC_BAD_REQUEST, BODY_LENGTH_NONE);
}
break;
case "DELETE":
id = extractId(exchange);
if (id >= 0) {
handleDelete(exchange, id);
} else {
exchange.sendResponseHeaders(HttpStatus.SC_BAD_REQUEST, BODY_LENGTH_NONE);
}
break;
default:
log.warn("Unknown request method {}", mtd);
exchange.sendResponseHeaders(HttpStatus.SC_METHOD_NOT_ALLOWED, -1);
exchange.sendResponseHeaders(
HttpStatus.SC_METHOD_NOT_ALLOWED, BODY_LENGTH_NONE);
break;
}
} finally {
exchange.close();
}
}

private static int extractId(HttpExchange exchange) throws IOException {
Matcher m = PATH_ID_PATTERN.matcher(exchange.getRequestURI().getPath());
if (!m.find()) {
return Integer.MIN_VALUE;
}
return Integer.parseInt(m.group(1));
}

private void handleGetList(HttpExchange exchange) {
try (OutputStream response = exchange.getResponseBody()) {
List<SerializableRecordingDescriptor> recordings = getRecordings();
exchange.sendResponseHeaders(HttpStatus.SC_OK, BODY_LENGTH_UNKNOWN);
mapper.writeValue(response, recordings);
} catch (Exception e) {
log.error("recordings serialization failure", e);
}
}

private void handleStart(HttpExchange exchange) throws IOException {
try (InputStream body = exchange.getRequestBody()) {
StartRecordingRequest req = mapper.readValue(body, StartRecordingRequest.class);
if (!req.isValid()) {
exchange.sendResponseHeaders(HttpStatus.SC_BAD_REQUEST, BODY_LENGTH_NONE);
return;
}
SerializableRecordingDescriptor recording = startRecording(req);
exchange.sendResponseHeaders(HttpStatus.SC_CREATED, BODY_LENGTH_UNKNOWN);
try (OutputStream response = exchange.getResponseBody()) {
mapper.writeValue(response, recording);
}
} catch (QuantityConversionException
| ServiceNotAvailableException
| FlightRecorderException
| org.openjdk.jmc.rjmx.services.jfr.FlightRecorderException
| InvalidEventTemplateException
| InvalidXmlException
| IOException e) {
log.error("Failed to start recording", e);
exchange.sendResponseHeaders(HttpStatus.SC_INTERNAL_SERVER_ERROR, BODY_LENGTH_NONE);
}
}

private void handleStop(HttpExchange exchange, int id) throws IOException {
invokeOnRecording(
exchange,
id,
r -> {
try {
boolean stopped = r.stop();
if (!stopped) {
sendHeader(exchange, HttpStatus.SC_BAD_REQUEST);
} else {
sendHeader(exchange, HttpStatus.SC_NO_CONTENT);
}
} catch (IllegalStateException e) {
sendHeader(exchange, HttpStatus.SC_CONFLICT);
}
});
}

private void handleDelete(HttpExchange exchange, int id) throws IOException {
invokeOnRecording(
exchange,
id,
r -> {
r.close();
sendHeader(exchange, HttpStatus.SC_NO_CONTENT);
});
}

private void invokeOnRecording(HttpExchange exchange, long id, Consumer<Recording> consumer) {
FlightRecorder.getFlightRecorder().getRecordings().stream()
.filter(r -> r.getId() == id)
.findFirst()
.ifPresentOrElse(
consumer::accept, () -> sendHeader(exchange, HttpStatus.SC_NOT_FOUND));
}

private void sendHeader(HttpExchange exchange, int status) {
try {
exchange.sendResponseHeaders(status, BODY_LENGTH_NONE);
} catch (IOException e) {
throw new IllegalStateException(e);
}
}

private boolean ensureMethodAccepted(HttpExchange exchange) throws IOException {
Set<String> blocked = Set.of("POST");
Set<String> alwaysAllowed = Set.of("GET");
String mtd = exchange.getRequestMethod();
boolean restricted = blocked.contains(mtd);
boolean restricted = !alwaysAllowed.contains(mtd);
if (!restricted) {
return true;
}
boolean passed = restricted && MutatingRemoteContext.apiWritesEnabled(config);
if (!passed) {
exchange.sendResponseHeaders(HttpStatus.SC_FORBIDDEN, -1);
exchange.sendResponseHeaders(HttpStatus.SC_FORBIDDEN, BODY_LENGTH_NONE);
}
return passed;
}
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/io/cryostat/agent/remote/RemoteContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
import com.sun.net.httpserver.HttpHandler;

public interface RemoteContext extends HttpHandler {

public static final int BODY_LENGTH_NONE = -1;
public static final int BODY_LENGTH_UNKNOWN = 0;

String path();

default boolean available() {
Expand Down

0 comments on commit 82002e8

Please sign in to comment.