diff --git a/client/src/main/java/io/opencmw/client/DataSource.java b/client/src/main/java/io/opencmw/client/DataSource.java index 843c0798..917fbc73 100644 --- a/client/src/main/java/io/opencmw/client/DataSource.java +++ b/client/src/main/java/io/opencmw/client/DataSource.java @@ -2,8 +2,13 @@ import java.net.URI; import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import org.jetbrains.annotations.NotNull; import org.zeromq.ZContext; import org.zeromq.ZMQ.Socket; import org.zeromq.ZMsg; @@ -16,8 +21,8 @@ * Should provide a static boolean matches(String address) function to determine whether * it is eligible for a given address. */ -public abstract class DataSource { - private static final List IMPLEMENTATIONS = new NoDuplicatesList<>(); +public abstract class DataSource implements AutoCloseable { + private static final List IMPLEMENTATIONS = Collections.synchronizedList(new NoDuplicatesList<>()); private DataSource() { // prevent implementers from implementing default constructor @@ -27,31 +32,29 @@ private DataSource() { * Constructor * @param endpoint Endpoint to subscribe to */ - public DataSource(final URI endpoint) { - if (endpoint == null || !getFactory().matches(endpoint)) { + protected DataSource(final @NotNull URI endpoint) { + if (!getFactory().matches(endpoint)) { throw new UnsupportedOperationException(this.getClass().getName() + " DataSource Implementation does not support endpoint: " + endpoint); } } /** - * Factory method to get a DataSource for a given endpoint - * @param endpoint endpoint address - * @return if there is a DataSource implementation for the protocol of the endpoint return a Factory to create a new - * Instance of this DataSource - * @throws UnsupportedOperationException in case there is no valid implementation + * Perform a get request on this endpoint. + * @param requestId request id which later allows to match the returned value to this query. + * This is the only mandatory parameter, all the following may be null. + * @param endpoint extend the filters originally supplied to the endpoint e.g. "ctx=selector&channel=channelA" + * @param data The serialised data which can be used by the get call + * @param rbacToken byte array containing signed body hash-key and corresponding RBAC role */ - public static Factory getFactory(final URI endpoint) { - for (Factory factory : IMPLEMENTATIONS) { - if (factory.matches(endpoint)) { - return factory; - } - } - throw new UnsupportedOperationException("No DataSource implementation available for endpoint: " + endpoint); - } + public abstract void get(final String requestId, final URI endpoint, final byte[] data, final byte[] rbacToken); - public static void register(final Factory factory) { - IMPLEMENTATIONS.add(0, factory); // custom added implementations are added in front to be discovered first - } + /** + * Gets called whenever data is available on the DataSource's socket. + * Should then try to receive data and return any results back to the calling event loop. + * @return null if there is no more data available, a Zero length ZMsg if there was data which was only used internally + * or a ZMsg with [reqId, endpoint, byte[] data, [byte[] optional RBAC token]] + */ + public abstract ZMsg getMessage(); /** * Get Socket to wait for in the event loop. @@ -61,25 +64,26 @@ public static void register(final Factory factory) { */ public abstract Socket getSocket(); - protected abstract Factory getFactory(); - /** - * Gets called whenever data is available on the DataSoure's socket. - * Should then try to receive data and return any results back to the calling event loop. - * @return null if there is no more data available, a Zero length Zmsg if there was data which was only used internally - * or a ZMsg with [reqId, endpoint, byte[] data, [byte[] optional RBAC token]] + * Perform housekeeping tasks like connection management, heartbeats, subscriptions, etc + * @return UTC time-stamp in [ms] when the next housekeeping duties should be performed */ - public abstract ZMsg getMessage(); + public abstract long housekeeping(); /** - * Perform housekeeping tasks like connection management, heartbeats, subscriptions, etc - * @return next time housekeeping duties should be performed + * Perform a set request on this endpoint using additional filters + * @param requestId request id which later allows to match the returned value to this query. + * This is the only mandatory parameter, all the following may be null. + * @param endpoint extend the filters originally supplied to the endpoint e.g. "ctx=selector&channel=channelA" + * @param data The serialised data which can be used by the get call + * @param rbacToken byte array containing signed body hash-key and corresponding RBAC role */ - public abstract long housekeeping(); + public abstract void set(final String requestId, final URI endpoint, final byte[] data, final byte[] rbacToken); /** * Subscribe to this endpoint * @param reqId the id to join the result of this subscribe with + * @param endpoint endpoint URI to subscribe to * @param rbacToken byte array containing signed body hash-key and corresponding RBAC role */ public abstract void subscribe(final String reqId, final URI endpoint, final byte[] rbacToken); @@ -90,28 +94,51 @@ public static void register(final Factory factory) { public abstract void unsubscribe(final String reqId); /** - * Perform a get request on this endpoint. - * @param requestId request id which later allows to match the returned value to this query. - * This is the only mandatory parameter, all the following may be null. - * @param endpoint extend the filters originally supplied to the endpoint e.g. "ctx=selector&channel=chanA" - * @param data The serialised data which can be used by the get call - * @param rbacToken byte array containing signed body hash-key and corresponding RBAC role + * Factory method to get a DataSource for a given endpoint + * @param endpoint endpoint address + * @return if there is a DataSource implementation for the protocol of the endpoint return a Factory to create a new + * Instance of this DataSource + * @throws UnsupportedOperationException in case there is no valid implementation */ - public abstract void get(final String requestId, final URI endpoint, final byte[] data, final byte[] rbacToken); + public static Factory getFactory(final @NotNull URI endpoint) { + for (Factory factory : IMPLEMENTATIONS) { + if (factory.matches(endpoint)) { + return factory; + } + } + throw new UnsupportedOperationException("No DataSource implementation available for endpoint: " + endpoint); + } - /** - * Perform a set request on this endpoint using additional filters - * @param requestId request id which later allows to match the returned value to this query. - * This is the only mandatory parameter, all the following may be null. - * @param endpoint extend the filters originally supplied to the endpoint e.g. "ctx=selector&channel=chanA" - * @param data The serialised data which can be used by the get call - * @param rbacToken byte array containing signed body hash-key and corresponding RBAC role - */ - public abstract void set(final String requestId, final URI endpoint, final byte[] data, final byte[] rbacToken); + public static void register(final Factory factory) { + IMPLEMENTATIONS.add(0, factory); // custom added implementations are added in front to be discovered first + } - protected interface Factory { - boolean matches(final URI endpoint); - Class getMatchingSerialiserType(final URI endpoint); - DataSource newInstance(final ZContext context, final URI endpoint, final Duration timeout, final String clientId); + protected abstract Factory getFactory(); + + public interface Factory { + /** + * @return returns the list of applicable schemes (and protocols this resolver can handle) this resolver can handle + */ + List getApplicableSchemes(); + + Class getMatchingSerialiserType(final @NotNull URI endpoint); + + List getRegisteredDnsResolver(); + + default boolean matches(final @NotNull URI endpoint) { + final String scheme = Objects.requireNonNull(endpoint.getScheme(), "required URI has no scheme defined: " + endpoint); + return getApplicableSchemes().stream().anyMatch(s -> s.equalsIgnoreCase(scheme)); + } + + DataSource newInstance(final ZContext context, final @NotNull URI endpoint, final @NotNull Duration timeout, final @NotNull ExecutorService executorService, final @NotNull String clientId); + + default void registerDnsResolver(final @NotNull DnsResolver resolver) { + final ArrayList list = new ArrayList<>(getApplicableSchemes()); + list.retainAll(resolver.getApplicableSchemes()); + if (list.isEmpty()) { + throw new IllegalArgumentException("resolver schemes not compatible with this DataSource: " + resolver); + } + getRegisteredDnsResolver().add(resolver); + } } } diff --git a/client/src/main/java/io/opencmw/client/DataSourcePublisher.java b/client/src/main/java/io/opencmw/client/DataSourcePublisher.java index 7b1ba818..471d30f8 100644 --- a/client/src/main/java/io/opencmw/client/DataSourcePublisher.java +++ b/client/src/main/java/io/opencmw/client/DataSourcePublisher.java @@ -95,48 +95,47 @@ @SuppressWarnings({ "PMD.GodClass", "PMD.ExcessiveImports", "PMD.TooManyFields" }) public class DataSourcePublisher implements Runnable, Closeable { public static final int MIN_FRAMES_INTERNAL_MSG = 3; + protected static final ZFrame EMPTY_ZFRAME = new ZFrame(EMPTY_FRAME); private static final Logger LOGGER = LoggerFactory.getLogger(DataSourcePublisher.class); - private static final ZFrame EMPTY_ZFRAME = new ZFrame(EMPTY_FRAME); private static final AtomicInteger INSTANCE_COUNT = new AtomicInteger(); + + static { // register default data sources + DataSource.register(CmwLightDataSource.FACTORY); + DataSource.register(RestDataSource.FACTORY); + DataSource.register(OpenCmwDataSource.FACTORY); + } + protected final long heartbeatInterval = SystemProperties.getValueIgnoreCase(HEARTBEAT, HEARTBEAT_DEFAULT); // [ms] time between to heartbeats in ms - private final String inprocCtrl = "inproc://dsPublisher#" + INSTANCE_COUNT.incrementAndGet(); - private final Map> requests = new ConcurrentHashMap<>(); // - private final Map clientMap = new ConcurrentHashMap<>(); // scheme://authority -> DataSource + protected final String inprocCtrl = "inproc://dsPublisher#" + INSTANCE_COUNT.incrementAndGet(); + protected final Map> requests = new ConcurrentHashMap<>(); // + protected final Map clientMap = new ConcurrentHashMap<>(); // scheme://authority -> DataSource + protected final AtomicInteger internalReqIdGenerator = new AtomicInteger(0); + protected final ExecutorService executor; // NOPMD - threads are ok, not a webapp + protected final ZContext context; + protected final ZMQ.Poller poller; + protected final ZMQ.Socket sourceSocket; + protected final String clientId; + private final IoBuffer byteBuffer = new FastByteBuffer(0, true, null); // never actually used + private final IoClassSerialiser ioClassSerialiser = new IoClassSerialiser(byteBuffer); private final AtomicBoolean shallRun = new AtomicBoolean(false); private final AtomicBoolean running = new AtomicBoolean(false); - private final AtomicInteger internalReqIdGenerator = new AtomicInteger(0); private final EventStore rawDataEventStore; - private final boolean owningContext; - private final ZContext context; - private final ZMQ.Poller poller; - private final ZMQ.Socket sourceSocket; - private final IoBuffer byteBuffer = new FastByteBuffer(0, true, null); // never actually used - private final IoClassSerialiser ioClassSerialiser = new IoClassSerialiser(byteBuffer); - private final String clientId; private final RbacProvider rbacProvider; - private final ExecutorService executor; // NOPMD - threads are ok, not a webapp private final EventStore publicationTarget; private final AtomicReference threadReference = new AtomicReference<>(); - static { // register default data sources - DataSource.register(CmwLightDataSource.FACTORY); - DataSource.register(RestDataSource.FACTORY); - DataSource.register(OpenCmwDataSource.FACTORY); - } - public DataSourcePublisher(final RbacProvider rbacProvider, final ExecutorService executorService, final String... clientId) { this(null, null, rbacProvider, executorService, clientId); start(); // NOPMD } public DataSourcePublisher(final ZContext ctx, final EventStore publicationTarget, final RbacProvider rbacProvider, final ExecutorService executorService, final String... clientId) { - owningContext = ctx == null; this.context = Objects.requireNonNullElse(ctx, new ZContext(SystemProperties.getValueIgnoreCase(N_IO_THREADS, N_IO_THREADS_DEFAULT))); this.executor = Objects.requireNonNullElse(executorService, Executors.newCachedThreadPool()); poller = context.createPoller(1); // control socket for adding subscriptions / triggering requests from other threads sourceSocket = context.createSocket(SocketType.DEALER); - sourceSocket.setHWM(SystemProperties.getValueIgnoreCase(HIGH_WATER_MARK, HIGH_WATER_MARK_DEFAULT)); + setDefaultSocketParameters(sourceSocket); sourceSocket.bind(inprocCtrl); poller.register(sourceSocket, ZMQ.Poller.POLLIN); @@ -180,9 +179,8 @@ public void close() { if (running.get() && thread != null) { thread.interrupt(); } - if (owningContext) { - context.destroy(); - } + poller.close(); + sourceSocket.close(); } public void start() { @@ -213,7 +211,10 @@ public void run() { // event loop polling all data sources and performing regular housekeeping jobs long nextHousekeeping = System.currentTimeMillis(); // immediately perform first housekeeping long timeOut = 0L; - while (!Thread.interrupted() && shallRun.get() && (timeOut <= 0 || -1 != poller.poll(timeOut))) { + while (!Thread.interrupted() && shallRun.get() && !context.isClosed() && (timeOut <= 0 || -1 != poller.poll(timeOut))) { + if (context.isClosed()) { + break; + } boolean dataAvailable = true; while (dataAvailable && System.currentTimeMillis() < nextHousekeeping && shallRun.get()) { dataAvailable = handleDataSourceSockets(); // get data from clients @@ -229,6 +230,13 @@ public void run() { LOGGER.atDebug().log("Shutting down DataSourcePublisher"); } rawDataEventStore.stop(); + for (DataSource dataSource : clientMap.values()) { + try { + dataSource.close(); + } catch (Exception e) { // NOPMD + // shut-down close + } + } running.set(false); threadReference.set(null); } @@ -319,7 +327,7 @@ protected ThePromisedFuture newSubscriptionFuture(final URI endpoin } @SuppressWarnings({ "PMD.UnusedFormalParameter" }) // method signature is mandated by functional interface - private void internalEventHandler(final RingBufferEvent event, final long sequence, final boolean endOfBatch) { + protected void internalEventHandler(final RingBufferEvent event, final long sequence, final boolean endOfBatch) { final EvtTypeFilter evtTypeFilter = event.getFilter(EvtTypeFilter.class); final boolean notifyFuture; switch (evtTypeFilter.updateType) { @@ -369,11 +377,11 @@ private void internalEventHandler(final RingBufferEvent event, final long sequen final StringWriter sw = new StringWriter(); final PrintWriter pw = new PrintWriter(sw); e.printStackTrace(pw); - final ProtocolException protException = new ProtocolException(ANSI_RED + "error deserialising object:\n" + sw.toString() + ANSI_RESET); + final ProtocolException protocolException = new ProtocolException(ANSI_RED + "error deserialising object:\n" + sw.toString() + ANSI_RESET); if (notifyFuture) { - domainObject.future.setException(protException); + domainObject.future.setException(protocolException); } else { - executor.submit(() -> domainObject.future.listener.updateException(protException)); // NOPMD - threads are ok, not a webapp + executor.submit(() -> domainObject.future.listener.updateException(protocolException)); // NOPMD - threads are ok, not a webapp } } } else if (notifyFuture) { @@ -388,7 +396,7 @@ private void internalEventHandler(final RingBufferEvent event, final long sequen } @SuppressWarnings({ "PMD.UnusedFormalParameter" }) // method signature is mandated by functional interface - private void publishToExternalStore(final RingBufferEvent publishEvent, final long seq, final RingBufferEvent sourceEvent, final Object replyDomainObject, final String exception) { + protected void publishToExternalStore(final RingBufferEvent publishEvent, final long seq, final RingBufferEvent sourceEvent, final Object replyDomainObject, final String exception) { sourceEvent.copyTo(publishEvent); publishEvent.payload = new SharedPointer<>(); if (replyDomainObject != null) { @@ -411,9 +419,12 @@ private void publishToExternalStore(final RingBufferEvent publishEvent, final lo } } - private DataSource getClient(final URI endpoint) { - return clientMap.computeIfAbsent(endpoint.getScheme() + "://" + getDeviceName(endpoint), requestedEndPoint -> { - final DataSource dataSource = DataSource.getFactory(URI.create(requestedEndPoint)).newInstance(context, endpoint, Duration.ofMillis(100), Long.toString(internalReqIdGenerator.incrementAndGet())); + protected DataSource getClient(final URI endpoint) { + // N.B. protected method so that knowledgeable/courageous developer can define their own multiplexing 'key' map-criteria + // e.g. a key including the volatile authority and/or a more specific 'device/property' path information, e.g. + // key := "://authority/path" (N.B. usually the authority is resolved by the DnsResolver/any Broker) + return clientMap.computeIfAbsent(endpoint.getScheme() + ":/" + getDeviceName(endpoint), requestedEndPoint -> { + final DataSource dataSource = DataSource.getFactory(URI.create(requestedEndPoint)).newInstance(context, endpoint, Duration.ofMillis(100), executor, Long.toString(internalReqIdGenerator.incrementAndGet())); poller.register(dataSource.getSocket(), ZMQ.Poller.POLLIN); return dataSource; }); diff --git a/client/src/main/java/io/opencmw/client/DnsResolver.java b/client/src/main/java/io/opencmw/client/DnsResolver.java new file mode 100644 index 00000000..bd6ea597 --- /dev/null +++ b/client/src/main/java/io/opencmw/client/DnsResolver.java @@ -0,0 +1,25 @@ +package io.opencmw.client; + +import java.net.URI; +import java.net.UnknownHostException; +import java.util.List; +import java.util.Map; + +/** + * basic interface to resolve a given service URI to connectable URI. + * + * N.B. Follows URI syntax, ie. '
scheme:[//authority]path[?query][#fragment]
' see documentation + */ +public interface DnsResolver extends AutoCloseable { + /** + * @return returns the list of applicable schemes (and protocols this resolver can handle) this resolver can handle + */ + List getApplicableSchemes(); + + /** + * + * @param devicesToResolve list of partial (ie. w/o scheme, authority,or only the beginning of the path) URIs that need to get resolved. + * @return map containing the initial request as key and list of matching fully resolved URI (ie. including scheme, authority and full path). N.B. list may be empty + */ + Map> resolveNames(List devicesToResolve) throws UnknownHostException; +} diff --git a/client/src/main/java/io/opencmw/client/OpenCmwDataSource.java b/client/src/main/java/io/opencmw/client/OpenCmwDataSource.java index 9ce18ad0..d71aa150 100644 --- a/client/src/main/java/io/opencmw/client/OpenCmwDataSource.java +++ b/client/src/main/java/io/opencmw/client/OpenCmwDataSource.java @@ -2,29 +2,46 @@ import static java.nio.charset.StandardCharsets.UTF_8; -import static io.opencmw.OpenCmwProtocol.*; -import static io.opencmw.OpenCmwProtocol.Command.*; +import static org.zeromq.ZMonitor.Event; + +import static io.opencmw.OpenCmwConstants.*; +import static io.opencmw.OpenCmwProtocol.Command.GET_REQUEST; +import static io.opencmw.OpenCmwProtocol.Command.SET_REQUEST; +import static io.opencmw.OpenCmwProtocol.Command.SUBSCRIBE; +import static io.opencmw.OpenCmwProtocol.Command.UNSUBSCRIBE; +import static io.opencmw.OpenCmwProtocol.EMPTY_FRAME; +import static io.opencmw.OpenCmwProtocol.EMPTY_URI; +import static io.opencmw.OpenCmwProtocol.MdpMessage; import static io.opencmw.OpenCmwProtocol.MdpSubProtocol.PROT_CLIENT; +import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; +import java.net.UnknownHostException; import java.time.Duration; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; +import java.util.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiPredicate; +import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.zeromq.SocketType; import org.zeromq.ZContext; import org.zeromq.ZFrame; import org.zeromq.ZMQ.Socket; +import org.zeromq.ZMonitor; import org.zeromq.ZMsg; import io.opencmw.filter.SubscriptionMatcher; import io.opencmw.serialiser.IoSerialiser; import io.opencmw.serialiser.spi.BinarySerialiser; +import io.opencmw.utils.NoDuplicatesList; +import io.opencmw.utils.SystemProperties; /** * Client implementation for the OpenCMW protocol. @@ -34,65 +51,156 @@ * * @author Alexander Krimm */ -public class OpenCmwDataSource extends DataSource { +@SuppressWarnings({ "PMD.TooManyFields", "PMD.ExcessiveImports" }) +public class OpenCmwDataSource extends DataSource implements AutoCloseable { private static final Logger LOGGER = LoggerFactory.getLogger(OpenCmwDataSource.class); + private static final AtomicInteger INSTANCE_COUNT = new AtomicInteger(); private static final String MDP = "mdp"; private static final String MDS = "mds"; private static final String MDR = "mdr"; + private static final List APPLICABLE_SCHEMES = List.of(MDP, MDS, MDR); + private static final List RESOLVERS = Collections.synchronizedList(new NoDuplicatesList<>()); public static final Factory FACTORY = new Factory() { @Override - public boolean matches(final URI endpoint) { - return endpoint.getScheme().equals(MDP) || endpoint.getScheme().equals(MDS) || endpoint.getScheme().equals(MDR); + public List getApplicableSchemes() { + return APPLICABLE_SCHEMES; } @Override - public Class getMatchingSerialiserType(final URI endpoint) { + public Class getMatchingSerialiserType(final @NotNull URI endpoint) { return BinarySerialiser.class; } @Override - public DataSource newInstance(final ZContext context, final URI endpoint, final Duration timeout, final String clientId) { - return new OpenCmwDataSource(context, endpoint, clientId); + public DataSource newInstance(final ZContext context, final @NotNull URI endpoint, final @NotNull Duration timeout, final @NotNull ExecutorService executorService, final @NotNull String clientId) { + return new OpenCmwDataSource(context, endpoint, timeout, executorService, clientId); + } + + @Override + public List getRegisteredDnsResolver() { + return RESOLVERS; } }; + private final AtomicReference connectionState = new AtomicReference<>(Event.CLOSED); + private final AtomicInteger reconnectAttempt = new AtomicInteger(0); + private final String sourceName; + private final Duration timeout; + private final ExecutorService executorService; private final String clientId; private final URI endpoint; + private final ZContext context; private final Socket socket; + private final ZMonitor socketMonitor; private final Map subscriptions = new HashMap<>(); // NOPMD: not accessed concurrently private final URI serverUri; private final BiPredicate subscriptionMatcher = new SubscriptionMatcher(); // + private final long heartbeatInterval; + private Future dnsWorkerResult; + private long nextReconnectAttemptTimeStamp; + private URI connectedAddress; + static { // register default data sources + DataSource.register(OpenCmwDataSource.FACTORY); + } /** * @param context zeroMQ context used for internal as well as external communication * @param endpoint The endpoint to connect to. Only the server part is used and everything else is discarded. + * @param timeout time-out for reconnects and DNS queries + * @param executorService thread-pool used to do parallel DNS queries * @param clientId Identification string sent to the OpenCMW server. Should be unique per client and is used by the * Server to identify clients. */ - public OpenCmwDataSource(final ZContext context, final URI endpoint, final String clientId) { + public OpenCmwDataSource(final @NotNull ZContext context, final @NotNull URI endpoint, final @NotNull Duration timeout, final @NotNull ExecutorService executorService, final String clientId) { super(endpoint); - this.endpoint = endpoint; // todo: Strip unneeded parts? + this.context = context; + this.endpoint = Objects.requireNonNull(endpoint, "endpoint is null"); + this.timeout = timeout; + this.executorService = executorService; this.clientId = clientId; + this.sourceName = OpenCmwDataSource.class.getSimpleName() + "(ID: " + INSTANCE_COUNT.getAndIncrement() + ", endpoint: " + endpoint + ", clientId: " + clientId + ")"; try { this.serverUri = new URI(endpoint.getScheme(), endpoint.getAuthority(), "/", null, null); } catch (URISyntaxException e) { LOGGER.atError().addArgument(endpoint).setCause(e).log("cannot create serverURI from endpoint: {}"); throw new IllegalArgumentException("Invalid endpoint", e); } - if (MDP.equals(endpoint.getScheme())) { + switch (endpoint.getScheme().toLowerCase(Locale.UK)) { + case MDP: this.socket = context.createSocket(SocketType.DEALER); - socket.setHWM(0); - socket.setIdentity(clientId.getBytes(UTF_8)); - socket.connect("tcp://" + this.endpoint.getAuthority()); - } else if (MDS.equals(endpoint.getScheme())) { + break; + case MDS: this.socket = context.createSocket(SocketType.SUB); - socket.setHWM(0); - socket.setIdentity(clientId.getBytes(UTF_8)); - socket.connect("tcp://" + this.endpoint.getAuthority()); // determine whether udp or tcp should be used - } else if (MDR.equals(endpoint.getScheme())) { + break; + case MDR: throw new UnsupportedOperationException("RADIO-DISH pattern is not yet implemented"); - } else { + //this.socket = context.createSocket(SocketType.DISH) + default: throw new UnsupportedOperationException("Unsupported protocol type " + endpoint.getScheme()); } + setDefaultSocketParameters(socket); + + socketMonitor = new ZMonitor(context, socket); + socketMonitor.add(Event.CLOSED, Event.CONNECTED, Event.DISCONNECTED); + socketMonitor.start(); + + final long basicHeartBeat = SystemProperties.getValueIgnoreCase(HEARTBEAT, HEARTBEAT_DEFAULT); + final long clientTimeOut = SystemProperties.getValueIgnoreCase(CLIENT_TIMEOUT, CLIENT_TIMEOUT_DEFAULT); // [s] N.B. '0' means disabled + // take the minimum of the (albeit worker) heartbeat, client (if defined) or locally prescribed timeout + heartbeatInterval = clientTimeOut == 0 ? Math.min(basicHeartBeat, timeout.toMillis()) : Math.min(Math.min(basicHeartBeat, timeout.toMillis()), TimeUnit.SECONDS.toMicros(clientTimeOut)); + + nextReconnectAttemptTimeStamp = System.currentTimeMillis() + timeout.toMillis(); + final URI reply = connect(); + if (reply == EMPTY_URI) { + LOGGER.atWarn().addArgument(endpoint).addArgument(sourceName).log("could not connect URI {} immediately - source {}"); + } + } + + public final URI connect() { + if (context.isClosed()) { + LOGGER.atDebug().addArgument(sourceName).log("ZContext closed for '{}'"); + return EMPTY_URI; + } + connectionState.set(Event.CONNECT_RETRIED); + URI address = endpoint; + if (address.getAuthority() == null) { + // need to resolve authority if unknown + //here: implemented first available DNS resolver, could also be round-robin or rotation if there are several resolver registered + final Optional resolver = getFactory().getRegisteredDnsResolver().stream().findFirst(); + if (resolver.isEmpty()) { + LOGGER.atWarn().addArgument(endpoint).log("cannot resolve {} without a registered DNS resolver"); + return EMPTY_URI; + } + try { + // resolve address + address = new URI(address.getScheme(), null, '/' + getDeviceName(address), null, null); + final Map> candidates = resolver.get().resolveNames(List.of(address)); + if (Objects.requireNonNull(candidates.get(address), "candidates did not contain '" + address + "':" + candidates).isEmpty()) { + throw new UnknownHostException("DNS resolver could not resolve " + endpoint + " - unknown service - candidates" + candidates + " - " + address); + } + address = candidates.get(address).get(0); // take first matching - may upgrade in the future if there are more than one option + } catch (URISyntaxException | UnknownHostException e) { + LOGGER.atWarn().addArgument(address).addArgument(e.getMessage()).log("cannot resolve {} - error message: {}"); // NOPMD the original exception is retained + return EMPTY_URI; + } + } + + switch (endpoint.getScheme().toLowerCase(Locale.UK)) { + case MDP: + case MDS: + address = replaceSchemeKeepOnlyAuthority(address, SCHEME_TCP); + if (!socket.connect(address.toString())) { + LOGGER.atError().addArgument(address.toString()).log("could not connect to '{}'"); + connectedAddress = EMPTY_URI; + return EMPTY_URI; + } + connectedAddress = address; + connectionState.set(Event.CONNECTED); + return connectedAddress; + case MDR: + throw new UnsupportedOperationException("RADIO-DISH pattern is not yet implemented"); // well yes, but not released by the JeroMQ folks + default: + } + throw new UnsupportedOperationException("Unsupported protocol type " + endpoint.getScheme()); } @Override @@ -100,9 +208,23 @@ public Socket getSocket() { return socket; } + public URI reconnect() { + LOGGER.atDebug().addArgument(this.endpoint).addArgument(sourceName).log("need to reconnect for URI {} - source {} "); + if (connectedAddress != null) { + socket.disconnect(connectedAddress.toString()); + } + + final URI result = connect(); + if (result == EMPTY_URI) { + LOGGER.atDebug().addArgument(endpoint).addArgument(sourceName).log("could not reconnect for URI '{}' - source {} "); + } + return result; + } + @Override - protected Factory getFactory() { - return FACTORY; + public void close() throws IOException { + socketMonitor.close(); + socket.close(); } @Override @@ -123,50 +245,58 @@ public ZMsg getMessage() { } } - private ZMsg handleRequest(final MdpMessage msg) { - switch (msg.command) { - case PARTIAL: - case FINAL: - case W_NOTIFY: - if (msg.clientRequestID != null && msg.clientRequestID.length > 0) { // for get/set the request id is provided by the server - return createInternalMsg(msg.clientRequestID, msg.topic, new ZFrame(msg.data), msg.errors); + @Override + public long housekeeping() { + final long now = System.currentTimeMillis(); + ZMonitor.ZEvent event; + while ((event = socketMonitor.nextEvent(false)) != null) { + switch (event.type) { + case DISCONNECTED: + case CLOSED: + connectionState.set(Event.CLOSED); + break; + case CONNECTED: + connectionState.set(Event.CONNECTED); + break; + default: + LOGGER.atDebug().addArgument(event).log("received unknown event {}"); + break; } - // for subscriptions the request id is missing and has to be recovered from the endpoint url - final Optional reqId = subscriptions.entrySet().stream() // - .filter(e -> subscriptionMatcher.test(serverUri.relativize(msg.topic), serverUri.relativize(e.getValue()))) // - .map(Map.Entry::getKey) - .findFirst(); - if (reqId.isPresent()) { - return createInternalMsg(reqId.get().getBytes(), msg.topic, new ZFrame(msg.data), msg.errors); + } + + switch (connectionState.get()) { + case CONNECTED: + reconnectAttempt.set(0); + return now + heartbeatInterval; + case CONNECT_RETRIED: + // reconnection in process but not yet finished + if (now < nextReconnectAttemptTimeStamp) { + // not yet time to give the reconnect another try + return nextReconnectAttemptTimeStamp; } - LOGGER.atWarn().addArgument(msg.topic).log("Could not find subscription for notified request with endpoint: {}"); - return new ZMsg(); // ignore unknown notification - case W_HEARTBEAT: - case READY: - case DISCONNECT: - case UNSUBSCRIBE: - case SUBSCRIBE: - case GET_REQUEST: - case SET_REQUEST: - case UNKNOWN: + if (dnsWorkerResult != null) { + dnsWorkerResult.cancel(true); + } + dnsWorkerResult = executorService.submit(this::reconnect); // <--- actual reconnect + if (reconnectAttempt.getAndIncrement() < SystemProperties.getValueIgnoreCase(RECONNECT_THRESHOLD1, DEFAULT_RECONNECT_THRESHOLD1)) { + nextReconnectAttemptTimeStamp = now + timeout.toMillis(); + } else if (reconnectAttempt.getAndIncrement() < SystemProperties.getValueIgnoreCase(RECONNECT_THRESHOLD2, DEFAULT_RECONNECT_THRESHOLD2)) { + nextReconnectAttemptTimeStamp = now + 10 * timeout.toMillis(); + } else { + nextReconnectAttemptTimeStamp = now + 100 * timeout.toMillis(); + } + return nextReconnectAttemptTimeStamp; + case CLOSED: + // need to (re-)start connection immediately + connectionState.compareAndSet(Event.CLOSED, Event.CONNECT_RETRIED); + dnsWorkerResult = executorService.submit(this::reconnect); + nextReconnectAttemptTimeStamp = now + timeout.toMillis(); + return nextReconnectAttemptTimeStamp; default: - LOGGER.atDebug().addArgument(msg).log("Ignoring unexpected message: {}"); - return new ZMsg(); // ignore unknown request + // other cases } - } - public static ZMsg createInternalMsg(final byte[] reqId, final URI endpoint, final ZFrame body, final String exception) { - final ZMsg result = new ZMsg(); - result.add(reqId); - result.add(endpoint.toString()); - result.add(body == null ? new ZFrame(new byte[0]) : body); - result.add(exception == null ? new ZFrame(new byte[0]) : new ZFrame(exception)); - return result; - } - - @Override - public long housekeeping() { - return System.currentTimeMillis() + 1000; + return now + heartbeatInterval; } @Override @@ -175,17 +305,8 @@ public void subscribe(final String reqId, final URI endpoint, final byte[] rbacT final byte[] serviceId = endpoint.getPath().substring(1).getBytes(UTF_8); if (socket.getSocketType() == SocketType.DEALER) { // mpd // only tcp fallback? - final boolean sent = new MdpMessage(null, - PROT_CLIENT, - SUBSCRIBE, - serviceId, - reqId.getBytes(UTF_8), - endpoint, - EMPTY_FRAME, - "", - rbacToken) - .send(socket); - if (!sent) { + final MdpMessage msg = new MdpMessage(null, PROT_CLIENT, SUBSCRIBE, serviceId, reqId.getBytes(UTF_8), endpoint, EMPTY_FRAME, "", rbacToken); + if (!msg.send(socket)) { LOGGER.atError().addArgument(reqId).addArgument(endpoint).log("subscription error (reqId: {}) for endpoint: {}"); } } else { // mds @@ -213,6 +334,7 @@ public void get(final String requestId, final URI endpoint, final byte[] data, f // todo: filters which are not in endpoint final byte[] serviceId = endpoint.getPath().substring(1).getBytes(UTF_8); final MdpMessage msg = new MdpMessage(null, PROT_CLIENT, GET_REQUEST, serviceId, requestId.getBytes(UTF_8), endpoint, EMPTY_FRAME, "", rbacToken); + if (!msg.send(socket)) { LOGGER.atError().addArgument(requestId).addArgument(endpoint).log("get error (reqId: {}) for endpoint: {}"); } @@ -227,4 +349,55 @@ public void set(final String requestId, final URI endpoint, final byte[] data, f LOGGER.atError().addArgument(requestId).addArgument(endpoint).log("set error (reqId: {}) for endpoint: {}"); } } + + @Override + public String toString() { + return sourceName; + } + + @Override + protected Factory getFactory() { + return FACTORY; + } + + private ZMsg handleRequest(final MdpMessage msg) { + switch (msg.command) { + case PARTIAL: + case FINAL: + case W_NOTIFY: + if (msg.clientRequestID != null && msg.clientRequestID.length > 0) { // for get/set the request id is provided by the server + return createInternalMsg(msg.clientRequestID, msg.topic, new ZFrame(msg.data), msg.errors); + } + // for subscriptions the request id is missing and has to be recovered from the endpoint url + final Optional reqId = subscriptions.entrySet().stream() // + .filter(e -> subscriptionMatcher.test(serverUri.relativize(msg.topic), serverUri.relativize(e.getValue()))) // + .map(Map.Entry::getKey) + .findFirst(); + if (reqId.isPresent()) { + return createInternalMsg(reqId.get().getBytes(), msg.topic, new ZFrame(msg.data), msg.errors); + } + LOGGER.atWarn().addArgument(msg.topic).log("Could not find subscription for notified request with endpoint: {}"); + return new ZMsg(); // ignore unknown notification + case W_HEARTBEAT: + case READY: + case DISCONNECT: + case UNSUBSCRIBE: + case SUBSCRIBE: + case GET_REQUEST: + case SET_REQUEST: + case UNKNOWN: + default: + LOGGER.atDebug().addArgument(msg).log("Ignoring unexpected message: {}"); + return new ZMsg(); // ignore unknown request + } + } + + public static ZMsg createInternalMsg(final byte[] reqId, final URI endpoint, final ZFrame body, final String exception) { + final ZMsg result = new ZMsg(); + result.add(reqId); + result.add(endpoint.toString()); + result.add(body == null ? new ZFrame(new byte[0]) : body); + result.add(exception == null ? new ZFrame(new byte[0]) : new ZFrame(exception)); + return result; + } } diff --git a/client/src/main/java/io/opencmw/client/OpenCmwDnsResolver.java b/client/src/main/java/io/opencmw/client/OpenCmwDnsResolver.java new file mode 100644 index 00000000..832c4329 --- /dev/null +++ b/client/src/main/java/io/opencmw/client/OpenCmwDnsResolver.java @@ -0,0 +1,130 @@ +package io.opencmw.client; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetDecoder; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.locks.LockSupport; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.ZContext; + +import io.opencmw.OpenCmwConstants; +import io.opencmw.domain.BinaryData; +import io.opencmw.utils.SystemProperties; + +public class OpenCmwDnsResolver implements DnsResolver, AutoCloseable { + private static final Logger LOGGER = LoggerFactory.getLogger(OpenCmwDnsResolver.class); + private static final List APPLICABLE_SCHEMES = Arrays.asList("mdp", "mds", "mdr", "http", "https"); + private static final Pattern DNS_PATTERN = Pattern.compile("\\[(.*?)]"); + private final ZContext context; + private final boolean ownsCtx; + private final URI dnsServer; + private final Duration timeOut; + private final DataSourcePublisher dataSource; + + public OpenCmwDnsResolver(final @NotNull URI dnsServer) { + this(null, dnsServer, Duration.ofSeconds(1)); + } + + public OpenCmwDnsResolver(final ZContext context, final @NotNull URI dnsServer, final @NotNull Duration timeOut) { + this.dnsServer = Objects.requireNonNull(dnsServer, "dnsServer may not be null"); + ownsCtx = context == null; + this.context = Objects.requireNonNullElse(context, new ZContext(1)); + this.timeOut = timeOut; + dataSource = new DataSourcePublisher(context, null, null, null, OpenCmwDnsResolver.class.getName()); + dataSource.start(); + LockSupport.parkNanos(Duration.ofMillis(1000).toNanos()); // wait until DNS client has been initialised + } + + @Override + public List getApplicableSchemes() { + return APPLICABLE_SCHEMES; + } + + @Override + public Map> resolveNames(final List devicesToResolve) throws UnknownHostException { + final int liveness = SystemProperties.getValueIgnoreCase(OpenCmwConstants.HEARTBEAT_LIVENESS, OpenCmwConstants.HEARTBEAT_LIVENESS_DEFAULT); + try (DataSourcePublisher.Client client = dataSource.getClient()) { + final String query = devicesToResolve.stream().map(URI::toString).collect(Collectors.joining(",")); + final URI queryURI = URI.create(dnsServer + "/mmi.dns?" + query); + final Future reply4 = client.get(queryURI, null, BinaryData.class); + for (int attempt = 0; attempt < liveness; attempt++) { + try { + return parseDnsReply(Objects.requireNonNull(reply4.get(timeOut.toMillis(), TimeUnit.MILLISECONDS).data, "reply data is null")); + } catch (InterruptedException | ExecutionException | TimeoutException | NullPointerException e) { // NOPMD NOSONAR - fail only after three attempts + final String exception = e.getClass().getName() + ": " + e.getMessage(); + LOGGER.atWarn().addArgument(OpenCmwDnsResolver.class.getSimpleName()).addArgument(attempt).addArgument(dnsServer).addArgument(devicesToResolve).addArgument(exception).log("{} - attempt {}: dns server {} could not resolve '{}' error: {}"); + } + } + } + throw new UnknownHostException("cannot resolve URI - dnsServer: " + dnsServer + " (timeout reached: " + (timeOut.toMillis() * liveness) + " ms) - URI list: " + devicesToResolve); + } + + @Override + public void close() { + dataSource.close(); + if (ownsCtx) { + context.close(); + } + } + + public static Map> parseDnsReply(final byte[] dnsReply) { + if (dnsReply == null || dnsReply.length == 0 || !isUTF8(dnsReply)) { + return Collections.emptyMap(); + } + final String reply = new String(dnsReply, UTF_8); + if (reply.isBlank()) { + return Collections.emptyMap(); + } + + // parse reply + final Matcher matchPattern = DNS_PATTERN.matcher(reply); + final Map> map = new ConcurrentHashMap<>(); + while (matchPattern.find()) { + final String device = matchPattern.group(1); + final String[] message = device.split("(: )", 2); + assert message.length == 2 : "could not split into 2 segments: " + device; + try { + final List uriList = map.computeIfAbsent(new URI(message[0]), deviceName -> new ArrayList<>()); // NOPMD - in loop allocation OK + Stream.of(StringUtils.split(message[1], ",")).filter(uriString -> !"null".equalsIgnoreCase(uriString)).forEach(uriString -> { + try { + uriList.add(new URI(StringUtils.strip(uriString))); + } catch (final URISyntaxException e) { + LOGGER.atError().setCause(e).addArgument(message[0]).addArgument(uriString).log("could not parse device '{}' uri: '{}}'"); + } + }); + + } catch (final URISyntaxException e) { + LOGGER.atError().setCause(e).addArgument(message[1]).log("could not parse device line '{}'"); + } + } + + return map; + } + + public static boolean isUTF8(byte[] array) { + final CharsetDecoder decoder = UTF_8.newDecoder(); + final ByteBuffer buf = ByteBuffer.wrap(array); + try { + decoder.decode(buf); + } catch (CharacterCodingException e) { + return false; + } + return true; + } +} diff --git a/client/src/main/java/io/opencmw/client/cmwlight/CmwLightDataSource.java b/client/src/main/java/io/opencmw/client/cmwlight/CmwLightDataSource.java index 7ca707b9..c0a4d243 100644 --- a/client/src/main/java/io/opencmw/client/cmwlight/CmwLightDataSource.java +++ b/client/src/main/java/io/opencmw/client/cmwlight/CmwLightDataSource.java @@ -2,30 +2,37 @@ import static java.nio.charset.StandardCharsets.UTF_8; +import static org.zeromq.ZMonitor.Event; + import static io.opencmw.OpenCmwConstants.*; import static io.opencmw.client.OpenCmwDataSource.createInternalMsg; +import java.io.IOException; import java.net.InetAddress; import java.net.URI; import java.net.URISyntaxException; import java.net.UnknownHostException; import java.time.Duration; import java.util.*; +import java.util.concurrent.ExecutorService; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; -import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.zeromq.*; +import io.opencmw.OpenCmwProtocol; import io.opencmw.QueryParameterParser; import io.opencmw.client.DataSource; +import io.opencmw.client.DnsResolver; import io.opencmw.serialiser.IoSerialiser; import io.opencmw.serialiser.spi.CmwLightSerialiser; +import io.opencmw.utils.NoDuplicatesList; import io.opencmw.utils.SystemProperties; /** @@ -37,54 +44,147 @@ */ @SuppressWarnings({ "PMD.UseConcurrentHashMap", "PMD.ExcessiveImports" }) // - only accessed from main thread public class CmwLightDataSource extends DataSource { // NOPMD - class should probably be smaller - public static final String RDA_3_PROTOCOL = "rda3"; + private static final String RDA_3_PROTOCOL = "rda3"; + private static final List APPLICABLE_SCHEMES = List.of(RDA_3_PROTOCOL); + private static final List RESOLVERS = Collections.synchronizedList(new NoDuplicatesList<>()); public static final Factory FACTORY = new Factory() { @Override - public boolean matches(final URI endpoint) { - return endpoint.getScheme().equals(RDA_3_PROTOCOL); + public List getApplicableSchemes() { + return APPLICABLE_SCHEMES; } @Override - public Class getMatchingSerialiserType(final URI endpoint) { + public Class getMatchingSerialiserType(final @NotNull URI endpoint) { return CmwLightSerialiser.class; } @Override - public DataSource newInstance(final ZContext context, final URI endpoint, final Duration timeout, final String clientId) { - return new CmwLightDataSource(context, endpoint, clientId); + public List getRegisteredDnsResolver() { + return RESOLVERS; + } + + @Override + public DataSource newInstance(final ZContext context, final @NotNull URI endpoint, final @NotNull Duration timeout, final @NotNull ExecutorService executorService, final @NotNull String clientId) { + return new CmwLightDataSource(context, endpoint, executorService, clientId); } }; private static final Logger LOGGER = LoggerFactory.getLogger(CmwLightDataSource.class); private static final AtomicLong CONNECTION_ID_GENERATOR = new AtomicLong(0); // global counter incremented for each connection private static final AtomicInteger REQUEST_ID_GENERATOR = new AtomicInteger(0); - private static DirectoryLightClient directoryLightClient; protected final AtomicInteger channelId = new AtomicInteger(0); // connection local counter incremented for each channel protected final ZContext context; protected final ZMQ.Socket socket; - protected final AtomicReference connectionState = new AtomicReference<>(ConnectionState.DISCONNECTED); + protected final AtomicReference connectionState = new AtomicReference<>(Event.CLOSED); protected final String sessionId; protected final long heartbeatInterval = SystemProperties.getValueIgnoreCase(HEARTBEAT, HEARTBEAT_DEFAULT); // [ms] time between to heartbeats in ms - protected final int heartbeatAllowedMisses = SystemProperties.getValueIgnoreCase(HEARTBEAT_LIVENESS, HEARTBEAT_LIVENESS_DEFAULT); // [counts] 3-5 is reasonable number of heartbeats which can be missed before resetting the conection + protected final int heartbeatAllowedMisses = SystemProperties.getValueIgnoreCase(HEARTBEAT_LIVENESS, HEARTBEAT_LIVENESS_DEFAULT); // [counts] 3-5 is reasonable number of heartbeats which can be missed before resetting the connection protected final long subscriptionTimeout = SystemProperties.getValueIgnoreCase(SUBSCRIPTION_TIMEOUT, SUBSCRIPTION_TIMEOUT_DEFAULT); // maximum time after which a connection should be reconnected protected final Map subscriptions = new HashMap<>(); // all subscriptions added to the server protected final Map subscriptionsByReqId = new HashMap<>(); // all subscriptions added to the server protected final Map replyIdMap = new HashMap<>(); // all acknowledged subscriptions by their reply id + protected final URI endpoint; + private final AtomicInteger reconnectAttempt = new AtomicInteger(0); + private final ZMonitor socketMonitor; private final Queue queuedRequests = new LinkedBlockingQueue<>(); private final Map pendingRequests = new HashMap<>(); - protected URI hostAddress; + private final ExecutorService executorService; protected long connectionId; protected long lastHeartbeatReceived = -1; protected long lastHeartbeatSent = -1; protected int backOff = 20; - private String connectedAddress = ""; + private URI connectedAddress = OpenCmwProtocol.EMPTY_URI; + static { // register default data sources + DataSource.register(CmwLightDataSource.FACTORY); + } - public CmwLightDataSource(final ZContext context, final URI hostAddress, final String clientId) { - super(hostAddress); - LOGGER.atTrace().addArgument(hostAddress).log("connecting to: {}"); + public CmwLightDataSource(final @NotNull ZContext context, final @NotNull URI endpoint, final @NotNull ExecutorService executorService, final String clientId) { + super(endpoint); + LOGGER.atTrace().addArgument(endpoint).log("connecting to: {}"); this.context = context; + this.executorService = executorService; this.socket = context.createSocket(SocketType.DEALER); + setDefaultSocketParameters(socket); this.sessionId = getSessionId(clientId); - this.hostAddress = hostAddress; + this.endpoint = endpoint; + + socketMonitor = new ZMonitor(context, socket); + socketMonitor.add(Event.CLOSED, Event.CONNECTED, Event.DISCONNECTED); + socketMonitor.start(); + + connect(); // NOPMD - override allowed + } + + @Override + public void close() throws IOException { + socketMonitor.close(); + socket.close(); + } + + public void connect() { + if (connectionState.getAndSet(Event.CONNECT_RETRIED) != Event.CLOSED) { + return; // already connected + } + + URI resolveAddress = endpoint; + if (resolveAddress.getAuthority() == null || resolveAddress.getPort() == -1) { + // need to resolve authority if unknown + // here: implemented first available DNS resolver, could also be round-robin or rotation if there are several resolver registered + final Optional resolver = getFactory().getRegisteredDnsResolver().stream().findFirst(); + if (resolver.isEmpty()) { + LOGGER.atWarn().addArgument(endpoint).log("cannot resolve {} without a registered DNS resolver"); + backOff = backOff * 2; + connectionState.set(Event.CLOSED); + return; + } + try { + // resolve address + resolveAddress = new URI(resolveAddress.getScheme(), null, '/' + getDeviceName(endpoint), null, null); + final Map> candidates = resolver.get().resolveNames(List.of(resolveAddress)); + if (Objects.requireNonNull(candidates.get(resolveAddress), "candidates did not contain '" + resolveAddress + "':" + candidates).isEmpty()) { + throw new UnknownHostException("DNS resolver could not resolve " + endpoint + " - unknown service - candidates" + candidates + " - " + resolveAddress); + } + resolveAddress = candidates.get(resolveAddress).get(0); // take first matching - may upgrade in the future if there are more than one option + } catch (URISyntaxException | UnknownHostException e) { // NOPMD - directory client must be refactored anyway + LOGGER.atError().setCause(e).addArgument(e.getMessage()).log("Error resolving device from nameserver, using address from endpoint. Error was: {}"); + backOff = backOff * 2; + connectionState.set(Event.CLOSED); + return; + } + } + lastHeartbeatSent = System.currentTimeMillis(); + if (resolveAddress.getPort() == -1) { + LOGGER.atError().addArgument(endpoint).log("could not resolve host service address: '{}'"); + } + try { + final String identity = getIdentity(); + resolveAddress = replaceSchemeKeepOnlyAuthority(resolveAddress, SCHEME_TCP); + socket.setIdentity(identity.getBytes()); // hostname/process/id/channel -- seems to be needed by CMW :-| + LOGGER.atDebug().addArgument(resolveAddress).addArgument(resolveAddress).addArgument(identity).log("connecting to: '{}'->'{}' with identity {}"); + if (!socket.connect(resolveAddress.toString())) { + LOGGER.atError().addArgument(endpoint).addArgument(resolveAddress.toString()).log("could not connect requested URI '{}' to '{}'"); + connectedAddress = OpenCmwProtocol.EMPTY_URI; + } + connectedAddress = resolveAddress; + CmwLightProtocol.sendMsg(socket, CmwLightMessage.connect(CmwLightProtocol.VERSION)); + } catch (ZMQException | CmwLightProtocol.RdaLightException | NumberFormatException e) { + LOGGER.atError().setCause(e).addArgument(connectedAddress).addArgument(endpoint).log("failed to connect to '{}' source host address: '{}'"); + backOff = backOff * 2; + connectionState.set(Event.CLOSED); + } + } + + @Override + public void get(final String requestId, final URI endpoint, final byte[] data, final byte[] rbacToken) { + final Request request = new Request(CmwLightProtocol.RequestType.GET, requestId, endpoint, data, rbacToken); + queuedRequests.add(request); + } + + public Event getConnectionState() { + return connectionState.get(); + } + + public ZContext getContext() { + return context; } @Override @@ -96,7 +196,7 @@ public ZMsg getMessage() { // return maintenance objects instead of replies final long currentTime = System.currentTimeMillis(); // NOPMD switch (reply.messageType) { case SERVER_CONNECT_ACK: - if (connectionState.compareAndSet(ConnectionState.CONNECTING, ConnectionState.CONNECTED)) { + if (connectionState.compareAndSet(Event.CONNECT_RETRIED, Event.CONNECTED)) { LOGGER.atTrace().addArgument(connectedAddress).log("Connected to server: {}"); lastHeartbeatReceived = currentTime; backOff = 20; // reset back-off time @@ -105,14 +205,14 @@ public ZMsg getMessage() { // return maintenance objects instead of replies } return new ZMsg(); case SERVER_HB: - if (connectionState.get() != ConnectionState.CONNECTED) { + if (connectionState.get() != Event.CONNECTED) { LOGGER.atWarn().addArgument(reply).log("ignoring heartbeat received before connection established: {}"); return new ZMsg(); } lastHeartbeatReceived = currentTime; return new ZMsg(); case SERVER_REP: - if (connectionState.get() != ConnectionState.CONNECTED) { + if (connectionState.get() != Event.CONNECTED) { LOGGER.atWarn().addArgument(reply).log("ignoring data received before connection established: {}"); return new ZMsg(); } @@ -127,103 +227,39 @@ public ZMsg getMessage() { // return maintenance objects instead of replies } } - @Override - public void get(final String requestId, final URI endpoint, final byte[] data, final byte[] rbacToken) { - final Request request = new Request(CmwLightProtocol.RequestType.GET, requestId, endpoint, data, rbacToken); - queuedRequests.add(request); - } - - @Override - public void set(final String requestId, final URI endpoint, final byte[] data, final byte[] rbacToken) { - final Request request = new Request(CmwLightProtocol.RequestType.SET, requestId, endpoint, data, rbacToken); - queuedRequests.add(request); - } - - @Override - public void subscribe(final String reqId, final URI endpoint, final byte[] rbacToken) { - try { - final ParsedEndpoint ep = new ParsedEndpoint(endpoint); - final Subscription sub = new Subscription(endpoint, ep.device, ep.property, ep.ctx, ep.filters); - sub.idString = reqId; - subscriptions.put(sub.id, sub); - subscriptionsByReqId.put(reqId, sub); - } catch (CmwLightProtocol.RdaLightException e) { - throw new IllegalArgumentException("invalid endpoint: '" + endpoint + "'", e); - } - } - - @Override - public void unsubscribe(final String reqId) { - subscriptionsByReqId.get(reqId).subscriptionState = SubscriptionState.CANCELED; - } - - public ConnectionState getConnectionState() { - return connectionState.get(); - } - - public ZContext getContext() { - return context; - } - @Override public ZMQ.Socket getSocket() { return socket; } - public void connect() { - if (connectionState.getAndSet(ConnectionState.CONNECTING) != ConnectionState.DISCONNECTED) { - return; // already connected - } - URI resolveAddress = hostAddress; - if ((resolveAddress.getAuthority() == null || resolveAddress.getPort() == -1) && directoryLightClient != null) { - try { - DirectoryLightClient.Device device = directoryLightClient.getDeviceInfo(Collections.singletonList(getDeviceName(resolveAddress))).get(0); - LOGGER.atTrace().addArgument(resolveAddress).addArgument(device).log("resolved address for device {}: {}"); - final URI specificHost = URI.create(device.servers.stream().findFirst().orElseThrow().get("Address:")); - resolveAddress = new URI(resolveAddress.getScheme(), null, specificHost.getHost(), specificHost.getPort(), resolveAddress.getPath(), resolveAddress.getQuery(), null); - } catch (NullPointerException | NoSuchElementException | DirectoryLightClient.DirectoryClientException | URISyntaxException e) { // NOPMD - directory client must be refactored anyway - LOGGER.atError().setCause(e).addArgument(e.getMessage()).log("Error resolving device from nameserver, using address from endpoint. Error was: {}"); - backOff = backOff * 2; - connectionState.set(ConnectionState.DISCONNECTED); - return; - } - } - lastHeartbeatSent = System.currentTimeMillis(); - if (resolveAddress.getPort() == -1) { - LOGGER.atError().addArgument(hostAddress).log("could not resolve host service address: '{}'"); - } - try { - final String identity = getIdentity(); - final URI modifiedURI = URI.create(resolveAddress.getScheme() + "://" + resolveAddress.getAuthority()); - connectedAddress = StringUtils.stripEnd(StringUtils.replace(modifiedURI.toString(), RDA_3_PROTOCOL + "://", "tcp://"), "/"); - LOGGER.atDebug().addArgument(resolveAddress).addArgument(connectedAddress).addArgument(identity).log("connecting to: '{}'->'{}' with identity {}"); - socket.setIdentity(identity.getBytes()); // hostname/process/id/channel - socket.connect(connectedAddress); - CmwLightProtocol.sendMsg(socket, CmwLightMessage.connect(CmwLightProtocol.VERSION)); - hostAddress = resolveAddress; - } catch (ZMQException | CmwLightProtocol.RdaLightException | NumberFormatException e) { - LOGGER.atError().setCause(e).addArgument(connectedAddress).addArgument(hostAddress).log("failed to connect to '{}' source host address: '{}'"); - backOff = backOff * 2; - connectionState.set(ConnectionState.DISCONNECTED); - } - } - @Override public long housekeeping() { final long currentTime = System.currentTimeMillis(); + ZMonitor.ZEvent event; + while ((event = socketMonitor.nextEvent(false)) != null) { + switch (event.type) { + case DISCONNECTED: + case CLOSED: + // low-level detection that the connection has been separated + connectionState.set(Event.CLOSED); + break; + case CONNECTED: + // connectionState.set(Event.CONNECTED) - may not be needed + break; + default: + LOGGER.atDebug().addArgument(event).log("unknown socket event: {}"); + break; + } + } + switch (connectionState.get()) { - case DISCONNECTED: // reconnect after adequate back off - reset authority since we may likely need to resolve the server:port via the DNS + case DISCONNECTED: + case CLOSED: // reconnect after adequate back off - reset authority since we may likely need to resolve the server:port via the DNS if (currentTime > lastHeartbeatSent + backOff) { - try { - hostAddress = new URI(hostAddress.getScheme(), null, hostAddress.getPath(), hostAddress.getQuery(), null); - } catch (URISyntaxException e) { - throw new IllegalStateException("error while resetting authority part of host '" + hostAddress + "'", e); - } - LOGGER.atTrace().addArgument(hostAddress).log("Connecting to {}"); - connect(); + executorService.submit(this::reconnect); } return lastHeartbeatSent + backOff; - case CONNECTING: + case CONNECT_RETRIED: if (currentTime > lastHeartbeatSent + heartbeatInterval * heartbeatAllowedMisses) { // connect timed out -> increase back of and retry backOff = backOff * 2; lastHeartbeatSent = currentTime; @@ -232,10 +268,13 @@ public long housekeeping() { } return lastHeartbeatSent + heartbeatInterval * heartbeatAllowedMisses; case CONNECTED: + reconnectAttempt.set(0); Request request; while ((request = queuedRequests.poll()) != null) { pendingRequests.put(request.id, request); - sendRequest(request); + if (!sendRequest(request)) { + LOGGER.atWarn().addArgument(endpoint).log("could not send request for host {}"); + } } if (currentTime > lastHeartbeatSent + heartbeatInterval) { // check for heartbeat interval // send Heartbeats @@ -258,6 +297,17 @@ public long housekeeping() { } } + public void reconnect() { + LOGGER.atDebug().addArgument(endpoint).log("need to reconnect for URI {}"); + disconnect(); + connect(); + } + + public void registerDnsResolver(final @NotNull DnsResolver resolver) { + // delegate method primarily for testing + getFactory().registerDnsResolver(resolver); + } + public void sendHeartBeat() { try { CmwLightProtocol.sendMsg(socket, CmwLightMessage.CLIENT_HB); @@ -266,23 +316,66 @@ public void sendHeartBeat() { } } + @Override + public void set(final String requestId, final URI endpoint, final byte[] data, final byte[] rbacToken) { + final Request request = new Request(CmwLightProtocol.RequestType.SET, requestId, endpoint, data, rbacToken); + queuedRequests.add(request); + } + + @Override + public void subscribe(final String reqId, final URI endpoint, final byte[] rbacToken) { + try { + final ParsedEndpoint ep = new ParsedEndpoint(endpoint); + final Subscription sub = new Subscription(endpoint, ep.device, ep.property, ep.ctx, ep.filters); + sub.idString = reqId; + subscriptions.put(sub.id, sub); + subscriptionsByReqId.put(reqId, sub); + } catch (CmwLightProtocol.RdaLightException e) { + throw new IllegalArgumentException("invalid endpoint: '" + endpoint + "'", e); + } + } + + @Override + public void unsubscribe(final String reqId) { + subscriptionsByReqId.get(reqId).subscriptionState = SubscriptionState.CANCELED; + } + @Override protected Factory getFactory() { return FACTORY; } - private CmwLightMessage receiveData() { - // receive data - try { - final ZMsg data = ZMsg.recvMsg(socket, ZMQ.DONTWAIT); - if (data == null) { - return null; + private void disconnect() { + LOGGER.atDebug().addArgument(connectedAddress).log("disconnecting {}"); + connectionState.set(Event.CLOSED); + if (connectedAddress != OpenCmwProtocol.EMPTY_URI) { + try { + socket.disconnect(connectedAddress.toString()); + } catch (ZMQException e) { + LOGGER.atError().setCause(e).log("Failed to disconnect socket"); } - return CmwLightProtocol.parseMsg(data); - } catch (CmwLightProtocol.RdaLightException e) { - LOGGER.atDebug().setCause(e).log("error parsing cmw light reply: "); - return null; } + // disconnect/reset subscriptions + for (Subscription sub : subscriptions.values()) { + sub.subscriptionState = SubscriptionState.UNSUBSCRIBED; + } + } + + private String getIdentity() { + String hostname; + try { + hostname = InetAddress.getLocalHost().getHostName(); + } catch (UnknownHostException e) { + hostname = "localhost"; + } + final long processId = ProcessHandle.current().pid(); + connectionId = CONNECTION_ID_GENERATOR.incrementAndGet(); + final int chId = this.channelId.incrementAndGet(); + return hostname + '/' + processId + '/' + connectionId + '/' + chId; // N.B. this scheme is parsed/enforced by CMW + } + + private String getSessionId(final String clientId) { + return "CmwLightClient{pid=" + ProcessHandle.current().pid() + ", conn=" + connectionId + ", clientId=" + clientId + '}'; } private ZMsg handleServerReply(final CmwLightMessage reply, final long currentTime) { //NOPMD @@ -352,87 +445,40 @@ private ZMsg handleServerReply(final CmwLightMessage reply, final long currentTi } } - private String getIdentity() { - String hostname; - try { - hostname = InetAddress.getLocalHost().getHostName(); - } catch (UnknownHostException e) { - hostname = "localhost"; - } - final long processId = ProcessHandle.current().pid(); - connectionId = CONNECTION_ID_GENERATOR.incrementAndGet(); - final int chId = this.channelId.incrementAndGet(); - return hostname + '/' + processId + '/' + connectionId + '/' + chId; - } - - private String getSessionId(final String clientId) { - return "cmwLightClient{pid=" + ProcessHandle.current().pid() + ", conn=" + connectionId + ", clientId=" + clientId + '}'; // Todo: create identification string, cmw uses string with user/app name, pid, etc - } - - private void disconnect() { - LOGGER.atDebug().addArgument(connectedAddress).log("disconnecting {}"); - connectionState.set(ConnectionState.DISCONNECTED); + private CmwLightMessage receiveData() { + // receive data try { - socket.disconnect(connectedAddress); - } catch (ZMQException e) { - LOGGER.atError().setCause(e).log("Failed to disconnect socket"); - } - // disconnect/reset subscriptions - for (Subscription sub : subscriptions.values()) { - sub.subscriptionState = SubscriptionState.UNSUBSCRIBED; + final ZMsg data = ZMsg.recvMsg(socket, ZMQ.DONTWAIT); + if (data == null) { + return null; + } + return CmwLightProtocol.parseMsg(data); + } catch (CmwLightProtocol.RdaLightException e) { + LOGGER.atDebug().setCause(e).log("error parsing cmw light reply: "); + return null; } } - private void sendRequest(final Request request) { + private boolean sendRequest(final Request request) { try { - final ParsedEndpoint endpoint = new ParsedEndpoint(request.endpoint); + final ParsedEndpoint parsedEndpoint = new ParsedEndpoint(request.endpoint); switch (request.requestType) { case GET: - CmwLightProtocol.sendMsg(socket, CmwLightMessage.getRequest( - sessionId, request.id, endpoint.device, endpoint.property, - new CmwLightMessage.RequestContext(endpoint.ctx, endpoint.filters, null))); - break; + return CmwLightProtocol.sendMsg(socket, CmwLightMessage.getRequest( + sessionId, request.id, parsedEndpoint.device, parsedEndpoint.property, + new CmwLightMessage.RequestContext(parsedEndpoint.ctx, parsedEndpoint.filters, null))); case SET: Objects.requireNonNull(request.data, "Data for set cannot be null"); - CmwLightProtocol.sendMsg(socket, CmwLightMessage.setRequest( - sessionId, request.id, endpoint.device, endpoint.property, - new ZFrame(request.data), - new CmwLightMessage.RequestContext(endpoint.ctx, endpoint.filters, null))); - break; + return CmwLightProtocol.sendMsg(socket, CmwLightMessage.setRequest( + sessionId, request.id, parsedEndpoint.device, parsedEndpoint.property, + new ZFrame(request.data), + new CmwLightMessage.RequestContext(parsedEndpoint.ctx, parsedEndpoint.filters, null))); default: throw new CmwLightProtocol.RdaLightException("Message of unknown type"); } } catch (CmwLightProtocol.RdaLightException e) { LOGGER.atDebug().setCause(e).log("Error sending get request:"); - } - } - - private void updateSubscription(final long currentTime, final Subscription sub) { - switch (sub.subscriptionState) { - case SUBSCRIBING: - // check timeout - if (currentTime > sub.timeoutValue) { - sub.subscriptionState = SubscriptionState.UNSUBSCRIBED; - sub.timeoutValue = currentTime + sub.backOff; - sub.backOff = sub.backOff * 2; // exponential back of - LOGGER.atDebug().addArgument(sub.device).addArgument(sub.property).log("subscription timed out, retrying: {}/{}"); - } - break; - case UNSUBSCRIBED: - if (currentTime > sub.timeoutValue) { - LOGGER.atDebug().addArgument(sub.device).addArgument(sub.property).log("subscribing {}/{}"); - sendSubscribe(sub); - } - break; - case SUBSCRIBED: - case UNSUBSCRIBE_SENT: - // do nothing - break; - case CANCELED: - sendUnsubscribe(sub); - break; - default: - throw new IllegalStateException("unexpected subscription state: " + sub.subscriptionState); + return false; } } @@ -467,18 +513,33 @@ private void sendUnsubscribe(final Subscription sub) { } } - public static DirectoryLightClient getDirectoryLightClient() { - return directoryLightClient; - } - - public static void setDirectoryLightClient(final DirectoryLightClient directoryLightClient) { - CmwLightDataSource.directoryLightClient = directoryLightClient; - } - - public enum ConnectionState { - DISCONNECTED, - CONNECTING, - CONNECTED + private void updateSubscription(final long currentTime, final Subscription sub) { + switch (sub.subscriptionState) { + case SUBSCRIBING: + // check timeout + if (currentTime > sub.timeoutValue) { + sub.subscriptionState = SubscriptionState.UNSUBSCRIBED; + sub.timeoutValue = currentTime + sub.backOff; + sub.backOff = sub.backOff * 2; // exponential back of + LOGGER.atDebug().addArgument(sub.device).addArgument(sub.property).log("subscription timed out, retrying: {}/{}"); + } + break; + case UNSUBSCRIBED: + if (currentTime > sub.timeoutValue) { + LOGGER.atDebug().addArgument(sub.device).addArgument(sub.property).log("subscribing {}/{}"); + sendSubscribe(sub); + } + break; + case SUBSCRIBED: + case UNSUBSCRIBE_SENT: + // do nothing + break; + case CANCELED: + sendUnsubscribe(sub); + break; + default: + throw new IllegalStateException("unexpected subscription state: " + sub.subscriptionState); + } } public enum SubscriptionState { @@ -593,6 +654,23 @@ public ParsedEndpoint(final URI endpoint, final String ctx) throws CmwLightProto })); } + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ParsedEndpoint)) { + return false; + } + final ParsedEndpoint that = (ParsedEndpoint) o; + return filters.equals(that.filters) && ctx.equals(that.ctx) && device.equals(that.device) && property.equals(that.property); + } + + @Override + public int hashCode() { + return Objects.hash(filters, ctx, device, property); + } + public URI toURI() throws URISyntaxException { final String filterString = filters.entrySet().stream() // .map(e -> { @@ -619,22 +697,5 @@ public URI toURI() throws URISyntaxException { .collect(Collectors.joining("&")); return new URI(RDA_3_PROTOCOL, authority, '/' + device + '/' + property, "ctx=" + ctx + '&' + filterString, null); } - - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (!(o instanceof ParsedEndpoint)) { - return false; - } - final ParsedEndpoint that = (ParsedEndpoint) o; - return filters.equals(that.filters) && ctx.equals(that.ctx) && device.equals(that.device) && property.equals(that.property); - } - - @Override - public int hashCode() { - return Objects.hash(filters, ctx, device, property); - } } } diff --git a/client/src/main/java/io/opencmw/client/cmwlight/CmwLightProtocol.java b/client/src/main/java/io/opencmw/client/cmwlight/CmwLightProtocol.java index 481db8b7..be1d6b4f 100644 --- a/client/src/main/java/io/opencmw/client/cmwlight/CmwLightProtocol.java +++ b/client/src/main/java/io/opencmw/client/cmwlight/CmwLightProtocol.java @@ -188,7 +188,6 @@ public static CmwLightMessage recvMsg(final ZMQ.Socket socket, int tout) throws } public static CmwLightMessage parseMsg(final @NotNull ZMsg data) throws RdaLightException { // NOPMD - NPath complexity acceptable (complex protocol) - assert data != null : "data"; final ZFrame firstFrame = data.pollFirst(); if (firstFrame != null && Arrays.equals(firstFrame.getData(), new byte[] { MessageType.SERVER_CONNECT_ACK.value() })) { final CmwLightMessage reply = new CmwLightMessage(MessageType.SERVER_CONNECT_ACK); @@ -296,8 +295,8 @@ public static CmwLightMessage parseMsg(final @NotNull ZMsg data) throws RdaLight } } - public static void sendMsg(final ZMQ.Socket socket, final CmwLightMessage msg) throws RdaLightException { - serialiseMsg(msg).send(socket); + public static boolean sendMsg(final ZMQ.Socket socket, final CmwLightMessage msg) throws RdaLightException { + return serialiseMsg(msg).send(socket); } public static ZMsg serialiseMsg(final CmwLightMessage msg) throws RdaLightException { @@ -522,7 +521,6 @@ private static CmwLightMessage.ExceptionMessage parseExceptionMessage(final ZFra } private static CmwLightMessage.RequestContext parseRequestContext(final @NotNull ZFrame contextData) throws RdaLightException { - assert contextData != null : "contextData"; CmwLightMessage.RequestContext requestContext = new CmwLightMessage.RequestContext(); IO_CLASS_SERIALISER.setDataBuffer(FastByteBuffer.wrap(contextData.getData())); final FieldDescription contextMap; @@ -556,7 +554,6 @@ private static CmwLightMessage.RequestContext parseRequestContext(final @NotNull } private static CmwLightMessage.DataContext parseContextData(final @NotNull ZFrame contextData) throws RdaLightException { - assert contextData != null : "contextData"; CmwLightMessage.DataContext dataContext = new CmwLightMessage.DataContext(); IO_CLASS_SERIALISER.setDataBuffer(FastByteBuffer.wrap(contextData.getData())); final FieldDescription contextMap; diff --git a/client/src/main/java/io/opencmw/client/cmwlight/DirectoryLightClient.java b/client/src/main/java/io/opencmw/client/cmwlight/DirectoryLightClient.java index 5d8e0f12..7e5686e7 100644 --- a/client/src/main/java/io/opencmw/client/cmwlight/DirectoryLightClient.java +++ b/client/src/main/java/io/opencmw/client/cmwlight/DirectoryLightClient.java @@ -6,19 +6,25 @@ import java.io.PrintWriter; import java.net.InetSocketAddress; import java.net.Socket; +import java.net.URI; import java.net.URLDecoder; +import java.net.UnknownHostException; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; +import io.opencmw.OpenCmwConstants; +import io.opencmw.client.DnsResolver; + /** * Obtain device info from the directory server */ -public class DirectoryLightClient { +public class DirectoryLightClient implements DnsResolver { public static final String GET_DEVICE_INFO = "get-device-info"; // public static final String GET_SERVER_INFO = "get-server-info"; // private static final String SUPPORTED_CHARACTERS = "\\.\\-\\+_a-zA-Z0-9"; @@ -34,16 +40,41 @@ public class DirectoryLightClient { private final String nameserver; private final int nameserverPort; - public DirectoryLightClient(final String... nameservers) throws DirectoryClientException { + public DirectoryLightClient(final String... nameservers) { if (nameservers.length != 1) { - throw new DirectoryClientException("only one nameserver supported at the moment"); + throw new IllegalArgumentException("only one nameserver supported at the moment"); + } + final String[] hostPort = nameservers[0].split(HOST_PORT_SEPARATOR); + if (hostPort.length != 2) { + throw new IllegalArgumentException("nameserver address has wrong format: " + nameservers[0]); } - final String[] hostport = nameservers[0].split(HOST_PORT_SEPARATOR); - if (hostport.length != 2) { - throw new DirectoryClientException("nameserver address has wrong format: " + nameservers[0]); + nameserver = hostPort[0]; + nameserverPort = Integer.parseInt(hostPort[1]); + } + + @Override + public void close() { + // nothing to be closed here + } + + @Override + public List getApplicableSchemes() { + return List.of("rda3"); + } + + @Override + public Map> resolveNames(final List devicesToResolve) throws UnknownHostException { + final List deviceList = devicesToResolve.stream().map(OpenCmwConstants::getDeviceName).collect(Collectors.toList()); + try { + final List deviceInfos = getDeviceInfo(deviceList); + final Map> map = new ConcurrentHashMap<>(); + for (Device device : deviceInfos) { + map.put(URI.create("rda3:/" + device.name), List.of(URI.create(device.getAddress()))); + } + return map; + } catch (Exception e) { // NOPMD + throw new UnknownHostException("resolveNames : " + e.getMessage()); // NOPMD - exception retained in message } - nameserver = hostport[0]; - nameserverPort = Integer.parseInt(hostport[1]); } /** diff --git a/client/src/main/java/io/opencmw/client/rest/RestDataSource.java b/client/src/main/java/io/opencmw/client/rest/RestDataSource.java index 0475e49d..d0dcddcd 100644 --- a/client/src/main/java/io/opencmw/client/rest/RestDataSource.java +++ b/client/src/main/java/io/opencmw/client/rest/RestDataSource.java @@ -1,6 +1,7 @@ package io.opencmw.client.rest; +import static io.opencmw.OpenCmwConstants.setDefaultSocketParameters; import static io.opencmw.OpenCmwProtocol.EMPTY_FRAME; import java.io.IOException; @@ -9,15 +10,9 @@ import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Duration; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Timer; -import java.util.TimerTask; +import java.util.*; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutorService; import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -40,8 +35,10 @@ import io.opencmw.MimeType; import io.opencmw.client.DataSource; +import io.opencmw.client.DnsResolver; import io.opencmw.serialiser.IoSerialiser; import io.opencmw.serialiser.spi.JsonSerialiser; +import io.opencmw.utils.NoDuplicatesList; import okhttp3.Call; import okhttp3.Callback; @@ -54,21 +51,28 @@ @SuppressWarnings({ "PMD.TooManyFields", "PMD.ExcessiveImports" }) public class RestDataSource extends DataSource implements Runnable { + private static final List APPLICABLE_SCHEMES = List.of("http", "https"); + private static final List RESOLVERS = Collections.synchronizedList(new NoDuplicatesList<>()); public static final Factory FACTORY = new Factory() { @Override - public boolean matches(final URI endpoint) { - return endpoint != null && endpoint.getScheme().toLowerCase(Locale.UK).startsWith("http"); + public List getApplicableSchemes() { + return APPLICABLE_SCHEMES; } @Override - public Class getMatchingSerialiserType(final URI endpoint) { + public Class getMatchingSerialiserType(final @NotNull URI endpoint) { return JsonSerialiser.class; } @Override - public DataSource newInstance(final ZContext context, final URI endpoint, final Duration timeout, final String clientId) { + public DataSource newInstance(final ZContext context, final @NotNull URI endpoint, final @NotNull Duration timeout, final @NotNull ExecutorService executorService, final @NotNull String clientId) { return new RestDataSource(context, endpoint, timeout, clientId); } + + @Override + public List getRegisteredDnsResolver() { + return RESOLVERS; + } }; private static final Logger LOGGER = LoggerFactory.getLogger(RestDataSource.class); private static final int WAIT_TIMEOUT_MILLIS = 1000; @@ -137,25 +141,8 @@ public RestDataSource(final ZContext ctx, final URI endpoint, final Duration tim timer.scheduleAtFixedRate(wakeupTask, 0, timeOut.toMillis()); } - start(); // NOPMD - starts on initialisation - } - - /** - * Connect or reconnect to broker - */ - private void createPair() { - if (internalSocket != null) { - internalSocket.close(); - } - if (externalSocket != null) { - externalSocket.close(); - } - internalSocket = ctxCopy.createSocket(SocketType.PAIR); - assert internalSocket != null : "internalSocket being initialised"; - if (!internalSocket.setHWM(0)) { - throw new IllegalStateException("could not set HWM on internalSocket"); - } + setDefaultSocketParameters(internalSocket); if (!internalSocket.setIdentity(uniqueIdBytes)) { throw new IllegalStateException("could not set identity on internalSocket"); } @@ -164,15 +151,14 @@ private void createPair() { } externalSocket = ctxCopy.createSocket(SocketType.PAIR); - assert externalSocket != null : "externalSocket being initialised"; - if (!externalSocket.setHWM(0)) { - throw new IllegalStateException("could not set HWM on externalSocket"); - } + setDefaultSocketParameters(externalSocket); if (!externalSocket.connect("inproc://" + uniqueID)) { throw new IllegalStateException("could not bind externalSocket to: inproc://" + uniqueID); } - LOGGER.atTrace().addArgument(endpoint).log("(re-)connecting to REST endpoint: '{}'"); + LOGGER.atTrace().addArgument(endpoint).log("connecting to REST endpoint: '{}'"); + + start(); // NOPMD - starts on initialisation } @Override @@ -227,6 +213,12 @@ public ZMQ.Socket getSocket() { return externalSocket; } + @Override + public void close() { + internalSocket.close(); + externalSocket.close(); + } + @Override protected Factory getFactory() { return FACTORY; @@ -235,7 +227,7 @@ protected Factory getFactory() { /** * Gets called whenever data is available on the DataSource's socket. * Should then try to receive data and return any results back to the calling event loop. - * @return null if there is no more data available, a Zero length Zmsg if there was data which was only used internally + * @return null if there is no more data available, a Zero length ZMsg if there was data which was only used internally * or a ZMsg with [reqId, endpoint, byte[] data, [byte[] optional RBAC token]] */ @Override @@ -291,7 +283,6 @@ public void run() { // NOPMD NOSONAR - complexity // exception branch data = EMPTY_FRAME; } else { - // callBack.response.headers().toString().getBytes(StandardCharsets.UTF_8); data = callBack.response.peekBody(Long.MAX_VALUE).bytes(); callBack.response.close(); } @@ -322,7 +313,6 @@ public void run() { // NOPMD NOSONAR - complexity } public void start() { - createPair(); new Thread(this).start(); // NOPMD } diff --git a/client/src/test/java/io/opencmw/client/DataSourcePublisherTest.java b/client/src/test/java/io/opencmw/client/DataSourcePublisherTest.java index 5f4cdcb3..a7c916da 100644 --- a/client/src/test/java/io/opencmw/client/DataSourcePublisherTest.java +++ b/client/src/test/java/io/opencmw/client/DataSourcePublisherTest.java @@ -9,21 +9,13 @@ import java.net.URI; import java.net.URISyntaxException; import java.time.Duration; -import java.util.Arrays; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; -import java.util.Timer; -import java.util.TimerTask; -import java.util.concurrent.CancellationException; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; +import java.util.*; +import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import org.awaitility.Awaitility; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; @@ -44,29 +36,35 @@ import io.opencmw.serialiser.IoSerialiser; import io.opencmw.serialiser.spi.BinarySerialiser; import io.opencmw.serialiser.spi.FastByteBuffer; +import io.opencmw.utils.NoDuplicatesList; @Timeout(30) class DataSourcePublisherTest { private static final Logger LOGGER = LoggerFactory.getLogger(DataSourcePublisherTest.class); private static final AtomicReference testObject = new AtomicReference<>(); private static final String TEST_ERROR_MSG = "Test error occurred"; - + private static final List RESOLVERS = Collections.synchronizedList(new NoDuplicatesList<>()); private static class TestDataSource extends DataSource { public static final Factory FACTORY = new Factory() { @Override - public boolean matches(final URI endpoint) { - return endpoint.getScheme().equals("test"); + public List getApplicableSchemes() { + return List.of("test"); } @Override - public Class getMatchingSerialiserType(final URI endpoint) { + public Class getMatchingSerialiserType(final @NotNull URI endpoint) { return BinarySerialiser.class; } @Override - public DataSource newInstance(final ZContext context, final URI endpoint, final Duration timeout, final String clientId) { + public DataSource newInstance(final ZContext context, final @NotNull URI endpoint, final @NotNull Duration timeout, final @NotNull ExecutorService executorService, final @NotNull String clientId) { return new TestDataSource(context, endpoint); } + + @Override + public List getRegisteredDnsResolver() { + return RESOLVERS; + } }; private final static String INPROC = "inproc://testDataSource"; private final ZContext context; @@ -171,6 +169,11 @@ public ZMQ.Socket getSocket() { return socket; } + @Override + public void close() { + socket.close(); + } + @Override protected Factory getFactory() { return FACTORY; @@ -193,8 +196,8 @@ public void unsubscribe(final String reqId) { } public static class TestContext { - public String ctx; - public String filter; + public final String ctx; + public final String filter; public TestContext(final String ctx, final String filter) { this.ctx = ctx; @@ -281,7 +284,7 @@ void testSubscribe() throws URISyntaxException { new Thread(dataSourcePublisher).start(); try (final DataSourcePublisher.Client client = dataSourcePublisher.getClient()) { - client.subscribe(new URI("test://foobar/testdev/prop?ctx=FAIR.SELECTOR.ALL&filter=foobar"), TestObject.class); + client.subscribe(new URI("test://foobar/testDevice/prop?ctx=FAIR.SELECTOR.ALL&filter=foobar"), TestObject.class); } Awaitility.waitAtMost(Duration.ofSeconds(10)).until(eventReceived::get); @@ -315,7 +318,7 @@ public void updateException(final Throwable exception) { } }; try (final DataSourcePublisher.Client client = dataSourcePublisher.getClient()) { - client.subscribe(URI.create("test://foobar/testdev/prop"), TestObject.class, ctx, TestContext.class, listener); + client.subscribe(URI.create("test://foobar/testDevice/prop"), TestObject.class, ctx, TestContext.class, listener); } Awaitility.waitAtMost(Duration.ofSeconds(1)).until(eventReceived::get); @@ -346,7 +349,7 @@ public void updateException(final Throwable exception) { } }; try (final DataSourcePublisher.Client client = dataSourcePublisher.getClient()) { - client.subscribe(URI.create("test://foobar/testdev/prop"), TestObject.class, ctx, TestContext.class, listener); + client.subscribe(URI.create("test://foobar/testDevice/prop"), TestObject.class, ctx, TestContext.class, listener); } Awaitility.waitAtMost(Duration.ofSeconds(1)).until(exceptionReceived::get); @@ -368,7 +371,7 @@ void testGet() throws InterruptedException, ExecutionException, TimeoutException final Future future; try (final DataSourcePublisher.Client client = dataSourcePublisher.getClient()) { - future = client.get(new URI("test://foobar/testdev/prop?ctx=FAIR.SELECTOR.ALL&filter=foobar"), null, TestObject.class); + future = client.get(new URI("test://foobar/testDevice/prop?ctx=FAIR.SELECTOR.ALL&filter=foobar"), null, TestObject.class); } final TestObject result = future.get(1000, TimeUnit.MILLISECONDS); @@ -391,7 +394,7 @@ void testGetException() { final Future future; try (final DataSourcePublisher.Client client = dataSourcePublisher.getClient()) { - future = client.get(URI.create("test://foobar/testdev/prop?ctx=FAIR.SELECTOR.ALL&filter=foobar"), null, TestObject.class); + future = client.get(URI.create("test://foobar/testDevice/prop?ctx=FAIR.SELECTOR.ALL&filter=foobar"), null, TestObject.class); } try { @@ -419,7 +422,7 @@ void testGetTimeout() throws URISyntaxException { final Future future; try (final DataSourcePublisher.Client client = dataSourcePublisher.getClient()) { - future = client.get(new URI("test://foobar/testdev/prop?ctx=FAIR.SELECTOR.ALL&filter=foobar"), null, TestObject.class); + future = client.get(new URI("test://foobar/testDevice/prop?ctx=FAIR.SELECTOR.ALL&filter=foobar"), null, TestObject.class); } assertNotNull(future); @@ -480,9 +483,9 @@ public void run() { @Test void testHelperFunctions() throws URISyntaxException { assertEquals("device", getDeviceName(URI.create("mdp:/device/property/sub-property"))); - assertEquals("device", getDeviceName(URI.create("mdp://authrority/device/property/sub-property"))); - assertEquals("device", getDeviceName(URI.create("mdp://authrority//device/property/sub-property"))); - assertEquals("authrority", URI.create("mdp://authrority//device/property/sub-property").getAuthority()); + assertEquals("device", getDeviceName(URI.create("mdp://authority/device/property/sub-property"))); + assertEquals("device", getDeviceName(URI.create("mdp://authority//device/property/sub-property"))); + assertEquals("authority", URI.create("mdp://authority//device/property/sub-property").getAuthority()); assertEquals("property/sub-property", getPropertyName(URI.create("mdp:/device/property/sub-property"))); assertEquals("property/sub-property", getPropertyName(URI.create("mdp:/device/property/sub-property"))); diff --git a/client/src/test/java/io/opencmw/client/DnsDataSourceTests.java b/client/src/test/java/io/opencmw/client/DnsDataSourceTests.java index 9efea58b..3f1a4189 100644 --- a/client/src/test/java/io/opencmw/client/DnsDataSourceTests.java +++ b/client/src/test/java/io/opencmw/client/DnsDataSourceTests.java @@ -1,22 +1,32 @@ package io.opencmw.client; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.*; import java.io.IOException; import java.net.URI; import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Timer; +import java.util.TimerTask; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.LockSupport; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.zeromq.util.ZData; +import io.opencmw.OpenCmwConstants; import io.opencmw.domain.BinaryData; import io.opencmw.domain.NoData; import io.opencmw.rbac.BasicRbacRole; @@ -31,19 +41,26 @@ * @author rstein */ class DnsDataSourceTests { + private final static int TIMEOUT_STARTUP = 5; // [s] private final static int TIMEOUT = 1000; // [ms] private static MajordomoBroker dnsBroker; private static MajordomoBroker brokerB; private static MajordomoBroker brokerC; private static URI dnsBrokerAddress; - - @BeforeAll - static void init() throws IOException { - dnsBroker = getTestBroker("dnsBroker", "deviceA/property", null); - dnsBrokerAddress = dnsBroker.bind(URI.create("mdp://*:" + Utils.findOpenPort())); - brokerB = getTestBroker("CustomBrokerB", "deviceB/property", dnsBrokerAddress); - brokerC = getTestBroker("CustomBrokerC", "deviceC/property", dnsBrokerAddress); - LockSupport.parkNanos(Duration.ofMillis(1000).toNanos()); + private static OpenCmwDnsResolver openCmwResolver; + + private void launchSubscription(final DataSourcePublisher.Client client, final AtomicInteger notificationCounterA, final URI subscriptionURI) { + client.subscribe(subscriptionURI, DomainData.class, null, NoData.class, new DataSourcePublisher.NotificationListener<>() { + @Override + public void dataUpdate(final DomainData updatedObject, final NoData contextObject) { + notificationCounterA.getAndIncrement(); + } + + @Override + public void updateException(final Throwable exception) { + fail("subscription exception occurred ", exception); + } + }); } @Test @@ -55,15 +72,42 @@ void testBasicResolver() throws InterruptedException, ExecutionException, Timeou assertEquals("deviceA/property", reply1.get(TIMEOUT, TimeUnit.MILLISECONDS).data); final Future reply2 = client.get(URI.create(dnsBrokerAddress + "/mmi.service"), null, BinaryData.class); - assertEquals("deviceA/property,mmi.dns,mmi.echo,mmi.openapi,mmi.service", ZData.toString(reply2.get(TIMEOUT, TimeUnit.MILLISECONDS).data)); + assertEquals("deviceA/property,dnsBroker/mmi.dns,dnsBroker/mmi.echo,dnsBroker/mmi.openapi,dnsBroker/mmi.service", ZData.toString(reply2.get(TIMEOUT, TimeUnit.MILLISECONDS).data)); final Future reply3 = client.get(URI.create(dnsBrokerAddress + "/mmi.dns"), null, BinaryData.class); final byte[] dnsResult = reply3.get(TIMEOUT, TimeUnit.MILLISECONDS).data; assertNotNull(dnsResult); - // System.err.println("dns query: '" + ZData.toString(dnsResult) + "'") + final Map> dnsMapAll = OpenCmwDnsResolver.parseDnsReply(dnsResult); + assertFalse(dnsMapAll.isEmpty()); + assertEquals(3, dnsMapAll.size(), "number of brokers"); + + final List queryDevice = Arrays.asList("mds:/deviceB", "deviceA", "/deviceA", "mds:/deviceC", "dnsBroker", "unknown"); + final String query = String.join(",", queryDevice); + final Future reply4 = client.get(URI.create(dnsBrokerAddress + "/mmi.dns?" + query), null, BinaryData.class); + final byte[] dnsSpecificResult = reply4.get(TIMEOUT, TimeUnit.MILLISECONDS).data; + assertNotNull(dnsSpecificResult); + final Map> dnsMapSelective = OpenCmwDnsResolver.parseDnsReply(dnsSpecificResult); + assertFalse(dnsMapSelective.isEmpty()); + assertEquals(queryDevice.size(), dnsMapSelective.size(), "number of returned dns resolves"); } } + @Test + void testOpenCmwDnsResolver() throws Exception { + final List queryDevice = Stream.of("mds:/deviceB", "deviceA", "/deviceA", "mds:/deviceC", "dnsBroker", "unknown").map(URI::create).collect(Collectors.toList()); + final Map> dnsMapSelective = openCmwResolver.resolveNames(queryDevice); + assertFalse(dnsMapSelective.isEmpty()); + assertEquals(queryDevice.size(), dnsMapSelective.size(), "number of returned dns resolves"); + + checkNumberOfEndPoints(dnsMapSelective, 1, "mds:/deviceB"); + checkNumberOfEndPoints(dnsMapSelective, 1, "mds:/deviceB"); + checkNumberOfEndPoints(dnsMapSelective, 3, "deviceA"); // two private entries (mdp & mds), one public mdp + checkNumberOfEndPoints(dnsMapSelective, 3, "/deviceA"); // two private entries (mdp & mds), one public mdp + checkNumberOfEndPoints(dnsMapSelective, 1, "mds:/deviceC"); + checkNumberOfEndPoints(dnsMapSelective, 12, "dnsBroker"); + checkNumberOfEndPoints(dnsMapSelective, 0, "unknown"); + } + @Test void testWithoutResolver() throws InterruptedException, ExecutionException, TimeoutException { // uses full/known URI definition @@ -73,13 +117,106 @@ void testWithoutResolver() throws InterruptedException, ExecutionException, Time assertEquals("deviceA/property", reply1.get(TIMEOUT, TimeUnit.MILLISECONDS).data); final Future reply2 = client.get(URI.create(dnsBrokerAddress + "/mmi.service"), null, BinaryData.class); - assertEquals("deviceA/property,mmi.dns,mmi.echo,mmi.openapi,mmi.service", ZData.toString(reply2.get(TIMEOUT, TimeUnit.MILLISECONDS).data)); + assertEquals("deviceA/property,dnsBroker/mmi.dns,dnsBroker/mmi.echo,dnsBroker/mmi.openapi,dnsBroker/mmi.service", ZData.toString(reply2.get(TIMEOUT, TimeUnit.MILLISECONDS).data)); final Future reply3 = client.get(URI.create(dnsBrokerAddress + "/dnsBroker/mmi.service"), null, BinaryData.class); - assertEquals("deviceA/property,mmi.dns,mmi.echo,mmi.openapi,mmi.service", ZData.toString(reply3.get(TIMEOUT, TimeUnit.MILLISECONDS).data)); + assertEquals("deviceA/property,dnsBroker/mmi.dns,dnsBroker/mmi.echo,dnsBroker/mmi.openapi,dnsBroker/mmi.service", ZData.toString(reply3.get(TIMEOUT, TimeUnit.MILLISECONDS).data)); } } + @Test + void testWithResolver() throws InterruptedException, ExecutionException, TimeoutException { + DataSource.getFactory(URI.create("mdp:/mmi.dns")).registerDnsResolver(new OpenCmwDnsResolver(dnsBrokerAddress)); + // uses full/known URI definition + try (DataSourcePublisher dataSource = new DataSourcePublisher(null, null, "test-client"); + DataSourcePublisher.Client client = dataSource.getClient()) { + Future reply1 = client.get(URI.create("mdp:/deviceA/property"), null, DomainData.class); + assertEquals("deviceA/property", reply1.get(TIMEOUT, TimeUnit.MILLISECONDS).data); + + final Future reply2 = client.get(URI.create("mdp:/mmi.service"), null, BinaryData.class); + assertEquals("deviceA/property,dnsBroker/mmi.dns,dnsBroker/mmi.echo,dnsBroker/mmi.openapi,dnsBroker/mmi.service", ZData.toString(reply2.get(TIMEOUT, TimeUnit.MILLISECONDS).data)); + + final Future reply3 = client.get(URI.create("mdp:/dnsBroker/mmi.service"), null, BinaryData.class); + assertEquals("deviceA/property,dnsBroker/mmi.dns,dnsBroker/mmi.echo,dnsBroker/mmi.openapi,dnsBroker/mmi.service", ZData.toString(reply3.get(TIMEOUT, TimeUnit.MILLISECONDS).data)); + } + } + + @Test + void testWithWorkerStartStopping() throws IOException { + DataSource.getFactory(URI.create("mdp:/mmi.dns")).registerDnsResolver(new OpenCmwDnsResolver(dnsBrokerAddress)); + System.setProperty(OpenCmwConstants.RECONNECT_THRESHOLD1, "10000"); // to reduce waiting time for reconnects + System.setProperty(OpenCmwConstants.RECONNECT_THRESHOLD2, "1000"); // to reduce waiting time for reconnects + + try (DataSourcePublisher dataSource = new DataSourcePublisher(null, null, "test-client"); + DataSourcePublisher.Client client = dataSource.getClient()) { + AtomicInteger notificationCounterA = new AtomicInteger(); + AtomicInteger notificationCounterD = new AtomicInteger(); + launchSubscription(client, notificationCounterA, URI.create("mds:/deviceA/property")); + launchSubscription(client, notificationCounterD, URI.create("mds:/deviceD/property")); + + await().alias("subscribe and receive from an existing 'deviceA/property'").atMost(Duration.ofSeconds(TIMEOUT_STARTUP)).until(() -> notificationCounterA.get() >= 10); + assertNotEquals(0, notificationCounterA.get()); + + final MajordomoBroker brokerD = getTestBroker("CustomBrokerD", "deviceD/property", dnsBrokerAddress); + assertNotNull(brokerD, "new brokerD is not running"); + await().alias("wait for all CustomBrokerD services to report in").atMost(Duration.ofSeconds(TIMEOUT_STARTUP)).until(() -> providedServices("CustomBrokerD", "deviceD", 2)); + assertTrue(providedServices("CustomBrokerD", "deviceD", 2), "check that all required CustomBrokerD services have reported in"); + // brokerD started + + await().alias("subscribe and receive from an existing 'deviceD/property' - first stage").atMost(Duration.ofSeconds(TIMEOUT_STARTUP)).until(() -> notificationCounterD.get() >= 10); + assertNotEquals(0, notificationCounterD.get()); + brokerD.stopBroker(); + //LockSupport.parkNanos(Duration.ofMillis(TIMEOUT).toNanos()); // wait until the old brokerD and connected services have shut down + // reset counter + dnsBroker.getDnsCache().clear(); + notificationCounterD.set(0); + assertEquals(0, notificationCounterD.get(), "NotificationListener not acquiring any more new events"); + + final MajordomoBroker newBrokerD = getTestBroker("CustomBrokerD", "deviceD/property", dnsBrokerAddress); + assertNotNull(newBrokerD, "new brokerD is not running"); + await().alias("wait for all CustomBrokerD services to report in").atMost(Duration.ofSeconds(TIMEOUT_STARTUP)).until(() -> providedServices("CustomBrokerD", "deviceD", 2)); + assertTrue(providedServices("CustomBrokerD", "deviceD", 2), "check that all required CustomBrokerD services have reported in"); + // brokerD started with new port + + await().alias("subscribe and receive from an existing 'deviceD/property' - second stage").atMost(Duration.ofSeconds(TIMEOUT_STARTUP)).until(() -> notificationCounterD.get() >= 10); + assertNotEquals(0, notificationCounterD.get()); + + // finished test + newBrokerD.stopBroker(); + } + } + + static boolean providedServices(final @NotNull String brokerName, final @NotNull String serviceName, final int requiredEndpoints) { + return dnsBroker.getDnsCache().get(brokerName).getUri().stream().filter(s -> s != null && s.toString().contains(serviceName)).count() == requiredEndpoints; + } + + static void checkNumberOfEndPoints(final Map> dnsMapSelective, final int expected, final String endpointName) { + final URI uri = URI.create(endpointName); + final List list = dnsMapSelective.get(uri); + assertEquals(expected, list.size(), endpointName + " available endpoints: " + list); + } + + @BeforeAll + static void init() throws IOException { + System.setProperty(OpenCmwConstants.HEARTBEAT, "100"); // to reduce waiting time for changes + dnsBroker = getTestBroker("dnsBroker", "deviceA/property", null); + dnsBrokerAddress = dnsBroker.bind(URI.create("mdp://*:" + Utils.findOpenPort())); + openCmwResolver = new OpenCmwDnsResolver(dnsBroker.getContext(), dnsBrokerAddress, Duration.ofMillis(TIMEOUT)); + brokerB = getTestBroker("CustomBrokerB", "deviceB/property", dnsBrokerAddress); + brokerC = getTestBroker("CustomBrokerC", "deviceC/property", dnsBrokerAddress); + await().atMost(Duration.ofSeconds(TIMEOUT_STARTUP)).until(() -> dnsBroker.getDnsCache().size() == 3); + assertEquals(3, dnsBroker.getDnsCache().size(), "reported: " + String.join(",", dnsBroker.getDnsCache().keySet())); + await().alias("wait for all CustomBrokerB services to report in").atMost(Duration.ofSeconds(TIMEOUT_STARTUP)).until(() -> providedServices("CustomBrokerB", "deviceB", 2)); + await().alias("wait for all CustomBrokerC services to report in").atMost(Duration.ofSeconds(TIMEOUT_STARTUP)).until(() -> providedServices("CustomBrokerC", "deviceC", 2)); + await().alias("wait for all dnsBroker services to report in").atMost(Duration.ofSeconds(TIMEOUT_STARTUP)).until(() -> providedServices("dnsBroker", "deviceA", 3)); + + assertTrue(providedServices("dnsBroker", "deviceA", 3), "check that all required dnsBroker services have reported in"); + assertTrue(providedServices("CustomBrokerB", "deviceB", 2), "check that all required CustomBrokerB services have reported in"); + assertTrue(providedServices("CustomBrokerC", "deviceC", 2), "check that all required CustomBrokerC services have reported in"); + + LockSupport.parkNanos(Duration.ofMillis(TIMEOUT).toNanos()); // wait until brokers and DNS client have been initialised + } + @AfterAll static void finish() { brokerC.stopBroker(); @@ -87,11 +224,25 @@ static void finish() { dnsBroker.stopBroker(); } - static MajordomoBroker getTestBroker(final String brokerName, final String devicePropertyName, final URI dnsServer) { + static MajordomoBroker getTestBroker(final String brokerName, final String devicePropertyName, final URI dnsServer) throws IOException { final MajordomoBroker broker = new MajordomoBroker(brokerName, dnsServer, BasicRbacRole.values()); + final URI privateBrokerAddress = broker.bind(URI.create("mdp://*:" + Utils.findOpenPort())); // not directly visible by clients + final URI privateBrokerPubAddress = broker.bind(URI.create("mds://*:" + Utils.findOpenPort())); // not directly visible by clients + assertNotNull(privateBrokerAddress, "private broker address for " + brokerName); + assertNotNull(privateBrokerPubAddress, "private broker address for " + brokerName); final MajordomoWorker worker = new MajordomoWorker<>(broker.getContext(), devicePropertyName, NoData.class, NoData.class, DomainData.class); worker.setHandler((raw, reqCtx, req, repCtx, rep) -> rep.data = devicePropertyName); // simple property returning the / description + final Timer timer = new Timer("NotifyTimer-" + devicePropertyName, true); + final NoData noData = new NoData(); + final DomainData domainData = new DomainData(); + domainData.data = devicePropertyName; + timer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + worker.notify(noData, domainData); + } + }, 0, 100); broker.addInternalService(worker); broker.start(); return broker; diff --git a/client/src/test/java/io/opencmw/client/cmwlight/CmwLightDataSourceTest.java b/client/src/test/java/io/opencmw/client/cmwlight/CmwLightDataSourceTest.java index 14b17ede..7cb472d8 100644 --- a/client/src/test/java/io/opencmw/client/cmwlight/CmwLightDataSourceTest.java +++ b/client/src/test/java/io/opencmw/client/cmwlight/CmwLightDataSourceTest.java @@ -2,36 +2,35 @@ import static org.junit.jupiter.api.Assertions.*; +import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.nio.charset.Charset; import java.time.Duration; import java.util.Map; import java.util.Objects; +import java.util.concurrent.Executors; import java.util.concurrent.locks.LockSupport; import org.awaitility.Awaitility; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.zeromq.*; @Timeout(20) class CmwLightDataSourceTest { - final static Logger LOGGER = LoggerFactory.getLogger(CmwLightDataSourceTest.class); @Test - void testCmwLightSubscription() throws CmwLightProtocol.RdaLightException, URISyntaxException { + void testCmwLightSubscription() throws CmwLightProtocol.RdaLightException, URISyntaxException, IOException { // setup zero mq socket to mock cmw server - try (final ZContext context = new ZContext(1)) { - ZMQ.Socket socket = context.createSocket(SocketType.DEALER); + try (final ZContext context = new ZContext(1); ZMQ.Socket socket = context.createSocket(SocketType.DEALER)) { socket.bind("tcp://localhost:7777"); - final CmwLightDataSource client = new CmwLightDataSource(context, new URI("rda3://localhost:7777/testdevice/testprop?ctx=test.selector&nFilter=int:1"), "testClientId"); - + final CmwLightDataSource client = new CmwLightDataSource(context, new URI("rda3://localhost:7777/testdevice/testprop?ctx=test.selector&nFilter=int:1"), Executors.newCachedThreadPool(), "testClientId"); client.connect(); client.housekeeping(); + assertDoesNotThrow(() -> client.registerDnsResolver(new DirectoryLightClient("unknownHost:42"))); + // check connection request was received final CmwLightMessage connectMsg = CmwLightProtocol.parseMsg(ZMsg.recvMsg(socket)); assertEquals(CmwLightProtocol.MessageType.CLIENT_CONNECT, connectMsg.messageType); @@ -48,7 +47,7 @@ void testCmwLightSubscription() throws CmwLightProtocol.RdaLightException, URISy Awaitility.await().atMost(Duration.ofSeconds(2)).until(() -> { client.getMessage(); // Make client receive ack and update connection status client.housekeeping(); // allow the subscription to be sent out - return client.connectionState.get().equals(CmwLightDataSource.ConnectionState.CONNECTED); + return client.getConnectionState().equals(ZMonitor.Event.CONNECTED); }); // request subscription @@ -56,7 +55,7 @@ void testCmwLightSubscription() throws CmwLightProtocol.RdaLightException, URISy final URI endpoint = new URI("rda3://localhost:7777/testdevice/testprop?ctx=FAIR.SELECTOR.ALL&nFilter=int:1"); client.subscribe(reqId, endpoint, null); - final CmwLightMessage subMsg = getNextNonHeartbeatMsg(socket, client, false); + final CmwLightMessage subMsg = getNextNonHeartbeatMsg(socket, client); assertEquals(CmwLightProtocol.MessageType.CLIENT_REQ, subMsg.messageType); assertEquals(CmwLightProtocol.RequestType.SUBSCRIBE, subMsg.requestType); assertEquals(Map.of("nFilter", 1), subMsg.requestContext.filters); @@ -89,24 +88,18 @@ void testCmwLightSubscription() throws CmwLightProtocol.RdaLightException, URISy && Objects.requireNonNull(reply.pollFirst()).getData().length == 0; }); } + client.close(); } } /* / get next message sent from client to server ignoring heartbeats, periodically send heartbeat and perform housekeeping */ - private CmwLightMessage getNextNonHeartbeatMsg(final ZMQ.Socket socket, final CmwLightDataSource client, boolean debug) throws CmwLightProtocol.RdaLightException { + private CmwLightMessage getNextNonHeartbeatMsg(final ZMQ.Socket socket, final CmwLightDataSource client) throws CmwLightProtocol.RdaLightException { int i = 0; while (true) { final ZMsg msg = ZMsg.recvMsg(socket, false); final CmwLightMessage result = msg == null ? null : CmwLightProtocol.parseMsg(msg); - if (debug) { - if (result == null) { - System.out.print('.'); - } else { - System.out.println(result); - } - } if (result != null && result.messageType != CmwLightProtocol.MessageType.CLIENT_HB) { return result; } @@ -126,7 +119,6 @@ void testParsedEndpoint() throws URISyntaxException, CmwLightProtocol.RdaLightEx final String refProperty = "MyProperty"; final String refPath = '/' + refDevice + '/' + refProperty; final String testAuthority = "server:1337"; - final Map filterMap = Map.of("filterA", 3, "filterB", true, "filterC", "foo=bar", "filterD", 1234567890987654321L, "filterE", 1.5, "filterF", -3.5f); final String testQuery = "ctx=Test.Context.C=5&filterA=int:3&filterB=bool:true&filterC=foo=bar&filterD=long:1234567890987654321&filterE=double:1.5&filterF=float:-3.5"; final URI testUri1 = new URI("rda3", testAuthority, refPath, testQuery, null); final URI testUri2 = new URI("rda3", null, refPath, testQuery, null); @@ -140,7 +132,7 @@ void testParsedEndpoint() throws URISyntaxException, CmwLightProtocol.RdaLightEx assertEquals(refDevice, parsed2.device); assertEquals(refProperty, parsed2.property); - assertEquals(parsed1, parsed1); + assertEquals(parsed1, parsed1); // NOSONAR assertNotEquals(parsed1, new Object()); assertEquals(testUri1, parsed1.toURI()); assertNotEquals(testUri1, parsed2.toURI()); // since testURI2 has no authority given diff --git a/client/src/test/java/io/opencmw/client/cmwlight/CmwLightExample.java b/client/src/test/java/io/opencmw/client/cmwlight/CmwLightExample.java index b99990d0..15a73a7c 100644 --- a/client/src/test/java/io/opencmw/client/cmwlight/CmwLightExample.java +++ b/client/src/test/java/io/opencmw/client/cmwlight/CmwLightExample.java @@ -2,10 +2,13 @@ import java.net.URI; import java.net.URISyntaxException; +import java.net.UnknownHostException; import java.nio.charset.Charset; import java.util.Arrays; import java.util.Collections; +import java.util.List; import java.util.Objects; +import java.util.concurrent.Executors; import org.zeromq.ZContext; import org.zeromq.ZMQ; @@ -26,7 +29,7 @@ public class CmwLightExample { // NOPMD is not a utility class but a sample private final static String PROPERTY = "AcquisitionDAQ"; private final static String SELECTOR = "FAIR.SELECTOR.ALL"; - public static void main(String[] args) throws DirectoryLightClient.DirectoryClientException, URISyntaxException { + public static void main(String[] args) throws DirectoryLightClient.DirectoryClientException, URISyntaxException, UnknownHostException { if (args.length == 0) { System.out.println("no directory server supplied"); return; @@ -34,13 +37,23 @@ public static void main(String[] args) throws DirectoryLightClient.DirectoryClie subscribeAcqFromDigitizer(args[0]); } - public static void subscribeAcqFromDigitizer(final String nameserver) throws DirectoryLightClient.DirectoryClientException, URISyntaxException { + public static void subscribeAcqFromDigitizer(final String nameserver) throws DirectoryLightClient.DirectoryClientException, URISyntaxException, UnknownHostException { + // mini DirectoryLightClient tests final DirectoryLightClient directoryClient = new DirectoryLightClient(nameserver); + final URI queryDevice = URI.create("rda3:/" + DEVICE); + System.out.println("resolve name " + queryDevice + " to: " + directoryClient.resolveNames(List.of(queryDevice))); DirectoryLightClient.Device device = directoryClient.getDeviceInfo(Collections.singletonList(DEVICE)).get(0); System.out.println(device); final String address = device.servers.stream().findFirst().orElseThrow().get("Address:").replace("tcp://", "rda3://"); System.out.println("connect client to " + address); - final CmwLightDataSource client = new CmwLightDataSource(new ZContext(1), URI.create(address + '/'), "testclient"); + // mini DirectoryLightClient tests -- done + + // without DNS resolver: + // final CmwLightDataSource client = new CmwLightDataSource(new ZContext(1), URI.create(address + '/' + DEVICE), Executors.newCachedThreadPool(), "testclient") + // with DNS resolver: + final CmwLightDataSource client = new CmwLightDataSource(new ZContext(1), URI.create("rda3:/" + DEVICE), Executors.newCachedThreadPool(), "testclient"); + client.getFactory().registerDnsResolver(new DirectoryLightClient(nameserver)); // direct DNS registration - can be done also via DefaultDataSource + final ZMQ.Poller poller = client.getContext().createPoller(1); poller.register(client.getSocket(), ZMQ.Poller.POLLIN); client.connect(); diff --git a/client/src/test/java/io/opencmw/client/cmwlight/CmwLightViaPublisherExample.java b/client/src/test/java/io/opencmw/client/cmwlight/CmwLightViaPublisherExample.java new file mode 100644 index 00000000..d9577db8 --- /dev/null +++ b/client/src/test/java/io/opencmw/client/cmwlight/CmwLightViaPublisherExample.java @@ -0,0 +1,54 @@ +package io.opencmw.client.cmwlight; + +import static org.junit.jupiter.api.Assertions.fail; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.LockSupport; + +import io.opencmw.client.DataSource; +import io.opencmw.client.DataSourcePublisher; +import io.opencmw.client.cmwlight.CmwLightExample.AcquisitionDAQ; +import io.opencmw.domain.NoData; + +public class CmwLightViaPublisherExample { + private final static String DEVICE = "GSCD002"; + // private final static String PROPERTY = "SnoopTriggerEvents"; + private final static String PROPERTY = "AcquisitionDAQ"; + private final static String SELECTOR = "FAIR.SELECTOR.ALL"; + + public static void main(String[] args) throws URISyntaxException { + if (args.length == 0) { + System.out.println("no directory server supplied"); + return; + } + + String filtersString = "acquisitionModeFilter=int:0&channelNameFilter=GS11MU2:Current_1@10Hz"; + final URI subURI = new URI("rda3", null, '/' + DEVICE + '/' + PROPERTY, "ctx=" + SELECTOR + "&" + filtersString, null); + + try (DataSourcePublisher dataSource = new DataSourcePublisher(null, null, "test-client"); + DataSourcePublisher.Client client = dataSource.getClient()) { + // set DNS reference + DataSource.getFactory(URI.create("rda3:/")).registerDnsResolver(new DirectoryLightClient(args[0])); + + AtomicInteger notificationCounter = new AtomicInteger(); + client.subscribe(subURI, AcquisitionDAQ.class, null, NoData.class, new DataSourcePublisher.NotificationListener<>() { + @Override + public void dataUpdate(final AcquisitionDAQ updatedObject, final NoData contextObject) { + System.out.println(notificationCounter.get() + ": notified property with " + updatedObject); + notificationCounter.getAndIncrement(); + } + + @Override + public void updateException(final Throwable exception) { + fail("subscription exception occurred ", exception); + } + }); + System.out.println("start monitoring"); + LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(10)); + System.out.println("counted notification: " + notificationCounter.get()); + } + } +} diff --git a/client/src/test/java/io/opencmw/client/rest/RestDataSourceTest.java b/client/src/test/java/io/opencmw/client/rest/RestDataSourceTest.java index 2f638836..22eb9059 100644 --- a/client/src/test/java/io/opencmw/client/rest/RestDataSourceTest.java +++ b/client/src/test/java/io/opencmw/client/rest/RestDataSourceTest.java @@ -74,7 +74,7 @@ void basicEvent() { @Test void basicRestDataSourceTests() { - assertThrows(UnsupportedOperationException.class, () -> new RestDataSource(null, null)); + assertThrows(UnsupportedOperationException.class, () -> new RestDataSource(null, URI.create("mdp://unsupported"))); assertThrows(IllegalArgumentException.class, () -> new RestDataSource(null, server.url("/sse").uri(), null, "clientName")); // NOSONAR RestDataSource dataSource = new RestDataSource(null, server.url("/sse").uri()); assertNotNull(dataSource); @@ -127,7 +127,7 @@ void testRestDataSourceConnectionError() { dataSource.cancelLastCall = 3; // required for unit-testing dataSource.enqueueRequest("testHashKey#1"); ZMsg returnMessage = receiveAndCheckData(dataSource, "testHashKey#1", true); - System.err.println("returnMessage = " + returnMessage); + assertNotEquals(0, requireNonNull(requireNonNull(returnMessage.pollLast())).getData().length); assertNotEquals(0, requireNonNull(requireNonNull(returnMessage.pollLast())).getData().length); @@ -162,7 +162,7 @@ private void enqueue(MockResponse response) { throw new IllegalStateException("wrong dispatcher type: " + dispatcher); } CustomDispatcher customDispatcher = (CustomDispatcher) dispatcher; - customDispatcher.enquedEvents.offer(response); + customDispatcher.enqueuedEvents.offer(response); } private EventSource newEventSource() { @@ -209,12 +209,12 @@ private ZMsg receiveAndCheckData(final RestDataSource dataSource, final String h } private static class CustomDispatcher extends Dispatcher { - public final BlockingQueue enquedEvents = new LinkedBlockingQueue<>(); + public final BlockingQueue enqueuedEvents = new LinkedBlockingQueue<>(); @Override public @NotNull MockResponse dispatch(@NotNull RecordedRequest request) { - if (!enquedEvents.isEmpty()) { + if (!enqueuedEvents.isEmpty()) { // dispatch enqued events - return enquedEvents.poll(); + return enqueuedEvents.poll(); } final String acceptHeader = request.getHeader("Accept"); final String contentType = request.getHeader("content-type"); diff --git a/core/src/main/java/io/opencmw/OpenCmwConstants.java b/core/src/main/java/io/opencmw/OpenCmwConstants.java index 62fb19a1..9ff1b872 100644 --- a/core/src/main/java/io/opencmw/OpenCmwConstants.java +++ b/core/src/main/java/io/opencmw/OpenCmwConstants.java @@ -1,15 +1,16 @@ package io.opencmw; -import java.net.DatagramSocket; -import java.net.InetAddress; -import java.net.SocketException; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.UnknownHostException; +import static io.opencmw.OpenCmwProtocol.MdpSubProtocol.PROT_CLIENT; + +import java.net.*; import java.util.Locale; +import java.util.Objects; import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.NotNull; +import org.zeromq.ZMQ; + +import io.opencmw.utils.SystemProperties; /** * OpenCMW global constant definitions: @@ -50,12 +51,64 @@ public final class OpenCmwConstants { public static final String CLIENT_TIMEOUT = "OpenCMW.clientTimeOut"; // [s] public static final long CLIENT_TIMEOUT_DEFAULT = 0L; // [s] public static final String ADDRESS_GIVEN = "address given: "; + public static final String RECONNECT_THRESHOLD1 = "OpenCMW.reconnectThreshold1"; // [] + public static final int DEFAULT_RECONNECT_THRESHOLD1 = 3; // [] + public static final String RECONNECT_THRESHOLD2 = "OpenCMW.reconnectThreshold2"; // [] + public static final int DEFAULT_RECONNECT_THRESHOLD2 = 6; // [] private OpenCmwConstants() { // this is a utility class } - public static URI replaceScheme(final @NotNull URI address, final String schemeReplacement) { + public static String getDeviceName(final @NotNull URI endpoint) { + return StringUtils.stripStart(endpoint.getPath(), "/").split("/", 2)[0]; + } + + public static String getLocalHostName() { + String ip; + try (DatagramSocket socket = new DatagramSocket()) { + socket.connect(InetAddress.getByName("8.8.8.8"), 10_002); // NOPMD - bogus hardcoded IP acceptable in this context + if (socket.getLocalAddress() == null) { + throw new UnknownHostException("bogus exception can be ignored"); + } + ip = socket.getLocalAddress().getHostAddress(); + + if (ip != null) { + return ip; + } + return "localhost"; + } catch (final SocketException | UnknownHostException e) { + throw new IllegalStateException("cannot resolve own host IP address", e); + } + } + + public static String getPropertyName(final @NotNull URI endpoint) { + return StringUtils.stripStart(endpoint.getPath(), "/").split("/", 2)[1]; + } + + public static URI replacePath(final @NotNull URI address, final @NotNull String pathReplacement) { + if (pathReplacement.equals(address.getPath())) { + return address; + } + try { + return new URI(address.getScheme(), address.getAuthority(), pathReplacement, address.getQuery(), null); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(ADDRESS_GIVEN + address, e); + } + } + + public static URI replaceQuery(final @NotNull URI address, final String queryReplacement) { + if (queryReplacement != null && queryReplacement.equals(address.getQuery())) { + return address; + } + try { + return new URI(address.getScheme(), address.getAuthority(), address.getPath(), queryReplacement, null); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(ADDRESS_GIVEN + address, e); + } + } + + public static URI replaceScheme(final @NotNull URI address, final @NotNull String schemeReplacement) { if (address.getScheme() != null && address.getScheme().toLowerCase(Locale.UK).equals(SCHEME_INPROC)) { return address; } @@ -66,7 +119,11 @@ public static URI replaceScheme(final @NotNull URI address, final String schemeR } } - public static URI resolveHost(final @NotNull URI address, final String hostName) { + public static URI replaceSchemeKeepOnlyAuthority(final @NotNull URI address, final @NotNull String schemeReplacement) { + return URI.create(schemeReplacement + "://" + Objects.requireNonNull(address.getAuthority(), "authority is null: " + address)); + } + + public static URI resolveHost(final @NotNull URI address, final @NotNull String hostName) { if (((address.getScheme() != null && address.getScheme().toLowerCase(Locale.UK).equals(SCHEME_INPROC)) || (address.getAuthority() == null || !address.getAuthority().toLowerCase(Locale.UK).contains(WILDCARD)))) { return address; } @@ -79,6 +136,16 @@ public static URI resolveHost(final @NotNull URI address, final String hostName) } } + public static void setDefaultSocketParameters(final @NotNull ZMQ.Socket socket) { + final int heartBeatInterval = (int) SystemProperties.getValueIgnoreCase(HEARTBEAT, HEARTBEAT_DEFAULT); + final int liveness = SystemProperties.getValueIgnoreCase(HEARTBEAT_LIVENESS, HEARTBEAT_LIVENESS_DEFAULT); + socket.setHWM(SystemProperties.getValueIgnoreCase(HIGH_WATER_MARK, HIGH_WATER_MARK_DEFAULT)); + socket.setHeartbeatContext(PROT_CLIENT.getData()); + socket.setHeartbeatTtl(heartBeatInterval * liveness); + socket.setHeartbeatTimeout(heartBeatInterval * liveness); + socket.setHeartbeatIvl(heartBeatInterval); + } + public static URI stripPathTrailingSlash(final @NotNull URI address) { try { return new URI(address.getScheme(), address.getAuthority(), StringUtils.stripEnd(address.getPath(), "/"), address.getQuery(), null); @@ -86,30 +153,4 @@ public static URI stripPathTrailingSlash(final @NotNull URI address) { throw new IllegalArgumentException(ADDRESS_GIVEN + address, e); } } - - public static String getDeviceName(final @NotNull URI endpoint) { - return StringUtils.stripStart(endpoint.getPath(), "/").split("/", 2)[0]; - } - - public static String getPropertyName(final @NotNull URI endpoint) { - return StringUtils.stripStart(endpoint.getPath(), "/").split("/", 2)[1]; - } - - public static String getLocalHostName() { - String ip; - try (DatagramSocket socket = new DatagramSocket()) { - socket.connect(InetAddress.getByName("8.8.8.8"), 10_002); // NOPMD - bogus hardcoded IP acceptable in this context - if (socket.getLocalAddress() == null) { - throw new UnknownHostException("bogus exception can be ignored"); - } - ip = socket.getLocalAddress().getHostAddress(); - - if (ip != null) { - return ip; - } - return "localhost"; - } catch (final SocketException | UnknownHostException e) { - throw new IllegalStateException("cannot resolve own host IP address", e); - } - } } diff --git a/core/src/main/java/io/opencmw/utils/CustomFuture.java b/core/src/main/java/io/opencmw/utils/CustomFuture.java index a57ebb40..80df5c02 100644 --- a/core/src/main/java/io/opencmw/utils/CustomFuture.java +++ b/core/src/main/java/io/opencmw/utils/CustomFuture.java @@ -58,7 +58,7 @@ public T get(final long timeout, final TimeUnit unit) throws ExecutionException, while (!isDone()) { if (timeout > 0) { if (!processorNotifyCondition.await(timeout, unit)) { - throw new TimeoutException(); + throw new TimeoutException("timeout = " + timeout + " " + unit); } } else { processorNotifyCondition.await(); diff --git a/core/src/test/java/io/opencmw/OpenCmwConstantsTest.java b/core/src/test/java/io/opencmw/OpenCmwConstantsTest.java index bed9b4c7..d1e11907 100644 --- a/core/src/test/java/io/opencmw/OpenCmwConstantsTest.java +++ b/core/src/test/java/io/opencmw/OpenCmwConstantsTest.java @@ -1,12 +1,21 @@ package io.opencmw; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static io.opencmw.OpenCmwConstants.*; +import static io.opencmw.OpenCmwProtocol.MdpSubProtocol.PROT_CLIENT; import java.net.URI; import org.junit.jupiter.api.Test; +import org.zeromq.SocketType; +import org.zeromq.ZContext; +import org.zeromq.ZMQ; + +import io.opencmw.utils.SystemProperties; class OpenCmwConstantsTest { @Test @@ -17,6 +26,17 @@ void testReplaceScheme() { assertEquals(URI.create("mdp://host:20"), replaceScheme(URI.create("mdp://host:20"), SCHEME_MDP)); assertEquals(URI.create("mds://host:20"), replaceScheme(URI.create("mdp://host:20"), SCHEME_MDS)); + assertEquals(URI.create("tcp://host:20"), replaceSchemeKeepOnlyAuthority(URI.create("mdp://host:20/device/property?test"), SCHEME_TCP)); + assertThrows(NullPointerException.class, () -> replaceSchemeKeepOnlyAuthority(URI.create("mdp:/device/property"), SCHEME_TCP)); + + assertEquals(URI.create("mdp://host:20"), replacePath(URI.create("mdp://host:20/device/property"), "")); + assertEquals(URI.create("mdp://host:20/device/property"), replacePath(URI.create("mdp://host:20/device/property"), "/device/property")); + assertEquals(URI.create("mdp://host:20/otherDevice/path"), replacePath(URI.create("mdp://host:20/device/property"), "/otherDevice/path")); + + assertEquals(URI.create("mdp://host:20/device/property?queryA"), replaceQuery(URI.create("mdp://host:20/device/property?queryA"), "queryA")); + assertEquals(URI.create("mdp://host:20/device/property?queryB"), replaceQuery(URI.create("mdp://host:20/device/property?queryA"), "queryB")); + assertEquals(URI.create("mdp://host:20/device/property"), replaceQuery(URI.create("mdp://host:20/device/property?queryA"), null)); + assertEquals(URI.create("tcp://host:20/path"), replaceScheme(URI.create("mdp://host:20/path"), SCHEME_TCP)); assertEquals(URI.create("tcp://host:20/path"), replaceScheme(URI.create("mdp://host:20/path"), SCHEME_TCP)); @@ -30,9 +50,9 @@ void testReplaceScheme() { @Test void testDeviceAndPropertyNames() { assertEquals("device", getDeviceName(URI.create("mdp:/device/property/sub-property"))); - assertEquals("device", getDeviceName(URI.create("mdp://authrority/device/property/sub-property"))); - assertEquals("device", getDeviceName(URI.create("mdp://authrority//device/property/sub-property"))); - assertEquals("authrority", URI.create("mdp://authrority//device/property/sub-property").getAuthority()); + assertEquals("device", getDeviceName(URI.create("mdp://authority/device/property/sub-property"))); + assertEquals("device", getDeviceName(URI.create("mdp://authority//device/property/sub-property"))); + assertEquals("authority", URI.create("mdp://authority//device/property/sub-property").getAuthority()); assertEquals("property/sub-property", getPropertyName(URI.create("mdp:/device/property/sub-property"))); assertEquals("property/sub-property", getPropertyName(URI.create("mdp:/device/property/sub-property"))); } @@ -46,4 +66,20 @@ void testResolveLocalHostName() { assertThrows(IllegalArgumentException.class, () -> resolveHost(URI.create("tcp://*:aa/path/"), "")); } + + @Test + void testMisc() { + try (ZContext ctx = new ZContext(); ZMQ.Socket socket = ctx.createSocket(SocketType.DEALER)) { + assertDoesNotThrow(() -> setDefaultSocketParameters(socket)); + final int hwm = SystemProperties.getValueIgnoreCase(HIGH_WATER_MARK, HIGH_WATER_MARK_DEFAULT); + final int heartBeatInterval = (int) SystemProperties.getValueIgnoreCase(HEARTBEAT, HEARTBEAT_DEFAULT); + final int liveness = SystemProperties.getValueIgnoreCase(HEARTBEAT_LIVENESS, HEARTBEAT_LIVENESS_DEFAULT); + assertEquals(hwm, socket.getRcvHWM(), "receive high-water mark"); + assertEquals(hwm, socket.getSndHWM(), "send high-water mark"); + assertArrayEquals(PROT_CLIENT.getData(), socket.getHeartbeatContext(), "heart-beat payload message"); + assertEquals(heartBeatInterval * liveness, socket.getHeartbeatTtl(), "time-out for remote socket [ms]"); + assertEquals(heartBeatInterval * liveness, socket.getHeartbeatTimeout(), "time-out for local socket [ms]"); + assertEquals(heartBeatInterval, socket.getHeartbeatIvl(), "heart-beat ping period [ms]"); + } + } } \ No newline at end of file diff --git a/pom.xml b/pom.xml index 7361b68c..3c96dc56 100644 --- a/pom.xml +++ b/pom.xml @@ -32,7 +32,7 @@ 20.1.0 0.5.2 3.4.2 - 3.13.3 + 3.13.4 9.4.35.v20201120 0.9.23 8.5.2 @@ -44,8 +44,8 @@ 0.5.0 1.28 1.6.4 - 2.2 - 11.2.3 + 2.3 + 11.2.5 @@ -101,14 +101,14 @@ maven-compiler-plugin 3.8.1 - 11 - 11 + ${maven.compiler.source} + ${maven.compiler.target} org.codehaus.mojo flatten-maven-plugin - 1.2.5 + 1.2.7 true resolveCiFriendliesOnly diff --git a/serialiser/src/main/java/io/opencmw/serialiser/spi/ClassFieldDescription.java b/serialiser/src/main/java/io/opencmw/serialiser/spi/ClassFieldDescription.java index d74b7887..b8af78ec 100644 --- a/serialiser/src/main/java/io/opencmw/serialiser/spi/ClassFieldDescription.java +++ b/serialiser/src/main/java/io/opencmw/serialiser/spi/ClassFieldDescription.java @@ -1,12 +1,6 @@ package io.opencmw.serialiser.spi; -import java.lang.reflect.AnnotatedElement; -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Modifier; -import java.lang.reflect.ParameterizedType; -import java.lang.reflect.Type; +import java.lang.reflect.*; import java.nio.CharBuffer; import java.util.ArrayList; import java.util.Arrays; @@ -27,7 +21,7 @@ import io.opencmw.serialiser.annotations.Unit; import io.opencmw.serialiser.utils.ClassUtils; -import sun.misc.Unsafe; // NOPMD - there is nothing more suitable under the Sun +import sun.misc.Unsafe; // NOPMD NOSONAR - there is nothing more suitable under the Sun /** * @author rstein @@ -109,7 +103,12 @@ protected ClassFieldDescription(final Class referenceClass, final Field field if (field == null) { throw new IllegalArgumentException("field must not be null"); } - fieldAccess = new FieldAccess(field); + try { + fieldAccess = new FieldAccess(field); + } catch (final Exception e) { //NOPMD NOSONAR + LOGGER.atError().addArgument(field.getName()).addArgument(parent).log("error initialising field '{}' (parent: {})"); + throw e; + } classType = field.getType(); fieldNameHashCode = field.getName().hashCode(); fieldName = field.getName().intern(); @@ -680,7 +679,7 @@ protected static void exploreClass(final Class classType, final ClassFieldDes protected static void printClassStructure(final ClassFieldDescription field, final boolean fullView, final int recursionLevel) { final String enumOrClass = field.isEnum() ? "Enum " : "class "; - final String typeCategorgy = (field.isInterface() ? "interface " : (field.isPrimitive() ? "" : enumOrClass)); //NOSONAR //NOPMD + final String typeCategory = (field.isInterface() ? "interface " : (field.isPrimitive() ? "" : enumOrClass)); //NOSONAR //NOPMD final String typeName = field.getTypeName() + field.getGenericFieldTypeString(); final String mspace = spaces(recursionLevel * ClassUtils.getIndentationNumberOfSpace()); final boolean isSerialisable = field.isSerializable(); @@ -688,7 +687,7 @@ protected static void printClassStructure(final ClassFieldDescription field, fin if (isSerialisable || fullView) { LOGGER.atInfo().addArgument(mspace).addArgument(isSerialisable ? " " : "//") // .addArgument(field.getModifierString()) - .addArgument(typeCategorgy) + .addArgument(typeCategory) .addArgument(typeName) .addArgument(field.getFieldName()) .log("{} {} {} {}{} {}"); diff --git a/serialiser/src/main/java/io/opencmw/serialiser/utils/ClassUtils.java b/serialiser/src/main/java/io/opencmw/serialiser/utils/ClassUtils.java index f88e5b7c..ebd4df06 100644 --- a/serialiser/src/main/java/io/opencmw/serialiser/utils/ClassUtils.java +++ b/serialiser/src/main/java/io/opencmw/serialiser/utils/ClassUtils.java @@ -14,6 +14,8 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,13 +30,15 @@ public final class ClassUtils { //NOPMD nomen est omen private static final Map, Class> WRAPPER_PRIMITIVE_MAP = new HashMap<>(); private static final Map, Class> PRIMITIVE_ARRAY_BOXED_MAP = new HashMap<>(); private static final Map, Class> BOXED_ARRAY_PRIMITIVE_MAP = new HashMap<>(); - public static final Map, String> DO_NOT_PARSE_MAP = new HashMap<>(); // NOPMD should not be a threading issue - static one-time/init write, multiple reads afterwards are safe + public static final Map, String> DO_NOT_PARSE_MAP = new HashMap<>(); // NOPMD NOSONAR should not be a threading issue - static one-time/init write, multiple reads afterwards are safe private static final Map CLASS_FIELD_DESCRIPTION_MAP = new ConcurrentHashMap<>(); private static final Map> CLASS_STRING_MAP = new ConcurrentHashMap<>(); private static final Map, Map> CLASS_METHOD_MAP = new ConcurrentHashMap<>(); private static int indentationNumberOfSpace = 4; private static int maxRecursionDepth = 10; + public static final String DOES_NOT_LIKE_TO_BE_PARSED = "does not like to be parsed"; + static { // primitive types add(WRAPPER_PRIMITIVE_MAP, PRIMITIVE_WRAPPER_MAP, Boolean.class, boolean.class); @@ -64,8 +68,10 @@ public final class ClassUtils { //NOPMD nomen est omen // do not parse following classes DO_NOT_PARSE_MAP.put(Class.class, "private java implementation"); DO_NOT_PARSE_MAP.put(Thread.class, "recursive definitions"); // NOPMD - not an issue/not a use within a J2EE context - DO_NOT_PARSE_MAP.put(AtomicBoolean.class, "does not like to be parsed"); - DO_NOT_PARSE_MAP.put(URI.class, "does not like to be parsed"); + DO_NOT_PARSE_MAP.put(AtomicBoolean.class, DOES_NOT_LIKE_TO_BE_PARSED); + DO_NOT_PARSE_MAP.put(AtomicInteger.class, DOES_NOT_LIKE_TO_BE_PARSED); + DO_NOT_PARSE_MAP.put(AtomicReference.class, DOES_NOT_LIKE_TO_BE_PARSED); + DO_NOT_PARSE_MAP.put(URI.class, DOES_NOT_LIKE_TO_BE_PARSED); } private ClassUtils() { // utility class diff --git a/server-rest/src/main/java/io/opencmw/server/rest/MajordomoRestPlugin.java b/server-rest/src/main/java/io/opencmw/server/rest/MajordomoRestPlugin.java index 27206e31..013be2fa 100644 --- a/server-rest/src/main/java/io/opencmw/server/rest/MajordomoRestPlugin.java +++ b/server-rest/src/main/java/io/opencmw/server/rest/MajordomoRestPlugin.java @@ -6,6 +6,7 @@ import static io.javalin.apibuilder.ApiBuilder.post; import static io.javalin.plugin.openapi.dsl.DocumentedContentKt.anyOf; import static io.javalin.plugin.openapi.dsl.DocumentedContentKt.documentedContent; +import static io.opencmw.OpenCmwConstants.setDefaultSocketParameters; import static io.opencmw.OpenCmwProtocol.Command.GET_REQUEST; import static io.opencmw.OpenCmwProtocol.Command.READY; import static io.opencmw.OpenCmwProtocol.Command.SET_REQUEST; @@ -127,7 +128,7 @@ public MajordomoRestPlugin(ZContext ctx, final String serverDescription, String assert (httpAddress != null); RestServer.setName(Objects.requireNonNullElse(serverDescription, MajordomoRestPlugin.class.getName())); subSocket = ctx.createSocket(SocketType.SUB); - subSocket.setHWM(0); + setDefaultSocketParameters(subSocket); subSocket.connect(INTERNAL_ADDRESS_PUBLISHER); subSocket.subscribe(INTERNAL_SERVICE_NAMES); subscriptionCount.computeIfAbsent(INTERNAL_SERVICE_NAMES, s -> new AtomicInteger()).incrementAndGet(); @@ -190,7 +191,7 @@ public synchronized void start() { // NOPMD 'synchronized' comes from JDK class // perform initial get request String services = "(uninitialised)"; - final CustomFuture reply = dispatchRequest(new MdpMessage(null, PROT_CLIENT, GET_REQUEST, INTERNAL_SERVICE_NAMES.getBytes(UTF_8), EMPTY_FRAME, URI.create(INTERNAL_SERVICE_NAMES), EMPTY_FRAME, "", RBAC), true); + final CustomFuture reply = dispatchRequest(new MdpMessage(null, PROT_CLIENT, GET_REQUEST, INTERNAL_SERVICE_NAMES.getBytes(UTF_8), EMPTY_FRAME, URI.create(INTERNAL_SERVICE_NAMES), EMPTY_FRAME, "", RBAC)); try { final MdpMessage msg = reply.get(); services = msg.data == null ? "" : new String(msg.data, UTF_8); @@ -291,20 +292,28 @@ protected Runnable getServiceSubscriptionTask() { // NOSONAR NOPMD - complexity shallRun.set(true); try (ZMQ.Poller subPoller = ctx.createPoller(1)) { subPoller.register(subSocket, ZMQ.Poller.POLLIN); - while (shallRun.get() && !Thread.interrupted() && subPoller.poll(TimeUnit.MILLISECONDS.toMillis(100)) != -1) { + while (shallRun.get() && !Thread.interrupted() && !ctx.isClosed() && subPoller.poll(TimeUnit.MILLISECONDS.toMillis(100)) != -1) { + if (ctx.isClosed()) { + break; + } // handle message from or to broker boolean dataReceived = true; - while (dataReceived) { + while (dataReceived && !ctx.isClosed()) { dataReceived = false; // handle subscription message from or to broker - final MdpMessage brokerMsg = receive(subSocket, true); + final MdpMessage brokerMsg = receive(subSocket, false); if (brokerMsg != null) { dataReceived = true; liveness = heartBeatLiveness; // handle subscription message if (brokerMsg.data != null && brokerMsg.getServiceName().startsWith(INTERNAL_SERVICE_NAMES)) { - registerEndPoint(new String(brokerMsg.data, UTF_8)); // NOPMD in-loop instantiation necessary + final String newServiceName = new String(brokerMsg.data, UTF_8); // NOPMD in-loop instantiation necessary + registerEndPoint(newServiceName); + // special handling for internal MMI interface + if (newServiceName.contains("/mmi.")) { + registerEndPoint(StringUtils.split(newServiceName, "/", 2)[1]); + } } notifySubscribedClients(brokerMsg.topic); } @@ -327,7 +336,7 @@ protected void registerEndPoint(final String endpoint) { // needs to be synchronised since Javalin get(..), put(..) seem to be not thread safe (usually initialised during startup) registeredEndpoints.computeIfAbsent(endpoint, ep -> { final MdpMessage requestMsg = new MdpMessage(null, PROT_CLIENT, GET_REQUEST, INTERNAL_SERVICE_OPENAPI.getBytes(UTF_8), EMPTY_FRAME, URI.create(INTERNAL_SERVICE_OPENAPI), ep.getBytes(UTF_8), "", RBAC); - final CustomFuture openApiReply = dispatchRequest(requestMsg, true); + final CustomFuture openApiReply = dispatchRequest(requestMsg); try { final MdpMessage serviceOpenApiData = openApiReply.get(); if (!serviceOpenApiData.errors.isBlank()) { @@ -403,15 +412,9 @@ private OpenApiDocumentation getOpenApiDocumentation(final String handlerClassNa return openApi; } - private CustomFuture dispatchRequest(final MdpMessage requestMsg, final boolean expectReply) { + private CustomFuture dispatchRequest(final MdpMessage requestMsg) { final String requestID = MajordomoRestPlugin.class.getSimpleName() + "#" + REQUEST_COUNTER.getAndIncrement(); requestMsg.clientRequestID = requestID.getBytes(UTF_8); - - if (expectReply) { - requestMsg.clientRequestID = requestID.getBytes(UTF_8); - } else { - requestMsg.clientRequestID = REST_SUB_ID; - } CustomFuture reply = new CustomFuture<>(); final Object ret = requestReplies.put(requestID, reply); if (ret != null) { @@ -461,7 +464,7 @@ private Handler getDefaultServiceRestHandler(final String restHandler) { // NOSO } final MdpMessage requestMsg = new MdpMessage(null, PROT_CLIENT, cmd, service.getBytes(UTF_8), EMPTY_FRAME, topic, requestData, "", RBAC); - CustomFuture reply = dispatchRequest(requestMsg, true); + CustomFuture reply = dispatchRequest(requestMsg); try { final MdpMessage replyMessage = reply.get(); //TODO: add max time-out -- only if not long-polling (to be checked) MimeType replyMimeType = QueryParameterParser.getMimeType(replyMessage.topic.getQuery()); @@ -507,7 +510,7 @@ private Handler getDefaultServiceRestHandler(final String restHandler) { // NOSO return; default: } - throw new BadRequestResponse(MajordomoRestPlugin.class.getName() + ": could not process service '" + service + "' - exception:\n" + e.getMessage()); // NOPMD original exception forwared within the text, BadRequestResponse does not support exception forwarding + throw new BadRequestResponse(MajordomoRestPlugin.class.getName() + ": could not process service '" + service + "' - exception:\n" + e.getMessage()); // NOPMD original exception forwarded within the text, BadRequestResponse does not support exception forwarding } }, newSseClientHandler); } diff --git a/server-rest/src/main/resources/velocity/property/defaultTextPropertyLayout.vm b/server-rest/src/main/resources/velocity/property/defaultTextPropertyLayout.vm index 91714521..727d2830 100644 --- a/server-rest/src/main/resources/velocity/property/defaultTextPropertyLayout.vm +++ b/server-rest/src/main/resources/velocity/property/defaultTextPropertyLayout.vm @@ -1,11 +1,15 @@ #if (!$noMenu) #parse("/velocity/layout.vm") #@mainLayout() +
$textBody +
#end #else #parse("/velocity/layoutNoFrame.vm") #@mainLayoutNoFrame() +
$textBody +
#end #end \ No newline at end of file diff --git a/server-rest/src/test/java/io/opencmw/server/rest/MajordomoRestPluginTests.java b/server-rest/src/test/java/io/opencmw/server/rest/MajordomoRestPluginTests.java index 698210d0..025640bb 100644 --- a/server-rest/src/test/java/io/opencmw/server/rest/MajordomoRestPluginTests.java +++ b/server-rest/src/test/java/io/opencmw/server/rest/MajordomoRestPluginTests.java @@ -1,5 +1,7 @@ package io.opencmw.server.rest; +import static java.nio.charset.StandardCharsets.UTF_8; + import static org.awaitility.Awaitility.await; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; @@ -9,9 +11,8 @@ import java.io.IOException; import java.net.URI; +import java.time.Duration; import java.util.Objects; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -33,8 +34,11 @@ import org.slf4j.LoggerFactory; import io.opencmw.MimeType; +import io.opencmw.domain.BinaryData; +import io.opencmw.domain.NoData; import io.opencmw.rbac.BasicRbacRole; import io.opencmw.server.MajordomoBroker; +import io.opencmw.server.MajordomoWorker; import io.opencmw.server.rest.test.HelloWorldService; import io.opencmw.server.rest.test.ImageService; @@ -51,17 +55,20 @@ @TestInstance(TestInstance.Lifecycle.PER_CLASS) class MajordomoRestPluginTests { private static final Logger LOGGER = LoggerFactory.getLogger(MajordomoRestPluginTests.class); - private MajordomoBroker primaryBroker; - private MajordomoRestPlugin restPlugin; - private URI brokerRouterAddress; - private MajordomoBroker secondaryBroker; - private URI secondaryBrokerRouterAddress; - private OkHttpClient okHttp; + private static final Duration STARTUP = Duration.ofSeconds(3); + public static final String PRIMARY_BROKER = "PrimaryBroker"; + public static final String SECONDARY_BROKER = "SecondaryBroker"; + private static MajordomoBroker primaryBroker; + private static MajordomoRestPlugin restPlugin; + private static URI brokerRouterAddress; + private static MajordomoBroker secondaryBroker; + private static URI secondaryBrokerRouterAddress; + private static OkHttpClient okHttp; @BeforeAll - void init() throws IOException { + static void init() throws IOException { okHttp = getUnsafeOkHttpClient(); // N.B. ignore SSL certificates - primaryBroker = new MajordomoBroker("PrimaryBroker", null, BasicRbacRole.values()); + primaryBroker = new MajordomoBroker(PRIMARY_BROKER, null, BasicRbacRole.values()); brokerRouterAddress = primaryBroker.bind(URI.create("mdp://localhost:" + Utils.findOpenPort())); primaryBroker.bind(URI.create("mds://localhost:" + Utils.findOpenPort())); restPlugin = new MajordomoRestPlugin(primaryBroker.getContext(), "My test REST server", "*:8080", BasicRbacRole.ADMIN); @@ -78,13 +85,22 @@ void init() throws IOException { // TODO: add OpenCMW client requesting binary and json models // second broker to test DNS functionalities - secondaryBroker = new MajordomoBroker("SecondaryTestBroker", brokerRouterAddress, BasicRbacRole.values()); + secondaryBroker = new MajordomoBroker(SECONDARY_BROKER, brokerRouterAddress, BasicRbacRole.values()); secondaryBrokerRouterAddress = secondaryBroker.bind(URI.create("tcp://*:" + Utils.findOpenPort())); + final MajordomoWorker worker = new MajordomoWorker<>(secondaryBroker.getContext(), "deviceA/property", NoData.class, NoData.class, BinaryData.class); + worker.setHandler((raw, reqCtx, req, repCtx, rep) -> rep.data = "deviceA/property".getBytes(UTF_8)); // simple property returning the / description + secondaryBroker.addInternalService(worker); secondaryBroker.start(); + await().alias("wait for primary services to report in").atMost(STARTUP).until(() -> providedServices(PRIMARY_BROKER, PRIMARY_BROKER + "/mmi.service")); + await().alias("wait for secondary services to report in").atMost(STARTUP).until(() -> providedServices(SECONDARY_BROKER, "deviceA")); + } + + static boolean providedServices(final @NotNull String brokerName, final @NotNull String serviceName) { + return primaryBroker.getDnsCache().get(brokerName).getUri().stream().filter(s -> s != null && s.toString().contains(serviceName)).count() >= 1; } @AfterAll - void finish() { + static void finish() { secondaryBroker.stopBroker(); primaryBroker.stopBroker(); } @@ -93,7 +109,7 @@ void finish() { @ValueSource(strings = { "http://localhost:8080", "https://localhost:8443" }) @Timeout(value = 2) void testDns(final String address) throws IOException { - final Request request = new Request.Builder().url(address + "/mmi.dns?noMenu").addHeader("accept", MimeType.HTML.getMediaType()).get().build(); + final Request request = new Request.Builder().url(address + "/mmi.dns?noMenu," + PRIMARY_BROKER + "/mmi.service," + SECONDARY_BROKER).addHeader("accept", MimeType.HTML.getMediaType()).get().build(); final Response response = okHttp.newCall(request).execute(); final String body = Objects.requireNonNull(response.body()).string(); @@ -104,7 +120,7 @@ void testDns(final String address) throws IOException { @ParameterizedTest @EnumSource(value = MimeType.class, names = { "HTML", "BINARY", "JSON", "CMWLIGHT", "TEXT", "UNKNOWN" }) - @Timeout(value = 2) + @Timeout(value = 4) void testGet(final MimeType contentType) throws IOException { final Request request = new Request.Builder().url("http://localhost:8080/helloWorld?noMenu").addHeader("accept", contentType.getMediaType()).get().build(); final Response response = okHttp.newCall(request).execute(); @@ -132,6 +148,7 @@ void testGetException(final MimeType contentType) throws IOException { final Request request = new Request.Builder().url("http://localhost:8080/mmi.openapi?noMenu").addHeader("accept", contentType.getMediaType()).get().build(); final Response response = okHttp.newCall(request).execute(); final Headers header = response.headers(); + assertNotNull(header); final String body = Objects.requireNonNull(response.body()).string(); switch (contentType) { case HTML: @@ -169,6 +186,7 @@ void testSet(final MimeType contentType) throws IOException { final Request getRequest = new Request.Builder().url("http://localhost:8080/helloWorld?noMenu").addHeader("accept", contentType.getMediaType()).get().build(); final Response response = okHttp.newCall(getRequest).execute(); final Headers header = response.headers(); + assertNotNull(header); final String body = Objects.requireNonNull(response.body()).string(); switch (contentType) { case HTML: @@ -188,8 +206,6 @@ void testSSE() { AtomicInteger eventCounter = new AtomicInteger(); Request request = new Request.Builder().url("http://localhost:8080/" + ImageService.PROPERTY_NAME).build(); EventSourceListener eventSourceListener = new EventSourceListener() { - private final BlockingQueue events = new LinkedBlockingDeque<>(); - @Override public void onEvent(final @NotNull EventSource eventSource, final String id, final String type, @NotNull String data) { eventCounter.getAndIncrement(); diff --git a/server/src/main/java/io/opencmw/server/BasicMdpWorker.java b/server/src/main/java/io/opencmw/server/BasicMdpWorker.java index 73046c39..d164b254 100644 --- a/server/src/main/java/io/opencmw/server/BasicMdpWorker.java +++ b/server/src/main/java/io/opencmw/server/BasicMdpWorker.java @@ -21,7 +21,6 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.Locale; import java.util.Objects; import java.util.Set; import java.util.SortedSet; @@ -64,11 +63,11 @@ */ @MetaInfo(description = "default BasicMdpWorker implementation") @SuppressWarnings({ "PMD.GodClass", "PMD.ExcessiveImports", "PMD.TooManyStaticImports", "PMD.DoNotUseThreads", "PMD.TooManyFields", "PMD.TooManyMethods" }) // makes the code more readable/shorter lines -public class BasicMdpWorker extends Thread { +public class BasicMdpWorker extends Thread implements AutoCloseable { private static final Logger LOGGER = LoggerFactory.getLogger(BasicMdpWorker.class); protected static final byte[] RBAC = {}; //TODO: implement RBAC between Majordomo and Worker protected static final AtomicInteger WORKER_COUNTER = new AtomicInteger(); - protected BiPredicate subscriptionMatcher = new SubscriptionMatcher(); // + protected final BiPredicate subscriptionMatcher = new SubscriptionMatcher(); // static { final String reason = "recursive definitions inside ZeroMQ"; @@ -80,6 +79,7 @@ public class BasicMdpWorker extends Thread { // --------------------------------------------------------------------- protected final String uniqueID; protected final ZContext ctx; + protected final boolean ownsContext; protected final URI brokerAddress; protected final String serviceName; protected final byte[] serviceBytes; @@ -90,7 +90,6 @@ public class BasicMdpWorker extends Thread { protected final ZMQ.Socket notifySocket; // Socket to listener -- needed for thread-decoupling protected final ZMQ.Socket notifyListenerSocket; // Socket to notifier -- needed for thread-decoupling protected final List activeSubscriptions = Collections.synchronizedList(new ArrayList<>()); - protected final boolean isExternal; // used to skip heart-beating and disconnect checks protected ZMQ.Socket workerSocket; // Socket to broker protected ZMQ.Socket pubSocket; // Socket to broker protected final int heartBeatLiveness = SystemProperties.getValueIgnoreCase(HEARTBEAT_LIVENESS, HEARTBEAT_LIVENESS_DEFAULT); // [counts] 3-5 is reasonable @@ -116,13 +115,13 @@ protected BasicMdpWorker(ZContext ctx, URI brokerAddress, String serviceName, fi this.brokerAddress = stripPathTrailingSlash(brokerAddress); this.serviceName = StringUtils.stripStart(serviceName, "/"); this.serviceBytes = this.serviceName.getBytes(UTF_8); - this.isExternal = !brokerAddress.getScheme().toLowerCase(Locale.UK).contains("inproc"); + this.ownsContext = ctx == null; // initialise RBAC role-based priority queues this.rbacRoles = Collections.unmodifiableSortedSet(new TreeSet<>(Set.of(rbacRoles))); this.ctx = Objects.requireNonNullElseGet(ctx, ZContext::new); - if (ctx != null) { + if (!ownsContext) { this.setDaemon(true); } this.setName(BasicMdpWorker.class.getSimpleName() + "#" + WORKER_COUNTER.getAndIncrement()); @@ -131,10 +130,10 @@ protected BasicMdpWorker(ZContext ctx, URI brokerAddress, String serviceName, fi notifyListenerSocket = this.ctx.createSocket(SocketType.PAIR); notifyListenerSocket.bind("inproc://notifyListener" + uniqueID); - notifyListenerSocket.setHWM(SystemProperties.getValueIgnoreCase(HIGH_WATER_MARK, HIGH_WATER_MARK_DEFAULT)); + setDefaultSocketParameters(notifyListenerSocket); notifySocket = this.ctx.createSocket(SocketType.PAIR); notifySocket.connect("inproc://notifyListener" + uniqueID); - notifySocket.setHWM(SystemProperties.getValueIgnoreCase(HIGH_WATER_MARK, HIGH_WATER_MARK_DEFAULT)); + setDefaultSocketParameters(notifySocket); LOGGER.atTrace().addArgument(serviceName).addArgument(uniqueID).log("created new service '{}' worker - uniqueID: {}"); } @@ -248,8 +247,25 @@ public void stopWorker() { } catch (InterruptedException e) { // NOPMD NOSONAR -- re-throwing with different type throw new IllegalStateException(this.getName() + " did not shut down in " + heartBeatInterval + " ms", e); } - if (isExternal && !ctx.isClosed()) { - ctx.destroy(); + close(); + } + + @Override + public void close() { + if (running.get()) { + LOGGER.atWarn().addArgument(serviceName).log("trying to shut-down service '{}' while not fully finished"); + try { + join(heartBeatInterval); + } catch (InterruptedException e) { // NOPMD NOSONAR -- re-throwing with different type + throw new IllegalStateException(this.getName() + " did not shut down in " + heartBeatInterval + " ms", e); + } + } + pubSocket.close(); + workerSocket.close(); + notifyListenerSocket.close(); + notifySocket.close(); + if (ownsContext) { + ctx.close(); } } @@ -273,7 +289,7 @@ protected List handleRequestsFromBroker(final MdpMessage request) { case DISCONNECT: if (Arrays.equals(BROKER_SHUTDOWN, request.data)) { shallRun.set(false); - LOGGER.atInfo().addArgument(getName()).log("broker requested to shut-down '{}'"); + LOGGER.atInfo().addArgument(serviceName).log("broker requested to shut-down '{}'"); return Collections.emptyList(); } reconnectToBroker(); @@ -323,7 +339,11 @@ protected boolean handleSubscriptionMsg(final ZMsg subMsg) { // NOPMD } protected boolean notifyRaw(@NotNull final MdpMessage notifyMessage) { - return notifyMessage.send(notifySocket); + if (running.get()) { + return notifyMessage.send(notifySocket); + } + LOGGER.atDebug().addArgument(serviceName).log("Service '{}' is not running"); + return false; } protected List processRequest(final MdpMessage request) { @@ -357,16 +377,14 @@ protected void reconnectToBroker() { } final URI translatedBrokerAddress = replaceScheme(brokerAddress, SCHEME_TCP); workerSocket = ctx.createSocket(SocketType.DEALER); - assert workerSocket != null : "worker socket is null"; - workerSocket.setHWM(SystemProperties.getValueIgnoreCase(HIGH_WATER_MARK, HIGH_WATER_MARK_DEFAULT)); + setDefaultSocketParameters(workerSocket); workerSocket.connect(translatedBrokerAddress + SUFFIX_ROUTER); if (pubSocket != null) { pubSocket.close(); } pubSocket = ctx.createSocket(SocketType.XPUB); - assert pubSocket != null : "publication socket is null"; - pubSocket.setHWM(SystemProperties.getValueIgnoreCase(HIGH_WATER_MARK, HIGH_WATER_MARK_DEFAULT)); + setDefaultSocketParameters(pubSocket); pubSocket.setXpubVerbose(true); pubSocket.connect(translatedBrokerAddress + SUFFIX_SUBSCRIBE); diff --git a/server/src/main/java/io/opencmw/server/MajordomoBroker.java b/server/src/main/java/io/opencmw/server/MajordomoBroker.java index bd033ee7..1619c51d 100644 --- a/server/src/main/java/io/opencmw/server/MajordomoBroker.java +++ b/server/src/main/java/io/opencmw/server/MajordomoBroker.java @@ -19,6 +19,7 @@ import java.net.URI; import java.text.SimpleDateFormat; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -62,9 +63,9 @@ * * @see io.opencmw.OpenCmwConstants for more details */ -@SuppressWarnings({ "PMD.DefaultPackage", "PMD.UseConcurrentHashMap", "PMD.TooManyFields", "PMD.TooManyMethods", "PMD.ExcessiveImports", "PMD.CommentSize", "PMD.UseConcurrentHashMap" }) +@SuppressWarnings({ "PMD.GodClass", "PMD.DefaultPackage", "PMD.UseConcurrentHashMap", "PMD.TooManyFields", "PMD.TooManyMethods", "PMD.ExcessiveImports", "PMD.CommentSize", "PMD.UseConcurrentHashMap" }) // package private explicitly needed for MmiServiceHelper, thread-safe/performance use of HashMap -public class MajordomoBroker extends Thread { +public class MajordomoBroker extends Thread implements AutoCloseable { public static final byte[] RBAC = {}; // TODO: implement RBAC between Majordomo and Worker private static final Logger LOGGER = LoggerFactory.getLogger(MajordomoBroker.class); // ----------------- default service names ----------------------------- @@ -89,16 +90,16 @@ public class MajordomoBroker extends Thread { /* default */ final Map services = new HashMap<>(); // known services Map<'service name', Service> protected final Map workers = new HashMap<>(); // known workers Map clients = new HashMap<>(); - protected BiPredicate subscriptionMatcher = new SubscriptionMatcher(); // + protected final BiPredicate subscriptionMatcher = new SubscriptionMatcher(); // protected final Map activeSubscriptions = new HashMap<>(); // Map> protected final Map> routerBasedSubscriptions = new HashMap<>(); // Map> private final AtomicBoolean run = new AtomicBoolean(false); // NOPMD - nomen est omen private final AtomicBoolean running = new AtomicBoolean(false); // NOPMD - nomen est omen protected final Deque waiting = new ArrayDeque<>(); // idle workers - /* default */ final Map dnsCache = new HashMap<>(); // protected final int heartBeatLiveness = SystemProperties.getValueIgnoreCase(HEARTBEAT_LIVENESS, HEARTBEAT_LIVENESS_DEFAULT); // [counts] 3-5 is reasonable protected final long heartBeatInterval = SystemProperties.getValueIgnoreCase(HEARTBEAT, HEARTBEAT_DEFAULT); // [ms] protected final long heartBeatExpiry = heartBeatInterval * heartBeatLiveness; + private final Map dnsCache = new ConcurrentHashMap<>(); // protected final long clientTimeOut = TimeUnit.SECONDS.toMillis(SystemProperties.getValueIgnoreCase(CLIENT_TIMEOUT, CLIENT_TIMEOUT_DEFAULT)); // [s] protected final long dnsTimeOut = TimeUnit.SECONDS.toMillis(SystemProperties.getValueIgnoreCase("OpenCMW.dnsTimeOut", 10)); // [ms] time when protected long heartbeatAt = System.currentTimeMillis() + heartBeatInterval; // When to send HEARTBEAT @@ -124,20 +125,20 @@ public MajordomoBroker(@NotNull final String brokerName, final URI dnsAddress, f // generate and register internal default inproc socket routerSocket = ctx.createSocket(SocketType.ROUTER); - routerSocket.setHWM(SystemProperties.getValueIgnoreCase(HIGH_WATER_MARK, HIGH_WATER_MARK_DEFAULT)); + setDefaultSocketParameters(routerSocket); routerSocket.bind(INTERNAL_ADDRESS_BROKER); // NOPMD pubSocket = ctx.createSocket(SocketType.XPUB); - pubSocket.setHWM(SystemProperties.getValueIgnoreCase(HIGH_WATER_MARK, HIGH_WATER_MARK_DEFAULT)); + setDefaultSocketParameters(pubSocket); pubSocket.setXpubVerbose(true); pubSocket.bind(INTERNAL_ADDRESS_PUBLISHER); // NOPMD subSocket = ctx.createSocket(SocketType.SUB); - subSocket.setHWM(SystemProperties.getValueIgnoreCase(HIGH_WATER_MARK, HIGH_WATER_MARK_DEFAULT)); + setDefaultSocketParameters(subSocket); subSocket.bind(INTERNAL_ADDRESS_SUBSCRIBE); // NOPMD registerDefaultServices(rbacRoles); // NOPMD dnsSocket = ctx.createSocket(SocketType.DEALER); - dnsSocket.setHWM(SystemProperties.getValueIgnoreCase(HIGH_WATER_MARK, HIGH_WATER_MARK_DEFAULT)); + setDefaultSocketParameters(dnsSocket); if (this.dnsAddress == null) { dnsSocket.connect(INTERNAL_ADDRESS_BROKER); } else { @@ -172,9 +173,6 @@ public URI bind(URI endpoint) { final URI endpointAdjusted = replaceScheme(endpoint, isRouterSocket ? SCHEME_MDP : SCHEME_MDS); final URI adjustedAddressPublic = resolveHost(endpointAdjusted, getLocalHostName()); routerSockets.add(adjustedAddressPublic.toString()); - if (endpoint.getAuthority().contains(WILDCARD)) { - routerSockets.add(resolveHost(endpointAdjusted, "localhost").toString()); - } LOGGER.atDebug().addArgument(adjustedAddressPublic).log("Majordomo broker/0.1 is active at '{}'"); return adjustedAddressPublic; } @@ -200,6 +198,14 @@ public Collection getServices() { return services.values(); } + /** + * + * @return Map containing known brokers as keys and service items + */ + public Map getDnsCache() { + return dnsCache; + } + public boolean isRunning() { return running.get(); } @@ -224,7 +230,10 @@ public void run() { items.register(dnsSocket, ZMQ.Poller.POLLIN); items.register(pubSocket, ZMQ.Poller.POLLIN); items.register(subSocket, ZMQ.Poller.POLLIN); - while (run.get() && !Thread.currentThread().isInterrupted() && items.poll(heartBeatInterval) != -1) { + while (run.get() && !Thread.currentThread().isInterrupted() && !ctx.isClosed() && items.poll(heartBeatInterval) != -1) { + if (ctx.isClosed()) { + break; + } int loopCount = 0; boolean receivedMsg = true; while (run.get() && !Thread.currentThread().isInterrupted() && receivedMsg) { @@ -276,7 +285,7 @@ public void stopBroker() { throw new IllegalStateException(this.getName() + " did not shut down in " + heartBeatInterval + " ms", e); } } - destroy(); // interrupted + close(); } /** @@ -300,8 +309,13 @@ protected void deleteWorker(Worker worker, boolean disconnect) { /** * Disconnect all workers, destroy context. */ - protected void destroy() { - ctx.destroy(); + @Override + public void close() { + routerSocket.close(); + pubSocket.close(); + subSocket.close(); + dnsSocket.close(); + ctx.close(); } /** @@ -349,6 +363,7 @@ protected boolean handleReceivedMessage(final Socket receiveSocket, final MdpMes case READY: if (msg.topic.getScheme() != null) { // register potentially new service + registerNewService(msg.getServiceName()); DnsServiceItem ret = dnsCache.computeIfAbsent(msg.getServiceName(), s -> new DnsServiceItem(msg.senderID, msg.getServiceName())); ret.uri.add(msg.topic); ret.updateExpiryTimeStamp(); @@ -442,6 +457,7 @@ protected void processWorker(final Socket receiveSocket, final MdpMessage msg) { worker.service.serviceDescription = Arrays.copyOf(msg.data, msg.data.length); if (!msg.topic.toString().isBlank() && msg.topic.getScheme() != null) { + registerNewService(brokerName); routerSockets.add(msg.topic.toString()); DnsServiceItem ret = dnsCache.computeIfAbsent(brokerName, s -> new DnsServiceItem(msg.senderID, brokerName)); ret.uri.add(msg.topic); @@ -466,7 +482,7 @@ protected void processWorker(final Socket receiveSocket, final MdpMessage msg) { } break; case DISCONNECT: - //deleteWorker(worker, false); + //deleteWorker(worker, false) break; case PARTIAL: case FINAL: @@ -622,19 +638,34 @@ protected void sendDnsHeartbeats(boolean force) { // Send heartbeats to idle workers if it's time if (System.currentTimeMillis() >= dnsHeartbeatAt || force) { final MdpMessage readyMsg = new MdpMessage(null, PROT_CLIENT, READY, brokerName.getBytes(UTF_8), "clientID".getBytes(UTF_8), EMPTY_URI, EMPTY_FRAME, "", RBAC); - for (String routerAddress : this.getRouterSockets()) { + final ArrayList localCopy = new ArrayList<>(getRouterSockets()); + for (String routerAddress : localCopy) { readyMsg.topic = URI.create(routerAddress); - if (dnsAddress != null) { - readyMsg.send(dnsSocket); // register with external DNS - } - // register with internal DNS - DnsServiceItem ret = dnsCache.computeIfAbsent(brokerName, s -> new DnsServiceItem(dnsSocket.getIdentity(), brokerName)); // NOPMD instantiation in loop necessary - ret.uri.add(URI.create(routerAddress)); - ret.updateExpiryTimeStamp(); + registerWithDnsServices(readyMsg); } + services.keySet().forEach(this::registerNewService); } } + private void registerNewService(final String serviceName) { + final MdpMessage readyMsg = new MdpMessage(null, PROT_CLIENT, READY, brokerName.getBytes(UTF_8), "clientID".getBytes(UTF_8), EMPTY_URI, EMPTY_FRAME, "", RBAC); + final ArrayList localCopy = new ArrayList<>(getRouterSockets()); + for (String routerAddress : localCopy) { + readyMsg.topic = URI.create(routerAddress + '/' + serviceName); + registerWithDnsServices(readyMsg); + } + } + + private void registerWithDnsServices(final MdpMessage readyMsg) { + if (dnsAddress != null) { + readyMsg.send(dnsSocket); // register with external DNS + } + // register with internal DNS + DnsServiceItem ret = dnsCache.computeIfAbsent(brokerName, s -> new DnsServiceItem(readyMsg.senderID, brokerName)); // NOPMD instantiation in loop necessary + ret.uri.add(readyMsg.topic); + ret.updateExpiryTimeStamp(); + } + /** * Send heartbeats to idle workers if it's time. */ @@ -659,12 +690,6 @@ protected void sendHeartbeats() { protected void workerWaiting(Worker worker) { // Queue to broker and service waiting lists waiting.addLast(worker); - // TODO: evaluate addLast vs. push (addFirst) - latter should be more - // beneficial w.r.t. CPU context switches (reuses the same thread/context - // frequently - // do not know why original implementation wanted to spread across different - // workers (load balancing across different machines perhaps?!=) - // worker.service.waiting.addLast(worker) worker.service.waiting.push(worker); worker.updateExpiryTimeStamp(); dispatch(worker.service); @@ -716,9 +741,14 @@ private boolean handleSubscriptionMsg(final ZMsg subMsg) { } /* default */ Service getBestMatchingService(final String serviceName) { // NOPMD package private OK - final String shortServiceName = StringUtils.removeStart(serviceName, brokerName + '/'); - final List sortedList = services.keySet().stream().filter(shortServiceName::startsWith).sorted(Comparator.comparingInt(String::length)).collect(Collectors.toList()); - return sortedList.isEmpty() ? null : services.get(sortedList.get(0)); + final List sortedList = services.keySet().stream().filter(serviceName::startsWith).sorted(Comparator.comparingInt(String::length)).collect(Collectors.toList()); + if (!sortedList.isEmpty()) { + return services.get(sortedList.get(0)); + } + // assume serviceName is a shorten (e.g. 'mmi.*') form + final String longServiceName = brokerName + '/' + serviceName; + final List sortedList2nd = services.keySet().stream().filter(longServiceName::startsWith).sorted(Comparator.comparingInt(String::length)).collect(Collectors.toList()); + return sortedList2nd.isEmpty() ? null : services.get(sortedList2nd.get(0)); } /** @@ -835,7 +865,7 @@ private void updateExpiryTimeStamp() { * This defines one DNS service item, idle or active. */ @SuppressWarnings("PMD.CommentDefaultAccessModifier") // needed for utility classes in the same package - class DnsServiceItem { + public class DnsServiceItem { protected final byte[] address; // Address ID frame to route to protected final String serviceName; protected final List uri = new NoDuplicatesList<>(); @@ -868,7 +898,11 @@ public String getDnsEntryHtml() { webHandler = uri.stream().filter(u -> "http".equalsIgnoreCase(u.getScheme())).findFirst(); } final String wrappedService = webHandler.isEmpty() ? serviceName : wrapInAnchor(serviceName, webHandler.get()); - return '[' + wrappedService + ": " + uri.stream().map(u -> wrapInAnchor(u.toString(), u)).collect(Collectors.joining(",")) + "]"; + return '[' + wrappedService + ": " + uri.stream().map(u -> wrapInAnchor(u.toString(), u)).collect(Collectors.joining(", ")) + "]"; + } + + public List getUri() { + return uri; } @Override @@ -878,6 +912,7 @@ public int hashCode() { @Override public String toString() { + @SuppressWarnings("SpellCheckingInspection") final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.UK); return "DnsServiceItem{address=" + ZData.toString(address) + ", serviceName='" + serviceName + "', uri= '" + uri + "',expiry=" + expiry + " - " + sdf.format(expiry) + '}'; } diff --git a/server/src/main/java/io/opencmw/server/MmiServiceHelper.java b/server/src/main/java/io/opencmw/server/MmiServiceHelper.java index e10c9495..105a0e1a 100644 --- a/server/src/main/java/io/opencmw/server/MmiServiceHelper.java +++ b/server/src/main/java/io/opencmw/server/MmiServiceHelper.java @@ -3,8 +3,15 @@ import static java.nio.charset.StandardCharsets.UTF_8; import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; +import org.apache.commons.lang3.StringUtils; + import io.opencmw.MimeType; import io.opencmw.QueryParameterParser; import io.opencmw.rbac.RbacRole; @@ -34,32 +41,85 @@ public static String wrapInAnchor(final String text, final URI uri) { @MetaInfo(description = "output = input : echo service is that complex :-)", unit = "MMI Echo Service") public static class MmiEcho extends BasicMdpWorker { public MmiEcho(final MajordomoBroker broker, final RbacRole... rbacRoles) { - super(broker.getContext(), INTERNAL_SERVICE_ECHO, rbacRoles); + super(broker.getContext(), broker.brokerName + '/' + INTERNAL_SERVICE_ECHO, rbacRoles); this.registerHandler(ctx -> ctx.rep.data = ctx.req.data); // output = input : echo service is complex :-) } } @MetaInfo(description = "Dynamic Name Service (DNS) returning registered internal and external broker endpoints' URIs (protocol, host ip, port etc.)", unit = "MMI DNS Service") public static class MmiDns extends BasicMdpWorker { + private final Map dnsCache; + private final String brokerName; + public MmiDns(final MajordomoBroker broker, final RbacRole... rbacRoles) { - super(broker.getContext(), INTERNAL_SERVICE_DNS, rbacRoles); + super(broker.getContext(), broker.brokerName + '/' + INTERNAL_SERVICE_DNS, rbacRoles); + dnsCache = broker.getDnsCache(); + brokerName = broker.brokerName; this.registerHandler(ctx -> { - if (isHtmlRequest(ctx.req.topic)) { - ctx.rep.data = broker.dnsCache.values().stream().map(MajordomoBroker.DnsServiceItem::getDnsEntryHtml).collect(Collectors.joining(",
")).getBytes(UTF_8); - } else { - ctx.rep.data = broker.dnsCache.values().stream().map(MajordomoBroker.DnsServiceItem::getDnsEntry).collect(Collectors.joining(",")).getBytes(UTF_8); + final boolean isHtml = isHtmlRequest(ctx.req.topic); + final String delimiter = isHtml ? ", " : ","; + final String query = ctx.req.topic.getQuery() == null ? null : ctx.req.topic.getQuery().split("&")[0]; + if (query == null || query.isBlank() || !(query.contains(",") || query.contains(":") || query.contains("/"))) { + final Function mapper = isHtml ? MajordomoBroker.DnsServiceItem::getDnsEntryHtml : MajordomoBroker.DnsServiceItem::getDnsEntry; + ctx.rep.data = dnsCache.values().stream().map(mapper).collect(Collectors.joining(delimiter)).getBytes(UTF_8); + return; } + // search for specific service + ctx.rep.data = Arrays.stream(StringUtils.split(query, ',')).map(StringUtils::strip).map(this::findDnsEntry).collect(Collectors.joining(delimiter)).getBytes(UTF_8); }); } + + /** + * + * @param entry scheme - path entry to look for (N.B. any potential authority, user or query parameter will be ignored + * @return String containing the 'broker : fully resolved device(service)/property address(es)' or 'null' + */ + public String findDnsEntry(final String entry) { + StringBuilder builder = new StringBuilder(64); + final URI query; + try { + query = new URI(entry); + } catch (URISyntaxException e) { + builder.append('[').append(entry).append(": null]"); + return builder.toString(); + } + + final String queryPath = StringUtils.stripStart(query.getPath(), "/"); + final boolean providedScheme = query.getScheme() != null && !query.getScheme().isBlank(); + final String stripStartFromSearchPath = queryPath.startsWith("mmi.") ? ('/' + brokerName) : "/"; // crop initial broker name for broker-specific MMI services + Predicate matcher = dnsEntry -> { + if (providedScheme && !dnsEntry.getScheme().equalsIgnoreCase(query.getScheme())) { + // require scheme but entry does not provide any + return false; + } + // scheme matches - check path compatibility + return StringUtils.stripStart(dnsEntry.getPath(), stripStartFromSearchPath).startsWith(queryPath); + }; + for (final String brokerName : dnsCache.keySet()) { + final MajordomoBroker.DnsServiceItem serviceItem = dnsCache.get(brokerName); + final String matchedItems = serviceItem.uri.stream().filter(matcher).map(URI::toString).collect(Collectors.joining(", ")); + if (matchedItems.isBlank()) { + continue; + } + builder.append('[').append(query.toString()).append(": ").append(matchedItems).append(']'); + } + if (builder.length() == 0) { + builder.append('[').append(query.toString()).append(": null]"); + } + return builder.toString(); + } } @MetaInfo(description = "endpoint returning OpenAPI definitions", unit = "MMI OpenAPI Worker Class Definitions") public static class MmiOpenApi extends BasicMdpWorker { public MmiOpenApi(final MajordomoBroker broker, final RbacRole... rbacRoles) { - super(broker.getContext(), INTERNAL_SERVICE_OPENAPI, rbacRoles); + super(broker.getContext(), broker.brokerName + '/' + INTERNAL_SERVICE_OPENAPI, rbacRoles); this.registerHandler(context -> { final String serviceName = context.req.data == null ? "" : new String(context.req.data, UTF_8); - final MajordomoBroker.Service service = broker.services.get(serviceName); + MajordomoBroker.Service service = broker.services.get(serviceName); + if (service == null) { + service = broker.services.get(broker.brokerName + '/' + serviceName); + } if (service == null) { throw new IllegalArgumentException("requested invalid service name '" + serviceName + "' msg " + context.req); } @@ -71,7 +131,7 @@ public MmiOpenApi(final MajordomoBroker broker, final RbacRole... rbacRoles) @MetaInfo(description = "definition according to http://rfc.zeromq.org/spec:8", unit = "MMI Service/Property Definitions") public static class MmiService extends BasicMdpWorker { public MmiService(final MajordomoBroker broker, final RbacRole... rbacRoles) { - super(broker.getContext(), INTERNAL_SERVICE_NAMES, rbacRoles); + super(broker.getContext(), broker.brokerName + '/' + INTERNAL_SERVICE_NAMES, rbacRoles); this.registerHandler(context -> { final String serviceName = (context.req.data == null) ? "" : new String(context.req.data, UTF_8); if (serviceName.isBlank()) { @@ -81,7 +141,7 @@ public MmiService(final MajordomoBroker broker, final RbacRole... rbacRoles) context.rep.data = broker.services.keySet().stream().sorted().collect(Collectors.joining(",")).getBytes(UTF_8); } } else { - context.rep.data = (broker.services.containsKey(serviceName) ? "200" : "400").getBytes(UTF_8); + context.rep.data = ((broker.services.containsKey(serviceName) || broker.services.containsKey(broker.brokerName + '/' + serviceName)) ? "200" : "400").getBytes(UTF_8); } }); } diff --git a/server/src/test/java/io/opencmw/server/ClipboardWorkerTests.java b/server/src/test/java/io/opencmw/server/ClipboardWorkerTests.java index 98e08890..d6fe7006 100644 --- a/server/src/test/java/io/opencmw/server/ClipboardWorkerTests.java +++ b/server/src/test/java/io/opencmw/server/ClipboardWorkerTests.java @@ -11,7 +11,6 @@ import java.nio.charset.StandardCharsets; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.LockSupport; -import java.util.stream.Collectors; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -91,7 +90,7 @@ void testSubmitExternal() { // check subscription result LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(200)); - System.err.println("messages = " + subClientRoot.mdpMessages.stream().map(s -> s.topic.toString()).collect(Collectors.joining(", "))); + assertEquals(2, subClientRoot.mdpMessages.size(), "raw message count"); assertEquals(2, subClientRoot.domainMessages.size(), "domain message count"); assertEquals(1, subClientMatching.mdpMessages.size(), "matching message count"); diff --git a/server/src/test/java/io/opencmw/server/MajordomoBrokerTests.java b/server/src/test/java/io/opencmw/server/MajordomoBrokerTests.java index 105959fc..ab7769b2 100644 --- a/server/src/test/java/io/opencmw/server/MajordomoBrokerTests.java +++ b/server/src/test/java/io/opencmw/server/MajordomoBrokerTests.java @@ -8,11 +8,14 @@ import static io.opencmw.OpenCmwConstants.SCHEME_TCP; import static io.opencmw.OpenCmwConstants.replaceScheme; +import static io.opencmw.OpenCmwConstants.setDefaultSocketParameters; import static io.opencmw.OpenCmwProtocol.*; import static io.opencmw.OpenCmwProtocol.Command.*; import static io.opencmw.OpenCmwProtocol.MdpSubProtocol.PROT_CLIENT; import static io.opencmw.OpenCmwProtocol.MdpSubProtocol.PROT_WORKER; import static io.opencmw.server.MmiServiceHelper.INTERNAL_SERVICE_DNS; +import static io.opencmw.server.MmiServiceHelper.INTERNAL_SERVICE_ECHO; +import static io.opencmw.server.MmiServiceHelper.INTERNAL_SERVICE_NAMES; import static io.opencmw.utils.AnsiDefs.ANSI_RED; import java.io.IOException; @@ -27,14 +30,14 @@ import org.zeromq.Utils; import org.zeromq.ZMQ; import org.zeromq.ZMsg; +import org.zeromq.util.ZData; import io.opencmw.rbac.BasicRbacRole; import io.opencmw.rbac.RbacToken; class MajordomoBrokerTests { private static final byte[] DEFAULT_RBAC_TOKEN = new RbacToken(BasicRbacRole.ADMIN, "HASHCODE").getBytes(); - private static final String DEFAULT_MMI_SERVICE = "mmi.service"; - private static final String DEFAULT_ECHO_SERVICE = "mmi.echo"; + private static final String DEFAULT_BROKER_NAME = "TestMdpBroker"; private static final String DEFAULT_REQUEST_MESSAGE = "Hello World!"; private static final byte[] DEFAULT_REQUEST_MESSAGE_BYTES = DEFAULT_REQUEST_MESSAGE.getBytes(UTF_8); @@ -44,7 +47,7 @@ class MajordomoBrokerTests { * @param args none */ public static void main(String[] args) { - MajordomoBroker broker = new MajordomoBroker("TestMdpBroker", URI.create("tcp://*:5555"), BasicRbacRole.values()); + MajordomoBroker broker = new MajordomoBroker(DEFAULT_BROKER_NAME, URI.create("tcp://*:5555"), BasicRbacRole.values()); // broker.setDaemon(true); // use this if running in another app that // controls threads Can be called multiple times with different endpoints broker.bind(URI.create("tcp://*:5555")); @@ -62,7 +65,7 @@ public static void main(String[] args) { @Test void basicLowLevelRequestReplyTest() throws IOException { - MajordomoBroker broker = new MajordomoBroker("TestBroker", null, BasicRbacRole.values()); + MajordomoBroker broker = new MajordomoBroker(DEFAULT_BROKER_NAME, null, BasicRbacRole.values()); // broker.setDaemon(true); // use this if running in another app that controls threads final URI brokerAddress = broker.bind(URI.create("mdp://*:" + Utils.findOpenPort())); assertFalse(broker.isRunning(), "broker not running"); @@ -88,14 +91,14 @@ void basicLowLevelRequestReplyTest() throws IOException { LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(200)); final byte[] clientRequestID = "unit-test-clientRequestID".getBytes(UTF_8); - new MdpMessage(null, PROT_CLIENT, GET_REQUEST, DEFAULT_ECHO_SERVICE.getBytes(UTF_8), clientRequestID, URI.create(DEFAULT_ECHO_SERVICE), DEFAULT_REQUEST_MESSAGE_BYTES, "", new byte[0]).send(clientSocket); + new MdpMessage(null, PROT_CLIENT, GET_REQUEST, INTERNAL_SERVICE_ECHO.getBytes(UTF_8), clientRequestID, URI.create(INTERNAL_SERVICE_ECHO), DEFAULT_REQUEST_MESSAGE_BYTES, "", new byte[0]).send(clientSocket); final MdpMessage clientMessage = MdpMessage.receive(clientSocket); assertNotNull(clientMessage, "reply message w/o RBAC token not being null"); assertNotNull(clientMessage.toString()); assertNotNull(clientMessage.senderID); // default dealer socket does not export sender ID (only ROUTER and/or enabled sockets) assertEquals(MdpSubProtocol.PROT_CLIENT, clientMessage.protocol, "equal protocol"); assertEquals(FINAL, clientMessage.command, "matching command"); - assertArrayEquals(DEFAULT_ECHO_SERVICE.getBytes(UTF_8), clientMessage.serviceNameBytes, "equal service name"); + assertArrayEquals((DEFAULT_BROKER_NAME + '/' + INTERNAL_SERVICE_ECHO).getBytes(UTF_8), clientMessage.serviceNameBytes, "equal service name: " + ZData.toString(clientMessage.serviceNameBytes)); assertNotNull(clientMessage.data, "user-data not being null"); assertArrayEquals(DEFAULT_REQUEST_MESSAGE_BYTES, clientMessage.data, "equal data"); assertFalse(clientMessage.hasRbackToken()); @@ -106,8 +109,8 @@ void basicLowLevelRequestReplyTest() throws IOException { } @Test - void basicSynchronousRequestReplyTest() throws IOException { - final MajordomoBroker broker = new MajordomoBroker("TestBroker", null, BasicRbacRole.values()); + void basicSynchronousRequestReplyTest() throws IOException { // NOSONAR - we need the asserts + final MajordomoBroker broker = new MajordomoBroker(DEFAULT_BROKER_NAME, null, BasicRbacRole.values()); // broker.setDaemon(true); // use this if running in another app that controls threads final URI brokerAddress = broker.bind(URI.create("mdp://*:" + Utils.findOpenPort())); broker.start(); @@ -194,7 +197,7 @@ void basicSynchronousRequestReplyTest() throws IOException { @Test void basicMmiTests() throws IOException { - MajordomoBroker broker = new MajordomoBroker("TestBroker", null, BasicRbacRole.values()); + MajordomoBroker broker = new MajordomoBroker(DEFAULT_BROKER_NAME, null, BasicRbacRole.values()); // broker.setDaemon(true); // use this if running in another app that controls threads final int openPort = Utils.findOpenPort(); broker.bind(URI.create("tcp://*:" + openPort)); @@ -211,14 +214,14 @@ void basicMmiTests() throws IOException { } { - final MdpMessage replyWithoutRbac = clientSession.send(SET_REQUEST, DEFAULT_MMI_SERVICE, DEFAULT_MMI_SERVICE.getBytes(UTF_8)); // w/o RBAC + final MdpMessage replyWithoutRbac = clientSession.send(SET_REQUEST, INTERNAL_SERVICE_NAMES, INTERNAL_SERVICE_NAMES.getBytes(UTF_8)); // w/o RBAC assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); assertNotNull(replyWithoutRbac.data, "user-data not being null"); assertEquals("200", new String(replyWithoutRbac.data, UTF_8), "known MMI service request"); } { - final MdpMessage replyWithoutRbac = clientSession.send(SET_REQUEST, DEFAULT_MMI_SERVICE, DEFAULT_ECHO_SERVICE.getBytes(UTF_8)); // w/o RBAC + final MdpMessage replyWithoutRbac = clientSession.send(SET_REQUEST, INTERNAL_SERVICE_NAMES, INTERNAL_SERVICE_ECHO.getBytes(UTF_8)); // w/o RBAC assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); assertNotNull(replyWithoutRbac.data, "user-data not being null"); assertEquals("200", new String(replyWithoutRbac.data, UTF_8), "known MMI service request"); @@ -226,7 +229,7 @@ void basicMmiTests() throws IOException { { // MMI service request: service should not exist - final MdpMessage replyWithoutRbac = clientSession.send(SET_REQUEST, DEFAULT_MMI_SERVICE, DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + final MdpMessage replyWithoutRbac = clientSession.send(SET_REQUEST, INTERNAL_SERVICE_NAMES, DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); assertNotNull(replyWithoutRbac.data, "user-data not being null"); assertEquals("400", new String(replyWithoutRbac.data, UTF_8), "known MMI service request"); @@ -245,7 +248,7 @@ void basicMmiTests() throws IOException { final MdpMessage replyWithoutRbac = clientSession.send(GET_REQUEST, INTERNAL_SERVICE_DNS, EMPTY_FRAME); // w/o RBAC assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); assertNotNull(replyWithoutRbac.data, "user-data not being null"); - assertTrue(new String(replyWithoutRbac.data, UTF_8).startsWith("[TestBroker: "), "unknown MMI service request"); + assertTrue(new String(replyWithoutRbac.data, UTF_8).startsWith("[" + DEFAULT_BROKER_NAME + ": "), "unknown MMI service request"); } broker.stopBroker(); @@ -253,7 +256,7 @@ void basicMmiTests() throws IOException { @Test void basicASynchronousRequestReplyTest() throws IOException { - MajordomoBroker broker = new MajordomoBroker("TestBroker", null, BasicRbacRole.values()); + MajordomoBroker broker = new MajordomoBroker(DEFAULT_BROKER_NAME, null, BasicRbacRole.values()); // broker.setDaemon(true); // use this if running in another app that controls threads final URI brokerAddress = broker.bind(URI.create("tcp://*:" + Utils.findOpenPort())); broker.start(); @@ -269,7 +272,7 @@ void basicASynchronousRequestReplyTest() throws IOException { // send bursts of 10 messages for (int i = 0; i < 5; i++) { clientSession.send("mmi.echo", DEFAULT_REQUEST_MESSAGE_BYTES); - clientSession.send(DEFAULT_ECHO_SERVICE, DEFAULT_REQUEST_MESSAGE_BYTES); + clientSession.send(INTERNAL_SERVICE_ECHO, DEFAULT_REQUEST_MESSAGE_BYTES); } // receive bursts of 10 messages @@ -290,7 +293,7 @@ void basicASynchronousRequestReplyTest() throws IOException { @Test void testSubscription() throws IOException { - final MajordomoBroker broker = new MajordomoBroker("TestBroker", null, BasicRbacRole.values()); + final MajordomoBroker broker = new MajordomoBroker(DEFAULT_BROKER_NAME, null, BasicRbacRole.values()); // broker.setDaemon(true); // use this if running in another app that controls threads final URI brokerAddress = broker.bind(URI.create("mdp://*:" + Utils.findOpenPort())); final URI brokerPubAddress = broker.bind(URI.create("mds://*:" + Utils.findOpenPort())); @@ -342,10 +345,10 @@ void testSubscription() throws IOException { // low-level subscription final AtomicInteger subCounter = new AtomicInteger(0); final AtomicBoolean started2 = new AtomicBoolean(false); - final Thread subcriptionThread = new Thread(() -> { + final Thread subscriptionThread = new Thread(() -> { // low-level subscription final ZMQ.Socket sub = broker.getContext().createSocket(SocketType.SUB); - sub.setHWM(0); + setDefaultSocketParameters(sub); sub.connect(replaceScheme(brokerPubAddress, SCHEME_TCP).toString()); sub.subscribe("device/property"); sub.subscribe("device/otherProperty"); @@ -361,7 +364,7 @@ void testSubscription() throws IOException { } sub.unsubscribe("device/property"); }); - subcriptionThread.start(); + subscriptionThread.start(); // wait until all services are initialised await().alias("wait for thread1 to start").atMost(1, TimeUnit.SECONDS).until(started1::get, equalTo(true)); @@ -373,7 +376,7 @@ void testSubscription() throws IOException { await().alias("wait for reply messages").atMost(2, TimeUnit.SECONDS).until(counter::get, equalTo(10)); run.set(false); - await().alias("wait for subscription thread shut-down").atMost(1, TimeUnit.SECONDS).until(subcriptionThread::isAlive, equalTo(false)); + await().alias("wait for subscription thread shut-down").atMost(1, TimeUnit.SECONDS).until(subscriptionThread::isAlive, equalTo(false)); assertEquals(10, counter.get(), "received expected number of replies"); assertEquals(10, subCounter.get(), "received expected number of subscription replies"); @@ -382,10 +385,10 @@ void testSubscription() throws IOException { @Test void testMisc() { - final MajordomoBroker broker = new MajordomoBroker("TestBroker", null, BasicRbacRole.values()); + final MajordomoBroker broker = new MajordomoBroker(DEFAULT_BROKER_NAME, null, BasicRbacRole.values()); assertDoesNotThrow(() -> broker.new Client(null, "testClient", "testClient".getBytes(UTF_8))); final MajordomoBroker.Client testClient = broker.new Client(null, "testClient", "testClient".getBytes(UTF_8)); - final MdpMessage testMsg = new MdpMessage(null, PROT_CLIENT, GET_REQUEST, DEFAULT_ECHO_SERVICE.getBytes(UTF_8), EMPTY_FRAME, URI.create(DEFAULT_ECHO_SERVICE), DEFAULT_REQUEST_MESSAGE_BYTES, "", new byte[0]); + final MdpMessage testMsg = new MdpMessage(null, PROT_CLIENT, GET_REQUEST, INTERNAL_SERVICE_ECHO.getBytes(UTF_8), EMPTY_FRAME, URI.create(INTERNAL_SERVICE_ECHO), DEFAULT_REQUEST_MESSAGE_BYTES, "", new byte[0]); assertDoesNotThrow(() -> testClient.offerToQueue(testMsg)); assertEquals(testMsg, testClient.pop()); } diff --git a/server/src/test/java/io/opencmw/server/MajordomoTestClientAsync.java b/server/src/test/java/io/opencmw/server/MajordomoTestClientAsync.java index 8da15838..1d8c8b9d 100644 --- a/server/src/test/java/io/opencmw/server/MajordomoTestClientAsync.java +++ b/server/src/test/java/io/opencmw/server/MajordomoTestClientAsync.java @@ -1,5 +1,6 @@ package io.opencmw.server; +import static io.opencmw.OpenCmwConstants.setDefaultSocketParameters; import static io.opencmw.OpenCmwProtocol.Command.GET_REQUEST; import static io.opencmw.OpenCmwProtocol.Command.SUBSCRIBE; import static io.opencmw.OpenCmwProtocol.Command.UNSUBSCRIBE; @@ -119,7 +120,7 @@ void reconnectToBroker() { clientSocket.close(); } clientSocket = ctx.createSocket(SocketType.DEALER); - clientSocket.setHWM(0); + setDefaultSocketParameters(clientSocket); clientSocket.setIdentity("clientV2".getBytes(StandardCharsets.UTF_8)); clientSocket.connect(broker); if (poller != null) { diff --git a/server/src/test/java/io/opencmw/server/MajordomoTestClientSubscription.java b/server/src/test/java/io/opencmw/server/MajordomoTestClientSubscription.java index 45351c32..356cd460 100644 --- a/server/src/test/java/io/opencmw/server/MajordomoTestClientSubscription.java +++ b/server/src/test/java/io/opencmw/server/MajordomoTestClientSubscription.java @@ -1,5 +1,6 @@ package io.opencmw.server; +import static io.opencmw.OpenCmwConstants.setDefaultSocketParameters; import static io.opencmw.OpenCmwProtocol.MdpMessage; import java.lang.management.ManagementFactory; @@ -65,7 +66,7 @@ public MajordomoTestClientSubscription(final @NotNull URI broker, final @NotNull public void run() { try (ZMQ.Socket socket = ctx.createSocket(SocketType.SUB); ZMQ.Poller poller = ctx.createPoller(1)) { clientSocket = socket; - socket.setHWM(0); + setDefaultSocketParameters(clientSocket); socket.setIdentity(uniqueIdBytes); socket.connect(broker.replace("mds://", "tcp://")); poller.register(socket, ZMQ.Poller.POLLIN); diff --git a/server/src/test/java/io/opencmw/server/MajordomoTestClientSync.java b/server/src/test/java/io/opencmw/server/MajordomoTestClientSync.java index 456a699f..b5bdd5ff 100644 --- a/server/src/test/java/io/opencmw/server/MajordomoTestClientSync.java +++ b/server/src/test/java/io/opencmw/server/MajordomoTestClientSync.java @@ -2,6 +2,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; +import static io.opencmw.OpenCmwConstants.setDefaultSocketParameters; import static io.opencmw.OpenCmwProtocol.Command; import static io.opencmw.OpenCmwProtocol.MdpMessage; import static io.opencmw.OpenCmwProtocol.MdpSubProtocol.PROT_CLIENT; @@ -67,7 +68,7 @@ void reconnectToBroker() { clientSocket.close(); } clientSocket = ctx.createSocket(SocketType.DEALER); - clientSocket.setHWM(0); + setDefaultSocketParameters(clientSocket); clientSocket.setIdentity(uniqueIdBytes); clientSocket.connect(broker); diff --git a/server/src/test/java/io/opencmw/server/MajordomoWorkerTests.java b/server/src/test/java/io/opencmw/server/MajordomoWorkerTests.java index 404e8986..a41624b7 100644 --- a/server/src/test/java/io/opencmw/server/MajordomoWorkerTests.java +++ b/server/src/test/java/io/opencmw/server/MajordomoWorkerTests.java @@ -10,6 +10,7 @@ import static org.hamcrest.Matchers.not; import static org.junit.jupiter.api.Assertions.*; +import static io.opencmw.OpenCmwConstants.setDefaultSocketParameters; import static io.opencmw.OpenCmwProtocol.Command.SET_REQUEST; import static io.opencmw.OpenCmwProtocol.MdpMessage; @@ -94,6 +95,8 @@ void simpleTest() throws IOException { // broker.setDaemon(true); // use this if running in another app that controls threads final URI brokerAddress = broker.bind(URI.create("mdp://*:" + Utils.findOpenPort())); final URI brokerPubAddress = broker.bind(URI.create("mds://*:" + Utils.findOpenPort())); + assertNotNull(brokerAddress); + assertNotNull(brokerPubAddress); broker.start(); RequestDataType inputData = new RequestDataType(); @@ -216,7 +219,7 @@ void basicEchoTest(final String contentType) { case "HTML": // HTML return final String replyHtml = new String(rawReply.data); - // very crude check whether required reply field ids are present - we skip detailed HTLM parsing -> more efficiently done by a human and browser + // very crude check whether required reply field ids are present - we skip detailed HTML parsing -> more efficiently done by a human and browser assertThat(replyHtml, containsString("id=\"resourceName\"")); assertThat(replyHtml, containsString("id=\"contentType\"")); assertThat(replyHtml, containsString("id=\"data\"")); @@ -232,7 +235,6 @@ void basicEchoTest(final String contentType) { ioBuffer.flip(); assertNotNull(reply); - System.err.println("reply " + reply); assertEquals(inputData.resourceName, reply.resourceName, "identity resourceName field"); assertEquals(inputData.contentType, reply.contentType, "identity contentType field"); assertArrayEquals(inputData.data, reply.data, "identity data field"); @@ -246,7 +248,7 @@ void basicEchoTest(final String contentType) { void basicDefaultHtmlHandlerTest() { assertDoesNotThrow(() -> new DefaultHtmlHandler<>(this.getClass(), null, map -> { map.put("extraKey", "extraValue"); - map.put("extraUnkownObject", new NoData()); + map.put("extraUnknownObject", new NoData()); })); } @@ -263,9 +265,9 @@ void testNotifySubscription() { final AtomicInteger subCounter = new AtomicInteger(0); final AtomicBoolean run = new AtomicBoolean(true); final AtomicBoolean startedSubscriber = new AtomicBoolean(false); - final Thread subcriptionThread = new Thread(() -> { + final Thread subscriptionThread = new Thread(() -> { try (ZMQ.Socket sub = broker.getContext().createSocket(SocketType.SUB)) { - sub.setHWM(0); + setDefaultSocketParameters(sub); sub.connect(MajordomoBroker.INTERNAL_ADDRESS_PUBLISHER); sub.subscribe(TEST_SERVICE_NAME); while (run.get() && !Thread.interrupted()) { @@ -289,7 +291,7 @@ void testNotifySubscription() { sub.unsubscribe(TEST_SERVICE_NAME); } }); - subcriptionThread.start(); + subscriptionThread.start(); // wait until all services are initialised await().alias("wait for thread2 to start").atMost(1, TimeUnit.SECONDS).until(startedSubscriber::get, equalTo(true)); @@ -305,8 +307,8 @@ void testNotifySubscription() { await().alias("wait for reply messages").atMost(2, TimeUnit.SECONDS).until(subCounter::get, equalTo(10)); run.set(false); - LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100)); - assertFalse(subcriptionThread.isAlive(), "subscription thread shut-down"); + await().alias("wait for subscription thread to shut-down").atMost(1, TimeUnit.SECONDS).until(subscriptionThread::isAlive, equalTo(false)); + assertFalse(subscriptionThread.isAlive(), "subscription thread shut-down"); assertEquals(10, subCounter.get(), "received expected number of subscription replies"); } @@ -388,7 +390,7 @@ private TestHtmlService(ZContext ctx) { super(ctx, TEST_SERVICE_NAME, MajordomoWorkerTests.TestContext.class, BinaryData.class, BinaryData.class); setHtmlHandler(new DefaultHtmlHandler<>(this.getClass(), null, map -> { map.put("extraKey", "extraValue"); - map.put("extraUnkownObject", new NoData()); + map.put("extraUnknownObject", new NoData()); })); super.setHandler((rawCtx, reqCtx, request, repCtx, reply) -> { reply.data = request.data; diff --git a/server/src/test/java/io/opencmw/server/MmiServiceHelperTests.java b/server/src/test/java/io/opencmw/server/MmiServiceHelperTests.java index bd47f1a9..bc952dd2 100644 --- a/server/src/test/java/io/opencmw/server/MmiServiceHelperTests.java +++ b/server/src/test/java/io/opencmw/server/MmiServiceHelperTests.java @@ -8,7 +8,19 @@ import java.io.IOException; import java.net.URI; - +import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetDecoder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -22,13 +34,14 @@ @TestInstance(TestInstance.Lifecycle.PER_CLASS) class MmiServiceHelperTests { private static final String DEFAULT_REQUEST_MESSAGE = "Hello World!"; + private static final String DEFAULT_BROKER_NAME = "TestBroker"; private static final byte[] DEFAULT_REQUEST_MESSAGE_BYTES = DEFAULT_REQUEST_MESSAGE.getBytes(UTF_8); private MajordomoBroker broker; private URI brokerAddress; @BeforeAll void startBroker() throws IOException { - broker = new MajordomoBroker("TestBroker", null, BasicRbacRole.values()); + broker = new MajordomoBroker(DEFAULT_BROKER_NAME, null, BasicRbacRole.values()); // broker.setDaemon(true); // use this if running in another app that controls threads brokerAddress = broker.bind(URI.create("mdp://*:" + Utils.findOpenPort())); broker.start(); @@ -66,7 +79,30 @@ void basicDnsTest() { final OpenCmwProtocol.MdpMessage reply = clientSession.send(SET_REQUEST, "mmi.dns", DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC assertNotNull(reply, "reply not being null"); assertNotNull(reply.data, "user-data not being null"); - assertTrue(ZData.toString(reply.data).startsWith("[TestBroker: mdp://")); + assertTrue(ZData.toString(reply.data).startsWith("[" + DEFAULT_BROKER_NAME + ": mdp://")); + + final OpenCmwProtocol.MdpMessage dnsAll = clientSession.send(SET_REQUEST, "mmi.dns", DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(dnsAll, "dnsAll not being null"); + assertNotNull(dnsAll.data, "dnsAll user-data not being null"); + final Map> dnsMapAll = parseDnsReply(dnsAll.data); + assertFalse(dnsMapAll.isEmpty()); + assertFalse(dnsMapAll.get(DEFAULT_BROKER_NAME).isEmpty()); + // System.err.println("dnsMapAll: '" + dnsMapAll + "'\n") // enable for debugging + + final List queryDevice = Arrays.asList("mdp:/TestBroker/mmi.service", "/TestBroker/mmi.openapi", "mds:/TestBroker/mmi.service", "/TestBroker", "unknown"); + final String query = String.join(",", queryDevice); + final OpenCmwProtocol.MdpMessage dnsSpecific = clientSession.send(SET_REQUEST, "mmi.dns?" + query, DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(dnsSpecific, "dnsSpecific not being null"); + assertNotNull(dnsSpecific.data, " dnsSpecificuser-data not being null"); + final Map> dnsMapSelective = parseDnsReply(dnsSpecific.data); + System.err.println("dnsMapSelective: '" + dnsMapSelective + "'\n"); // enable for debugging + assertFalse(dnsMapSelective.isEmpty()); + assertEquals(5, dnsMapSelective.size()); + assertEquals(1, dnsMapSelective.get("mdp:/TestBroker/mmi.service").size(), "match full scheme and path"); + assertEquals(1, dnsMapSelective.get("/TestBroker/mmi.openapi").size(), "match full path w/o scheme"); + assertEquals(4, dnsMapSelective.get("/TestBroker").size(), "list of all properties for a given device"); + assertTrue(dnsMapSelective.get("mds:/TestBroker/mmi.service").isEmpty(), "protocol mismatch"); + assertTrue(dnsMapSelective.get("unknown").isEmpty(), "unknown service/device"); } @Test @@ -76,7 +112,7 @@ void basicDnsHtmlTest() { final OpenCmwProtocol.MdpMessage reply = clientSession.send(SET_REQUEST, "mmi.dns?contentType=HTML", DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC assertNotNull(reply, "reply not being null"); assertNotNull(reply.data, "user-data not being null"); - assertTrue(ZData.toString(reply.data).startsWith("[TestBroker: mdp://")); + assertTrue(ZData.toString(reply.data).startsWith("[" + DEFAULT_BROKER_NAME + ": mdp://"), " reply data was: " + ZData.toString(reply.data)); } @Test @@ -87,7 +123,10 @@ void basicServiceTest() { final OpenCmwProtocol.MdpMessage reply = clientSession.send(SET_REQUEST, "mmi.service", "".getBytes(UTF_8)); // w/o RBAC assertNotNull(reply, "reply not being null"); assertNotNull(reply.data, "user-data not being null"); - assertTrue(ZData.toString(reply.data).startsWith("mmi.dns,mmi.echo,mmi.openapi,mmi.service")); + assertTrue(ZData.toString(reply.data).contains(DEFAULT_BROKER_NAME + "/mmi.dns")); + assertTrue(ZData.toString(reply.data).contains(DEFAULT_BROKER_NAME + "/mmi.echo")); + assertTrue(ZData.toString(reply.data).contains(DEFAULT_BROKER_NAME + "/mmi.openapi")); + assertTrue(ZData.toString(reply.data).contains(DEFAULT_BROKER_NAME + "/mmi.service")); } { @@ -113,7 +152,10 @@ void basicServiceHtmlTest() { final OpenCmwProtocol.MdpMessage reply = clientSession.send(SET_REQUEST, "mmi.service?contentType=HTML", "".getBytes(UTF_8)); // w/o RBAC assertNotNull(reply, "reply not being null"); assertNotNull(reply.data, "user-data not being null"); - assertTrue(ZData.toString(reply.data).startsWith("mmi.dns,mmi.echo,mmi.openapi,mmi.service")); + assertTrue(ZData.toString(reply.data).contains("")); + assertTrue(ZData.toString(reply.data).contains("")); + assertTrue(ZData.toString(reply.data).contains("")); + assertTrue(ZData.toString(reply.data).contains("")); } { @@ -151,4 +193,47 @@ void basicOpenAPIExceptionTest() { assertNotNull(reply.errors); assertFalse(reply.errors.isBlank()); } + + private static Map> parseDnsReply(final byte[] dnsReply) { + final HashMap> map = new HashMap<>(); + if (dnsReply == null || dnsReply.length == 0 || !isUTF8(dnsReply)) { + return map; + } + final String reply = new String(dnsReply, UTF_8); + if (reply.isBlank()) { + return map; + } + + // parse reply + Pattern dnsPattern = Pattern.compile("\\[(.*?)]"); // N.B. need only one instance of this + final Matcher matchPattern = dnsPattern.matcher(reply); + while (matchPattern.find()) { + final String device = matchPattern.group(1); + final String[] message = device.split("(: )", 2); + assert message.length == 2 : "could not split into 2 segments: " + device; + final List uriList = map.computeIfAbsent(message[0], deviceName -> new ArrayList<>()); + for (String uriString : StringUtils.split(message[1], ",")) { + if (!"null".equalsIgnoreCase(uriString)) { + try { + uriList.add(new URI(StringUtils.strip(uriString))); + } catch (final URISyntaxException e) { + System.err.println("could not parse device '" + message[0] + "' uri: '" + uriString + "' cause: " + e); + } + } + } + } + + return map; + } + + private static boolean isUTF8(byte[] array) { + final CharsetDecoder decoder = UTF_8.newDecoder(); + final ByteBuffer buf = ByteBuffer.wrap(array); + try { + decoder.decode(buf); + } catch (CharacterCodingException e) { + return false; + } + return true; + } }