diff --git a/config/presubmits.py b/config/presubmits.py index 9a412dab9..874623e1b 100644 --- a/config/presubmits.py +++ b/config/presubmits.py @@ -100,12 +100,12 @@ PRESUBMITS = { {"node_modules/"}, REQUIRED): "Source files must end in a newline.", - # System.(out|err).println should only appear in tools/ + # System.(out|err).println should only appear in tools/ or load-testing/ PresubmitCheck( r".*\bSystem\.(out|err)\.print", "java", { "StackdriverDashboardBuilder.java", "/tools/", "/example/", - "RegistryTestServerMain.java", "TestServerExtension.java", - "FlowDocumentationTool.java" + "/load-testing/", "RegistryTestServerMain.java", + "TestServerExtension.java", "FlowDocumentationTool.java" }): "System.(out|err).println is only allowed in tools/ packages. Please " "use a logger instead.", diff --git a/load-testing/build.gradle b/load-testing/build.gradle new file mode 100644 index 000000000..776b1570e --- /dev/null +++ b/load-testing/build.gradle @@ -0,0 +1,54 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// 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. + +apply plugin: 'java' + +createUberJar('buildLoadTestClient', 'loadTest', 'google.registry.client.EppClient') + +dependencies { + def deps = rootProject.dependencyMap + implementation deps['joda-time:joda-time'] + implementation deps['io.netty:netty-buffer'] + implementation deps['io.netty:netty-codec'] + implementation deps['io.netty:netty-codec-http'] + implementation deps['io.netty:netty-common'] + implementation deps['io.netty:netty-handler'] + implementation deps['io.netty:netty-transport'] + implementation deps['com.google.guava:guava'] + implementation deps['org.bouncycastle:bcpg-jdk18on'] + implementation deps['org.bouncycastle:bcpkix-jdk18on'] + implementation deps['org.bouncycastle:bcprov-jdk18on'] + implementation deps['org.jcommander:jcommander'] + implementation deps['com.google.flogger:flogger'] + runtimeOnly deps['com.google.flogger:flogger-system-backend'] +} + +task makeStagingDirectory { + mkdir layout.buildDirectory.dir('stage') +} + +task copyFilesToStaging(dependsOn: makeStagingDirectory, type: Copy) { + from layout.buildDirectory.file('libs/loadTest.jar'), "${projectDir}/certificate.pem", "${projectDir}/key.pem" + into layout.buildDirectory.dir('stage') +} + +task deployLoadTestsToInstances (dependsOn: copyFilesToStaging, type: Exec) { + executable "sh" + workingDir "${projectDir}/" + args "-c", "./deploy.sh" +} + +test { + useJUnitPlatform() +} diff --git a/load-testing/deploy.sh b/load-testing/deploy.sh new file mode 100755 index 000000000..044534704 --- /dev/null +++ b/load-testing/deploy.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Copyright 2024 The Nomulus Authors. All Rights Reserved. +# +# 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. + +HOSTS=$(gcloud compute instances list | awk '/^loadtest/ { print $5 }') +for host in $HOSTS; do rsync -avz ./build/stage/ $host:test-client/; done diff --git a/load-testing/instanceCleanUp.sh b/load-testing/instanceCleanUp.sh new file mode 100755 index 000000000..cb8da4bec --- /dev/null +++ b/load-testing/instanceCleanUp.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Copyright 2024 The Nomulus Authors. All Rights Reserved. +# +# 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. + +# Find and delete the instances used for load testing + +gcloud compute instances list --filter="name ~ loadtest.*" --zones us-east4-a \ +--format="value(name)" | xargs gcloud compute instances delete --zone us-east4-a diff --git a/load-testing/instanceSetUp.sh b/load-testing/instanceSetUp.sh new file mode 100755 index 000000000..835c4c17a --- /dev/null +++ b/load-testing/instanceSetUp.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Copyright 2024 The Nomulus Authors. All Rights Reserved. +# +# 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. + +# Create the instances - modify this number for the amount of instances you +# would like to use for your load test +gcloud compute instances create loadtest-{1..2} --machine-type g1-small \ +--image-family ubuntu-2204-lts --image-project ubuntu-os-cloud --zone us-east4-a + +sleep 10 + +# Get all the created load tests instances +HOSTS=$(gcloud compute instances list | awk '/^loadtest/ { print $5 }') + +#Install rsync and Java - Retry is needed here since ssh connection will fail until instances are fully provisioned +for host in $HOSTS; + do + for i in {1..60}; do + if ssh $host 'sudo apt-get -y update && + sudo apt-get -y upgrade && + sudo apt-get -y install rsync && + sudo apt-get -y install openjdk-21-jdk'; then + break + else + sleep 5 + fi + done + done diff --git a/load-testing/run.sh b/load-testing/run.sh new file mode 100644 index 000000000..b31fc5ac9 --- /dev/null +++ b/load-testing/run.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Copyright 2024 The Nomulus Authors. All Rights Reserved. +# +# 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. + +HOSTS=$(gcloud compute instances list | awk '/^loadtest/ { print $5 }') + +for host in $HOSTS; + do ssh $host 'cd test-client/ && + java -jar loadTest.jar --host epp.example --certificate certificate.pem -k key.pem -pw examplePassword -ft'; + done diff --git a/load-testing/src/main/java/google/registry/client/EppClient.java b/load-testing/src/main/java/google/registry/client/EppClient.java new file mode 100644 index 000000000..21ccf5076 --- /dev/null +++ b/load-testing/src/main/java/google/registry/client/EppClient.java @@ -0,0 +1,353 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// 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 google.registry.client; + +import static com.google.common.io.Resources.getResource; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.joda.time.DateTimeZone.UTC; + +import com.beust.jcommander.IStringConverter; +import com.beust.jcommander.JCommander; +import com.beust.jcommander.Parameter; +import com.beust.jcommander.Parameters; +import com.google.common.collect.ImmutableList; +import com.google.common.io.Files; +import com.google.common.io.Resources; +import io.netty.bootstrap.Bootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.codec.LengthFieldBasedFrameDecoder; +import io.netty.handler.codec.LengthFieldPrepender; +import io.netty.handler.logging.LogLevel; +import io.netty.handler.logging.LoggingHandler; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; +import io.netty.util.AttributeKey; +import io.netty.util.concurrent.Promise; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.KeyPair; +import java.security.Security; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.joda.time.DateTime; + +/** A simple EPP client that can be used for load testing. */ +@Parameters(separators = " =") +@SuppressWarnings("FutureReturnValueIgnored") +public class EppClient implements Runnable { + + static { + Security.addProvider(new BouncyCastleProvider()); + } + + private static final String LOGIN_FILE = "login.xml"; + private static final String LOGOUT_FILE = "logout.xml"; + static final AttributeKey> REQUEST_SENT = + AttributeKey.valueOf("REQUEST_SENT"); + static final AttributeKey> RESPONSE_RECEIVED = + AttributeKey.valueOf("RESPONSE_RECEIVED"); + static final AttributeKey CHANNEL_NUMBER = AttributeKey.valueOf("CHANNEL_NUMBER"); + static final AttributeKey LOGGING_LOCATION = AttributeKey.valueOf("LOGGING_LOCATION"); + static final AttributeKey FORCE_TERMINATE = AttributeKey.valueOf("FORCE_TERMINATE"); + static final AttributeKey LOGGING_EXECUTOR = + AttributeKey.valueOf("LOGGING_EXECUTOR"); + static final AttributeKey> INPUT_ITERATOR = + AttributeKey.valueOf("INPUT_ITERATOR"); + static final AttributeKey> LOGGING_REQUEST_COMPLETE = + AttributeKey.valueOf("LOGGING_REQUEST_COMPLETE"); + private static final int PORT = 700; + private static final int TIMEOUT_SECONDS = 600; + + public static class InetAddressConverter implements IStringConverter { + + @Override + public InetAddress convert(String host) { + try { + return InetAddress.getByName(host); + } catch (UnknownHostException e) { + throw new IllegalArgumentException(host, e); + } + } + } + + @Parameter( + names = {"--help"}, + description = "Print this help message.", + help = true) + private boolean help = false; + + @Parameter( + names = {"--host", "-h"}, + description = "Epp server hostname/IP to connect to.", + converter = InetAddressConverter.class, + required = true) + private InetAddress host = null; + + @Parameter( + names = {"--certificate"}, + description = "Certificate pem file.", + required = true) + private String certFileName = null; + + @Parameter( + names = {"--key", "-k"}, + description = "Private key pem file.", + required = true) + private String keyFileName = null; + + @Parameter( + names = {"--connections", "-cn"}, + description = "Number of connections that are made to the EPP server.") + private int connections = 1; + + @Parameter( + names = {"--client", "-c"}, + description = "Registrar client id.") + private String client = "proxy"; + + @Parameter( + names = {"--password", "-pw"}, + description = "Registrar password.") + private String password = "abcde12345"; + + @Parameter( + names = {"--force_terminate", "-ft"}, + description = "Whether to explicitly close the connection after receiving a logout response.") + private boolean forceTerminate = false; + + public static void main(String[] args) { + EppClient eppClient = new EppClient(); + JCommander jCommander = new JCommander(eppClient); + jCommander.parse(args); + if (eppClient.help) { + jCommander.usage(); + return; + } + eppClient.run(); + } + + private ImmutableList makeInputList(ZonedDateTime now) { + ImmutableList.Builder templatesList = ImmutableList.builder(); + ImmutableList.Builder inputList = ImmutableList.builder(); + templatesList.add(readStringFromFile(LOGIN_FILE)); + templatesList.add(readStringFromFile(LOGOUT_FILE)); + for (String template : templatesList.build()) { + inputList.add( + template + .replace("@@CLIENT@@", client) + .replace("@@PASSWORD@@", password) + .replace("@@NOW@@", now.toString())); + } + return inputList.build(); + } + + private static String readStringFromFile(String filename) { + try { + return Resources.toString(getResource(EppClient.class, "resources/" + filename), UTF_8); + } catch (IOException e) { + throw new IllegalArgumentException("Cannot read from file: resources/" + filename); + } + } + + private static KeyPair getKeyPair(String filename) throws IOException { + byte[] keyBytes = Files.asCharSource(new File(filename), UTF_8).read().getBytes(UTF_8); + try { + PEMKeyPair pemPair = + (PEMKeyPair) + new PEMParser(new InputStreamReader(new ByteArrayInputStream(keyBytes), UTF_8)) + .readObject(); + return new JcaPEMKeyConverter().setProvider("BC").getKeyPair(pemPair); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static X509Certificate getCertificate(String filename) throws IOException { + byte[] certificateBytes = Files.asCharSource(new File(filename), UTF_8).read().getBytes(UTF_8); + try { + X509CertificateHolder certificateHolder = + (X509CertificateHolder) + new PEMParser( + new InputStreamReader(new ByteArrayInputStream(certificateBytes), UTF_8)) + .readObject(); + return new JcaX509CertificateConverter().setProvider("BC").getCertificate(certificateHolder); + } catch (IOException | CertificateException e) { + throw new RuntimeException(e); + } + } + + private ChannelInitializer makeChannelInitializer( + String outputFolder, ImmutableList loggingExecutors) throws IOException { + return new ChannelInitializer<>() { + + private final ImmutableList inputList = + makeInputList(ZonedDateTime.now(ZoneOffset.UTC)); + private final KeyPair key = getKeyPair(keyFileName); + private final X509Certificate cert = getCertificate(certFileName); + private final LoggingHandler loggingHandler = new LoggingHandler(LogLevel.INFO); + private final EppClientHandler eppClientHandler = new EppClientHandler(); + + @Override + protected void initChannel(SocketChannel ch) throws Exception { + ch.attr(REQUEST_SENT).set(new ArrayList<>()); + ch.attr(RESPONSE_RECEIVED).set(new ArrayList<>()); + Path loggingLocation = + Paths.get(String.format("%s/%d.log", outputFolder, ch.attr(CHANNEL_NUMBER).get())); + ch.attr(LOGGING_LOCATION).set(loggingLocation); + ch.attr(FORCE_TERMINATE).set(forceTerminate); + ch.attr(LOGGING_EXECUTOR) + .set(loggingExecutors.get(ch.attr(CHANNEL_NUMBER).get() % loggingExecutors.size())); + + ch.attr(INPUT_ITERATOR) + .set( + inputList.stream() + .map( + (String str) -> + str.replace( + "@@CHANNEL_NUMBER@@", + String.valueOf(ch.attr(CHANNEL_NUMBER).get())) + .replace("@@CHANNEL_NUMBER_MODULO@@", String.valueOf(0)) + .getBytes(UTF_8)) + .iterator()); + + ch.pipeline() + .addLast( + SslContextBuilder.forClient() + .keyManager(key.getPrivate(), cert) + .trustManager(InsecureTrustManagerFactory.INSTANCE) + .build() + .newHandler(ch.alloc(), host.getHostName(), PORT)); + ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(512 * 1024, 0, 4, -4, 4)); + ch.pipeline().addLast(new LengthFieldPrepender(4, true)); + ch.pipeline().addLast(loggingHandler); + ch.pipeline().addLast(eppClientHandler); + } + }; + } + + private static String createOutputFolder(String folderName) { + Path folder = Paths.get(folderName); + if (!folder.toFile().exists()) { + folder.toFile().mkdirs(); + } + System.out.printf("\nOutputs saved at %s\n", folder); + return folderName; + } + + @Override + public void run() { + String outputFolder = createOutputFolder(String.format("load-tests/%s", DateTime.now(UTC))); + ImmutableList.Builder builder = ImmutableList.builderWithExpectedSize(5); + for (int i = 0; i < 5; ++i) { + builder.add(Executors.newSingleThreadExecutor()); + } + final ImmutableList loggingExecutors = builder.build(); + + EventLoopGroup eventLoopGroup = new NioEventLoopGroup(); + + try { + Bootstrap bootstrap = + new Bootstrap() + .group(eventLoopGroup) + .channel(NioSocketChannel.class) + .handler(makeChannelInitializer(outputFolder, loggingExecutors)); + + List channelFutures = new ArrayList<>(); + + // Three requests: hello (from the proxy), login and logout. + int requestPerConnection = 3; + + for (int i = 0; i < connections; i++) { + bootstrap.attr(CHANNEL_NUMBER, i); + channelFutures.add( + bootstrap + .connect(host, PORT) + .addListener( + (ChannelFuture cf) -> { + if (!cf.isSuccess()) { + System.out.printf("Cannot connect to %s:%s\n", host, PORT); + } + })); + } + + LinkedHashSet killedConnections = new LinkedHashSet<>(); + + // Wait for all channels to close. + for (ChannelFuture channelFuture : channelFutures) { + Channel channel = channelFuture.syncUninterruptibly().channel(); + int channelNumber = channel.attr(CHANNEL_NUMBER).get(); + if (!channel + .closeFuture() + .awaitUninterruptibly( + TIMEOUT_SECONDS * 1000 + - Duration.between( + channel.attr(REQUEST_SENT).get().getFirst(), + ZonedDateTime.now(ZoneOffset.UTC)) + .toMillis())) { + channel.close().syncUninterruptibly(); + killedConnections.add(channelNumber); + } + } + + System.out.println(); + System.out.println("====== SUMMARY ======"); + System.out.printf("Number of connections: %d\n", connections); + System.out.printf("Number of requests per connection: %d\n", requestPerConnection); + if (!killedConnections.isEmpty()) { + System.out.printf("Force killed connections (%d): ", killedConnections.size()); + for (int channelNumber : killedConnections) { + System.out.printf("%d ", channelNumber); + } + System.out.print("\n"); + } + + eventLoopGroup.shutdownGracefully(); + + channelFutures.forEach( + channelFuture -> { + channelFuture.channel().attr(LOGGING_REQUEST_COMPLETE).get().syncUninterruptibly(); + }); + loggingExecutors.forEach(ExecutorService::shutdown); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/load-testing/src/main/java/google/registry/client/EppClientHandler.java b/load-testing/src/main/java/google/registry/client/EppClientHandler.java new file mode 100644 index 000000000..e2c9204e8 --- /dev/null +++ b/load-testing/src/main/java/google/registry/client/EppClientHandler.java @@ -0,0 +1,153 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// 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 google.registry.client; + +import static google.registry.client.EppClient.CHANNEL_NUMBER; +import static google.registry.client.EppClient.FORCE_TERMINATE; +import static google.registry.client.EppClient.INPUT_ITERATOR; +import static google.registry.client.EppClient.LOGGING_EXECUTOR; +import static google.registry.client.EppClient.LOGGING_LOCATION; +import static google.registry.client.EppClient.LOGGING_REQUEST_COMPLETE; +import static google.registry.client.EppClient.REQUEST_SENT; +import static google.registry.client.EppClient.RESPONSE_RECEIVED; +import static java.nio.file.StandardOpenOption.APPEND; + +import com.google.common.flogger.FluentLogger; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelDuplexHandler; +import io.netty.channel.ChannelHandler.Sharable; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelPromise; +import io.netty.util.ReferenceCountUtil; +import io.netty.util.concurrent.Promise; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; + +/** Handler that sends EPP requests and receives EPP responses. */ +@SuppressWarnings("FutureReturnValueIgnored") +@Sharable +public class EppClientHandler extends ChannelDuplexHandler { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + static class FileWriter implements Runnable { + + private final Path loggingLocation; + private final byte[] contents; + private final ZonedDateTime time; + + FileWriter(Path loggingLocation, byte[] contents, ZonedDateTime time) { + this.loggingLocation = loggingLocation; + this.contents = contents; + this.time = time; + } + + @Override + public void run() { + try { + if (!Files.exists(loggingLocation)) { + Files.createFile(loggingLocation); + } + Files.writeString(loggingLocation, time.toString() + "\n", APPEND); + Files.write(loggingLocation, contents, APPEND); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + @Override + public void channelRegistered(ChannelHandlerContext ctx) throws Exception { + ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); + ctx.channel().attr(REQUEST_SENT).get().add(now); + ctx.channel().attr(LOGGING_REQUEST_COMPLETE).set(ctx.executor().newPromise()); + super.channelRegistered(ctx); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + Promise loggingCompletePromise = ctx.channel().attr(LOGGING_REQUEST_COMPLETE).get(); + if (!loggingCompletePromise.isDone()) { + loggingCompletePromise.setSuccess(null); + } + logger.atWarning().withCause(cause).log( + "Connection %d closed.", ctx.channel().attr(CHANNEL_NUMBER).get()); + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); + Channel ch = ctx.channel(); + ctx.channel().attr(RESPONSE_RECEIVED).get().add(now); + if (msg instanceof ByteBuf buffer) { + byte[] contents = new byte[buffer.readableBytes()]; + buffer.readBytes(contents); + ReferenceCountUtil.release(buffer); + if (ch.attr(LOGGING_LOCATION).get() != null) { + ch.attr(LOGGING_EXECUTOR) + .get() + .submit(new FileWriter(ch.attr(LOGGING_LOCATION).get(), contents, now)); + } + if (ch.attr(INPUT_ITERATOR).get().hasNext()) { + ch.writeAndFlush(ch.attr(INPUT_ITERATOR).get().next()); + } else { + ch.attr(LOGGING_REQUEST_COMPLETE).get().setSuccess(null); + if (ch.attr(FORCE_TERMINATE).get()) { + ch.close(); + } + } + } + } + + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { + ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); + Channel ch = ctx.channel(); + ctx.channel().attr(REQUEST_SENT).get().add(now); + if (msg instanceof byte[] outputBytes) { + ByteBuf buffer = Unpooled.buffer(); + buffer.writeBytes(outputBytes); + ctx.write(buffer, promise); + if (ch.attr(LOGGING_LOCATION).get() != null) { + ch.attr(LOGGING_EXECUTOR) + .get() + .submit(new FileWriter(ch.attr(LOGGING_LOCATION).get(), outputBytes, now)); + } + } + } + + @Override + public void close(ChannelHandlerContext ctx, ChannelPromise promise) throws Exception { + Promise loggingCompletePromise = ctx.channel().attr(LOGGING_REQUEST_COMPLETE).get(); + if (!loggingCompletePromise.isDone()) { + loggingCompletePromise.setSuccess(null); + } + super.close(ctx, promise); + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + Promise loggingCompletePromise = ctx.channel().attr(LOGGING_REQUEST_COMPLETE).get(); + if (!loggingCompletePromise.isDone()) { + loggingCompletePromise.setSuccess(null); + } + super.channelInactive(ctx); + } +} diff --git a/load-testing/src/main/java/google/registry/client/README.md b/load-testing/src/main/java/google/registry/client/README.md new file mode 100644 index 000000000..cbed560e1 --- /dev/null +++ b/load-testing/src/main/java/google/registry/client/README.md @@ -0,0 +1,73 @@ +## EPP Load Testing Client + +This project contains an EPP client that can be use for load testing the full +registry platform. All the below commands should be run from the merged root. + +### Setting up the test instances + +* If you have not done s yet, you will need to set up ssh keys in your +[GCE metadata](https://pantheon.corp.google.com/compute/metadata?resourceTab=sshkeys): + +* To create however many GCE instances you want to run on, modify the +`instanceSetUp.sh` file to include the correct number of instances. + +* Run the instance set up script to create and configure each of the GCE +instances to be used for load testing: + + ```shell + $ load-testing/instanceSetUp.sh + ``` + +* Verify that the IP address of any created instances is in the allowlist of the + proxy registrar user. + * Use the below command to get the IP addresses of the created instances. + + ```shell + $ (gcloud compute instances list | awk '/^loadtest/ { print $5 }') + ``` + + * Check the proxy registrar's current allow list + + ```shell + $ nomulus -e sandbox get_registrar proxy | grep ipAddressAllowList + ``` + + * All of your host ip addresses should match a netmask specified in the proxy + allow list. If not, you'll need to redefine the list: + + ```shell + $ nomulus -e sandbox update_registrar proxy --ip_allow_list= + ``` + + +### Running the client + +* From the merged root build the load testing client: + ```shell + $ ./nom_build :load-testing:buildLoadTestClient + ``` + +* Deploy the client to the GCE instances (this will create a local staging +directory and deploy it to each of your previously created loadtest GCE instances): + ```shell + $ ./nom_build :load-testing:deployLoadTestsToInstances + ``` + +* Run the load test. Configurations of the load test can be made by configuring +this `run.sh` file locally. + + ```shell + $ load-testing/run.sh + ``` + +### Cleanup + +* Run the instance clean up script to delete the created instances + + ```shell + $ load-testing/instanceCleanUp.sh + ``` + +* You may want to remove any host key fingerprints for those hosts from your ~/.ssh/known_hosts file (these IPs tend to get reused with new host keys) + + diff --git a/load-testing/src/main/java/google/registry/client/resources/login.xml b/load-testing/src/main/java/google/registry/client/resources/login.xml new file mode 100644 index 000000000..4ff8ac658 --- /dev/null +++ b/load-testing/src/main/java/google/registry/client/resources/login.xml @@ -0,0 +1,19 @@ + + + + + @@CLIENT@@ + @@PASSWORD@@ + + 1.0 + en + + + urn:ietf:params:xml:ns:host-1.0 + urn:ietf:params:xml:ns:domain-1.0 + urn:ietf:params:xml:ns:contact-1.0 + + + epp-client-login-@@NOW@@-@@CHANNEL_NUMBER@@ + + diff --git a/load-testing/src/main/java/google/registry/client/resources/logout.xml b/load-testing/src/main/java/google/registry/client/resources/logout.xml new file mode 100644 index 000000000..5e2150051 --- /dev/null +++ b/load-testing/src/main/java/google/registry/client/resources/logout.xml @@ -0,0 +1,7 @@ + + + + + epp-client-logout-@@NOW@@-@@CHANNEL_NUMBER@@ + + diff --git a/settings.gradle b/settings.gradle index 0df4715e3..66c350058 100644 --- a/settings.gradle +++ b/settings.gradle @@ -47,3 +47,4 @@ include 'services:tools' include 'services:pubapi' include 'console-webapp' include 'jetty' +include 'load-testing'