diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java new file mode 100644 index 000000000..5659fb016 --- /dev/null +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2App.java @@ -0,0 +1,104 @@ +/* + * Factory+ RDF store + * App endpoints + * Copyright 2026 University of Sheffield AMRC + */ + +package uk.co.amrc.factoryplus.metadb.api; + +import java.util.Date; +import java.util.UUID; +import java.util.function.Supplier; + +import jakarta.inject.*; +import jakarta.json.*; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.*; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.jena.rdf.model.*; +import org.apache.jena.vocabulary.*; + +import io.vavr.Lazy; +import io.vavr.collection.*; +import io.vavr.control.*; + +import uk.co.amrc.factoryplus.metadb.db.*; + +@Path("v2/app/{app}") +@Consumes("application/json") +public class V2App { + private static final Logger log = LoggerFactory.getLogger(V2App.class); + + @Inject private RdfStore db; + @Inject private SecurityContext auth; + + /* Because we are pulling from dataflow we do not need our own txns. + * Dataflow will open a txn if it needs to do an initial fetch and + * doesn't have cached results already. This means we don't want to + * use requestRead and need to build our own RequestHandler. */ + private Lazy req = Lazy.of(() -> + new RequestHandler(db, auth) + .fetchACL()); + + @PathParam("app") private UUID app; + + private void checkACL (UUID perm, UUID targ) + { + req.get().checkACL(perm, targ); + } + + private JsonValue uuidSet (Set uuids) + { + var strs = uuids.map(UUID::toString) + .toJavaSet(); + return Json.createArrayBuilder(strs) + .build(); + } + + private Map appValues () + { + return db.dataflow() + .appValues(app) + .blockingFirst(); + } + + @GET @Path("object") + public JsonValue list () + { + checkACL(Vocab.Perm.ReadApp, app); + var objs = appValues().keySet(); + return uuidSet(objs); + } + + private Set findAll (JsonValue config) + { + log.info("Searching for config {}", config); + return appValues() + .filterValues(e -> e.value().equals(config)) + .keySet(); + } + + @POST @Path("find") + public JsonValue find (JsonValue config) + { + checkACL(Vocab.Perm.ReadApp, app); + return uuidSet(findAll(config)); + } + + @POST @Path("class/{class}/find") + public JsonValue findInClass (@PathParam("class") UUID klass, JsonValue config) + { + checkACL(Vocab.Perm.ReadApp, app); + var rel = Relation.of("member").bind(klass, true, false); + checkACL(rel.perm(), klass); + + var objs = findAll(config); + var mems = db.dataflow() + .relation(rel) + .blockingFirst(); + return uuidSet(objs.intersect(mems)); + } +} diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Dataflow.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Dataflow.java index 3eaca82ad..738fc4030 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Dataflow.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Dataflow.java @@ -181,6 +181,8 @@ private Map appState (UUID app) .withReplay() .build(); + /* XXX This returns an empty Map for UUIDs which aren't Apps. This + * is incorrect, we should probably have an Option return type. */ private Observable> _buildAppValues (UUID app) { return appUpdates(app) diff --git a/acs-service-setup/dumps/clusters.yaml b/acs-service-setup/dumps/clusters.yaml index b3863637f..b56648f79 100644 --- a/acs-service-setup/dumps/clusters.yaml +++ b/acs-service-setup/dumps/clusters.yaml @@ -1,4 +1,4 @@ -#-REQUIRE: auth git helm +#-REQUIRE: auth git helm metadb --- service: !u UUIDs.Service.ConfigDB version: 2 @@ -59,6 +59,11 @@ objects: !u Clusters.Class.Cluster: name: "Edge cluster" iri: edge/Cluster + subclassOf: + # Edge cluster is not the only possibility for a Sparkplug + # Group, so probably we should have a SparkplugGroup + # intermediate class here. + - !u MetaDB.Class.SparkplugEntity !u UUIDs.Class.App: !u Clusters.App.Bootstrap: diff --git a/acs-service-setup/dumps/sparkplug.yaml b/acs-service-setup/dumps/sparkplug.yaml index fb296ce53..242e1e070 100644 --- a/acs-service-setup/dumps/sparkplug.yaml +++ b/acs-service-setup/dumps/sparkplug.yaml @@ -1,5 +1,5 @@ # ACS-specific deployment decisions -#-REQUIRE: auth +#-REQUIRE: auth metadb --- service: !u UUIDs.Service.ConfigDB version: 2 @@ -19,10 +19,15 @@ objects: - !u UUIDs.Permission.CmdEsc.Rebirth !u Auth.Class.ClientRole: + # XXX There is some confusion here between 'an object with a + # Sparkplug Node address' and 'an object which is authorised to use + # its Node address to publish data'. The former is ⊂ Principal and ⊂ + # SparkplugEntity but only the latter is ∈ ClientRole. !u ACS.Group.SparkplugNode: name: "Sparkplug Node" subclassOf: - !u Auth.Class.Principal + - !u MetaDB.Class.SparkplugEntity # _Active Edge Agent_ is created by helm.js. !u ACS.Group.SparkplugReader: name: "Sparkplug global reader" diff --git a/lib/js-service-client/lib/service/configdb.js b/lib/js-service-client/lib/service/configdb.js index f7ecb5ce2..d83c3944d 100644 --- a/lib/js-service-client/lib/service/configdb.js +++ b/lib/js-service-client/lib/service/configdb.js @@ -315,6 +315,8 @@ export class ConfigDB extends ServiceInterface { * * The arguments are either `(app, query, results)` or an object * containing these properties. + * + * @deprecated The MetaDB will not implement this. * @arg app The application UUID * @arg klass (optional) Restrict to objects in this class * @arg query The query object @@ -344,27 +346,78 @@ export class ConfigDB extends ServiceInterface { return await res.json(); } - /** Use `search` to locate a single object. - * This calls `search` with the provided options. The `results` key - * must be omitted. If we get more than one result this throws an - * exception. - * @returns The UUID of the single object which matches the query. + /** List objects by config entry. + * + * Finds all objects with a config entry exactly matching the one + * supplied. Results may be restricted by class. + * + * @arg app The App to search + * @arg config The config entry to match against + * @arg klass (optional) Restrict results to this class + * @returns An array of UUIDs */ - async resolve (opts) { - if ("results" in opts) - throw new RangeError("resolve doesn't return results"); + async find (app, config, klass) { + const kpath = klass ? `/class/${klass}` : ""; - const uuids = await this.search(opts); + const [st, uuids] = await this.fetch({ + method: "POST", + url: `v2/app/${app}${kpath}/find`, + body: config, + }); - if (!Array.isArray(uuids)) - this.throw("search didn't return an array"); - if (uuids.length > 1) - this.throw(format("More than one result resolving %s with %o", - opts.app, opts.query)); + if (st != 200) { + const kmsg = klass ? ` in ${klass}` : ""; + this.throw(`Find for ${app}${kmsg} failed`, st); + } + return uuids; + } + /** Find a single object by config entry. + * + * Calls `find` and expects the result to contain no more than one + * object. Throws if we get multiple results. + * + * @arg app The App to search + * @arg config The config entry to match against + * @arg klass (optional) Restrict results to this class + * @returns A single UUID, or undefined + */ + async find_unique (app, config, klass) { + const uuids = await this.find(app, config, klass); + if (uuids.length > 1) { + const kmsg = klass ? ` in ${klass}` : ""; + this.throw(`Find for ${app}${kmsg} returned multiple results`); + } return uuids[0]; } + /** Find a single object by config entry. + * + * This is a compatibility method that calls `find_unique`. + * + * This used to call `search` but that endpoint is deprecated and + * not implemented by the MetaDB. The `query` parameter will be used + * directly as the config entry to match. This is only compatible + * with `search` as long as only top-level properties are used; the + * JSONpath feature of `search` is no longer supported. Properties + * explicitly mapped to `undefined` will not be included in the + * request; this matches the `search` behaviour of requiring these + * to not be present. Properties omitted from the query must also be + * not present in the config; this is a change from the `search` + * behaviour, which would ignore additional properties. + * + * Arguments are in an object. + * + * @deprecated Use find_unique for new code + * @arg app The App to search + * @arg query The config entry to search for + * @arg klass Restrict search to objects of this class + * @returns The UUID of the single object which matches the query. + */ + resolve (opts) { + return this.find_unique(opts.app, opts.query, opts.klass); + } + /** Create a ConfigDBWatcher object. * This returns a Promise to a ConfigDBWatcher object for this * ConfigDB. This allows watching config entry changes over