Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/main/java/com/knowledgepixels/nanodash/NanopubElement.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.nanopub.extra.security.MalformedCryptoElementException;
import org.nanopub.extra.security.NanopubSignatureElement;
import org.nanopub.extra.security.SignatureUtils;
import org.nanopub.extra.server.NanopubServerUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand Down Expand Up @@ -162,6 +163,18 @@ public boolean seemsToHaveSignature() {
return seemsToHaveSignature;
}

/**
* Checks whether the Nanopub is protected, i.e. typed {@code npx:ProtectedNanopub} in its own
* publication info. Such a nanopub is only accepted by registries and query services that are
* configured as local/private instances, so it is not part of the public network.
*
* @return True if the Nanopub is protected, false otherwise.
*/
public boolean isProtected() {
if (nanopub == null) return false;
return NanopubServerUtils.isProtectedNanopub(nanopub);
}

/**
* Returns the public key of the Nanopub's signature.
* If the signature is not valid, returns null.
Expand Down
197 changes: 197 additions & 0 deletions src/main/java/com/knowledgepixels/nanodash/ServiceHealth.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package com.knowledgepixels.nanodash;

import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.util.EntityUtils;
import org.nanopub.NanopubUtils;
import org.nanopub.extra.server.NanopubServerUtils;
import org.nanopub.extra.server.RegistryInfo;
import org.nanopub.extra.services.QueryCall;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

/**
* Watches whether the registry and the query service this instance talks to are in a state in
* which they can actually answer, so that the interface can say so once instead of letting every
* query-driven part of a page fail on its own with "API call failed." (issue #681).
* <p>
* A service that is still loading is filtered out by the nanopub library before any call is
* attempted: {@link QueryCall} admits a query instance only when it reports {@code READY} or
* {@code LOADING_UPDATES}, and {@code ServerIterator} skips a registry that is not {@code ready}
* or {@code updating}. That state changes while an instance is running β€” a service that starts
* with the deployment spends its first minutes loading β€” so the check is repeated in the
* background rather than made once at startup, and request threads only read the last answer.
*/
public class ServiceHealth {

private ServiceHealth() {
} // no instances allowed

/**
* How a service is doing, from the point of view of a caller that wants an answer from it.
*/
public enum State {

/**
* Calls can be made: the service reports a status the nanopub library accepts.
*/
HEALTHY,

/**
* The service answers but is still loading, so calls to it are not attempted yet. This
* passes by itself, and the content it would serve is incomplete rather than wrong.
*/
LOADING,

/**
* The service could not be reached at all.
*/
UNREACHABLE,

/**
* Not established yet, or the check itself failed. Treated as "nothing to report": not
* knowing must not turn into claiming an outage.
*/
UNKNOWN

}

private static final long CHECK_INTERVAL_SECONDS = 30;

private static volatile State queryState = State.UNKNOWN;
private static volatile State registryState = State.UNKNOWN;

private static ScheduledExecutorService scheduler;

/**
* Starts the periodic check. Meant to run once at application startup.
*/
public static synchronized void init() {
if (scheduler != null) return;
scheduler = Executors.newSingleThreadScheduledExecutor((r) -> {
Thread t = new Thread(r, "nanodash-service-health");
t.setDaemon(true);
return t;
});
scheduler.scheduleWithFixedDelay(ServiceHealth::check, 0, CHECK_INTERVAL_SECONDS, TimeUnit.SECONDS);
}

/**
* Stops the periodic check. Meant to run once at application shutdown.
*/
public static synchronized void shutdown() {
if (scheduler == null) return;
scheduler.shutdownNow();
scheduler = null;
}

/**
* Returns the state of the query service as of the last check.
*
* @return the state of the query service
*/
public static State getQueryState() {
return queryState;
}

/**
* Returns the state of the registry as of the last check.
*
* @return the state of the registry
*/
public static State getRegistryState() {
return registryState;
}

/**
* Returns what to tell the user about the services, or null when there is nothing to say
* because both are fine or their state is unknown.
* <p>
* A service that is loading and one that cannot be reached get different wording on purpose:
* the first passes by itself and asks the reader to wait, the second is an outage.
*
* @return the note to show, or null if there is nothing to report
*/
public static String getNote() {
String query = switch (queryState) {
case LOADING -> "The query service is still loading. Lists and status information are incomplete.";
case UNREACHABLE -> "The query service cannot be reached. Lists and status information are unavailable.";
default -> null;
};
String registry = switch (registryState) {
case LOADING -> "The registry is still loading. Some nanopublications cannot be retrieved yet.";
case UNREACHABLE -> "The registry cannot be reached. Nanopublications cannot be retrieved or published.";
default -> null;
};
if (query == null) return registry;
if (registry == null) return query;
return query + " " + registry;
}

/**
* Runs both checks and records their outcome. Package-private so that a test can run a check
* without waiting for the scheduler.
*/
static void check() {
State newQueryState = checkQuery();
State newRegistryState = checkRegistry();
if (newQueryState != queryState || newRegistryState != registryState) {
logger.info("Service health changed: query {} -> {}, registry {} -> {}",
queryState, newQueryState, registryState, newRegistryState);
}
queryState = newQueryState;
registryState = newRegistryState;
}

private static State checkQuery() {
try {
// The authoritative question is not what one instance reports but whether the library
// would dispatch a call at all, which is what every failing page element ran into.
if (!QueryCall.getApiInstances().isEmpty()) return State.HEALTHY;
} catch (Exception ex) {
logger.debug("No query instance available: {}", ex.toString());
}
// None admitted: ask the configured service directly to tell loading from unreachable.
return stateFromStatusHeader(Utils.getMainQueryUrl(), "Nanopub-Query-Status",
status -> "READY".equalsIgnoreCase(status) || "LOADING_UPDATES".equalsIgnoreCase(status));
}

private static State checkRegistry() {
String url = Utils.getMainRegistryUrl();
try {
String status = RegistryInfo.load(url).getStatus();
return NanopubServerUtils.isReadyRegistryStatus(status) ? State.HEALTHY : State.LOADING;
} catch (Exception ex) {
logger.debug("Could not load registry info from {}: {}", url, ex.toString());
return State.UNREACHABLE;
}
}

private static State stateFromStatusHeader(String url, String headerName, java.util.function.Predicate<String> isReady) {
try {
HttpResponse response = NanopubUtils.getHttpClient().execute(new HttpGet(url));
try {
var header = response.getFirstHeader(headerName);
String status = header == null ? null : header.getValue();
if (status == null || status.isEmpty()) {
// An instance that reports no status at all is an older one, which the library
// treats as usable; whatever kept it out of the list is not its loading state.
return State.UNKNOWN;
}
return isReady.test(status) ? State.HEALTHY : State.LOADING;
} finally {
EntityUtils.consumeQuietly(response.getEntity());
}
} catch (Exception ex) {
logger.debug("Could not reach {}: {}", url, ex.toString());
return State.UNREACHABLE;
}
}

private static final Logger logger = LoggerFactory.getLogger(ServiceHealth.class);

}
109 changes: 109 additions & 0 deletions src/main/java/com/knowledgepixels/nanodash/ServiceMode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.knowledgepixels.nanodash;

import org.apache.http.client.methods.HttpGet;
import org.nanopub.NanopubUtils;
import org.nanopub.extra.server.RegistryInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Tells whether the services this instance is connected to are local/private ones, i.e. instances
* that accept and serve nanopublications typed {@code npx:ProtectedNanopub} instead of rejecting
* them. Such content is not part of the public network, so it is worth saying so in the interface
* rather than letting it look like everything else (issue #671).
* <p>
* The Registry reports the mode as {@code isLocalInstance} in its info JSON (from version 1.12.0),
* the Query service as the response header {@code Nanopub-Query-Local-Instance} (from version
* 1.26.0). Both are probed once at startup and the answers are kept for the lifetime of the
* process: pointing an instance at a different service means restarting it anyway, and a request
* thread must never wait on these two calls.
*/
public class ServiceMode {

private ServiceMode() {
} // no instances allowed

/**
* The header a Query service sets, and only sets, when it is configured as a local instance.
*/
private static final String QUERY_LOCAL_INSTANCE_HEADER = "Nanopub-Query-Local-Instance";

private static volatile Boolean registryIsLocal;
private static volatile Boolean queryIsLocal;

/**
* Probes both services for their mode. Called at application startup, from where the (possibly
* slow, possibly failing) requests do not delay any user request.
*/
public static void init() {
registryIsLocal = probeRegistry();
queryIsLocal = probeQuery();
if (isRestricted()) {
logger.info("Connected to restricted services (registry local: {}, query local: {}); " +
"protected nanopublications are part of this deployment", registryIsLocal, queryIsLocal);
}
}

/**
* Returns whether this instance is connected to at least one local/private service, and
* therefore is a deployment in which protected nanopublications can exist.
*
* @return true if the registry or the query service reports itself as a local instance
*/
public static boolean isRestricted() {
return isRegistryLocal() || isQueryLocal();
}

/**
* Returns whether the main registry reports itself as a local/private instance. A registry
* older than 1.12.0 does not report the field at all, which is read as "not local".
*
* @return true if the registry is a local instance
*/
public static boolean isRegistryLocal() {
if (registryIsLocal == null) registryIsLocal = probeRegistry();
return registryIsLocal;
}

/**
* Returns whether the main query service reports itself as a local/private instance. A query
* service older than 1.26.0 does not send the header at all, which is read as "not local".
*
* @return true if the query service is a local instance
*/
public static boolean isQueryLocal() {
if (queryIsLocal == null) queryIsLocal = probeQuery();
return queryIsLocal;
}

private static boolean probeRegistry() {
String url = Utils.getMainRegistryUrl();
try {
return RegistryInfo.load(url).isLocalInstance();
} catch (Exception ex) {
// Not knowing the mode is the same as not being restricted: the flag is additional
// information, and its absence must never keep the interface from working.
logger.warn("Could not determine the mode of registry {}: {}", url, ex.toString());
return false;
}
}

private static boolean probeQuery() {
String url = Utils.getMainQueryUrl();
try {
var response = NanopubUtils.getHttpClient().execute(new HttpGet(url));
try {
var header = response.getFirstHeader(QUERY_LOCAL_INSTANCE_HEADER);
return header != null && "true".equalsIgnoreCase(header.getValue());
} finally {
org.apache.http.util.EntityUtils.consumeQuietly(response.getEntity());
}
} catch (Exception ex) {
logger.warn("Could not determine the mode of query service {}: {}", url, ex.toString());
return false;
}
}

private static final Logger logger = LoggerFactory.getLogger(ServiceMode.class);

}
7 changes: 7 additions & 0 deletions src/main/java/com/knowledgepixels/nanodash/Utils.java
Original file line number Diff line number Diff line change
Expand Up @@ -1071,6 +1071,13 @@ public static List<IRI> getTypes(Nanopub np) {
if (t.equals(FIP.FAIR_SUPPORTING_RESOURCE_TO_BE_DEVELOPED)) {
continue;
}
if (t.equals(NPX.PROTECTED_NANOPUB)) {
// Not a type of the content but a statement about where the nanopub may be
// stored, and shown as its own flag instead (see NanopubItem). As a type tag
// it would also link to a listing of all nanopubs of that type, which says
// nothing about them beyond that they are all protected.
continue;
}
l.add(t);
}
return l;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ protected void init() {
WicketWebjars.install(this);

Utils.initMainUrls();
ServiceMode.init();
ServiceHealth.init();

getMarkupSettings().setDefaultMarkupEncoding("UTF-8");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<span class="nanopub-icon"></span> &nbsp;<a wicket:id="nanopub-id-link"
href=".">link-ID</a>
</span>
<span class="protectedtag" wicket:id="protected-flag">πŸ”’ protected</span>
<span class="typetag-container" wicket:id="typespan">
<span class="typetag"><a wicket:id="type" href=".">type</a></span>
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.knowledgepixels.nanodash.page.ListPage;
import com.knowledgepixels.nanodash.page.UserPage;
import com.knowledgepixels.nanodash.template.*;
import org.apache.wicket.AttributeModifier;
import org.apache.wicket.markup.html.WebMarkupContainer;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.link.BookmarkablePageLink;
Expand Down Expand Up @@ -127,6 +128,13 @@ private void initialize() {
} else {
header.add(new Label("action-menu", "").setVisible(false));
}
// A protected nanopub lives only on local/private instances and is never part of the
// public network, which is worth showing next to its ID rather than leaving it to be
// spotted among the pubinfo statements.
Label protectedFlag = new Label("protected-flag", "πŸ”’ protected");
protectedFlag.add(new AttributeModifier("title",
"Protected nanopublication: only stored and served by local/private instances, not by the public network."));
header.add(protectedFlag.setVisible(n.isProtected()));
header.add(new DataView<IRI>("typespan", new ListDataProvider<IRI>(Utils.getTypes(n.getNanopub()))) {

@Override
Expand Down
Loading