se.arkalix.core.plugin.HttpJsonCloudPlugin Maven / Gradle / Ivy
package se.arkalix.core.plugin;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import se.arkalix.ArService;
import se.arkalix.ArSystem;
import se.arkalix.core.plugin.dto.*;
import se.arkalix.description.ProviderDescription;
import se.arkalix.description.ServiceDescription;
import se.arkalix.descriptor.EncodingDescriptor;
import se.arkalix.descriptor.InterfaceDescriptor;
import se.arkalix.internal.security.identity.X509Keys;
import se.arkalix.net.http.client.HttpClient;
import se.arkalix.net.http.consumer.HttpConsumer;
import se.arkalix.plugin.Plug;
import se.arkalix.plugin.Plugin;
import se.arkalix.query.ServiceQuery;
import se.arkalix.security.access.AccessByToken;
import se.arkalix.security.identity.SystemIdentity;
import se.arkalix.security.identity.UnsupportedKeyAlgorithm;
import se.arkalix.util.Result;
import se.arkalix.util.concurrent.Future;
import se.arkalix.util.concurrent.FutureAnnouncement;
import java.net.InetSocketAddress;
import java.security.PublicKey;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Objects;
import java.util.stream.Collectors;
import static se.arkalix.descriptor.SecurityDescriptor.CERTIFICATE;
import static se.arkalix.descriptor.SecurityDescriptor.NOT_SECURE;
import static se.arkalix.descriptor.TransportDescriptor.HTTP;
/**
* HTTP/JSON cloud plugin.
*
* This class helps one or more {@link se.arkalix.ArSystem systems} with
* joining a local cloud by communicating with the mandatory Arrowhead core
* services of that cloud using HTTP and JSON. More precisely, it (1) registers
* and unregisters the {@link se.arkalix.ArSystem#provide(ArService) services
* provided} by its systems, (2) retrieves the public key required to {@link
* se.arkalix.security.access.AccessByToken validate consumer tokens}, as well
* as (3) helps resolve {@link se.arkalix.ArSystem#consume() service
* consumption queries}.
*
* If used, this plugin should typically be the first one in the list of
* plugins provided to {@link se.arkalix.ArSystem.Builder#plugins(Plugin...)
* constructed systems}, as subsequent plugins may depend on it being
* available for service resolution when they attach to the system in question.
*
* Note that the plugin currently assumes that the service registry,
* authorization system public key and orchestrator of the cloud in question
* will never change. It also never adds the service discovery or orchestration
* services to the {@link ArSystem#consumedServices() service caches} of the
* systems using them, making them inaccessible via the
* {@link ArSystem#consume()} method.
*/
public class HttpJsonCloudPlugin implements Plugin {
private static final Logger logger = LoggerFactory.getLogger(HttpJsonCloudPlugin.class);
private final InetSocketAddress serviceRegistrySocketAddress;
private final Object serviceDiscoveryLock = new Object();
private final String serviceDiscoveryBasePath;
private FutureAnnouncement serviceDiscoveryAnnouncement = null;
private final Object orchestrationLock = new Object();
private final ArOrchestrationStrategy orchestrationStrategy;
private FutureAnnouncement orchestrationAnnouncement = null;
private HttpClient client = null;
private final Object authorizationKeyLock = new Object();
private FutureAnnouncement authorizationKeyAnnouncement = null;
private HttpJsonCloudPlugin(final Builder builder) {
serviceDiscoveryBasePath = Objects.requireNonNullElse(builder.serviceDiscoveryBasePath, "/serviceregistry");
serviceRegistrySocketAddress = Objects.requireNonNull(builder.serviceRegistrySocketAddress,
"Expected serviceRegistrySocketAddress");
orchestrationStrategy = Objects.requireNonNullElse(builder.orchestrationStrategy,
ArOrchestrationStrategy.STORED_THEN_DYNAMIC);
}
/**
* Creates new HTTP/JSON cloud plugin that tries to make its systems enter
* a certain local cloud via the service registry system available at the
* specified {@code socketAddress}.
*
* If more control over the behavior of the core integrator is desired,
* please use the {@link Builder builder class} instead.
*
* @param socketAddress IP address or hostname and port of service registry
* system to use for entering local cloud.
* @return New core integrator.
*/
public static HttpJsonCloudPlugin viaServiceRegistryAt(final InetSocketAddress socketAddress) {
return new HttpJsonCloudPlugin.Builder()
.serviceRegistrySocketAddress(socketAddress)
.build();
}
@Override
public void onAttach(final Plug plug) throws Exception {
client = HttpClient.from(plug.system());
if (logger.isInfoEnabled()) {
logger.info("HTTP/JSON cloud plugin attached to \"{}\"", plug.system().name());
}
}
@Override
public void onDetach(final Plug plug) {
if (logger.isInfoEnabled()) {
logger.info("HTTP/JSON cloud plugin detached from \"{}\"", plug.system().name());
}
}
@Override
public void onDetach(final Plug plug, final Throwable cause) {
if (logger.isErrorEnabled()) {
logger.error("HTTP/JSON cloud plugin forcibly detached " +
"from \"" + plug.system().name() + "\"", cause);
}
}
@Override
public Future> onServicePrepared(final Plug plug, final ArService service) {
final var accessPolicy = service.accessPolicy();
if (accessPolicy instanceof AccessByToken) {
return requestAuthorizationKey()
.ifSuccess(((AccessByToken) accessPolicy)::authorizationKey);
}
return Future.done();
}
@Override
public Future> onServiceProvided(final Plug plug, final ServiceDescription service) {
if (logger.isInfoEnabled()) {
logger.info("Registering \"{}\" provided by \"{}\" ...",
service.name(), plug.system().name());
}
final var provider = service.provider();
final var providerSocketAddress = provider.socketAddress();
final var registration = ServiceRegistration.from(service);
return requestServiceDiscovery()
.flatMap(serviceDiscovery -> serviceDiscovery
.register(registration)
.flatMapCatch(ErrorException.class, fault -> {
final var error = fault.error();
if ("INVALID_PARAMETER".equals(error.type())) {
return serviceDiscovery.unregister(
service.name(),
provider.name(),
providerSocketAddress.getHostString(),
providerSocketAddress.getPort())
.flatMap(ignored -> serviceDiscovery.register(registration)
.pass(null));
}
return Future.failure(fault);
})
.mapResult(result -> {
if (result.isSuccess()) {
if (logger.isInfoEnabled()) {
logger.info("Registered the \"{}\" service " +
"provided by the \"{}\" system",
service.name(), plug.system().name());
}
}
else {
if (logger.isErrorEnabled()) {
logger.error("Failed to register the \"" + service.name() +
"\" service provided by the \"" + plug.system().name() +
"\" system", result.fault());
}
}
return result;
}));
}
@Override
public void onServiceDismissed(final Plug plug, final ServiceDescription service) {
if (logger.isInfoEnabled()) {
logger.info("Unregistering the \"{}\" service provided by " +
"the \"{}\" system ...", service.name(), plug.system().name());
}
final var provider = service.provider();
final var providerSocketAddress = provider.socketAddress();
requestServiceDiscovery()
.flatMap(serviceDiscovery -> serviceDiscovery.unregister(
service.name(),
provider.name(),
providerSocketAddress.getAddress().getHostAddress(),
providerSocketAddress.getPort()))
.onResult(result -> {
if (result.isSuccess()) {
if (logger.isInfoEnabled()) {
logger.info("Unregistered the \"{}\" service " +
"provided by the \"{}\" system", service.name(), plug.system().name());
}
}
else {
if (logger.isWarnEnabled()) {
logger.warn("Failed to unregister the \"" + service.name() +
"\" service provided by the \"" + plug.system().name() +
"\" system", result.fault());
}
}
});
}
@Override
public Future> onServiceQueried(final Plug plug, final ServiceQuery query) {
final var system = plug.system();
switch (orchestrationStrategy) {
case STORED_ONLY:
return queryOrchestratorForStoredRules(system);
case STORED_THEN_DYNAMIC:
return queryOrchestratorForStoredRules(system)
.flatMap(services -> {
if (services.stream().anyMatch(query::matches)) {
return Future.success(services);
}
return queryOrchestratorForDynamicRules(system, query);
});
case DYNAMIC_ONLY:
return queryOrchestratorForDynamicRules(system, query);
}
throw new IllegalStateException("Unsupported orchestration strategy: " + orchestrationStrategy);
}
private Future requestServiceDiscovery() {
synchronized (serviceDiscoveryLock) {
if (serviceDiscoveryAnnouncement == null) {
if (logger.isInfoEnabled()) {
logger.info("HTTP/JSON cloud plugin connecting to " +
"\"service_registry\" system at {} ...", serviceRegistrySocketAddress);
}
serviceDiscoveryAnnouncement = client.connect(serviceRegistrySocketAddress)
.mapResult(result -> {
if (result.isFailure()) {
return Result.failure(result.fault());
}
final var connection = result.value();
final var isSecure = connection.isSecure();
final ProviderDescription provider;
if (isSecure) {
final var identity = new SystemIdentity(connection.certificateChain());
final var name = identity.name();
if (!Objects.equals(name, "service_registry")) {
return Result.failure(new ArCoreIntegrationException("" +
"HTTP/JSON cloud plugin connected to " +
"system at " + serviceRegistrySocketAddress +
" and found that its certificate name " +
"is \"" + name + "\" while expecting it " +
"to be \"service_registry\"; failed to " +
"resolve service discovery service "));
}
provider = new ProviderDescription(name, serviceRegistrySocketAddress, identity.publicKey());
}
else {
provider = new ProviderDescription("service_registry", serviceRegistrySocketAddress);
}
final var serviceDiscovery = new HttpJsonServiceDiscovery(client,
new ServiceDescription.Builder()
.name("service-discovery")
.provider(provider)
.uri(serviceDiscoveryBasePath)
.security(isSecure ? CERTIFICATE : NOT_SECURE)
.interfaces(InterfaceDescriptor.getOrCreate(HTTP, isSecure, EncodingDescriptor.JSON))
.build());
connection.close();
if (logger.isInfoEnabled()) {
logger.info("HTTP/JSON cloud plugin " +
"connected to \"service_registry\" system " +
"at {}", serviceRegistrySocketAddress);
}
return Result.success(serviceDiscovery);
})
.ifFailure(Throwable.class, fault -> {
if (logger.isErrorEnabled()) {
logger.error("HTTP/JSON cloud plugin failed to " +
"connect to \"service_registry\" system at " +
serviceRegistrySocketAddress, fault);
}
})
.toAnnouncement();
}
return serviceDiscoveryAnnouncement.subscribe();
}
}
private Future requestAuthorizationKey() {
synchronized (authorizationKeyLock) {
if (authorizationKeyAnnouncement == null) {
if (logger.isInfoEnabled()) {
logger.info("HTTP/JSON cloud plugin requesting authorization key ...");
}
authorizationKeyAnnouncement = requestServiceDiscovery()
.flatMap(serviceDiscovery -> serviceDiscovery.query(new ServiceQueryBuilder()
.name("auth-public-key")
.build()))
.mapResult(result -> {
if (result.isFailure()) {
return Result.failure(result.fault());
}
final var services = result.value().services();
if (services.size() == 0) {
return Result.failure(new ArCoreIntegrationException("" +
"No \"auth-public-key\" service seems to be " +
"available via the service registry at: " +
serviceRegistrySocketAddress + "; token " +
"authorization not possible"));
}
String publicKeyBase64 = null;
for (final var service : services) {
final var key = service.provider().publicKeyBase64();
if (key.isPresent()) {
publicKeyBase64 = key.get();
break;
}
}
if (publicKeyBase64 == null) {
return Result.failure(new ArCoreIntegrationException("" +
"Even though the service registry provided " +
"descriptions for " + services.size() + " " +
"\"auth-public-key\" service(s), none of them " +
"contains an authorization system public key; " +
"token authorization not possible"));
}
final PublicKey publicKey;
try {
publicKey = X509Keys.parsePublicKey(publicKeyBase64);
}
catch (final UnsupportedKeyAlgorithm exception) {
return Result.failure(new ArCoreIntegrationException("" +
"The \"auth-public-key\" service provider public " +
"key seems to use an unsupported key algorithm; " +
"token authorization not possible", exception));
}
if (logger.isInfoEnabled()) {
logger.info("Authorization key retrieved: {}", publicKeyBase64);
}
return Result.success(publicKey);
})
.ifFailure(Throwable.class, fault -> {
if (logger.isWarnEnabled()) {
logger.warn("Failed to retrieve authorization key", fault);
}
})
.toAnnouncement();
}
return authorizationKeyAnnouncement.subscribe();
}
}
private Future requestOrchestration() {
synchronized (orchestrationLock) {
if (orchestrationAnnouncement == null) {
if (logger.isInfoEnabled()) {
logger.info("HTTP/JSON cloud plugin connecting to " +
"\"orchestrator\" system ...");
}
final var isSecure = client.isSecure();
orchestrationAnnouncement = requestServiceDiscovery()
.flatMap(serviceDiscovery -> serviceDiscovery.query(new ServiceQueryBuilder()
.name("orchestration-service")
.interfaces(InterfaceDescriptor.getOrCreate(HTTP, isSecure, EncodingDescriptor.JSON))
.securityModes(isSecure ? CERTIFICATE : NOT_SECURE)
.build()))
.flatMapResult(result -> {
if (result.isFailure()) {
return Future.failure(result.fault());
}
final var queryResult = result.value();
final var services = queryResult.services();
if (services.isEmpty()) {
return Future.failure(new ArCoreIntegrationException("" +
"No orchestration service available; cannot " +
"request orchestration rules"));
}
final var orchestration = new HttpJsonOrchestration(new HttpConsumer(
client,
services.get(0).toServiceDescription(),
Collections.singleton(EncodingDescriptor.JSON)));
if (logger.isInfoEnabled()) {
logger.info("Orchestration service resolved at {}",
orchestration.service().provider().socketAddress());
}
return Future.success(orchestration);
})
.ifFailure(Throwable.class, fault -> {
if (logger.isErrorEnabled()) {
logger.error("HTTP/JSON cloud plugin failed " +
"to connect to \"orchestrator\" system", fault);
}
})
.toAnnouncement();
}
return orchestrationAnnouncement.subscribe();
}
}
private Future> queryOrchestratorForDynamicRules(
final ArSystem system,
final ServiceQuery query)
{
final var options = new HashMap();
options.put(OrchestrationOption.OVERRIDE_STORE, true);
if (!query.metadata().isEmpty()) {
options.put(OrchestrationOption.METADATA_SEARCH, true);
}
return requestOrchestration()
.flatMap(orchestration -> orchestration.query(new OrchestrationQueryBuilder()
.requester(SystemDetails.from(system))
.service(se.arkalix.core.plugin.dto.ServiceQuery.from(query))
.options(options)
.build()))
.map(queryResult -> queryResult.services()
.stream()
.map(ServiceConsumable::toServiceDescription)
.collect(Collectors.toUnmodifiableList()));
}
private Future> queryOrchestratorForStoredRules(final ArSystem system) {
return requestOrchestration()
.flatMap(orchestration -> orchestration.query(new OrchestrationQueryBuilder()
.requester(SystemDetails.from(system))
.build()))
.map(queryResult -> queryResult.services()
.stream()
.map(ServiceConsumable::toServiceDescription)
.collect(Collectors.toUnmodifiableList()));
}
/**
* Builder useful for constructing {@link HttpJsonCloudPlugin} instances.
*/
@SuppressWarnings("unused")
public static class Builder {
private String serviceDiscoveryBasePath;
private InetSocketAddress serviceRegistrySocketAddress;
private ArOrchestrationStrategy orchestrationStrategy;
/**
* Sets base path, or service URI, of the service discovery
* service provided by {@link
* #serviceRegistrySocketAddress(InetSocketAddress)} the designated
* service registry system}. If not specified, a default that should
* work with most service registries will be used.
*
* @param serviceDiscoveryBasePath Base path of service discovery
* service.
* @return This builder.
*/
public Builder serviceDiscoveryBasePath(final String serviceDiscoveryBasePath) {
this.serviceDiscoveryBasePath = serviceDiscoveryBasePath;
return this;
}
/**
* Sets hostname/IP-address and port of the service registry system to
* use for entering into an Arrowhead local cloud. Must be
* specified.
*
* @param serviceRegistrySocketAddress Service registry system socket
* address.
* @return This builder.
*/
public Builder serviceRegistrySocketAddress(final InetSocketAddress serviceRegistrySocketAddress) {
this.serviceRegistrySocketAddress = serviceRegistrySocketAddress;
return this;
}
/**
* Sets {@link ArOrchestrationStrategy orchestration strategy} to use
* when {@link se.arkalix.ArSystem#consume() resolving what services}
* to consume.
*
* @param orchestrationStrategy Desired orchestration strategy.
* @return This builder.
*/
public Builder orchestrationStrategy(final ArOrchestrationStrategy orchestrationStrategy) {
this.orchestrationStrategy = orchestrationStrategy;
return this;
}
/**
* @return New {@link HttpJsonCloudPlugin}.
*/
public HttpJsonCloudPlugin build() {
return new HttpJsonCloudPlugin(this);
}
}
}