All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.hyperfoil.clustering.ControllerServer Maven / Gradle / Ivy

There is a newer version: 0.27.1
Show newest version
package io.hyperfoil.clustering;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintStream;
import java.io.UnsupportedEncodingException;
import java.net.InetAddress;
import java.net.URLEncoder;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.BinaryOperator;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;

import io.hyperfoil.api.Version;
import io.hyperfoil.api.config.Benchmark;
import io.hyperfoil.api.config.BenchmarkBuilder;
import io.hyperfoil.api.config.BenchmarkData;
import io.hyperfoil.api.config.BenchmarkDefinitionException;
import io.hyperfoil.api.config.Model;
import io.hyperfoil.clustering.util.PersistedBenchmarkData;
import io.hyperfoil.clustering.webcli.WebCLI;
import io.hyperfoil.controller.ApiService;
import io.hyperfoil.controller.model.Histogram;
import io.hyperfoil.controller.model.RequestStats;
import io.hyperfoil.controller.router.ApiRouter;
import io.hyperfoil.controller.StatisticsStore;
import io.hyperfoil.core.parser.BenchmarkParser;
import io.hyperfoil.core.parser.ParserException;
import io.hyperfoil.core.print.YamlVisitor;
import io.hyperfoil.core.util.CountDown;
import io.hyperfoil.core.util.LowHigh;
import io.hyperfoil.core.util.Util;
import io.hyperfoil.internal.Controller;
import io.hyperfoil.internal.Properties;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.vertx.core.AsyncResult;
import io.vertx.core.CompositeFuture;
import io.vertx.core.Future;
import io.vertx.core.Handler;
import io.vertx.core.Promise;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.http.HttpHeaders;
import io.vertx.core.http.HttpServer;
import io.vertx.core.http.HttpServerOptions;
import io.vertx.core.http.HttpServerResponse;
import io.vertx.core.impl.NoStackTraceThrowable;
import io.vertx.core.json.Json;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;

import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.LogManager;

import io.vertx.core.net.JksOptions;
import io.vertx.core.net.PemKeyCertOptions;
import io.vertx.ext.web.FileUpload;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.handler.FaviconHandler;
import io.vertx.ext.web.handler.StaticHandler;

class ControllerServer implements ApiService {
   private static final Logger log = LogManager.getLogger(ControllerServer.class);

   private static final String MIME_TYPE_JSON = "application/json";
   private static final String MIME_TYPE_SERIALIZED = "application/java-serialized-object";
   private static final String MIME_TYPE_TEXT_PLAIN = "text/plain";
   private static final String MIME_TYPE_YAML = "text/vnd.yaml";

   private static final String KEYSTORE_PATH = Properties.get(Properties.CONTROLLER_KEYSTORE_PATH, null);
   private static final String KEYSTORE_PASSWORD = Properties.get(Properties.CONTROLLER_KEYSTORE_PASSWORD, null);
   private static final String PEM_KEYS = Properties.get(Properties.CONTROLLER_PEM_KEYS, null);
   private static final String PEM_CERTS = Properties.get(Properties.CONTROLLER_PEM_CERTS, null);
   private static final String CONTROLLER_PASSWORD = Properties.get(Properties.CONTROLLER_PASSWORD, null);
   private static final boolean CONTROLLER_SECURED_VIA_PROXY = Properties.getBoolean(Properties.CONTROLLER_SECURED_VIA_PROXY);
   private static final String CONTROLLER_EXTERNAL_URI = Properties.get(Properties.CONTROLLER_EXTERNAL_URI, null);
   private static final String TRIGGER_URL = Properties.get(Properties.TRIGGER_URL, null);

   private static final String BEARER_TOKEN;

   private static final Comparator PHASE_COMPARATOR =
         Comparator.comparing(ControllerPhase::absoluteStartTime).thenComparing(p -> p.definition().name);
   private static final BinaryOperator LAST_RUN_OPERATOR = (r1, r2) -> r1.id.compareTo(r2.id) > 0 ? r1 : r2;
   private static final String DATAKEY = "[/**DATAKEY**/]";

   static {
      byte[] token = new byte[48];
      new SecureRandom().nextBytes(token);
      BEARER_TOKEN = Base64.getEncoder().encodeToString(token);
   }

   final ControllerVerticle controller;
   HttpServer httpServer;
   String baseURL;

   ControllerServer(ControllerVerticle controller, CountDown countDown) {
      this.controller = controller;

      HttpServerOptions options = new HttpServerOptions();
      if (KEYSTORE_PATH != null) {
         options.setSsl(true).setUseAlpn(true).setKeyCertOptions(
               new JksOptions().setPath(KEYSTORE_PATH).setPassword(KEYSTORE_PASSWORD));
      } else if (PEM_CERTS != null || PEM_KEYS != null) {
         PemKeyCertOptions pem = new PemKeyCertOptions();
         if (PEM_CERTS != null) {
            for (String certPath : PEM_CERTS.split(",")) {
               pem.addCertPath(certPath.trim());
            }
         }
         if (PEM_KEYS != null) {
            for (String keyPath : PEM_KEYS.split(",")) {
               pem.addKeyPath(keyPath.trim());
            }
         }
         options.setSsl(true).setUseAlpn(true).setKeyCertOptions(pem);
      }

      Router router = Router.router(controller.getVertx());
      if (CONTROLLER_PASSWORD != null) {
         if (!options.isSsl() && !CONTROLLER_SECURED_VIA_PROXY) {
            throw new IllegalStateException("Server uses basic authentication scheme (" + Properties.CONTROLLER_PASSWORD +
                  " is set) but it does not use TLS connections. If the confidentiality is guaranteed by a proxy set -D" +
                  Properties.CONTROLLER_SECURED_VIA_PROXY + "=true.");
         }
         log.info("Server is protected using a password.");
         router.route().handler(new BasicAuthHandler());
      }
      StaticHandler staticHandler = StaticHandler.create().setCachingEnabled(true);
      router.route("/").handler(staticHandler);
      router.route("/web/*").handler(staticHandler);
      router.route("/favicon.ico").handler(FaviconHandler.create(controller.getVertx(), "webroot/favicon.ico"));
      new ApiRouter(this, router);

      String controllerHost = Properties.get(Properties.CONTROLLER_HOST, controller.getConfig().getString(Properties.CONTROLLER_HOST, "0.0.0.0"));
      int controllerPort = Properties.getInt(Properties.CONTROLLER_PORT, controller.getConfig().getInteger(Properties.CONTROLLER_PORT, 8090));
      WebCLI webCLI = new WebCLI(controller.getVertx());
      httpServer = controller.getVertx().createHttpServer(options).requestHandler(router)
            .webSocketHandler(webCLI)
            .listen(controllerPort, controllerHost, serverResult -> {
               if (serverResult.succeeded()) {
                  if (CONTROLLER_EXTERNAL_URI == null) {
                     String host = controllerHost;
                     // Can't advertise 0.0.0.0 as
                     if (host.equals("0.0.0.0")) {
                        try {
                           host = InetAddress.getLocalHost().getHostName();
                        } catch (UnknownHostException e) {
                           host = "localhost";
                        }
                     }
                     baseURL = (options.isSsl() ? "https://" : "http://") + host + ":" + serverResult.result().actualPort();
                  } else {
                     baseURL = CONTROLLER_EXTERNAL_URI;
                  }
                  webCLI.setConnectionOptions(serverResult.result().actualPort(), options.isSsl());
                  log.info("Hyperfoil controller listening on {}", baseURL);
               }
               countDown.handle(serverResult.mapEmpty());
            });
   }

   void stop(Promise stopFuture) {
      httpServer.close(result -> stopFuture.complete());
   }

   @Override
   public void openApi(RoutingContext ctx) {
      try {
         InputStream stream = ApiService.class.getClassLoader().getResourceAsStream("openapi.yaml");
         Buffer payload;
         String contentType;
         if (stream == null) {
            payload = Buffer.buffer("API definition not available");
            contentType = MIME_TYPE_TEXT_PLAIN;
         } else {
            payload = Buffer.buffer(Util.toString(stream));
            contentType = MIME_TYPE_YAML;
         }
         ctx.response()
               .putHeader(HttpHeaders.CONTENT_TYPE.toString(), contentType)
               .putHeader("x-epoch-millis", String.valueOf(System.currentTimeMillis()))
               .end(payload);
      } catch (IOException e) {
         log.error("Cannot read OpenAPI definition", e);
         ctx.response().setStatusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.code()).setStatusMessage("Cannot read OpenAPI definition.").end();
      }
   }

   @Override
   public void listBenchmarks(RoutingContext ctx) {
      ctx.response().end(Json.encodePrettily(controller.getBenchmarks()));
   }

   @Override
   public void addBenchmark$application_json(RoutingContext ctx, String ifMatch, String storedFilesBenchmark) {
      addBenchmark$text_vnd_yaml(ctx, ifMatch, storedFilesBenchmark);
   }

   private void addBenchmarkAndReply(RoutingContext ctx, Benchmark benchmark, String prevVersion) {
      if (benchmark != null) {
         if (benchmark.agents().length == 0 && controller.getVertx().isClustered()) {
            ctx.response().setStatusCode(HttpResponseStatus.BAD_REQUEST.code())
                  .end("Hyperfoil controller is clustered but the benchmark does not define any agents.");
            return;
         } else if (benchmark.agents().length != 0 && !controller.getVertx().isClustered()) {
            ctx.response().setStatusCode(HttpResponseStatus.BAD_REQUEST.code())
                  .end("Hyperfoil runs in standalone mode but the benchmark defines agents for clustering");
            return;
         }
         String location = baseURL + "/benchmark/" + encode(benchmark.name());
         if (!controller.addBenchmark(benchmark, prevVersion, event -> {
            if (event.succeeded()) {
               ctx.response()
                     .setStatusCode(HttpResponseStatus.NO_CONTENT.code())
                     .putHeader(HttpHeaders.LOCATION, location).end();
            } else {
               ctx.response().setStatusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.code()).end();
            }
         })) {
            ctx.response().setStatusCode(HttpResponseStatus.CONFLICT.code()).end();
         }

      } else {
         ctx.response().setStatusCode(HttpResponseStatus.BAD_REQUEST.code()).end("Cannot read benchmark.");
      }
   }

   private static String encode(String string) {
      try {
         return URLEncoder.encode(string, StandardCharsets.UTF_8.name());
      } catch (UnsupportedEncodingException e) {
         throw new IllegalArgumentException(e);
      }
   }

   @Override
   public void addBenchmark$text_vnd_yaml(RoutingContext ctx, String ifMatch, String storedFilesBenchmark) {
      String source = ctx.getBodyAsString();
      if (source == null || source.isEmpty()) {
         log.error("Benchmark is empty, upload failed.");
         ctx.response().setStatusCode(HttpResponseStatus.BAD_REQUEST.code()).end("Benchmark is empty.");
      }
      try {
         BenchmarkBuilder builder = BenchmarkParser.instance().builder(source, BenchmarkData.EMPTY);
         if (storedFilesBenchmark != null) {
            storedFilesBenchmark = PersistedBenchmarkData.sanitize(storedFilesBenchmark);
            builder.data(new PersistedBenchmarkData(Controller.BENCHMARK_DIR.resolve(storedFilesBenchmark + ".data")));
         }
         addBenchmarkAndReply(ctx, builder.build(), ifMatch);
      } catch (ParserException | BenchmarkDefinitionException e) {
         respondParsingError(ctx, e);
      }
   }

   private void respondParsingError(RoutingContext ctx, Exception e) {
      log.error("Failed to read benchmark", e);
      ctx.response().setStatusCode(HttpResponseStatus.BAD_REQUEST.code()).end("Cannot read benchmark: " + Util.explainCauses(e));
   }

   @Override
   public void addBenchmark$application_java_serialized_object(RoutingContext ctx, String ifMatch, String storedFilesBenchmark) {
      if (storedFilesBenchmark != null) {
         log.warn("Ignoring parameter useStoredData for serialized benchmark upload.");
      }
      byte[] bytes = ctx.getBody().getBytes();
      try {
         Benchmark benchmark = Util.deserialize(bytes);
         addBenchmarkAndReply(ctx, benchmark, ifMatch);
      } catch (IOException | ClassNotFoundException e) {
         log.error("Failed to deserialize", e);
         StringBuilder message = new StringBuilder("Cannot read benchmark - the controller (server) version and CLI version are probably not in sync.\n");
         message.append("This partial stack-track might help you diagnose the problematic part:\n---\n");
         for (StackTraceElement ste : e.getStackTrace()) {
            message.append(ste).append('\n');
            if (ste.getClassName().equals(Util.class.getName())) {
               break;
            }
         }
         message.append("---\n");
         ctx.response().setStatusCode(HttpResponseStatus.BAD_REQUEST.code()).end(message.toString());
      }
   }

   @Override
   public void addBenchmark$multipart_form_data(RoutingContext ctx, String ifMatch, String storedFilesBenchmark) {
      String source = null;
      RequestBenchmarkData data = new RequestBenchmarkData();
      for (FileUpload upload : ctx.fileUploads()) {
         byte[] bytes;
         try {
            bytes = Files.readAllBytes(Paths.get(upload.uploadedFileName()));
         } catch (IOException e) {
            log.error("Cannot read uploaded file " + upload.uploadedFileName(), e);
            ctx.response().setStatusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.code()).end();
            return;
         }
         if (upload.name().equals("benchmark")) {
            try {
               source = new String(bytes, upload.charSet());
            } catch (UnsupportedEncodingException e) {
               source = new String(bytes, StandardCharsets.UTF_8);
            }
         } else {
            data.addFile(upload.fileName(), bytes);
         }
      }
      if (source == null) {
         ctx.response().setStatusCode(HttpResponseStatus.BAD_REQUEST.code()).end("Multi-part definition missing benchmark=source-file.yaml");
         return;
      }
      try {
         BenchmarkBuilder builder = BenchmarkParser.instance().builder(source, data);
         if (storedFilesBenchmark != null) {
            // sanitize to prevent directory escape
            storedFilesBenchmark = PersistedBenchmarkData.sanitize(storedFilesBenchmark);
            Path dataDirPath = Controller.BENCHMARK_DIR.resolve(storedFilesBenchmark + ".data");
            log.info("Trying to use stored files from {}, adding files from request: {}", dataDirPath, data.files().keySet());
            if (!data.files().isEmpty()) {
               File dataDir = dataDirPath.toFile();
               //noinspection ResultOfMethodCallIgnored
               dataDir.mkdirs();
               if (dataDir.exists() && dataDir.isDirectory()) {
                  try {
                     PersistedBenchmarkData.store(data.files(), dataDirPath);
                  } catch (IOException e) {
                     ctx.response().setStatusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.code())
                           .end("Failed to store benchmark files.");
                  }
               }
            }
            builder.data(new PersistedBenchmarkData(dataDirPath));
         }
         addBenchmarkAndReply(ctx, builder.build(), ifMatch);
      } catch (ParserException | BenchmarkDefinitionException e) {
         respondParsingError(ctx, e);
      }
   }

   @Override
   public void getBenchmark$text_vnd_yaml(RoutingContext ctx, String name) {
      withBenchmark(ctx, name, benchmark -> sendYamlBenchmark(ctx, benchmark));
   }

   private void sendYamlBenchmark(RoutingContext ctx, Benchmark benchmark) {
      if (benchmark.source() == null) {
         ctx.response()
               .setStatusCode(HttpResponseStatus.NOT_ACCEPTABLE.code())
               .setStatusMessage("Benchmark does not preserve the original source.");
      } else {
         ctx.response()
               .putHeader(HttpHeaders.CONTENT_TYPE, "text/vnd.yaml; charset=UTF-8")
               .putHeader(HttpHeaders.ETAG.toString(), benchmark.version())
               .end(benchmark.source());
      }
   }

   @Override
   public void getBenchmark$application_java_serialized_object(RoutingContext ctx, String name) {
      withBenchmark(ctx, name, benchmark -> sendSerializedBenchmark(ctx, benchmark));
   }

   private void sendSerializedBenchmark(RoutingContext ctx, Benchmark benchmark) {
      try {
         byte[] bytes = Util.serialize(benchmark);
         ctx.response()
               .putHeader(HttpHeaders.CONTENT_TYPE, MIME_TYPE_SERIALIZED)
               .end(Buffer.buffer(bytes));
      } catch (IOException e) {
         log.error("Failed to serialize", e);
         ctx.response().setStatusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.code()).end("Error encoding benchmark.");
      }
   }

   @Override
   public void startBenchmark(RoutingContext ctx, String name, String desc, String xTriggerJob, String runId) {
      Benchmark benchmark = controller.getBenchmark(name);
      if (benchmark == null) {
         ctx.response().setStatusCode(HttpResponseStatus.NOT_FOUND.code()).end("Benchmark not found");
         return;
      }
      String triggerUrl = benchmark.triggerUrl() != null ? benchmark.triggerUrl() : TRIGGER_URL;
      if (triggerUrl != null) {
         if (xTriggerJob == null) {
            Run run = controller.createRun(benchmark, desc);
            ctx.response()
                  .setStatusCode(HttpResponseStatus.MOVED_PERMANENTLY.code())
                  .putHeader(HttpHeaders.LOCATION, triggerUrl + "BENCHMARK=" + name + "&RUN_ID=" + run.id)
                  .putHeader("x-run-id", run.id)
                  .end("This controller is configured to trigger jobs through CI instance.");
            return;
         }
      }
      Run run;
      if (runId == null) {
         run = controller.createRun(benchmark, desc);
      } else {
         run = controller.run(runId);
         if (run == null || run.startTime != Long.MIN_VALUE) {
            ctx.response().setStatusCode(HttpResponseStatus.FORBIDDEN.code()).end("Run already started");
            return;
         }
      }
      String error = controller.startBenchmark(run);
      if (error == null) {
         ctx.response().setStatusCode(HttpResponseStatus.ACCEPTED.code()).
               putHeader(HttpHeaders.LOCATION, baseURL + "/run/" + run.id)
               .end(Json.encodePrettily(runInfo(run, false)));
      } else {
         ctx.response()
               .setStatusCode(HttpResponseStatus.FORBIDDEN.code()).end(error);
      }
   }

   @Override
   public void getBenchmarkStructure(RoutingContext ctx, String name) {
      withBenchmark(ctx, name, benchmark -> {
         ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
         try (PrintStream stream = new PrintStream(byteStream)) {
            new YamlVisitor(stream).walk(benchmark);
         }
         ctx.response().putHeader(HttpHeaders.CONTENT_TYPE, MIME_TYPE_YAML).end(Buffer.buffer(byteStream.toByteArray()));
      });
   }

   @Override
   public void listRuns(RoutingContext ctx, boolean details) {
      io.hyperfoil.controller.model.Run[] runs = controller.runs().stream()
            .map(r -> details ? runInfo(r, false) : new io.hyperfoil.controller.model.Run(r.id, null, null, null, r.cancelled, r.completed, null, null, null, null))
            .toArray(io.hyperfoil.controller.model.Run[]::new);
      ctx.response().end(Json.encodePrettily(runs));
   }

   @Override
   public void getRun(RoutingContext ctx, String runId) {
      withRun(ctx, runId, run -> ctx.response().end(Json.encodePrettily(runInfo(run, true))));
   }

   @Override
   public void agentCpu(RoutingContext ctx, String runId) {
      withStats(ctx, runId, run -> ctx.response()
            .putHeader(HttpHeaders.CONTENT_TYPE, MIME_TYPE_JSON)
            .end(Json.encode(run.statisticsStore().cpuUsage())));
   }

   private io.hyperfoil.controller.model.Run runInfo(Run run, boolean reportPhases) {
      String benchmark = null;
      if (run.benchmark != null) {
         benchmark = run.benchmark.name();
      }

      Date started = null, terminated = null;
      if (run.startTime > Long.MIN_VALUE) {
         started = new Date(run.startTime);
      }
      if (run.terminateTime.future().isComplete()) {
         terminated = new Date(run.terminateTime.future().result());
      }
      List phases = null;
      if (reportPhases) {
         long now = System.currentTimeMillis();
         phases = run.phases.values().stream()
               .filter(p -> !(p.definition().model instanceof Model.Noop))
               .sorted(PHASE_COMPARATOR)
               .map(phase -> {
                  Date phaseStarted = null, phaseTerminated = null;
                  StringBuilder remaining = null;
                  StringBuilder totalDuration = null;
                  if (phase.absoluteStartTime() > Long.MIN_VALUE) {
                     phaseStarted = new Date(phase.absoluteStartTime());
                     if (!phase.status().isTerminated()) {
                        remaining = new StringBuilder()
                              .append(phase.definition().duration() - (now - phase.absoluteStartTime())).append(" ms");
                        if (phase.definition().maxDuration() >= 0) {
                           remaining.append(" (")
                                 .append(phase.definition().maxDuration() - (now - phase.absoluteStartTime())).append(" ms)");
                        }
                     } else {
                        phaseTerminated = new Date(phase.absoluteCompletionTime());
                        long totalDurationValue = phase.absoluteCompletionTime() - phase.absoluteStartTime();
                        totalDuration = new StringBuilder().append(totalDurationValue).append(" ms");
                        if (totalDurationValue > phase.definition().duration()) {
                           totalDuration.append(" (exceeded by ").append(totalDurationValue - phase.definition().duration()).append(" ms)");
                        }
                     }
                  }
                  String type = phase.definition().getClass().getSimpleName();
                  type = Character.toLowerCase(type.charAt(0)) + type.substring(1);
                  return new io.hyperfoil.controller.model.Phase(phase.definition().name(), phase.status().toString(), type,
                        phaseStarted, remaining == null ? null : remaining.toString(),
                        phaseTerminated, phase.isFailed(), totalDuration == null ? null : totalDuration.toString(),
                        phase.definition().description());
               }).collect(Collectors.toList());
      }
      List agents = run.agents.stream()
            .map(ai -> new io.hyperfoil.controller.model.Agent(ai.name, ai.deploymentId, ai.status.toString()))
            .collect(Collectors.toList());
      return new io.hyperfoil.controller.model.Run(run.id, benchmark, started, terminated, run.cancelled, run.completed, run.description, phases, agents,
            run.errors.stream().map(Run.Error::toString).collect(Collectors.toList()));
   }

   private void withRun(RoutingContext ctx, String runId, Consumer consumer) {
      Run run;
      if ("last".equals(runId)) {
         run = controller.runs.values().stream()
               .reduce(LAST_RUN_OPERATOR)
               .orElse(null);
      } else {
         run = controller.run(runId);
      }
      if (run == null) {
         ctx.response().setStatusCode(HttpResponseStatus.NOT_FOUND.code()).end();
      } else {
         consumer.accept(run);
      }
   }

   @Override
   public void killRun(RoutingContext ctx, String runId) {
      withRun(ctx, runId, run -> controller.kill(run, result -> {
         if (result.succeeded()) {
            ctx.response().setStatusCode(HttpResponseStatus.ACCEPTED.code()).end();
         } else {
            ctx.response().setStatusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.code()).setStatusMessage(result.cause().getMessage()).end();
         }
      }));
   }

   @Override
   public void createReport(RoutingContext ctx, String runId, String source) {
      withRun(ctx, runId, run -> {
         String template;
         File templateFile = Path.of(Properties.get(Properties.DIST_DIR, "."), "templates", "report-template-v3.0.html").toFile();
         if (templateFile.exists() && templateFile.isFile()) {
            try {
               template = Files.readString(templateFile.toPath(), StandardCharsets.UTF_8);
            } catch (IOException e) {
               log.error("Cannot read report template: ", e);
               ctx.response().setStatusCode(500).end();
               return;
            }
         } else {
            log.error("Template file is not available.");
            ctx.response().setStatusCode(500).end();
            return;
         }
         String sourceFile = source != null ? source : ControllerVerticle.DEFAULT_STATS_JSON;
         Path runDir = controller.getRunDir(run).toAbsolutePath();
         Path filePath = runDir.resolve(sourceFile).toAbsolutePath();
         if (!filePath.startsWith(runDir)) {
            ctx.response().setStatusCode(403).end("Requested file is not within the run directory!");
         } else if (!filePath.toFile().exists()) {
            ctx.response().setStatusCode(404).end("Requested file was not found");
         } else {
            try {
               String json = Files.readString(filePath);
               int placeholderIndex = template.indexOf(DATAKEY);
               HttpServerResponse response = ctx.response()
                     .putHeader(HttpHeaders.CONTENT_TYPE, "text/html").setChunked(true);
               response.write(template.substring(0, placeholderIndex));
               response.write(json);
               response.write(template.substring(placeholderIndex + DATAKEY.length()));
               response.end();
            } catch (IOException e) {
               log.error("Cannot read file " + filePath);
               ctx.response().setStatusCode(500).end("Cannot fetch file " + sourceFile);
            }
         }
      });
   }

   @Override
   public void listSessions(RoutingContext ctx, String runId, boolean inactive) {
      withRun(ctx, runId, run -> {
         ctx.response().setChunked(true);
         controller.listSessions(run, inactive,
               (agent, session) -> {
                  String line = agent.name + ": " + session + "\n";
                  ctx.response().write(Buffer.buffer(line.getBytes(StandardCharsets.UTF_8)));
               },
               commonListingHandler(ctx.response()));
      });
   }

   private Handler> commonListingHandler(HttpServerResponse response) {
      return result -> {
         if (result.succeeded()) {
            response.setStatusCode(HttpResponseStatus.OK.code()).end();
         } else if (result.cause() instanceof NoStackTraceThrowable) {
            response.setStatusCode(HttpResponseStatus.NOT_FOUND.code()).end();
         } else {
            response.setStatusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.code()).end(result.cause().getMessage());
         }
      };
   }

   @Override
   public void getRecentSessions(RoutingContext ctx, String runId) {
      getSessionStats(ctx, runId, ss -> ss.recentSessionPoolSummary(System.currentTimeMillis() - 5000));
   }

   @Override
   public void getTotalSessions(RoutingContext ctx, String runId) {
      getSessionStats(ctx, runId, StatisticsStore::totalSessionPoolSummary);
   }

   private void getSessionStats(RoutingContext ctx, String runId, Function>> func) {
      withStats(ctx, runId, run -> {
         Map> stats = func.apply(run.statisticsStore());
         JsonObject reply = new JsonObject();
         for (Map.Entry> entry : stats.entrySet()) {
            String phase = entry.getKey();
            Map addressStats = entry.getValue();
            JsonObject phaseStats = new JsonObject();
            reply.put(phase, phaseStats);
            addressStats.forEach((address, lowHigh) -> {
               String agent = run.agents.stream().filter(a -> a.deploymentId.equals(address)).map(a -> a.name).findFirst().orElse("unknown");
               phaseStats.put(agent, new JsonObject().put("min", lowHigh.low).put("max", lowHigh.high));
            });
         }
         ctx.response().end(reply.encodePrettily());
      });
   }


   @Override
   public void listConnections(RoutingContext ctx, String runId) {
      withRun(ctx, runId, run -> {
         ctx.response().setChunked(true);
         controller.listConnections(run,
               (agent, connection) -> {
                  String line = agent.name + ": " + connection + "\n";
                  ctx.response().write(Buffer.buffer(line.getBytes(StandardCharsets.UTF_8)));
               },
               commonListingHandler(ctx.response()));
      });
   }

   @Override
   public void getRecentConnections(RoutingContext ctx, String runId) {
      connectionStats(ctx, runId, StatisticsStore::recentConnectionsSummary);
   }

   @Override
   public void getTotalConnections(RoutingContext ctx, String runId) {
      connectionStats(ctx, runId, StatisticsStore::totalConnectionsSummary);
   }

   private void connectionStats(RoutingContext ctx, String runId, Function>> mapper) {
      withStats(ctx, runId, run -> {
         Map> stats = mapper.apply(run.statisticsStore());
         JsonObject result = stats.entrySet().stream().collect(JsonObject::new,
               (json, e) -> json.put(e.getKey(), lowHighMapToJson(e.getValue())), JsonObject::mergeIn);
         ctx.response().end(JsonObject.mapFrom(result).encodePrettily());
      });
   }

   private static JsonObject lowHighMapToJson(Map map) {
      return map.entrySet().stream().collect(JsonObject::new,
            (byType, e2) -> byType.put(e2.getKey(), new JsonObject().put("min", e2.getValue().low).put("max", e2.getValue().high)),
            JsonObject::mergeIn);
   }

   @Override
   public void getAllStats$application_zip(RoutingContext ctx, String runId) {
      getAllStatsCsv(ctx, runId);
   }

   @Override
   public void getAllStatsCsv(RoutingContext ctx, String runId) {
      withTerminatedRun(ctx, runId, run -> new Zipper(ctx.response(),
            controller.getRunDir(run).resolve("stats")).run());
   }

   @Override
   public void getAllStats$application_json(RoutingContext ctx, String runId) {
      getAllStatsJson(ctx, runId);
   }

   @Override
   public void getAllStatsJson(RoutingContext ctx, String runId) {
      withTerminatedRun(ctx, runId, run -> ctx.response()
            .putHeader(HttpHeaders.CONTENT_TYPE, MIME_TYPE_JSON)
            .sendFile(controller.getRunDir(run).resolve(ControllerVerticle.DEFAULT_STATS_JSON).toString()));
   }

   private void withTerminatedRun(RoutingContext ctx, String runId, Consumer consumer) {
      withRun(ctx, runId, run -> {
         if (!run.terminateTime.future().isComplete()) {
            ctx.response().setStatusCode(HttpResponseStatus.SEE_OTHER.code())
                  .setStatusMessage("Run is not completed yet.")
                  .putHeader(HttpHeaders.LOCATION, "/run/" + run.id)
                  .end();
         } else {
            consumer.accept(run);
         }
      });
   }

   @Override
   public void getRecentStats(RoutingContext ctx, String runId) {
      withStats(ctx, runId, run -> {
         List stats = run.statisticsStore().recentSummary(System.currentTimeMillis() - 5000);
         ctx.response().end(Json.encodePrettily(statsToJson(run, stats)));
      });
   }

   @Override
   public void getTotalStats(RoutingContext ctx, String runId) {
      withStats(ctx, runId, run -> {
         List stats = run.statisticsStore().totalSummary();
         ctx.response().end(Json.encodePrettily(statsToJson(run, stats)));
      });
   }

   @Override
   public void getHistogramStats(RoutingContext ctx, String runId, String phase, int stepId, String metric) {
      withStats(ctx, runId, run -> {
         Histogram histogram = run.statisticsStore().histogram(phase, stepId, metric);
         ctx.response().end(Json.encode(histogram));
      });
   }

   @Override
   public void getRunFile(RoutingContext ctx, String runId, String file) {
      withRun(ctx, runId, run -> {
         Path runDir = controller.getRunDir(run).toAbsolutePath();
         Path path = runDir.resolve(file).toAbsolutePath();
         if (!path.startsWith(runDir)) {
            ctx.response().setStatusCode(403).end("Requested file is not within the run directory!");
         } else if (!path.toFile().exists() || !path.toFile().isFile()) {
            ctx.response().setStatusCode(404).end("Requested file was not found");
         } else {
            ctx.response().sendFile(path.toString());
         }
      });
   }

   private void withStats(RoutingContext ctx, String runId, Consumer consumer) {
      withRun(ctx, runId, run -> {
         if (run.statisticsStore() == null) {
            ctx.response().setStatusCode(HttpResponseStatus.NOT_FOUND.code()).end();
         } else {
            consumer.accept(run);
         }
      });
   }

   private io.hyperfoil.controller.model.RequestStatisticsResponse statsToJson(Run run, List stats) {
      String status;
      if (run.terminateTime.future().isComplete()) {
         status = "TERMINATED";
      } else if (run.startTime > Long.MIN_VALUE) {
         status = "RUNNING";
      } else {
         status = "INITIALIZING";
      }
      return new io.hyperfoil.controller.model.RequestStatisticsResponse(status, stats);
   }

   @Override
   public void getBenchmarkForRun$text_vnd_yaml(RoutingContext ctx, String runId) {
      withRun(ctx, runId, run -> sendYamlBenchmark(ctx, controller.ensureBenchmark(run)));
   }

   @Override
   public void getBenchmarkForRun$application_java_serialized_object(RoutingContext ctx, String runId) {
      withRun(ctx, runId, run -> sendSerializedBenchmark(ctx, controller.ensureBenchmark(run)));
   }

   @Override
   public void listAgents(RoutingContext ctx) {
      ctx.response().end(new JsonArray(controller.runs.values().stream()
            .flatMap(run -> run.agents.stream().map(agentInfo -> agentInfo.name))
            .distinct().collect(Collectors.toList())).encodePrettily());
   }

   @Override
   public void getControllerLog(RoutingContext ctx, long offset, String ifMatch) {
      String logPath = Properties.get(Properties.CONTROLLER_LOG, controller.getConfig().getString(Properties.CONTROLLER_LOG));
      if (ifMatch != null && !ifMatch.equals(controller.deploymentID())) {
         ctx.response().setStatusCode(HttpResponseStatus.PRECONDITION_FAILED.code()).end();
         return;
      }
      if (controller.hasControllerLog()) {
         try {
            File tempFile = File.createTempFile("controller.", ".log");
            tempFile.deleteOnExit();
            controller.downloadControllerLog(offset, tempFile, result -> {
               if (result.succeeded()) {
                  sendFile(ctx, tempFile, controller.deploymentID());
               } else {
                  log.error("Failed to download controller log.", result.cause());
                  ctx.response()
                        .setStatusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.code())
                        .setStatusMessage("Cannot download controller log").end();
               }
            });
         } catch (IOException e) {
            log.error("Failed to create temporary file", e);
            ctx.response().setStatusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.code()).end();
         }
      } else {
         if (logPath == null) {
            ctx.response()
                  .setStatusCode(HttpResponseStatus.NOT_FOUND.code())
                  .setStatusMessage("Log file not defined.").end();
            return;
         }
         File logFile = new File(logPath);
         if (!logFile.exists()) {
            ctx.response()
                  .setStatusCode(HttpResponseStatus.NOT_FOUND.code())
                  .setStatusMessage("Log file does not exist.").end();
         } else {
            if (offset < 0) {
               ctx.response()
                     .setStatusCode(HttpResponseStatus.BAD_REQUEST.code())
                     .setStatusMessage("Offset must be non-negative").end();
            } else {
               ctx.response().putHeader(HttpHeaders.ETAG, controller.deploymentID());
               ctx.response().sendFile(logPath, offset);
            }
         }
      }
   }

   private void sendFile(RoutingContext ctx, File tempFile, String etag) {
      //noinspection ResultOfMethodCallIgnored
      ctx.response()
            .putHeader(HttpHeaders.ETAG, etag)
            .sendFile(tempFile.toString(), r -> tempFile.delete());
   }

   @Override
   public void getAgentLog(RoutingContext ctx, String agent, long offset, String ifMatch) {
      if (agent == null || "controller".equals(agent)) {
         getControllerLog(ctx, offset, ifMatch);
         return;
      }
      if (offset < 0) {
         ctx.response()
               .setStatusCode(HttpResponseStatus.BAD_REQUEST.code())
               .setStatusMessage("Offset must be non-negative").end();
      }
      Optional agentInfo = controller.runs.values().stream()
            .reduce(LAST_RUN_OPERATOR)
            .flatMap(run -> run.agents.stream().filter(ai -> agent.equals(ai.name)).findFirst());
      if (agentInfo.isEmpty()) {
         ctx.response()
               .setStatusCode(HttpResponseStatus.NOT_FOUND.code())
               .setStatusMessage("Agent " + agent + " not found.").end();
         return;
      }
      if (ifMatch != null && !ifMatch.equals(agentInfo.get().deploymentId)) {
         ctx.response().setStatusCode(HttpResponseStatus.PRECONDITION_FAILED.code()).end();
         return;
      }
      try {
         File tempFile = File.createTempFile("agent." + agent, ".log");
         tempFile.deleteOnExit();
         controller.downloadAgentLog(agentInfo.get().deployedAgent, offset, tempFile, result -> {
            if (result.succeeded()) {
               sendFile(ctx, tempFile, agentInfo.get().deploymentId);
            } else {
               log.error("Failed to download agent log for " + agentInfo.get(), result.cause());
               ctx.response()
                     .setStatusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.code())
                     .setStatusMessage("Cannot download agent log").end();
            }
         });
      } catch (IOException e) {
         log.error("Failed to create temporary file", e);
         ctx.response().setStatusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.code()).end();
      }
   }

   @Override
   public void shutdown(RoutingContext ctx, boolean force) {
      List runs = controller.runs.values().stream().filter(run -> !run.terminateTime.future().isComplete()).collect(Collectors.toList());
      if (force) {
         // We don't allow concurrent runs ATM, but...
         @SuppressWarnings("rawtypes")
         List futures = new ArrayList<>();
         for (Run run : runs) {
            Promise promise = Promise.promise();
            futures.add(promise.future());
            controller.kill(run, result -> promise.complete());
         }
         CompositeFuture.all(futures).onComplete(nil -> {
            ctx.response().end();
            controller.shutdown();
         });
      } else if (runs.isEmpty()) {
         ctx.response().end();
         controller.shutdown();
      } else {
         String running = runs.stream().map(run -> run.id).collect(Collectors.joining(", "));
         ctx.response()
               .setStatusCode(HttpResponseStatus.FORBIDDEN.code())
               .setStatusMessage("These runs are still in progress: " + running).end();
      }
   }

   @Override
   public void getToken(RoutingContext ctx) {
      ctx.response().putHeader(HttpHeaders.CONTENT_TYPE, "text/plain; charset=utf-8").end(BEARER_TOKEN);
   }

   @Override
   public void getVersion(RoutingContext ctx) {
      ctx.response().end(Json.encodePrettily(new io.hyperfoil.controller.model.Version(Version.VERSION, Version.COMMIT_ID, controller.deploymentID(), new Date())));
   }

   public void withBenchmark(RoutingContext ctx, String name, Consumer consumer) {
      Benchmark benchmark = controller.getBenchmark(name);
      if (benchmark == null) {
         ctx.response().setStatusCode(HttpResponseStatus.NOT_FOUND.code()).setStatusMessage("No benchmark '" + name + "'").end();
         return;
      }
      consumer.accept(benchmark);
   }

   private static class BasicAuthHandler implements Handler {
      @Override
      public void handle(RoutingContext ctx) {
         String authorization = ctx.request().getHeader(HttpHeaders.AUTHORIZATION);
         if (authorization != null && authorization.startsWith("Basic ")) {
            byte[] credentials = Base64.getDecoder().decode(authorization.substring(6).trim());
            for (int i = 0; i < credentials.length; ++i) {
               if (credentials[i] == ':') {
                  String password = new String(credentials, i + 1, credentials.length - i - 1, StandardCharsets.UTF_8);
                  if (password.equals(CONTROLLER_PASSWORD)) {
                     ctx.next();
                     return;
                  }
                  break;
               }
            }
            ctx.response().setStatusCode(403).end();
         } else if (authorization != null && authorization.startsWith("Bearer ")) {
            if (BEARER_TOKEN.equals(authorization.substring(7))) {
               ctx.next();
            } else {
               ctx.response().setStatusCode(403).end();
            }
         } else {
            ctx.response().setStatusCode(401).putHeader("WWW-Authenticate", "Basic realm=\"Hyperfoil\", charset=\"UTF-8\"").end();
         }
      }
   }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy