io.github.ddimitrov.nuggets.Ports Maven / Gradle / Ivy
Show all versions of nuggets Show documentation
/*
* Copyright 2017 by Dimitar Dimitrov
*
* 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.
*/
// NOTE: this class overuses nested classes, because I find I often need to copy/paste it to proprietary projects.
package io.github.ddimitrov.nuggets;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.util.*;
import java.util.function.Consumer;
import java.util.function.Supplier;
import static io.github.ddimitrov.nuggets.Exceptions.rethrow;
import static java.util.Objects.requireNonNull;
/**
* Entry Point Provides utilities for
* ad-hoc coordination of port usage for distributed systems in tests and
* lab environments.
*
* For example, when you do integration testing for a distributed system,
* and you want to run a couple of tests in parallel you need to make sure that
* none of the test environments shares ports and file paths with the others.
*
* To achieve that, you can use the {@code Ports} class to allocate the ports
* and tweak the app configuration in application-specific manner.
* See the user manual for details and more use-cases.
*
* This class has explicit lifecycle as follows:
*
* Ports p = new Ports(...);
* // CONFIGURE: call withExporter(...), reservePort(id[, offset]), withPorts(...) as needed
* p.freeze(...);
* // USE: call getBasePort() and port(id)
* p.close();
* // DEAD: any method call would result in exception
*
*/
public class Ports implements AutoCloseable {
private final List claimedOffsets = Collections.synchronizedList(new ArrayList<>());
private final Set dynamicOffsets = new HashSet<>();
private final Set exporters = new HashSet<>();
private final @NotNull Registrar registrar;
private final @NotNull Supplier dynamicPortFinder;
private volatile int basePort = -1;
private volatile boolean closed;
/**
* Creates new ports allocator with the specified registrar
* and default dynamic port allocation strategy.
*
* The default port allocation strategy assigns offsets to the
* dynamic port ID's starting from the base port of the block and
* skipping all explicitly assigned offsets.
*
* @param registrar defines a strategy for allocating a port block.
*/
public Ports(@NotNull Registrar registrar) {
this(registrar, null);
}
/**
* Creates new ports allocator with the specified registrar
* and default dynamic port allocation strategy.
*
* @param registrar defines a strategy for allocating a port block.
* @param dynamicPortFinder defines a strategy for dynamically allocating
* ports within a block. If {@code null}, a default strategy assigns
* offsets to the dynamic port ID's starting from the {@link #basePort base port}
* of the block and skipping all explicitly assigned offsets.
*/
public Ports(@NotNull Registrar registrar, @Nullable Supplier dynamicPortFinder) {
this.registrar = registrar;
this.dynamicPortFinder = dynamicPortFinder!=null
? dynamicPortFinder
: () -> {
int gapIdx = claimedOffsets.indexOf(null);
return gapIdx>=0 ? gapIdx : claimedOffsets.size();
};
}
/**
* Registers an {@link Exporter} that would be used to publish/validate
* the allocated ports. Can throw {@link PortVetoException} to reject a
* port and force the port allocator to use another block
*
* If more than one exporter is registered, exporters will be called in
* their registration order.
*
*
* If the implementation of this interface also implements
* {@link AutoCloseable}, the {@code close()} method will be called after
* all offsets have been exported and successfully validated, after
* the allocation is confirmed successful.
*
* @param exporter a callback to publish or validate an allocated port.
* @return {@code this} instance for chaining.
*
* @see Exporter#batching(Consumer)
*/
public @NotNull Ports withExporter(@NotNull Exporter exporter) {
if (basePort>=0) throw new IllegalStateException("Already frozen!");
if (closed) throw new IllegalStateException("Already closed!");
exporters.add(exporter);
return this;
}
/**
* Registers a port ID at desired offset within the block.
* Should be called as many times as needed before {@link #freeze(int)}.
*
* @param id the ID of the port we are registering.
* Should be unique.
* @param offset the desired offset for the finally allocated port.
* Should be unique.
*
* @return {@code this} instance for chaining.
*
* @throws IllegalArgumentException if any of the arguments is not unique
* within the scope of {@code this} instance.
* @throws IllegalArgumentException if this port allocator has already been
* {@link #freeze(int) frozen} or {@link #close() closed}
*
* @see #reservePort(String)
* @see #withPorts(int, Consumer)
*/
public @NotNull Ports reservePort(@NotNull String id, int offset) {
if (basePort>=0) throw new IllegalStateException("Port allocation already finalized!");
if (closed) throw new IllegalStateException("Already closed!");
if (Exporter.BASE_PORT_ID.equals(id)) throw new IllegalArgumentException("Port ID can not be empty");
if (dynamicOffsets.contains(id)) throw new IllegalArgumentException("Port ID '" + id + "' already registered as dynamic (requested " + offset + ")");
int pos = claimedOffsets.indexOf(id);
if (pos>=0 && pos!=offset) throw new IllegalArgumentException("Port ID '" + id + "' already registered at offset: " + pos + " (requested " + offset + ")");
while (offset>=claimedOffsets.size()) claimedOffsets.add(null); // pad the list so we don't get an IOBE
String old = claimedOffsets.set(offset, requireNonNull(id));
if (old!=null && !old.equals(id)) {
claimedOffsets.set(offset, old); // EAFP to avoid TOCTOU issues
throw new IllegalArgumentException("Clashing port reservations for offset " + offset + ": old='" + old + "', new='" + id + "'");
}
return this;
}
/**
* Registers a port ID at automatically chosen offset within the block.
* Should be called as many times as needed before {@link #freeze(int)}.
*
* @param id the ID of the port we are registering. Should be unique.
*
* @return {@code this} instance for chaining.
*
* @throws IllegalArgumentException if any of the ID is not unique
* within the scope of {@code this} instance.
* @throws IllegalArgumentException if this port allocator has already been
* {@link #freeze(int) frozen} or {@link #close() closed}
*
* @see #reservePort(String, int)
* @see #withPorts(int, Consumer)
*/
public @NotNull Ports reservePort(@NotNull String id) {
if (basePort>=0) throw new IllegalStateException("Port allocation already finalized!");
if (closed) throw new IllegalStateException("Already closed!");
if (Exporter.BASE_PORT_ID.equals(id)) throw new IllegalArgumentException("Port ID can not be empty");
int pos = claimedOffsets.indexOf(id);
if (pos>=0) throw new IllegalArgumentException("Port ID '" + id + "' already registered at offset " + pos + " (requested dynamic offset)");
dynamicOffsets.add(id);
return this;
}
/**
* Configures port IDs and freezes this allocator in one step. Uses a builder based DSL (see example).
* Should be called only once, IMPORTANT: internally it calls {@code freeze(basePortHint)}.
*
*
* p.withPorts(5000, register -> {
* register.id("foo");
* register.id("bar").offset(1);
* register.id("baz").offset(2);
* register.id("qux");
* });
* assert p.getBasePort()==5000; // p is ready to use
*
*
* @param spec a closure configuring the builder (see example above).
* @param basePortHint the desired base port - the actual base port may
* differ if there is a conflict.
*
* @return {@code this} instance in {@link #freeze(int) frozen} state.
*
* @throws IllegalArgumentException if any of the ID's and offsets are conflicting.
* @throws IllegalArgumentException if this port allocator has already been
* {@link #freeze(int) frozen} or {@link #close() closed}
*
* @see #reservePort(String)
* @see #reservePort(String, int)
* @see #freeze(int)
*/
public @NotNull Ports withPorts(int basePortHint, @NotNull Consumer spec) {
PortsSpecBuilder builder = new PortsSpecBuilder();
spec.accept(builder);
builder.flush();
return freeze(basePortHint);
}
/**
* Uses the {@code registrar} and {@code dynamicPortFinder} to allocate
* actual ports for each registered {@code portId} and transitions the
* internal state to USE.
* @param basePortHint the desired base port - the actual base port may
* differ if there is a conflict.
* @return this instance, on which now we can call {@link #port(String)}
* and {@link #getBasePort()}
*/
public @NotNull Ports freeze(int basePortHint) {
if (closed) throw new IllegalStateException("Already closed!");
if (this.basePort>=0) throw new IllegalStateException("Port range already decided!");
try {
int allocatedBasePort = registrar.lock(basePortHint);
if (allocatedBasePort<=0) {
throw new IllegalStateException("Failed to reserve a port range!");
}
for (String dynamicId : dynamicOffsets) {
int freePort = dynamicPortFinder.get();
while (freePort>=claimedOffsets.size()) claimedOffsets.add(null); // pad the list so we don't get an IOBE
String old = claimedOffsets.set(freePort, dynamicId);
if (old!=null) {
claimedOffsets.set(freePort, old); // undo - better undo than check to avoid TOCTOU errors
throw new IllegalArgumentException("The dynamic port finder caused conflict! Clashing port reservations for offset " + freePort + ": old='" + old + "', new='" + dynamicId + "'");
}
}
for (Exporter exporter : exporters) {
exporter.export(Exporter.BASE_PORT_ID, allocatedBasePort);
for (int portOffset = 0; portOffset < claimedOffsets.size(); portOffset++) {
String id = claimedOffsets.get(portOffset);
if (id!=null) {
exporter.export(id, allocatedBasePort+portOffset);
}
}
}
exporters.stream().filter(it -> it instanceof AutoCloseable)
.forEach(it -> rethrow(((AutoCloseable) it)::close));
this.basePort = allocatedBasePort;
return this;
} catch (PortVetoException e) {
return freeze(e.port + 1);
}
}
/**
* The lowest port of the allocated block or {@code IllegalStateException}
* if {@link #freeze(int)} has not been called yet.
*
* @return the lowest port of the allocated block.
*/
public int getBasePort() {
if (closed) throw new IllegalStateException("Already closed!");
if (basePort==-1) throw new IllegalStateException("Port range not allocated yet!");
return basePort;
}
/**
* Look up a port for ID.
* @param id the port ID, which we shall look up.
* @return the port corresponding to the {@code id}
*/
public int port(@NotNull String id) {
if (basePort<=0) throw new IllegalStateException("Port allocation not finished yet! Perhaps somebody forgot to call freeze()?");
if (closed) throw new IllegalStateException("Already closed!");
int offset = claimedOffsets.indexOf(id);
return offset>=0
? basePort + offset
: rethrow(new NoSuchElementException("No registered port for id: '" + id + "'"));
}
/**
* Releases all resources and transitions the internal state, so that
* a call to any methods other than {@code close()} will result in
* {@code IllegalStateException}.
*
* This method is idempotent.
*/
@Override
public void close() {
if (basePort>=0) registrar.close();
basePort = 0;
closed = true;
}
/** Builder interface used to provide constrained, typesafe DSL. */
public interface SpecOffsetBuilder {
/**
* Specify port offset for an ID specified by preceding builder step.
* @param offset the port offset
* @see PortsSpecBuilder#id(String)
*/
void offset(int offset);
}
/** Builder interface used to provide constrained, typesafe DSL. */
public interface SpecIdBuilder {
/**
* Specify a port ID. If followed by a call to
* {@link SpecOffsetBuilder#offset(int)},
* then this ID would be with explicit offset,
* otherwise it would be dynamic.
*
* @param id the port ID
* @return {@code this} instance for chaining.
*/
@NotNull Ports.SpecOffsetBuilder id(@NotNull String id);
}
/**
* Typesafe builder implementation, used by extensions for specifying ports and offsets.
*/
public class PortsSpecBuilder implements SpecIdBuilder, SpecOffsetBuilder {
private String id;
private Integer offset;
@Override
public @NotNull Ports.SpecOffsetBuilder id(@NotNull String id) {
flush();
this.id = id;
return this;
}
@Override
public void offset(int offset) {
this.offset = offset;
flush();
}
/**
* Needs to be called after finishing building the configuration,
* or the last configured port ID may be lost.
*/
public void flush() {
if (offset != null && id == null) {
throw new IllegalArgumentException("Ambiguous port offset in spec: " + offset);
}
if (id == null) return;
if (offset == null) {
reservePort(id);
} else {
reservePort(id, offset);
}
offset = null;
id = null;
}
}
/**
* A hook notified about allocated ports, and optionally validating them.
* Can be used to update configs, set system properties, validate port
* availability, etc.
*
* If the implementation of this interface also implements
* {@link AutoCloseable}, the {@code close()} method will be called after
* all offsets have been exported and successfully validated, after
* the allocation is confirmed successful.
*/
@FunctionalInterface
public interface Exporter {
/**
* The base port will be passed with empty-string ID.
* It is easier to check for empty string, but if you want to be explicit, you may use this constant.
*/
String BASE_PORT_ID = "";
/**
* Called for each allocated port in the range, as well as for the base port.
* Note that in event of {@code PortVetoException}, you would receive the
* base port and port ID's more than once - the last value is the actual.
*
* @param id the port ID (empty string means base port)
* @param port the allocated port
* @throws PortVetoException to indicate that this block is bad and
* request reallocation of different block.
*/
void export(@NotNull String id, int port) throws PortVetoException;
/**
* A convenient way to receive the allocated ports (without the base port)
* in one shot, rather than getting them one by one. Cannot do validation.
*
* If you need the base port maping during export, you may use the
* {@link BatchExporter} directly and pass {@code true} in the
* constructor.
*
* @param publisher a consumer that does something with a map
* mapping portId to port.
*
* @return the exporter aggregating the port mappings and
* on successful allocation calling the publishing
* consumer.
*
* @see BatchExporter
*/
static Exporter batching(Consumer